From 4ea7401190ad744e6cf105d98fed5cc34c92c08a Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:03:03 +0200 Subject: [PATCH] circle to search --- dots/.config/hypr/hyprland/keybinds.conf | 1 + .../quickshell/ii/modules/common/Config.qml | 2 + .../regionSelector/CircleSelectionDetails.qml | 48 ++ .../RectCornersSelectionDetails.qml | 84 +++ .../regionSelector/RegionSelection.qml | 563 +++++++++++++++++ .../modules/regionSelector/RegionSelector.qml | 565 +----------------- .../modules/regionSelector/TargetRegion.qml | 69 +++ 7 files changed, 790 insertions(+), 542 deletions(-) create mode 100644 dots/.config/quickshell/ii/modules/regionSelector/CircleSelectionDetails.qml create mode 100644 dots/.config/quickshell/ii/modules/regionSelector/RectCornersSelectionDetails.qml create mode 100644 dots/.config/quickshell/ii/modules/regionSelector/RegionSelection.qml create mode 100644 dots/.config/quickshell/ii/modules/regionSelector/TargetRegion.qml diff --git a/dots/.config/hypr/hyprland/keybinds.conf b/dots/.config/hypr/hyprland/keybinds.conf index 38fc05caf..74343d85b 100644 --- a/dots/.config/hypr/hyprland/keybinds.conf +++ b/dots/.config/hypr/hyprland/keybinds.conf @@ -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) 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, A, global, quickshell:regionSearch # Circle to Search # OCR 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 diff --git a/dots/.config/quickshell/ii/modules/common/Config.qml b/dots/.config/quickshell/ii/modules/common/Config.qml index 44b262b96..f93171584 100644 --- a/dots/.config/quickshell/ii/modules/common/Config.qml +++ b/dots/.config/quickshell/ii/modules/common/Config.qml @@ -355,6 +355,8 @@ Singleton { property JsonObject search: JsonObject { property int nonAppResultDelay: 30 // This prevents lagging when typing 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 excludedSites: ["quora.com", "facebook.com"] property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird. property JsonObject prefix: JsonObject { diff --git a/dots/.config/quickshell/ii/modules/regionSelector/CircleSelectionDetails.qml b/dots/.config/quickshell/ii/modules/regionSelector/CircleSelectionDetails.qml new file mode 100644 index 000000000..bea353d1f --- /dev/null +++ b/dots/.config/quickshell/ii/modules/regionSelector/CircleSelectionDetails.qml @@ -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 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 + } + } + } + +} diff --git a/dots/.config/quickshell/ii/modules/regionSelector/RectCornersSelectionDetails.qml b/dots/.config/quickshell/ii/modules/regionSelector/RectCornersSelectionDetails.qml new file mode 100644 index 000000000..b4e417c8d --- /dev/null +++ b/dots/.config/quickshell/ii/modules/regionSelector/RectCornersSelectionDetails.qml @@ -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 + } +} diff --git a/dots/.config/quickshell/ii/modules/regionSelector/RegionSelection.qml b/dots/.config/quickshell/ii/modules/regionSelector/RegionSelection.qml new file mode 100644 index 000000000..32549fbc0 --- /dev/null +++ b/dots/.config/quickshell/ii/modules/regionSelector/RegionSelection.qml @@ -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 points: [] + property var mouseButton: null + property var imageRegions: [] + readonly property list 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 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" + } + } + } + } +} diff --git a/dots/.config/quickshell/ii/modules/regionSelector/RegionSelector.qml b/dots/.config/quickshell/ii/modules/regionSelector/RegionSelector.qml index 6eecc365e..336c9fd6b 100644 --- a/dots/.config/quickshell/ii/modules/regionSelector/RegionSelector.qml +++ b/dots/.config/quickshell/ii/modules/regionSelector/RegionSelector.qml @@ -16,83 +16,13 @@ import Quickshell.Hyprland Scope { 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() { GlobalStates.regionSelectorOpen = false } - component TargetRegion: 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 - } - } - } - } + property var action: RegionSelection.SnipAction.Copy + property var selectionMode: RegionSelection.SelectionMode.RectCorners Variants { model: Quickshell.screens @@ -101,478 +31,24 @@ Scope { required property var modelData active: GlobalStates.regionSelectorOpen - sourceComponent: PanelWindow { - id: panelWindow + sourceComponent: RegionSelection { screen: regionSelectorLoader.modelData - 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 - } - - 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 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 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" - } - } - } - } + onDismiss: root.dismiss() + action: root.action + selectionMode: root.selectionMode } } } 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 } @@ -582,14 +58,19 @@ Scope { function screenshot() { root.screenshot() } + function search() { + root.search() + } } GlobalShortcut { name: "regionScreenshot" 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() } } diff --git a/dots/.config/quickshell/ii/modules/regionSelector/TargetRegion.qml b/dots/.config/quickshell/ii/modules/regionSelector/TargetRegion.qml new file mode 100644 index 000000000..0fc2774fb --- /dev/null +++ b/dots/.config/quickshell/ii/modules/regionSelector/TargetRegion.qml @@ -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 + } + } + } +} \ No newline at end of file