forked from Shinonome/dots-hyprland
circle to search
This commit is contained in:
@@ -60,6 +60,7 @@ bindd = Super, V, Copy clipboard history entry, exec, qs -c $qsConfig ipc call T
|
|||||||
bindd = Super, Period, Copy an emoji, exec, qs -c $qsConfig ipc call TEST_ALIVE || pkill fuzzel || ~/.config/hypr/hyprland/scripts/fuzzel-emoji.sh copy # [hidden] Emoji >> clipboard (fallback)
|
bindd = Super, Period, Copy an emoji, exec, qs -c $qsConfig ipc call TEST_ALIVE || pkill fuzzel || ~/.config/hypr/hyprland/scripts/fuzzel-emoji.sh copy # [hidden] Emoji >> clipboard (fallback)
|
||||||
bind = Super+Shift, S, global, quickshell:regionScreenshot # Screen snip
|
bind = Super+Shift, S, global, quickshell:regionScreenshot # Screen snip
|
||||||
bind = Super+Shift, S, exec, qs -c $qsConfig ipc call TEST_ALIVE || pidof slurp || hyprshot --freeze --clipboard-only --mode region --silent # [hidden] Screen snip (fallback)
|
bind = Super+Shift, S, exec, qs -c $qsConfig ipc call TEST_ALIVE || pidof slurp || hyprshot --freeze --clipboard-only --mode region --silent # [hidden] Screen snip (fallback)
|
||||||
|
bind = Super+Shift, A, global, quickshell:regionSearch # Circle to Search
|
||||||
# OCR
|
# OCR
|
||||||
bindd = Super+Shift, T, Character recognition,exec,grim -g "$(slurp $SLURP_ARGS)" "tmp.png" && tesseract "tmp.png" - | wl-copy && rm "tmp.png" # [hidden]
|
bindd = Super+Shift, T, Character recognition,exec,grim -g "$(slurp $SLURP_ARGS)" "tmp.png" && tesseract "tmp.png" - | wl-copy && rm "tmp.png" # [hidden]
|
||||||
# Color picker
|
# Color picker
|
||||||
|
|||||||
@@ -355,6 +355,8 @@ Singleton {
|
|||||||
property JsonObject search: JsonObject {
|
property JsonObject search: JsonObject {
|
||||||
property int nonAppResultDelay: 30 // This prevents lagging when typing
|
property int nonAppResultDelay: 30 // This prevents lagging when typing
|
||||||
property string engineBaseUrl: "https://www.google.com/search?q="
|
property string engineBaseUrl: "https://www.google.com/search?q="
|
||||||
|
property string imageSearchEngineBaseUrl: "https://lens.google.com/uploadbyurl?url="
|
||||||
|
property string fileUploadApiEndpoint: "https://uguu.se/upload"
|
||||||
property list<string> excludedSites: ["quora.com", "facebook.com"]
|
property list<string> excludedSites: ["quora.com", "facebook.com"]
|
||||||
property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird.
|
property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird.
|
||||||
property JsonObject prefix: JsonObject {
|
property JsonObject prefix: JsonObject {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import qs.modules.common.widgets
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Shapes
|
||||||
|
import Quickshell
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
required property color color
|
||||||
|
required property color overlayColor
|
||||||
|
required property list<point> points
|
||||||
|
property int strokeWidth: 10
|
||||||
|
|
||||||
|
function updatePoints() {
|
||||||
|
if (!root.dragging) return;
|
||||||
|
root.points.push({ x: root.mouseX, y: root.mouseY });
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: darkenOverlay
|
||||||
|
z: 1
|
||||||
|
anchors.fill: parent
|
||||||
|
color: root.overlayColor
|
||||||
|
}
|
||||||
|
|
||||||
|
Shape {
|
||||||
|
id: shape
|
||||||
|
z: 2
|
||||||
|
anchors.fill: parent
|
||||||
|
layer.enabled: true
|
||||||
|
layer.smooth: true
|
||||||
|
preferredRendererType: Shape.CurveRenderer
|
||||||
|
|
||||||
|
ShapePath {
|
||||||
|
id: shapePath
|
||||||
|
strokeWidth: root.strokeWidth
|
||||||
|
pathHints: ShapePath.PathLinear
|
||||||
|
fillColor: "transparent"
|
||||||
|
strokeColor: root.color
|
||||||
|
capStyle: ShapePath.RoundCap
|
||||||
|
joinStyle: ShapePath.RoundJoin
|
||||||
|
|
||||||
|
PathPolyline {
|
||||||
|
path: root.points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import qs.modules.common.widgets
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
required property real regionX
|
||||||
|
required property real regionY
|
||||||
|
required property real regionWidth
|
||||||
|
required property real regionHeight
|
||||||
|
required property real mouseX
|
||||||
|
required property real mouseY
|
||||||
|
required property color color
|
||||||
|
required property color overlayColor
|
||||||
|
|
||||||
|
// Overlay to darken screen
|
||||||
|
// Base dark overlay around region
|
||||||
|
Rectangle {
|
||||||
|
id: darkenOverlay
|
||||||
|
z: 1
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
top: parent.top
|
||||||
|
leftMargin: root.regionX - darkenOverlay.border.width
|
||||||
|
topMargin: root.regionY - darkenOverlay.border.width
|
||||||
|
}
|
||||||
|
width: root.regionWidth + darkenOverlay.border.width * 2
|
||||||
|
height: root.regionHeight + darkenOverlay.border.width * 2
|
||||||
|
color: "transparent"
|
||||||
|
border.color: root.overlayColor
|
||||||
|
border.width: Math.max(root.width, root.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection border
|
||||||
|
Rectangle {
|
||||||
|
id: selectionBorder
|
||||||
|
z: 1
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
top: parent.top
|
||||||
|
leftMargin: root.regionX
|
||||||
|
topMargin: root.regionY
|
||||||
|
}
|
||||||
|
width: root.regionWidth
|
||||||
|
height: root.regionHeight
|
||||||
|
color: "transparent"
|
||||||
|
border.color: root.color
|
||||||
|
border.width: 2
|
||||||
|
// radius: root.standardRounding
|
||||||
|
radius: 0 // TODO: figure out how to make the overlay thing work with rounding
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
z: 2
|
||||||
|
anchors {
|
||||||
|
top: selectionBorder.bottom
|
||||||
|
right: selectionBorder.right
|
||||||
|
margins: 8
|
||||||
|
}
|
||||||
|
color: root.color
|
||||||
|
text: `${Math.round(root.regionWidth)} x ${Math.round(root.regionHeight)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coord lines
|
||||||
|
Rectangle { // Vertical
|
||||||
|
z: 2
|
||||||
|
x: root.mouseX
|
||||||
|
anchors {
|
||||||
|
top: parent.top
|
||||||
|
bottom: parent.bottom
|
||||||
|
}
|
||||||
|
width: 1
|
||||||
|
color: root.color
|
||||||
|
}
|
||||||
|
Rectangle { // Horizontal
|
||||||
|
z: 2
|
||||||
|
y: root.mouseY
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
}
|
||||||
|
height: 1
|
||||||
|
color: root.color
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
import qs
|
||||||
|
import qs.modules.common
|
||||||
|
import qs.modules.common.functions
|
||||||
|
import qs.modules.common.widgets
|
||||||
|
import qs.services
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Qt5Compat.GraphicalEffects
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import Quickshell.Widgets
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: root
|
||||||
|
visible: false
|
||||||
|
WlrLayershell.namespace: "quickshell:regionSelector"
|
||||||
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||||
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
anchors {
|
||||||
|
left: true
|
||||||
|
right: true
|
||||||
|
top: true
|
||||||
|
bottom: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Ask: sidebar AI; Ocr: tesseract
|
||||||
|
enum SnipAction { Copy, Edit, Search }
|
||||||
|
enum SelectionMode { RectCorners, Circle }
|
||||||
|
property var action: RegionSelection.SnipAction.Copy
|
||||||
|
property var selectionMode: RegionSelection.SelectionMode.RectCorners
|
||||||
|
signal dismiss()
|
||||||
|
|
||||||
|
property string screenshotDir: Directories.screenshotTemp
|
||||||
|
property string imageSearchEngineBaseUrl: Config.options.search.imageSearchEngineBaseUrl
|
||||||
|
property string fileUploadApiEndpoint: Config.options.search.fileUploadApiEndpoint
|
||||||
|
property color overlayColor: "#77111111"
|
||||||
|
property color genericContentColor: Qt.alpha(root.overlayColor, 0.9)
|
||||||
|
property color genericContentForeground: "#ddffffff"
|
||||||
|
property color selectionBorderColor: "#ddf1f1f1"
|
||||||
|
property color selectionFillColor: "#33ffffff"
|
||||||
|
property color windowBorderColor: "#dda0c0da"
|
||||||
|
property color windowFillColor: "#22a0c0da"
|
||||||
|
property color imageBorderColor: "#ddf1d1ff"
|
||||||
|
property color imageFillColor: "#33f1d1ff"
|
||||||
|
property color onBorderColor: "#ff000000"
|
||||||
|
property real standardRounding: 4
|
||||||
|
readonly property var windows: [...HyprlandData.windowList].sort((a, b) => {
|
||||||
|
// Sort floating=true windows before others
|
||||||
|
if (a.floating === b.floating) return 0;
|
||||||
|
return a.floating ? -1 : 1;
|
||||||
|
})
|
||||||
|
readonly property var layers: HyprlandData.layers
|
||||||
|
readonly property real falsePositivePreventionRatio: 0.5
|
||||||
|
|
||||||
|
readonly property HyprlandMonitor hyprlandMonitor: Hyprland.monitorFor(screen)
|
||||||
|
readonly property real monitorScale: hyprlandMonitor.scale
|
||||||
|
readonly property real monitorOffsetX: hyprlandMonitor.x
|
||||||
|
readonly property real monitorOffsetY: hyprlandMonitor.y
|
||||||
|
property int activeWorkspaceId: hyprlandMonitor.activeWorkspace?.id ?? 0
|
||||||
|
property string screenshotPath: `${root.screenshotDir}/image-${screen.name}`
|
||||||
|
property real dragStartX: 0
|
||||||
|
property real dragStartY: 0
|
||||||
|
property real draggingX: 0
|
||||||
|
property real draggingY: 0
|
||||||
|
property real dragDiffX: 0
|
||||||
|
property real dragDiffY: 0
|
||||||
|
property bool draggedAway: (dragDiffX !== 0 || dragDiffY !== 0)
|
||||||
|
property bool dragging: false
|
||||||
|
property list<point> points: []
|
||||||
|
property var mouseButton: null
|
||||||
|
property var imageRegions: []
|
||||||
|
readonly property list<var> windowRegions: filterWindowRegionsByLayers(
|
||||||
|
root.windows.filter(w => w.workspace.id === root.activeWorkspaceId),
|
||||||
|
root.layerRegions
|
||||||
|
).map(window => {
|
||||||
|
return {
|
||||||
|
at: [window.at[0] - root.monitorOffsetX, window.at[1] - root.monitorOffsetY],
|
||||||
|
size: [window.size[0], window.size[1]],
|
||||||
|
class: window.class,
|
||||||
|
title: window.title,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
readonly property list<var> layerRegions: {
|
||||||
|
const layersOfThisMonitor = root.layers[root.hyprlandMonitor.name]
|
||||||
|
const topLayers = layersOfThisMonitor?.levels["2"]
|
||||||
|
if (!topLayers) return [];
|
||||||
|
const nonBarTopLayers = topLayers
|
||||||
|
.filter(layer => !(layer.namespace.includes(":bar") || layer.namespace.includes(":verticalBar") || layer.namespace.includes(":dock")))
|
||||||
|
.map(layer => {
|
||||||
|
return {
|
||||||
|
at: [layer.x, layer.y],
|
||||||
|
size: [layer.w, layer.h],
|
||||||
|
namespace: layer.namespace,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const offsetAdjustedLayers = nonBarTopLayers.map(layer => {
|
||||||
|
return {
|
||||||
|
at: [layer.at[0] - root.monitorOffsetX, layer.at[1] - root.monitorOffsetY],
|
||||||
|
size: layer.size,
|
||||||
|
namespace: layer.namespace,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return offsetAdjustedLayers;
|
||||||
|
}
|
||||||
|
|
||||||
|
property real targetedRegionX: -1
|
||||||
|
property real targetedRegionY: -1
|
||||||
|
property real targetedRegionWidth: 0
|
||||||
|
property real targetedRegionHeight: 0
|
||||||
|
|
||||||
|
function intersectionOverUnion(regionA, regionB) {
|
||||||
|
// region: { at: [x, y], size: [w, h] }
|
||||||
|
const ax1 = regionA.at[0], ay1 = regionA.at[1];
|
||||||
|
const ax2 = ax1 + regionA.size[0], ay2 = ay1 + regionA.size[1];
|
||||||
|
const bx1 = regionB.at[0], by1 = regionB.at[1];
|
||||||
|
const bx2 = bx1 + regionB.size[0], by2 = by1 + regionB.size[1];
|
||||||
|
|
||||||
|
const interX1 = Math.max(ax1, bx1);
|
||||||
|
const interY1 = Math.max(ay1, by1);
|
||||||
|
const interX2 = Math.min(ax2, bx2);
|
||||||
|
const interY2 = Math.min(ay2, by2);
|
||||||
|
|
||||||
|
const interArea = Math.max(0, interX2 - interX1) * Math.max(0, interY2 - interY1);
|
||||||
|
const areaA = (ax2 - ax1) * (ay2 - ay1);
|
||||||
|
const areaB = (bx2 - bx1) * (by2 - by1);
|
||||||
|
const unionArea = areaA + areaB - interArea;
|
||||||
|
|
||||||
|
return unionArea > 0 ? interArea / unionArea : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOverlappingImageRegions(regions) {
|
||||||
|
let keep = [];
|
||||||
|
let removed = new Set();
|
||||||
|
for (let i = 0; i < regions.length; ++i) {
|
||||||
|
if (removed.has(i)) continue;
|
||||||
|
let regionA = regions[i];
|
||||||
|
for (let j = i + 1; j < regions.length; ++j) {
|
||||||
|
if (removed.has(j)) continue;
|
||||||
|
let regionB = regions[j];
|
||||||
|
if (intersectionOverUnion(regionA, regionB) > 0) {
|
||||||
|
// Compare areas
|
||||||
|
let areaA = regionA.size[0] * regionA.size[1];
|
||||||
|
let areaB = regionB.size[0] * regionB.size[1];
|
||||||
|
if (areaA <= areaB) {
|
||||||
|
removed.add(j);
|
||||||
|
} else {
|
||||||
|
removed.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < regions.length; ++i) {
|
||||||
|
if (!removed.has(i)) keep.push(regions[i]);
|
||||||
|
}
|
||||||
|
return keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterWindowRegionsByLayers(windowRegions, layerRegions) {
|
||||||
|
return windowRegions.filter(windowRegion => {
|
||||||
|
for (let i = 0; i < layerRegions.length; ++i) {
|
||||||
|
if (intersectionOverUnion(windowRegion, layerRegions[i]) > 0)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterImageRegions(regions, windowRegions, threshold = 0.1) {
|
||||||
|
// Remove image regions that overlap too much with any window region
|
||||||
|
let filtered = regions.filter(region => {
|
||||||
|
for (let i = 0; i < windowRegions.length; ++i) {
|
||||||
|
if (intersectionOverUnion(region, windowRegions[i]) > threshold)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
// Remove overlapping image regions, keep only the smaller one
|
||||||
|
return filterOverlappingImageRegions(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTargetedRegion(x, y) {
|
||||||
|
// Image regions
|
||||||
|
const clickedRegion = root.imageRegions.find(region => {
|
||||||
|
return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1];
|
||||||
|
});
|
||||||
|
if (clickedRegion) {
|
||||||
|
root.targetedRegionX = clickedRegion.at[0];
|
||||||
|
root.targetedRegionY = clickedRegion.at[1];
|
||||||
|
root.targetedRegionWidth = clickedRegion.size[0];
|
||||||
|
root.targetedRegionHeight = clickedRegion.size[1];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer regions
|
||||||
|
const clickedLayer = root.layerRegions.find(region => {
|
||||||
|
return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1];
|
||||||
|
});
|
||||||
|
if (clickedLayer) {
|
||||||
|
root.targetedRegionX = clickedLayer.at[0];
|
||||||
|
root.targetedRegionY = clickedLayer.at[1];
|
||||||
|
root.targetedRegionWidth = clickedLayer.size[0];
|
||||||
|
root.targetedRegionHeight = clickedLayer.size[1];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window regions
|
||||||
|
const clickedWindow = root.windowRegions.find(region => {
|
||||||
|
return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1];
|
||||||
|
});
|
||||||
|
if (clickedWindow) {
|
||||||
|
root.targetedRegionX = clickedWindow.at[0];
|
||||||
|
root.targetedRegionY = clickedWindow.at[1];
|
||||||
|
root.targetedRegionWidth = clickedWindow.size[0];
|
||||||
|
root.targetedRegionHeight = clickedWindow.size[1];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.targetedRegionX = -1;
|
||||||
|
root.targetedRegionY = -1;
|
||||||
|
root.targetedRegionWidth = 0;
|
||||||
|
root.targetedRegionHeight = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
property real regionWidth: Math.abs(draggingX - dragStartX)
|
||||||
|
property real regionHeight: Math.abs(draggingY - dragStartY)
|
||||||
|
property real regionX: Math.min(dragStartX, draggingX)
|
||||||
|
property real regionY: Math.min(dragStartY, draggingY)
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: screenshotProcess
|
||||||
|
running: true
|
||||||
|
command: ["bash", "-c", `mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}' && grim -o '${StringUtils.shellSingleQuoteEscape(root.screen.name)}' '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}'`]
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.visible = true;
|
||||||
|
imageDetectionProcess.running = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: imageDetectionProcess
|
||||||
|
command: ["bash", "-c", `${Directories.scriptPath}/images/find-regions-venv.sh `
|
||||||
|
+ `--hyprctl `
|
||||||
|
+ `--image '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}' `
|
||||||
|
+ `--max-width ${Math.round(root.screen.width * root.falsePositivePreventionRatio)} `
|
||||||
|
+ `--max-height ${Math.round(root.screen.height * root.falsePositivePreventionRatio)} `]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: imageDimensionCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
imageRegions = filterImageRegions(
|
||||||
|
JSON.parse(imageDimensionCollector.text),
|
||||||
|
root.windowRegions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snip() {
|
||||||
|
// Validity check
|
||||||
|
if (root.regionWidth <= 0 || root.regionHeight <= 0) {
|
||||||
|
console.warn("[Region Selector] Invalid region size, skipping snip.");
|
||||||
|
root.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust action
|
||||||
|
if (root.action === RegionSelection.SnipAction.Copy || root.action === RegionSelection.SnipAction.Edit) {
|
||||||
|
root.action = root.mouseButton === Qt.RightButton ? RegionSelection.SnipAction.Edit : RegionSelection.SnipAction.Copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set command for action
|
||||||
|
const cropBase = `magick ${StringUtils.shellSingleQuoteEscape(root.screenshotPath)} `
|
||||||
|
+ `-crop ${root.regionWidth * root.monitorScale}x${root.regionHeight * root.monitorScale}+${root.regionX * root.monitorScale}+${root.regionY * root.monitorScale}`
|
||||||
|
const cropToStdout = `${cropBase} -`
|
||||||
|
const cropInPlace = `${cropBase} '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}'`
|
||||||
|
const cleanup = `rm '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}'`
|
||||||
|
const uploadAndGetUrl = (filePath) => {
|
||||||
|
return `curl -sF files[]=@'${StringUtils.shellSingleQuoteEscape(filePath)}' ${root.fileUploadApiEndpoint} | jq -r '.files[0].url'`
|
||||||
|
}
|
||||||
|
switch (root.action) {
|
||||||
|
case RegionSelection.SnipAction.Copy:
|
||||||
|
snipProc.command = ["bash", "-c", `${cropToStdout} | wl-copy && ${cleanup}`]
|
||||||
|
break;
|
||||||
|
case RegionSelection.SnipAction.Edit:
|
||||||
|
snipProc.command = ["bash", "-c", `${cropToStdout} | swappy -f - && ${cleanup}`]
|
||||||
|
break;
|
||||||
|
case RegionSelection.SnipAction.Search:
|
||||||
|
snipProc.command = ["bash", "-c", `${cropInPlace} && xdg-open "${root.imageSearchEngineBaseUrl}$(${uploadAndGetUrl(root.screenshotPath)})"`]
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn("[Region Selector] Unknown snip action, skipping snip.");
|
||||||
|
root.dismiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image post-processing
|
||||||
|
snipProc.startDetached();
|
||||||
|
root.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: snipProc
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreencopyView {
|
||||||
|
anchors.fill: parent
|
||||||
|
live: false
|
||||||
|
captureSource: root.screen
|
||||||
|
|
||||||
|
focus: root.visible
|
||||||
|
Keys.onPressed: (event) => { // Esc to close
|
||||||
|
if (event.key === Qt.Key_Escape) {
|
||||||
|
root.dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.CrossCursor
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
hoverEnabled: true
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
onPressed: (mouse) => {
|
||||||
|
root.dragStartX = mouse.x;
|
||||||
|
root.dragStartY = mouse.y;
|
||||||
|
root.draggingX = mouse.x;
|
||||||
|
root.draggingY = mouse.y;
|
||||||
|
root.dragging = true;
|
||||||
|
root.mouseButton = mouse.button;
|
||||||
|
}
|
||||||
|
onReleased: (mouse) => {
|
||||||
|
// Circle dragging?
|
||||||
|
if (root.selectionMode === RegionSelection.SelectionMode.Circle) {
|
||||||
|
const maxX = Math.max(...root.points.map(p => p.x));
|
||||||
|
const minX = Math.min(...root.points.map(p => p.x));
|
||||||
|
const maxY = Math.max(...root.points.map(p => p.y));
|
||||||
|
const minY = Math.min(...root.points.map(p => p.y));
|
||||||
|
root.regionX = minX;
|
||||||
|
root.regionY = minY;
|
||||||
|
root.regionWidth = maxX - minX;
|
||||||
|
root.regionHeight = maxY - minY;
|
||||||
|
}
|
||||||
|
// Detect if it was a click -> Try to select targeted region
|
||||||
|
if (root.draggingX === root.dragStartX && root.draggingY === root.dragStartY) {
|
||||||
|
if (root.targetedRegionX >= 0 && root.targetedRegionY >= 0) {
|
||||||
|
root.regionX = root.targetedRegionX;
|
||||||
|
root.regionY = root.targetedRegionY;
|
||||||
|
root.regionWidth = root.targetedRegionWidth;
|
||||||
|
root.regionHeight = root.targetedRegionHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.snip();
|
||||||
|
}
|
||||||
|
onPositionChanged: (mouse) => {
|
||||||
|
if (!root.dragging) return;
|
||||||
|
root.draggingX = mouse.x;
|
||||||
|
root.draggingY = mouse.y;
|
||||||
|
root.dragDiffX = mouse.x - root.dragStartX;
|
||||||
|
root.dragDiffY = mouse.y - root.dragStartY;
|
||||||
|
root.points.push({ x: mouse.x, y: mouse.y });
|
||||||
|
root.updateTargetedRegion(mouse.x, mouse.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
z: 2
|
||||||
|
anchors.fill: parent
|
||||||
|
active: root.selectionMode === RegionSelection.SelectionMode.RectCorners
|
||||||
|
sourceComponent: RectCornersSelectionDetails {
|
||||||
|
regionX: root.regionX
|
||||||
|
regionY: root.regionY
|
||||||
|
regionWidth: root.regionWidth
|
||||||
|
regionHeight: root.regionHeight
|
||||||
|
mouseX: mouseArea.mouseX
|
||||||
|
mouseY: mouseArea.mouseY
|
||||||
|
color: root.selectionBorderColor
|
||||||
|
overlayColor: root.overlayColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
z: 2
|
||||||
|
anchors.fill: parent
|
||||||
|
active: root.selectionMode === RegionSelection.SelectionMode.Circle
|
||||||
|
sourceComponent: CircleSelectionDetails {
|
||||||
|
color: root.selectionBorderColor
|
||||||
|
overlayColor: root.overlayColor
|
||||||
|
points: root.points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instructions
|
||||||
|
Rectangle {
|
||||||
|
z: 9999
|
||||||
|
anchors {
|
||||||
|
top: parent.top
|
||||||
|
horizontalCenter: parent.horizontalCenter
|
||||||
|
topMargin: (Appearance.sizes.barHeight - implicitHeight) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
opacity: root.dragging ? 0 : 1
|
||||||
|
visible: opacity > 0
|
||||||
|
Behavior on opacity {
|
||||||
|
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
color: root.genericContentColor
|
||||||
|
radius: 10
|
||||||
|
border.width: 1
|
||||||
|
border.color: Appearance.m3colors.m3outlineVariant
|
||||||
|
implicitWidth: instructionsRow.implicitWidth + 10 * 2
|
||||||
|
implicitHeight: instructionsRow.implicitHeight + 5 * 2
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: instructionsRow
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 4
|
||||||
|
MaterialSymbol {
|
||||||
|
id: screenshotRegionIcon
|
||||||
|
// anchors.centerIn: parent
|
||||||
|
iconSize: Appearance.font.pixelSize.larger
|
||||||
|
text: switch(root.selectionMode) {
|
||||||
|
case RegionSelection.SelectionMode.RectCorners:
|
||||||
|
return "crop_free"
|
||||||
|
break;
|
||||||
|
case RegionSelection.SelectionMode.Circle:
|
||||||
|
return "gesture"
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return "crop_free"
|
||||||
|
}
|
||||||
|
color: root.genericContentForeground
|
||||||
|
}
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: {
|
||||||
|
var instructionText = "";
|
||||||
|
var actionText = "";
|
||||||
|
if (root.selectionMode === RegionSelection.SelectionMode.RectCorners) {
|
||||||
|
instructionText = Translation.tr("Drag or click a region");
|
||||||
|
} else if (root.selectionMode === RegionSelection.SelectionMode.Circle) {
|
||||||
|
instructionText = Translation.tr("Circle");
|
||||||
|
}
|
||||||
|
switch (root.action) {
|
||||||
|
case RegionSelection.SnipAction.Copy:
|
||||||
|
case RegionSelection.SnipAction.Edit:
|
||||||
|
actionText = Translation.tr(" | LMB: Copy • RMB: Edit");
|
||||||
|
break;
|
||||||
|
case RegionSelection.SnipAction.Search:
|
||||||
|
actionText = Translation.tr(" to search");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
actionText = "";
|
||||||
|
}
|
||||||
|
return instructionText + actionText;
|
||||||
|
}
|
||||||
|
color: root.genericContentForeground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window regions
|
||||||
|
Repeater {
|
||||||
|
model: ScriptModel {
|
||||||
|
values: root.windowRegions
|
||||||
|
}
|
||||||
|
delegate: TargetRegion {
|
||||||
|
z: 2
|
||||||
|
required property var modelData
|
||||||
|
showIcon: true
|
||||||
|
targeted: !root.draggedAway &&
|
||||||
|
(root.targetedRegionX === modelData.at[0]
|
||||||
|
&& root.targetedRegionY === modelData.at[1]
|
||||||
|
&& root.targetedRegionWidth === modelData.size[0]
|
||||||
|
&& root.targetedRegionHeight === modelData.size[1])
|
||||||
|
|
||||||
|
opacity: root.draggedAway ? 0 : 1
|
||||||
|
visible: opacity > 0
|
||||||
|
Behavior on opacity {
|
||||||
|
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
x: modelData.at[0]
|
||||||
|
y: modelData.at[1]
|
||||||
|
width: modelData.size[0]
|
||||||
|
height: modelData.size[1]
|
||||||
|
borderColor: root.windowBorderColor
|
||||||
|
fillColor: targeted ? root.windowFillColor : "transparent"
|
||||||
|
border.width: targeted ? 4 : 2
|
||||||
|
text: `${modelData.class}`
|
||||||
|
radius: Appearance.rounding.windowRounding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer regions
|
||||||
|
Repeater {
|
||||||
|
model: ScriptModel {
|
||||||
|
values: root.layerRegions
|
||||||
|
}
|
||||||
|
delegate: TargetRegion {
|
||||||
|
z: 3
|
||||||
|
required property var modelData
|
||||||
|
targeted: !root.draggedAway &&
|
||||||
|
(root.targetedRegionX === modelData.at[0]
|
||||||
|
&& root.targetedRegionY === modelData.at[1]
|
||||||
|
&& root.targetedRegionWidth === modelData.size[0]
|
||||||
|
&& root.targetedRegionHeight === modelData.size[1])
|
||||||
|
|
||||||
|
opacity: root.draggedAway ? 0 : 1
|
||||||
|
visible: opacity > 0
|
||||||
|
Behavior on opacity {
|
||||||
|
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
x: modelData.at[0]
|
||||||
|
y: modelData.at[1]
|
||||||
|
width: modelData.size[0]
|
||||||
|
height: modelData.size[1]
|
||||||
|
borderColor: root.windowBorderColor
|
||||||
|
fillColor: targeted ? root.windowFillColor : "transparent"
|
||||||
|
border.width: targeted ? 4 : 2
|
||||||
|
text: `${modelData.namespace}`
|
||||||
|
radius: Appearance.rounding.windowRounding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image regions
|
||||||
|
Repeater {
|
||||||
|
model: ScriptModel {
|
||||||
|
values: Config.options.screenshotTool.showContentRegions ? root.imageRegions : []
|
||||||
|
}
|
||||||
|
delegate: TargetRegion {
|
||||||
|
z: 4
|
||||||
|
required property var modelData
|
||||||
|
targeted: !root.draggedAway &&
|
||||||
|
(root.targetedRegionX === modelData.at[0]
|
||||||
|
&& root.targetedRegionY === modelData.at[1]
|
||||||
|
&& root.targetedRegionWidth === modelData.size[0]
|
||||||
|
&& root.targetedRegionHeight === modelData.size[1])
|
||||||
|
|
||||||
|
opacity: root.draggedAway ? 0 : 1
|
||||||
|
visible: opacity > 0
|
||||||
|
Behavior on opacity {
|
||||||
|
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
x: modelData.at[0]
|
||||||
|
y: modelData.at[1]
|
||||||
|
width: modelData.size[0]
|
||||||
|
height: modelData.size[1]
|
||||||
|
borderColor: root.imageBorderColor
|
||||||
|
fillColor: targeted ? root.imageFillColor : "transparent"
|
||||||
|
border.width: targeted ? 4 : 2
|
||||||
|
text: "Content region"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,83 +16,13 @@ import Quickshell.Hyprland
|
|||||||
|
|
||||||
Scope {
|
Scope {
|
||||||
id: root
|
id: root
|
||||||
property string screenshotDir: Directories.screenshotTemp
|
|
||||||
property color overlayColor: "#77111111"
|
|
||||||
property color genericContentColor: Qt.alpha(root.overlayColor, 0.9)
|
|
||||||
property color genericContentForeground: "#ddffffff"
|
|
||||||
property color selectionBorderColor: "#ddf1f1f1"
|
|
||||||
property color selectionFillColor: "#33ffffff"
|
|
||||||
property color windowBorderColor: "#dda0c0da"
|
|
||||||
property color windowFillColor: "#22a0c0da"
|
|
||||||
property color imageBorderColor: "#ddf1d1ff"
|
|
||||||
property color imageFillColor: "#33f1d1ff"
|
|
||||||
property color onBorderColor: "#ff000000"
|
|
||||||
property real standardRounding: 4
|
|
||||||
readonly property var windows: [...HyprlandData.windowList].sort((a, b) => {
|
|
||||||
// Sort floating=true windows before others
|
|
||||||
if (a.floating === b.floating) return 0;
|
|
||||||
return a.floating ? -1 : 1;
|
|
||||||
})
|
|
||||||
readonly property var layers: HyprlandData.layers
|
|
||||||
readonly property real falsePositivePreventionRatio: 0.5
|
|
||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
GlobalStates.regionSelectorOpen = false
|
GlobalStates.regionSelectorOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
component TargetRegion: Rectangle {
|
property var action: RegionSelection.SnipAction.Copy
|
||||||
id: regionRect
|
property var selectionMode: RegionSelection.SelectionMode.RectCorners
|
||||||
property bool showIcon: false
|
|
||||||
property bool targeted: false
|
|
||||||
property color borderColor
|
|
||||||
property color fillColor: "transparent"
|
|
||||||
property string text: ""
|
|
||||||
property real textPadding: 10
|
|
||||||
z: 2
|
|
||||||
color: fillColor
|
|
||||||
border.color: borderColor
|
|
||||||
border.width: targeted ? 3 : 1
|
|
||||||
radius: root.standardRounding
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: regionLabelBackground
|
|
||||||
property real verticalPadding: 5
|
|
||||||
property real horizontalPadding: 10
|
|
||||||
radius: 10
|
|
||||||
color: root.genericContentColor
|
|
||||||
border.width: 1
|
|
||||||
border.color: Appearance.m3colors.m3outlineVariant
|
|
||||||
anchors {
|
|
||||||
top: parent.top
|
|
||||||
left: parent.left
|
|
||||||
topMargin: regionRect.textPadding
|
|
||||||
leftMargin: regionRect.textPadding
|
|
||||||
}
|
|
||||||
implicitWidth: regionInfoRow.implicitWidth + horizontalPadding * 2
|
|
||||||
implicitHeight: regionInfoRow.implicitHeight + verticalPadding * 2
|
|
||||||
Row {
|
|
||||||
id: regionInfoRow
|
|
||||||
anchors.centerIn: parent
|
|
||||||
spacing: 4
|
|
||||||
|
|
||||||
Loader {
|
|
||||||
id: regionIconLoader
|
|
||||||
active: regionRect.showIcon
|
|
||||||
visible: active
|
|
||||||
sourceComponent: IconImage {
|
|
||||||
implicitSize: Appearance.font.pixelSize.larger
|
|
||||||
source: Quickshell.iconPath(AppSearch.guessIcon(regionRect.text), "image-missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
id: regionText
|
|
||||||
text: regionRect.text
|
|
||||||
color: root.genericContentForeground
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: Quickshell.screens
|
model: Quickshell.screens
|
||||||
@@ -101,478 +31,24 @@ Scope {
|
|||||||
required property var modelData
|
required property var modelData
|
||||||
active: GlobalStates.regionSelectorOpen
|
active: GlobalStates.regionSelectorOpen
|
||||||
|
|
||||||
sourceComponent: PanelWindow {
|
sourceComponent: RegionSelection {
|
||||||
id: panelWindow
|
|
||||||
screen: regionSelectorLoader.modelData
|
screen: regionSelectorLoader.modelData
|
||||||
visible: false
|
onDismiss: root.dismiss()
|
||||||
WlrLayershell.namespace: "quickshell:regionSelector"
|
action: root.action
|
||||||
WlrLayershell.layer: WlrLayer.Overlay
|
selectionMode: root.selectionMode
|
||||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
|
||||||
exclusionMode: ExclusionMode.Ignore
|
|
||||||
anchors {
|
|
||||||
left: true
|
|
||||||
right: true
|
|
||||||
top: true
|
|
||||||
bottom: true
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property HyprlandMonitor hyprlandMonitor: Hyprland.monitorFor(screen)
|
|
||||||
readonly property real monitorScale: hyprlandMonitor.scale
|
|
||||||
readonly property real monitorOffsetX: hyprlandMonitor.x
|
|
||||||
readonly property real monitorOffsetY: hyprlandMonitor.y
|
|
||||||
property int activeWorkspaceId: hyprlandMonitor.activeWorkspace?.id ?? 0
|
|
||||||
property string screenshotPath: `${root.screenshotDir}/image-${screen.name}`
|
|
||||||
property real dragStartX: 0
|
|
||||||
property real dragStartY: 0
|
|
||||||
property real draggingX: 0
|
|
||||||
property real draggingY: 0
|
|
||||||
property real dragDiffX: 0
|
|
||||||
property real dragDiffY: 0
|
|
||||||
property bool draggedAway: (dragDiffX !== 0 || dragDiffY !== 0)
|
|
||||||
property bool dragging: false
|
|
||||||
property var mouseButton: null
|
|
||||||
property var imageRegions: []
|
|
||||||
readonly property list<var> windowRegions: filterWindowRegionsByLayers(
|
|
||||||
root.windows.filter(w => w.workspace.id === panelWindow.activeWorkspaceId),
|
|
||||||
panelWindow.layerRegions
|
|
||||||
).map(window => {
|
|
||||||
return {
|
|
||||||
at: [window.at[0] - panelWindow.monitorOffsetX, window.at[1] - panelWindow.monitorOffsetY],
|
|
||||||
size: [window.size[0], window.size[1]],
|
|
||||||
class: window.class,
|
|
||||||
title: window.title,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
readonly property list<var> layerRegions: {
|
|
||||||
const layersOfThisMonitor = root.layers[panelWindow.hyprlandMonitor.name]
|
|
||||||
const topLayers = layersOfThisMonitor?.levels["2"]
|
|
||||||
if (!topLayers) return [];
|
|
||||||
const nonBarTopLayers = topLayers
|
|
||||||
.filter(layer => !(layer.namespace.includes(":bar") || layer.namespace.includes(":verticalBar") || layer.namespace.includes(":dock")))
|
|
||||||
.map(layer => {
|
|
||||||
return {
|
|
||||||
at: [layer.x, layer.y],
|
|
||||||
size: [layer.w, layer.h],
|
|
||||||
namespace: layer.namespace,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const offsetAdjustedLayers = nonBarTopLayers.map(layer => {
|
|
||||||
return {
|
|
||||||
at: [layer.at[0] - panelWindow.monitorOffsetX, layer.at[1] - panelWindow.monitorOffsetY],
|
|
||||||
size: layer.size,
|
|
||||||
namespace: layer.namespace,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return offsetAdjustedLayers;
|
|
||||||
}
|
|
||||||
|
|
||||||
property real targetedRegionX: -1
|
|
||||||
property real targetedRegionY: -1
|
|
||||||
property real targetedRegionWidth: 0
|
|
||||||
property real targetedRegionHeight: 0
|
|
||||||
|
|
||||||
function intersectionOverUnion(regionA, regionB) {
|
|
||||||
// region: { at: [x, y], size: [w, h] }
|
|
||||||
const ax1 = regionA.at[0], ay1 = regionA.at[1];
|
|
||||||
const ax2 = ax1 + regionA.size[0], ay2 = ay1 + regionA.size[1];
|
|
||||||
const bx1 = regionB.at[0], by1 = regionB.at[1];
|
|
||||||
const bx2 = bx1 + regionB.size[0], by2 = by1 + regionB.size[1];
|
|
||||||
|
|
||||||
const interX1 = Math.max(ax1, bx1);
|
|
||||||
const interY1 = Math.max(ay1, by1);
|
|
||||||
const interX2 = Math.min(ax2, bx2);
|
|
||||||
const interY2 = Math.min(ay2, by2);
|
|
||||||
|
|
||||||
const interArea = Math.max(0, interX2 - interX1) * Math.max(0, interY2 - interY1);
|
|
||||||
const areaA = (ax2 - ax1) * (ay2 - ay1);
|
|
||||||
const areaB = (bx2 - bx1) * (by2 - by1);
|
|
||||||
const unionArea = areaA + areaB - interArea;
|
|
||||||
|
|
||||||
return unionArea > 0 ? interArea / unionArea : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterOverlappingImageRegions(regions) {
|
|
||||||
let keep = [];
|
|
||||||
let removed = new Set();
|
|
||||||
for (let i = 0; i < regions.length; ++i) {
|
|
||||||
if (removed.has(i)) continue;
|
|
||||||
let regionA = regions[i];
|
|
||||||
for (let j = i + 1; j < regions.length; ++j) {
|
|
||||||
if (removed.has(j)) continue;
|
|
||||||
let regionB = regions[j];
|
|
||||||
if (intersectionOverUnion(regionA, regionB) > 0) {
|
|
||||||
// Compare areas
|
|
||||||
let areaA = regionA.size[0] * regionA.size[1];
|
|
||||||
let areaB = regionB.size[0] * regionB.size[1];
|
|
||||||
if (areaA <= areaB) {
|
|
||||||
removed.add(j);
|
|
||||||
} else {
|
|
||||||
removed.add(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let i = 0; i < regions.length; ++i) {
|
|
||||||
if (!removed.has(i)) keep.push(regions[i]);
|
|
||||||
}
|
|
||||||
return keep;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterWindowRegionsByLayers(windowRegions, layerRegions) {
|
|
||||||
return windowRegions.filter(windowRegion => {
|
|
||||||
for (let i = 0; i < layerRegions.length; ++i) {
|
|
||||||
if (intersectionOverUnion(windowRegion, layerRegions[i]) > 0)
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterImageRegions(regions, windowRegions, threshold = 0.1) {
|
|
||||||
// Remove image regions that overlap too much with any window region
|
|
||||||
let filtered = regions.filter(region => {
|
|
||||||
for (let i = 0; i < windowRegions.length; ++i) {
|
|
||||||
if (intersectionOverUnion(region, windowRegions[i]) > threshold)
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
// Remove overlapping image regions, keep only the smaller one
|
|
||||||
return filterOverlappingImageRegions(filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTargetedRegion(x, y) {
|
|
||||||
// Image regions
|
|
||||||
const clickedRegion = panelWindow.imageRegions.find(region => {
|
|
||||||
return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1];
|
|
||||||
});
|
|
||||||
if (clickedRegion) {
|
|
||||||
panelWindow.targetedRegionX = clickedRegion.at[0];
|
|
||||||
panelWindow.targetedRegionY = clickedRegion.at[1];
|
|
||||||
panelWindow.targetedRegionWidth = clickedRegion.size[0];
|
|
||||||
panelWindow.targetedRegionHeight = clickedRegion.size[1];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer regions
|
|
||||||
const clickedLayer = panelWindow.layerRegions.find(region => {
|
|
||||||
return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1];
|
|
||||||
});
|
|
||||||
if (clickedLayer) {
|
|
||||||
panelWindow.targetedRegionX = clickedLayer.at[0];
|
|
||||||
panelWindow.targetedRegionY = clickedLayer.at[1];
|
|
||||||
panelWindow.targetedRegionWidth = clickedLayer.size[0];
|
|
||||||
panelWindow.targetedRegionHeight = clickedLayer.size[1];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Window regions
|
|
||||||
const clickedWindow = panelWindow.windowRegions.find(region => {
|
|
||||||
return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1];
|
|
||||||
});
|
|
||||||
if (clickedWindow) {
|
|
||||||
panelWindow.targetedRegionX = clickedWindow.at[0];
|
|
||||||
panelWindow.targetedRegionY = clickedWindow.at[1];
|
|
||||||
panelWindow.targetedRegionWidth = clickedWindow.size[0];
|
|
||||||
panelWindow.targetedRegionHeight = clickedWindow.size[1];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
panelWindow.targetedRegionX = -1;
|
|
||||||
panelWindow.targetedRegionY = -1;
|
|
||||||
panelWindow.targetedRegionWidth = 0;
|
|
||||||
panelWindow.targetedRegionHeight = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
property real regionWidth: Math.abs(draggingX - dragStartX)
|
|
||||||
property real regionHeight: Math.abs(draggingY - dragStartY)
|
|
||||||
property real regionX: Math.min(dragStartX, draggingX)
|
|
||||||
property real regionY: Math.min(dragStartY, draggingY)
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: screenshotProcess
|
|
||||||
running: true
|
|
||||||
command: ["bash", "-c", `mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}' && grim -o '${StringUtils.shellSingleQuoteEscape(panelWindow.screen.name)}' '${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)}'`]
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
panelWindow.visible = true;
|
|
||||||
imageDetectionProcess.running = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: imageDetectionProcess
|
|
||||||
command: ["bash", "-c", `${Directories.scriptPath}/images/find-regions-venv.sh `
|
|
||||||
+ `--hyprctl `
|
|
||||||
+ `--image '${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)}' `
|
|
||||||
+ `--max-width ${Math.round(panelWindow.screen.width * root.falsePositivePreventionRatio)} `
|
|
||||||
+ `--max-height ${Math.round(panelWindow.screen.height * root.falsePositivePreventionRatio)} `]
|
|
||||||
stdout: StdioCollector {
|
|
||||||
id: imageDimensionCollector
|
|
||||||
onStreamFinished: {
|
|
||||||
imageRegions = filterImageRegions(
|
|
||||||
JSON.parse(imageDimensionCollector.text),
|
|
||||||
panelWindow.windowRegions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: snipProc
|
|
||||||
function snip() {
|
|
||||||
if (panelWindow.regionWidth <= 0 || panelWindow.regionHeight <= 0) {
|
|
||||||
console.warn("Invalid region size, skipping snip.");
|
|
||||||
root.dismiss();
|
|
||||||
}
|
|
||||||
snipProc.startDetached();
|
|
||||||
root.dismiss();
|
|
||||||
}
|
|
||||||
command: ["bash", "-c",
|
|
||||||
`magick ${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)} `
|
|
||||||
+ `-crop ${panelWindow.regionWidth * panelWindow.monitorScale}x${panelWindow.regionHeight * panelWindow.monitorScale}+${panelWindow.regionX * panelWindow.monitorScale}+${panelWindow.regionY * panelWindow.monitorScale} - `
|
|
||||||
+ `| ${panelWindow.mouseButton === Qt.LeftButton ? "wl-copy" : "swappy -f -"}`]
|
|
||||||
}
|
|
||||||
|
|
||||||
ScreencopyView {
|
|
||||||
anchors.fill: parent
|
|
||||||
live: false
|
|
||||||
captureSource: panelWindow.screen
|
|
||||||
|
|
||||||
focus: panelWindow.visible
|
|
||||||
Keys.onPressed: (event) => { // Esc to close
|
|
||||||
if (event.key === Qt.Key_Escape) {
|
|
||||||
root.dismiss();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
cursorShape: Qt.CrossCursor
|
|
||||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
||||||
hoverEnabled: true
|
|
||||||
|
|
||||||
// Controls
|
|
||||||
onPressed: mouse => {
|
|
||||||
panelWindow.dragStartX = mouse.x;
|
|
||||||
panelWindow.dragStartY = mouse.y;
|
|
||||||
panelWindow.draggingX = mouse.x;
|
|
||||||
panelWindow.draggingY = mouse.y;
|
|
||||||
panelWindow.dragging = true;
|
|
||||||
panelWindow.mouseButton = mouse.button;
|
|
||||||
}
|
|
||||||
onReleased: mouse => {
|
|
||||||
// Detect if it was a click
|
|
||||||
|
|
||||||
// Image regions
|
|
||||||
if (panelWindow.draggingX === panelWindow.dragStartX && panelWindow.draggingY === panelWindow.dragStartY) {
|
|
||||||
if (panelWindow.targetedRegionX >= 0 && panelWindow.targetedRegionY >= 0) {
|
|
||||||
panelWindow.regionX = panelWindow.targetedRegionX;
|
|
||||||
panelWindow.regionY = panelWindow.targetedRegionY;
|
|
||||||
panelWindow.regionWidth = panelWindow.targetedRegionWidth;
|
|
||||||
panelWindow.regionHeight = panelWindow.targetedRegionHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
snipProc.snip();
|
|
||||||
}
|
|
||||||
onPositionChanged: mouse => {
|
|
||||||
if (panelWindow.dragging) {
|
|
||||||
panelWindow.draggingX = mouse.x;
|
|
||||||
panelWindow.draggingY = mouse.y;
|
|
||||||
panelWindow.dragDiffX = mouse.x - panelWindow.dragStartX;
|
|
||||||
panelWindow.dragDiffY = mouse.y - panelWindow.dragStartY;
|
|
||||||
}
|
|
||||||
panelWindow.updateTargetedRegion(mouse.x, mouse.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overlay to darken screen
|
|
||||||
Rectangle { // Base
|
|
||||||
id: darkenOverlay
|
|
||||||
z: 1
|
|
||||||
anchors {
|
|
||||||
left: parent.left
|
|
||||||
top: parent.top
|
|
||||||
leftMargin: panelWindow.regionX - darkenOverlay.border.width
|
|
||||||
topMargin: panelWindow.regionY - darkenOverlay.border.width
|
|
||||||
}
|
|
||||||
width: panelWindow.regionWidth + darkenOverlay.border.width * 2
|
|
||||||
height: panelWindow.regionHeight + darkenOverlay.border.width * 2
|
|
||||||
color: "transparent"
|
|
||||||
// border.color: root.selectionBorderColor
|
|
||||||
border.color: root.overlayColor
|
|
||||||
border.width: Math.max(panelWindow.width, panelWindow.height)
|
|
||||||
radius: root.standardRounding
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
id: selectionBorder
|
|
||||||
z: 1
|
|
||||||
anchors {
|
|
||||||
left: parent.left
|
|
||||||
top: parent.top
|
|
||||||
leftMargin: panelWindow.regionX
|
|
||||||
topMargin: panelWindow.regionY
|
|
||||||
}
|
|
||||||
width: panelWindow.regionWidth
|
|
||||||
height: panelWindow.regionHeight
|
|
||||||
color: "transparent"
|
|
||||||
border.color: root.selectionBorderColor
|
|
||||||
border.width: 2
|
|
||||||
// radius: root.standardRounding
|
|
||||||
radius: 0 // TODO: figure out how to make the overlay thing work with rounding
|
|
||||||
}
|
|
||||||
StyledText {
|
|
||||||
z: 2
|
|
||||||
anchors {
|
|
||||||
top: selectionBorder.bottom
|
|
||||||
right: selectionBorder.right
|
|
||||||
margins: 8
|
|
||||||
}
|
|
||||||
color: root.selectionBorderColor
|
|
||||||
text: `${Math.round(panelWindow.regionWidth)} x ${Math.round(panelWindow.regionHeight)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instructions
|
|
||||||
Rectangle {
|
|
||||||
z: 9999
|
|
||||||
anchors {
|
|
||||||
top: parent.top
|
|
||||||
horizontalCenter: parent.horizontalCenter
|
|
||||||
topMargin: (Appearance.sizes.barHeight - implicitHeight) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
opacity: panelWindow.dragging ? 0 : 1
|
|
||||||
visible: opacity > 0
|
|
||||||
Behavior on opacity {
|
|
||||||
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
color: root.genericContentColor
|
|
||||||
radius: 10
|
|
||||||
border.width: 1
|
|
||||||
border.color: Appearance.m3colors.m3outlineVariant
|
|
||||||
implicitWidth: instructionsRow.implicitWidth + 10 * 2
|
|
||||||
implicitHeight: instructionsRow.implicitHeight + 5 * 2
|
|
||||||
|
|
||||||
Row {
|
|
||||||
id: instructionsRow
|
|
||||||
anchors.centerIn: parent
|
|
||||||
spacing: 4
|
|
||||||
MaterialSymbol {
|
|
||||||
id: screenshotRegionIcon
|
|
||||||
// anchors.centerIn: parent
|
|
||||||
iconSize: Appearance.font.pixelSize.larger
|
|
||||||
text: "screenshot_region"
|
|
||||||
color: root.genericContentForeground
|
|
||||||
}
|
|
||||||
StyledText {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: Translation.tr("Drag or click a region • LMB: Copy • RMB: Edit")
|
|
||||||
color: root.genericContentForeground
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Window regions
|
|
||||||
Repeater {
|
|
||||||
model: ScriptModel {
|
|
||||||
values: panelWindow.windowRegions
|
|
||||||
}
|
|
||||||
delegate: TargetRegion {
|
|
||||||
z: 2
|
|
||||||
required property var modelData
|
|
||||||
showIcon: true
|
|
||||||
targeted: !panelWindow.draggedAway &&
|
|
||||||
(panelWindow.targetedRegionX === modelData.at[0]
|
|
||||||
&& panelWindow.targetedRegionY === modelData.at[1]
|
|
||||||
&& panelWindow.targetedRegionWidth === modelData.size[0]
|
|
||||||
&& panelWindow.targetedRegionHeight === modelData.size[1])
|
|
||||||
|
|
||||||
opacity: panelWindow.draggedAway ? 0 : 1
|
|
||||||
visible: opacity > 0
|
|
||||||
Behavior on opacity {
|
|
||||||
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
x: modelData.at[0]
|
|
||||||
y: modelData.at[1]
|
|
||||||
width: modelData.size[0]
|
|
||||||
height: modelData.size[1]
|
|
||||||
borderColor: root.windowBorderColor
|
|
||||||
fillColor: targeted ? root.windowFillColor : "transparent"
|
|
||||||
border.width: targeted ? 4 : 2
|
|
||||||
text: `${modelData.class}`
|
|
||||||
radius: Appearance.rounding.windowRounding
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer regions
|
|
||||||
Repeater {
|
|
||||||
model: ScriptModel {
|
|
||||||
values: panelWindow.layerRegions
|
|
||||||
}
|
|
||||||
delegate: TargetRegion {
|
|
||||||
z: 3
|
|
||||||
required property var modelData
|
|
||||||
targeted: !panelWindow.draggedAway &&
|
|
||||||
(panelWindow.targetedRegionX === modelData.at[0]
|
|
||||||
&& panelWindow.targetedRegionY === modelData.at[1]
|
|
||||||
&& panelWindow.targetedRegionWidth === modelData.size[0]
|
|
||||||
&& panelWindow.targetedRegionHeight === modelData.size[1])
|
|
||||||
|
|
||||||
opacity: panelWindow.draggedAway ? 0 : 1
|
|
||||||
visible: opacity > 0
|
|
||||||
Behavior on opacity {
|
|
||||||
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
x: modelData.at[0]
|
|
||||||
y: modelData.at[1]
|
|
||||||
width: modelData.size[0]
|
|
||||||
height: modelData.size[1]
|
|
||||||
borderColor: root.windowBorderColor
|
|
||||||
fillColor: targeted ? root.windowFillColor : "transparent"
|
|
||||||
border.width: targeted ? 4 : 2
|
|
||||||
text: `${modelData.namespace}`
|
|
||||||
radius: Appearance.rounding.windowRounding
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image regions
|
|
||||||
Repeater {
|
|
||||||
model: ScriptModel {
|
|
||||||
values: Config.options.screenshotTool.showContentRegions ? panelWindow.imageRegions : []
|
|
||||||
}
|
|
||||||
delegate: TargetRegion {
|
|
||||||
z: 4
|
|
||||||
required property var modelData
|
|
||||||
targeted: !panelWindow.draggedAway &&
|
|
||||||
(panelWindow.targetedRegionX === modelData.at[0]
|
|
||||||
&& panelWindow.targetedRegionY === modelData.at[1]
|
|
||||||
&& panelWindow.targetedRegionWidth === modelData.size[0]
|
|
||||||
&& panelWindow.targetedRegionHeight === modelData.size[1])
|
|
||||||
|
|
||||||
opacity: panelWindow.draggedAway ? 0 : 1
|
|
||||||
visible: opacity > 0
|
|
||||||
Behavior on opacity {
|
|
||||||
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
x: modelData.at[0]
|
|
||||||
y: modelData.at[1]
|
|
||||||
width: modelData.size[0]
|
|
||||||
height: modelData.size[1]
|
|
||||||
borderColor: root.imageBorderColor
|
|
||||||
fillColor: targeted ? root.imageFillColor : "transparent"
|
|
||||||
border.width: targeted ? 4 : 2
|
|
||||||
text: "Content region"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function screenshot() {
|
function screenshot() {
|
||||||
|
root.action = RegionSelection.SnipAction.Copy
|
||||||
|
root.selectionMode = RegionSelection.SelectionMode.RectCorners
|
||||||
|
GlobalStates.regionSelectorOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
root.action = RegionSelection.SnipAction.Search
|
||||||
|
root.selectionMode = RegionSelection.SelectionMode.Circle
|
||||||
GlobalStates.regionSelectorOpen = true
|
GlobalStates.regionSelectorOpen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,14 +58,19 @@ Scope {
|
|||||||
function screenshot() {
|
function screenshot() {
|
||||||
root.screenshot()
|
root.screenshot()
|
||||||
}
|
}
|
||||||
|
function search() {
|
||||||
|
root.search()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GlobalShortcut {
|
GlobalShortcut {
|
||||||
name: "regionScreenshot"
|
name: "regionScreenshot"
|
||||||
description: "Takes a screenshot of the selected region"
|
description: "Takes a screenshot of the selected region"
|
||||||
|
onPressed: root.screenshot()
|
||||||
onPressed: {
|
}
|
||||||
root.screenshot()
|
GlobalShortcut {
|
||||||
}
|
name: "regionSearch"
|
||||||
|
description: "Searches the selected region"
|
||||||
|
onPressed: root.search()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
import qs
|
||||||
|
import qs.modules.common
|
||||||
|
import qs.modules.common.functions
|
||||||
|
import qs.modules.common.widgets
|
||||||
|
import qs.services
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Qt5Compat.GraphicalEffects
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import Quickshell.Widgets
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: regionRect
|
||||||
|
property bool showIcon: false
|
||||||
|
property bool targeted: false
|
||||||
|
property color borderColor
|
||||||
|
property color fillColor: "transparent"
|
||||||
|
property string text: ""
|
||||||
|
property real textPadding: 10
|
||||||
|
z: 2
|
||||||
|
color: fillColor
|
||||||
|
border.color: borderColor
|
||||||
|
border.width: targeted ? 3 : 1
|
||||||
|
radius: root.standardRounding
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: regionLabelBackground
|
||||||
|
property real verticalPadding: 5
|
||||||
|
property real horizontalPadding: 10
|
||||||
|
radius: 10
|
||||||
|
color: root.genericContentColor
|
||||||
|
border.width: 1
|
||||||
|
border.color: Appearance.m3colors.m3outlineVariant
|
||||||
|
anchors {
|
||||||
|
top: parent.top
|
||||||
|
left: parent.left
|
||||||
|
topMargin: regionRect.textPadding
|
||||||
|
leftMargin: regionRect.textPadding
|
||||||
|
}
|
||||||
|
implicitWidth: regionInfoRow.implicitWidth + horizontalPadding * 2
|
||||||
|
implicitHeight: regionInfoRow.implicitHeight + verticalPadding * 2
|
||||||
|
Row {
|
||||||
|
id: regionInfoRow
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: regionIconLoader
|
||||||
|
active: regionRect.showIcon
|
||||||
|
visible: active
|
||||||
|
sourceComponent: IconImage {
|
||||||
|
implicitSize: Appearance.font.pixelSize.larger
|
||||||
|
source: Quickshell.iconPath(AppSearch.guessIcon(regionRect.text), "image-missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: regionText
|
||||||
|
text: regionRect.text
|
||||||
|
color: root.genericContentForeground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user