diff --git a/dots/.config/hypr/hyprland/keybinds.conf b/dots/.config/hypr/hyprland/keybinds.conf index c389df352..38fc05caf 100644 --- a/dots/.config/hypr/hyprland/keybinds.conf +++ b/dots/.config/hypr/hyprland/keybinds.conf @@ -58,7 +58,8 @@ bind = Ctrl+Super, R, exec, killall ags agsv1 gjs ydotool qs quickshell; qs -c $ # Screenshot, Record, OCR, Color picker, Clipboard history bindd = Super, V, Copy clipboard history entry, exec, qs -c $qsConfig 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 -c $qsConfig 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, qs -p ~/.config/quickshell/$qsConfig/screenshot.qml || pidof slurp || hyprshot --freeze --clipboard-only --mode region --silent # 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) # 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 @@ -67,9 +68,9 @@ bindd = Super+Shift, C, Color picker, exec, hyprpicker -a # Pick color (Hex) >> bindld = ,Print, Screenshot >> clipboard ,exec,grim - | wl-copy # Screenshot >> clipboard bindld = Ctrl,Print, Screenshot >> clipboard & save, exec, mkdir -p $(xdg-user-dir PICTURES)/Screenshots && grim $(xdg-user-dir PICTURES)/Screenshots/Screenshot_"$(date '+%Y-%m-%d_%H.%M.%S')".png # Screenshot >> clipboard & file # Recording stuff -bindd = Super+Alt, R, Record region (no sound), exec, ~/.config/hypr/hyprland/scripts/record.sh # Record region (no sound) -bindd = Ctrl+Alt, R, Record screen (no sound), exec, ~/.config/hypr/hyprland/scripts/record.sh --fullscreen # [hidden] Record screen (no sound) -bindd = Super+Shift+Alt, R, Record screen (with sound), exec, ~/.config/hypr/hyprland/scripts/record.sh --fullscreen-sound # Record screen (with sound) +bindl = Super+Alt, R, exec, ~/.config/hypr/hyprland/scripts/record.sh # Record region (no sound) +bindl = Ctrl+Alt, R, exec, ~/.config/hypr/hyprland/scripts/record.sh --fullscreen # [hidden] Record screen (no sound) +bindl = Super+Shift+Alt, R, exec, ~/.config/hypr/hyprland/scripts/record.sh --fullscreen-sound # Record screen (with sound) # AI bindd = Super+Shift+Alt, mouse:273, Generate AI summary for selected text, exec, ~/.config/hypr/hyprland/scripts/ai/primary-buffer-query.sh # AI summary for selected text @@ -247,7 +248,7 @@ bind = Ctrl+Alt, T, exec, ~/.config/hypr/hyprland/scripts/launch_first_available bind = Super, E, exec, ~/.config/hypr/hyprland/scripts/launch_first_available.sh "dolphin" "nautilus" "nemo" "thunar" "${TERMINAL}" "kitty -1 fish -c yazi" # File manager bind = Super, W, exec, ~/.config/hypr/hyprland/scripts/launch_first_available.sh "google-chrome-stable" "zen-browser" "firefox" "brave" "chromium" "microsoft-edge-stable" "opera" "librewolf" # Browser bind = Super, C, exec, ~/.config/hypr/hyprland/scripts/launch_first_available.sh "code" "codium" "cursor" "zed" "zedit" "zeditor" "kate" "gnome-text-editor" "emacs" "command -v nvim && kitty -1 nvim" "command -v micro && kitty -1 micro" # Code editor -bind = Super+Shift, W, exec, ~/.config/hypr/hyprland/scripts/launch_first_available.sh "wps" "onlyoffice-desktopeditors" # Office software +bind = Ctrl+Super+Shift+Alt, W, exec, ~/.config/hypr/hyprland/scripts/launch_first_available.sh "wps" "onlyoffice-desktopeditors" "libreoffice" # Office software bind = Super, X, exec, ~/.config/hypr/hyprland/scripts/launch_first_available.sh "kate" "gnome-text-editor" "emacs" # Text editor bind = Ctrl+Super, V, exec, ~/.config/hypr/hyprland/scripts/launch_first_available.sh "pavucontrol-qt" "pavucontrol" # Volume mixer bind = Super, I, exec, XDG_CURRENT_DESKTOP=gnome ~/.config/hypr/hyprland/scripts/launch_first_available.sh "qs -p ~/.config/quickshell/$qsConfig/settings.qml" "systemsettings" "gnome-control-center" "better-control" # Settings app diff --git a/dots/.config/hypr/hyprland/rules.conf b/dots/.config/hypr/hyprland/rules.conf index e4283b4cb..9cb877f7c 100644 --- a/dots/.config/hypr/hyprland/rules.conf +++ b/dots/.config/hypr/hyprland/rules.conf @@ -141,6 +141,7 @@ layerrule = noanim, quickshell:lockWindowPusher layerrule = animation fade, quickshell:notificationPopup layerrule = noanim, quickshell:overview layerrule = animation slide bottom, quickshell:osk +layerrule = noanim, quickshell:regionSelector layerrule = noanim, quickshell:screenshot layerrule = blur, quickshell:session layerrule = noanim, quickshell:session diff --git a/dots/.config/quickshell/ii/GlobalStates.qml b/dots/.config/quickshell/ii/GlobalStates.qml index 507f5d191..5cee09d96 100644 --- a/dots/.config/quickshell/ii/GlobalStates.qml +++ b/dots/.config/quickshell/ii/GlobalStates.qml @@ -18,13 +18,14 @@ Singleton { property bool osdVolumeOpen: false property bool oskOpen: false property bool overviewOpen: false - property bool wallpaperSelectorOpen: false + property bool regionSelectorOpen: false property bool screenLocked: false property bool screenLockContainsCharacters: false property bool screenUnlockFailed: false property bool sessionOpen: false property bool superDown: false property bool superReleaseMightTrigger: true + property bool wallpaperSelectorOpen: false property bool workspaceShowNumbers: false onSidebarRightOpenChanged: { diff --git a/dots/.config/quickshell/ii/modules/regionSelector/RegionSelector.qml b/dots/.config/quickshell/ii/modules/regionSelector/RegionSelector.qml new file mode 100644 index 000000000..6eecc365e --- /dev/null +++ b/dots/.config/quickshell/ii/modules/regionSelector/RegionSelector.qml @@ -0,0 +1,595 @@ +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 + +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 + } + } + } + } + + Variants { + model: Quickshell.screens + delegate: Loader { + id: regionSelectorLoader + required property var modelData + active: GlobalStates.regionSelectorOpen + + sourceComponent: PanelWindow { + id: panelWindow + 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" + } + } + } + } + } + } + } + + function screenshot() { + GlobalStates.regionSelectorOpen = true + } + + IpcHandler { + target: "region" + + function screenshot() { + root.screenshot() + } + } + + GlobalShortcut { + name: "regionScreenshot" + description: "Takes a screenshot of the selected region" + + onPressed: { + root.screenshot() + } + } +} diff --git a/dots/.config/quickshell/ii/screenshot.qml b/dots/.config/quickshell/ii/screenshot.qml deleted file mode 100644 index 499409daf..000000000 --- a/dots/.config/quickshell/ii/screenshot.qml +++ /dev/null @@ -1,578 +0,0 @@ -//@ pragma UseQApplication -//@ pragma Env QS_NO_RELOAD_POPUP=1 -//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic -//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 - -// Adjust this to make it smaller or larger -//@ pragma Env QT_SCALE_FACTOR=1 - -pragma ComponentBehavior: "Bound" -import qs.services -import qs.modules.common -import qs.modules.common.widgets -import qs.modules.common.functions -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Qt5Compat.GraphicalEffects -import Quickshell -import Quickshell.Io -import Quickshell.Widgets -import Quickshell.Wayland -import Quickshell.Hyprland - -ShellRoot { - 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 - - // Force initialization of some singletons - Component.onCompleted: { - MaterialThemeLoader.reapplyTheme(); - } - - 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 - } - } - } - } - - Variants { - model: Quickshell.screens - - PanelWindow { - id: panelWindow - required property var modelData - readonly property HyprlandMonitor hyprlandMonitor: Hyprland.monitorFor(modelData) - 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-${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: [] - 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) - - 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-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."); - Qt.quit(); - } - snipProc.startDetached(); - Qt.quit(); - } - 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: 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: 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" - } - } - } - } - } - } -} diff --git a/dots/.config/quickshell/ii/shell.qml b/dots/.config/quickshell/ii/shell.qml index 942bb282e..1dd1627f4 100644 --- a/dots/.config/quickshell/ii/shell.qml +++ b/dots/.config/quickshell/ii/shell.qml @@ -19,6 +19,7 @@ import "./modules/notificationPopup/" import "./modules/onScreenDisplay/" import "./modules/onScreenKeyboard/" import "./modules/overview/" +import "./modules/regionSelector/" import "./modules/screenCorners/" import "./modules/sessionScreen/" import "./modules/sidebarLeft/" @@ -45,6 +46,7 @@ ShellRoot { property bool enableOnScreenDisplay: true property bool enableOnScreenKeyboard: true property bool enableOverview: true + property bool enableRegionSelector: true property bool enableReloadPopup: true property bool enableScreenCorners: true property bool enableSessionScreen: true @@ -74,6 +76,7 @@ ShellRoot { LazyLoader { active: enableOnScreenDisplay; component: OnScreenDisplay {} } LazyLoader { active: enableOnScreenKeyboard; component: OnScreenKeyboard {} } LazyLoader { active: enableOverview; component: Overview {} } + LazyLoader { active: enableRegionSelector; component: RegionSelector {} } LazyLoader { active: enableReloadPopup; component: ReloadPopup {} } LazyLoader { active: enableScreenCorners; component: ScreenCorners {} } LazyLoader { active: enableSessionScreen; component: SessionScreen {} }