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.imageSearch.imageSearchEngineBaseUrl property string fileUploadApiEndpoint: "https://uguu.se/upload" property color overlayColor: "#88111111" property color genericContentColor: Qt.alpha(root.overlayColor, 0.9) property color genericContentForeground: "#ddffffff" property color brightText: Appearance.m3colors.darkmode ? Appearance.colors.colOnLayer0 : Appearance.colors.colLayer0 property color brightSecondary: Appearance.m3colors.darkmode ? Appearance.colors.colSecondary : Appearance.colors.colOnSecondary property color brightTertiary: Appearance.m3colors.darkmode ? Appearance.colors.colTertiary : Qt.lighter(Appearance.colors.colPrimary) property color selectionBorderColor: ColorUtils.mix(brightText, brightSecondary, 0.5) property color selectionFillColor: "#33ffffff" property color windowBorderColor: brightSecondary property color windowFillColor: ColorUtils.transparentize(windowBorderColor, 0.85) property color imageBorderColor: brightTertiary property color imageFillColor: ColorUtils.transparentize(imageBorderColor, 0.85) property color onBorderColor: "#ff000000" 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 bool isCircleSelection: (root.selectionMode === RegionSelection.SelectionMode.Circle) property bool enableWindowRegions: Config.options.regionSelector.targetRegions.windows && !isCircleSelection property bool enableLayerRegions: Config.options.regionSelector.targetRegions.layers && !isCircleSelection property bool enableContentRegions: Config.options.regionSelector.targetRegions.content property real targetRegionOpacity: Config.options.regionSelector.targetRegions.opacity property bool contentRegionOpacity: Config.options.regionSelector.targetRegions.contentRegionOpacity property real targetedRegionX: -1 property real targetedRegionY: -1 property real targetedRegionWidth: 0 property real targetedRegionHeight: 0 function targetedRegionValid() { return (root.targetedRegionX >= 0 && root.targetedRegionY >= 0) } function setRegionToTargeted() { root.regionX = root.targetedRegionX; root.regionY = root.targetedRegionY; root.regionWidth = root.targetedRegionWidth; root.regionHeight = root.targetedRegionHeight; } 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(); } // Clamp region to screen bounds root.regionX = Math.max(0, Math.min(root.regionX, root.screen.width - root.regionWidth)); root.regionY = Math.max(0, Math.min(root.regionY, root.screen.height - root.regionHeight)); root.regionWidth = Math.max(0, Math.min(root.regionWidth, root.screen.width - root.regionX)); root.regionHeight = Math.max(0, Math.min(root.regionHeight, root.screen.height - root.regionY)); // 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 padding = Config.options.regionSelector.circle.padding + Config.options.regionSelector.circle.strokeWidth / 2; const dragPoints = (root.points.length > 0) ? root.points : [{ x: mouseArea.mouseX, y: mouseArea.mouseY }]; const maxX = Math.max(...dragPoints.map(p => p.x)); const minX = Math.min(...dragPoints.map(p => p.x)); const maxY = Math.max(...dragPoints.map(p => p.y)); const minY = Math.min(...dragPoints.map(p => p.y)); root.regionX = minX - padding; root.regionY = minY - padding; root.regionWidth = maxX - minX + padding * 2; root.regionHeight = maxY - minY + padding * 2; if (root.targetedRegionValid() && imageRegions.find(region => { return (region.at[0] === root.targetedRegionX && region.at[1] === root.targetedRegionY && region.size[0] === root.targetedRegionWidth && region.size[1] === root.targetedRegionHeight) })) { root.setRegionToTargeted(); } } // Detect if it was a click -> Try to select targeted region else if (root.draggingX === root.dragStartX && root.draggingY === root.dragStartY) { if (root.targetedRegionValid()) { root.setRegionToTargeted(); } } root.snip(); } onPositionChanged: (mouse) => { root.updateTargetedRegion(mouse.x, mouse.y); 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 }); } 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 } } // Window regions Repeater { model: ScriptModel { values: root.enableWindowRegions ? 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]) colBackground: root.genericContentColor colForeground: root.genericContentForeground opacity: root.draggedAway ? 0 : root.targetRegionOpacity 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.enableLayerRegions ? 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]) colBackground: root.genericContentColor colForeground: root.genericContentForeground opacity: root.draggedAway ? 0 : root.targetRegionOpacity 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 } } // Content regions Repeater { model: ScriptModel { values: root.enableContentRegions ? 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]) colBackground: root.genericContentColor colForeground: root.genericContentForeground opacity: root.draggedAway ? 0 : root.contentRegionOpacity 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: Translation.tr("Content region") } } // Options toolbar Toolbar { id: toolbar z: 9999 anchors { horizontalCenter: parent.horizontalCenter bottom: parent.bottom bottomMargin: -height } opacity: 0 Connections { target: root function onVisibleChanged() { if (!visible) return; toolbar.anchors.bottomMargin = 8; toolbar.opacity = 1; } } Behavior on opacity { animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) } Behavior on anchors.bottomMargin { animation: Appearance.animation.elementMove.numberAnimation.createObject(this) } MaterialCookie { Layout.fillHeight: true Layout.leftMargin: 2 Layout.rightMargin: 2 implicitSize: 36 // Intentionally smaller because this one is brighter than others sides: 10 amplitude: implicitSize / 44 color: Appearance.colors.colPrimary MaterialSymbol { anchors.centerIn: parent iconSize: 22 color: Appearance.colors.colOnPrimary animateChange: true text: switch (root.action) { case RegionSelection.SnipAction.Copy: case RegionSelection.SnipAction.Edit: return "content_cut"; case RegionSelection.SnipAction.Search: return "image_search"; default: return ""; } } } IconAndTextToolbarButton { iconText: "activity_zone" text: Translation.tr("Rect") toggled: root.selectionMode === RegionSelection.SelectionMode.RectCorners onClicked: root.selectionMode = RegionSelection.SelectionMode.RectCorners } IconAndTextToolbarButton { iconText: "gesture" text: Translation.tr("Circle") toggled: root.selectionMode === RegionSelection.SelectionMode.Circle onClicked: root.selectionMode = RegionSelection.SelectionMode.Circle } IconToolbarButton { text: "close" colBackground: Appearance.colors.colLayer3 onClicked: root.dismiss(); } } } } }