From 604abfea96bc284874a5cb1084040a15523d911e Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Fri, 27 Jun 2025 22:10:45 +0200 Subject: [PATCH] screenshot: use quickshell, ksnip -> swappy --- .config/hypr/hyprland/keybinds.conf | 3 +- .../quickshell/modules/common/Directories.qml | 3 +- .config/quickshell/screenshot.qml | 415 ++++++++++++++++++ .../quickshell/scripts/images/find_regions.py | 120 +++++ .../illogical-impulse-screencapture/PKGBUILD | 2 +- 5 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 .config/quickshell/screenshot.qml create mode 100755 .config/quickshell/scripts/images/find_regions.py diff --git a/.config/hypr/hyprland/keybinds.conf b/.config/hypr/hyprland/keybinds.conf index 2a42f4f6e..c9053a52c 100644 --- a/.config/hypr/hyprland/keybinds.conf +++ b/.config/hypr/hyprland/keybinds.conf @@ -50,8 +50,7 @@ bind = Ctrl+Super, R, exec, killall ags agsv1 gjs ydotool qs quickshell; qs & # # Screenshot, Record, OCR, Color picker, Clipboard history bindd = Super, V, Copy clipboard history entry, exec, qs ipc call TEST_ALIVE || pkill fuzzel || cliphist list | fuzzel --match-mode fzf --dmenu | cliphist decode | wl-copy # [hidden] Clipboard history >> clipboard (fallback) bindd = Super, Period, Copy an emoji, exec, qs ipc call TEST_ALIVE || pkill fuzzel || ~/.config/hypr/hyprland/scripts/fuzzel-emoji.sh copy # [hidden] Emoji >> clipboard (fallback) -bindd = Super+Shift, S, Screen snip, exec, pidof slurp || hyprshot --freeze --clipboard-only --mode region --silent # Screen snip >> clipboard -bindd = Super+Shift+Alt, S, Screen snip and annotate, exec, pidof slurp || grim -g "$(slurp)" - | ksnip -e - # Screen snip and annotate +bindd = Super+Shift, S, Screen snip, exec, qs -p ~/.config/quickshell/screenshot.qml # Screen snip # 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/.config/quickshell/modules/common/Directories.qml b/.config/quickshell/modules/common/Directories.qml index 05741f204..59d4335b4 100644 --- a/.config/quickshell/modules/common/Directories.qml +++ b/.config/quickshell/modules/common/Directories.qml @@ -16,6 +16,7 @@ Singleton { readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[0] // Other dirs used by the shell, without "file://" + property string scriptPath: FileUtils.trimFileProtocol(`${Directories.config}/quickshell/scripts`) property string favicons: FileUtils.trimFileProtocol(`${Directories.cache}/media/favicons`) property string coverArt: FileUtils.trimFileProtocol(`${Directories.cache}/media/coverart`) property string booruPreviews: FileUtils.trimFileProtocol(`${Directories.cache}/media/boorus`) @@ -29,7 +30,7 @@ Singleton { property string notificationsPath: FileUtils.trimFileProtocol(`${Directories.cache}/notifications/notifications.json`) property string generatedMaterialThemePath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/colors.json`) property string cliphistDecode: FileUtils.trimFileProtocol(`/tmp/quickshell/media/cliphist`) - property string wallpaperSwitchScriptPath: FileUtils.trimFileProtocol(`${Directories.config}/quickshell/scripts/colors/switchwall.sh`) + property string wallpaperSwitchScriptPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/colors/switchwall.sh`) // Cleanup on init Component.onCompleted: { Quickshell.execDetached(["bash", "-c", `mkdir -p '${shellConfig}'`]) diff --git a/.config/quickshell/screenshot.qml b/.config/quickshell/screenshot.qml new file mode 100644 index 000000000..990334614 --- /dev/null +++ b/.config/quickshell/screenshot.qml @@ -0,0 +1,415 @@ +//@ pragma UseQApplication +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic + +// Adjust this to make it smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + +pragma ComponentBehavior: "Bound" +import "./modules/common/" +import "./modules/common/widgets" +import "./modules/common/functions/string_utils.js" as StringUtils +import QtQuick +import QtQuick.Effects +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland +import "./services/" + +ShellRoot { + id: root + property string screenshotDir: "/tmp/quickshell/media/screenshot" + 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: "#ddd4ecff" + property color windowFillColor: "#33d4ecff" + property color imageBorderColor: "#ddf1d1ff" + property color imageFillColor: "#33f1d1ff" + property color onBorderColor: "#ff000000" + property real standardRounding: 4 + readonly property var windows: HyprlandData.windowList + readonly property real falsePositivePreventionRatio: 0.5 + + // Force initialization of some singletons + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme(); + ConfigLoader.loadConfig(); + } + + component TargetRegion: Rectangle { + id: regionRect + 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: regionText.implicitWidth + horizontalPadding * 2 + implicitHeight: regionText.implicitHeight + verticalPadding * 2 + StyledText { + id: regionText + text: regionRect.text + color: root.genericContentForeground + anchors { + centerIn: parent + margins: regionLabelBackground.padding + } + } + } + } + + Variants { + model: Quickshell.screens + + PanelWindow { + id: panelWindow + required property var modelData + property HyprlandMonitor hyprlandMonitor: Hyprland.monitorFor(modelData) + property int activeWorkspaceId: hyprlandMonitor.activeWorkspace?.id ?? 0 + property string screenshotPath: `${root.screenshotDir}/image-${modelData.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: [] + property var windowRegions: root.windows.filter(w => { + return w.workspace.id === panelWindow.activeWorkspaceId; + }) + + 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 filterImageRegions(regions, windowRegions, threshold = 0.1) { + // Remove image regions that overlap too much with any window region + return regions.filter(region => { + for (let i = 0; i < windowRegions.length; ++i) { + if (intersectionOverUnion(region, windowRegions[i]) > threshold) + return false; + } + return true; + }); + } + + function updateTargetedRegion(x, y) { + 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; + } + + // 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) + + visible: false + screen: modelData + WlrLayershell.namespace: "quickshell:screenshot" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + anchors { + left: true + right: true + top: true + bottom: true + } + + Process { + id: screenshotProcess + running: true + command: ["bash", "-c", `mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}' && grim -o '${StringUtils.shellSingleQuoteEscape(modelData.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.py ` + + `--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 = JSON.parse(imageDimensionCollector.text); + 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."); + Qt.quit(); + } + snipProc.startDetached(); + Qt.quit(); + } + command: ["bash", "-c", `magick ${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)} -crop ${panelWindow.regionWidth}x${panelWindow.regionHeight}+${panelWindow.regionX}+${panelWindow.regionY} - ` + `| ${panelWindow.mouseButton === Qt.LeftButton ? "wl-copy" : "swappy -f -"}`] + } + + ScreencopyView { + anchors.fill: parent + live: false + captureSource: modelData + + focus: panelWindow.visible + Keys.onPressed: (event) => { // Esc to close + if (event.key === Qt.Key_Escape) { + Qt.quit(); + } + } + + 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: overlayRect + z: 0 + anchors.fill: parent + color: root.overlayColor + layer.enabled: true + } + Rectangle { + // TODO: Make this mask the base instead of just overlaying a border + 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 + } + + // Instructions + Rectangle { + 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 + + RowLayout { + id: instructionsRow + anchors.centerIn: parent + Item { + Layout.fillHeight: true + implicitWidth: screenshotRegionIcon.implicitWidth + MaterialSymbol { + id: screenshotRegionIcon + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.larger + text: "screenshot_region" + color: root.genericContentForeground + } + } + StyledText { + text: "Drag or click a region • LMB: Copy • RMB: Edit" + color: root.genericContentForeground + } + } + } + + Repeater { + model: ScriptModel { + values: panelWindow.windowRegions + } + delegate: TargetRegion { + 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.class}` + radius: Appearance.rounding.windowRounding + } + } + + Repeater { + model: ScriptModel { + values: panelWindow.imageRegions + } + delegate: TargetRegion { + 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" + } + } + } + } + } + } +} diff --git a/.config/quickshell/scripts/images/find_regions.py b/.config/quickshell/scripts/images/find_regions.py new file mode 100755 index 000000000..8d51154b6 --- /dev/null +++ b/.config/quickshell/scripts/images/find_regions.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +import argparse +import cv2 +import json +import numpy as np +import sys + +DEFAULT_IMAGE_PATH = '/tmp/quickshell/media/screenshot/image' + +def iou(boxA, boxB): + # Compute intersection over union for two boxes + xA = max(boxA['x'], boxB['x']) + yA = max(boxA['y'], boxB['y']) + xB = min(boxA['x'] + boxA['width'], boxB['x'] + boxB['width']) + yB = min(boxA['y'] + boxA['height'], boxB['y'] + boxB['height']) + interW = max(0, xB - xA) + interH = max(0, yB - yA) + interArea = interW * interH + boxAArea = boxA['width'] * boxA['height'] + boxBArea = boxB['width'] * boxB['height'] + iou = interArea / float(boxAArea + boxBArea - interArea) if (boxAArea + boxBArea - interArea) > 0 else 0 + return iou + +def non_max_suppression(regions, iou_threshold=0.7): + # Sort by area (largest first) + regions = sorted(regions, key=lambda r: r['width'] * r['height'], reverse=True) + keep = [] + while regions: + current = regions.pop(0) + keep.append(current) + regions = [r for r in regions if iou(current, r) < iou_threshold] + return keep + +def find_regions(image_path, min_width, min_height, max_width=None, max_height=None, quality=False, k=150, min_size=20, sigma=0.8, resize_factor=1.0): + image = cv2.imread(image_path) + if image is None: + print(f'Error: Could not load image {image_path}', file=sys.stderr) + sys.exit(1) + orig_h, orig_w = image.shape[:2] + if resize_factor != 1.0: + image = cv2.resize(image, (int(orig_w * resize_factor), int(orig_h * resize_factor)), interpolation=cv2.INTER_AREA) + ss = cv2.ximgproc.segmentation.createSelectiveSearchSegmentation() + ss.setBaseImage(image) + if quality: + ss.switchToSelectiveSearchQuality(k, min_size, sigma) + else: + ss.switchToSelectiveSearchFast(k, min_size, sigma) + rects = ss.process() + regions = [] + for (x, y, w, h) in rects: + # Scale regions back to original image size if resized + if resize_factor != 1.0: + x = int(x / resize_factor) + y = int(y / resize_factor) + w = int(w / resize_factor) + h = int(h / resize_factor) + # Filter out region that is exactly the same size as the original image + if w == orig_w and h == orig_h and x == 0 and y == 0: + continue + if w > min_width and h > min_height: + if (max_width is None or w < max_width) and (max_height is None or h < max_height): + regions.append({'x': int(x), 'y': int(y), 'width': int(w), 'height': int(h)}) + # Remove duplicates/overlaps + regions = non_max_suppression(regions, iou_threshold=0.7) + return regions, cv2.imread(image_path) # Return original image for drawing + +def draw_regions(image, regions, output_path): + for region in regions: + if 'x' in region: + x, y, w, h = region['x'], region['y'], region['width'], region['height'] + elif 'at' in region and 'size' in region: + x, y = region['at'] + w, h = region['size'] + else: + continue + cv2.rectangle(image, (x, y), (x + w, y + h), (0, 0, 255), 2) + cv2.imwrite(output_path, image) + +def main(): + parser = argparse.ArgumentParser(description='Find regions of interest in an image using selective search.') + parser.add_argument('-i', '--image', default=DEFAULT_IMAGE_PATH, help='Path to input image') + parser.add_argument('-do', '--debug-output', help='Path to save debug image with rectangles') + parser.add_argument('--min-width', type=int, default=200, help='Minimum width of detected region') + parser.add_argument('--min-height', type=int, default=100, help='Minimum height of detected region') + parser.add_argument('--max-width', type=int, help='Maximum width of detected region') + parser.add_argument('--max-height', type=int, help='Maximum height of detected region') + parser.add_argument('--single', action='store_true', help='Only output the most likely (largest) region') + parser.add_argument('--quality', action='store_true', help='Use quality mode for selective search (slower, less sensitive)') + parser.add_argument('--k', type=int, default=35000, help='Segmentation parameter k (default: 150)') + parser.add_argument('--min-size', type=int, default=150, help='Segmentation parameter min_size (default: 20)') + parser.add_argument('--sigma', type=float, default=0.6, help='Segmentation parameter sigma (default: 0.8)') + parser.add_argument('--resize-factor', type=float, default=0.1, help='Resize factor for input image before processing (default: 1.0, e.g. 0.5 for half size)') + parser.add_argument('--hyprctl', action='store_true', help='Mimics hyprctl\'s window output, like {"at": [x, y], "size": [w, h]}') + args = parser.parse_args() + + regions, image = find_regions( + args.image, + min_width=args.min_width, + min_height=args.min_height, + max_width=args.max_width, + max_height=args.max_height, + quality=args.quality, + k=args.k, + min_size=args.min_size, + sigma=args.sigma, + resize_factor=args.resize_factor + ) + if args.single and regions: + largest = max(regions, key=lambda r: r['width'] * r['height']) + regions = [largest] + if args.hyprctl: + regions = [{"at": [r['x'], r['y']], "size": [r['width'], r['height']]} for r in regions] + print(json.dumps(regions)) + if args.debug_output: + draw_regions(image, regions, args.debug_output) + +if __name__ == '__main__': + main() + diff --git a/arch-packages/illogical-impulse-screencapture/PKGBUILD b/arch-packages/illogical-impulse-screencapture/PKGBUILD index 6320c0c38..cde216bf0 100644 --- a/arch-packages/illogical-impulse-screencapture/PKGBUILD +++ b/arch-packages/illogical-impulse-screencapture/PKGBUILD @@ -6,8 +6,8 @@ arch=(any) license=(None) depends=( hyprshot - ksnip slurp + swappy tesseract tesseract-data-eng wf-recorder