diff --git a/bin/omarchy-capture-region b/bin/omarchy-capture-region index b9b75244..45a1bcc1 100755 --- a/bin/omarchy-capture-region +++ b/bin/omarchy-capture-region @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Pick a screen region over frozen screen content -# omarchy:args=[region|windows|smart|fullscreen] [--keep-freeze] [--match-monitor] +# omarchy:args=[region|windows|smart|fullscreen] [--keep-freeze] [--match-monitor] | --take-fullscreen | --take-window | --select-window # omarchy:hidden=true # Prints the picked geometry in slurp's "X,Y WxH" format, or exits 1 when the @@ -21,28 +21,7 @@ # exactly matches a monitor FULLSCREEN_MARKER="${XDG_RUNTIME_DIR:-/tmp}/omarchy-capture-region-fullscreen" - -# Pressing Return while slurp is open captures the entire focused monitor: -# a bind scoped to slurp's layer surface (default/hypr/bindings/utilities.lua) -# invokes this mode, which flags the intent and dismisses slurp. -if [[ ${1:-} == "--take-fullscreen" ]]; then - pgrep -x slurp >/dev/null || exit 0 - touch "$FULLSCREEN_MARKER" - pkill -x slurp - exit 0 -fi - -MODE=smart -KEEP_FREEZE=false -MATCH_MONITOR=false - -for arg in "$@"; do - case $arg in - --keep-freeze) KEEP_FREEZE=true ;; - --match-monitor) MATCH_MONITOR=true ;; - *) MODE=$arg ;; - esac -done +WINDOW_MARKER="${XDG_RUNTIME_DIR:-/tmp}/omarchy-capture-region-window" # accounting for portrait/transformed displays JQ_MONITOR_GEO=' @@ -58,26 +37,214 @@ JQ_MONITOR_GEO=' end; ' +active_workspace() { + hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .activeWorkspace.id' +} + +# Hidden group members and windows stacked at identical geometry collapse to +# one rectangle: slurp cannot tell them apart, and duplicates would stall the +# Tab cycle on the first copy. +window_rects() { + hyprctl clients -j | jq -r --arg ws "$(active_workspace)" \ + '[.[] | select(.workspace.id == ($ws | tonumber) and .hidden != true) | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"] | unique[]' +} + +monitor_rects() { + hyprctl monitors -j | jq -r --arg ws "$(active_workspace)" "${JQ_MONITOR_GEO} .[] | select(.activeWorkspace.id == (\$ws | tonumber)) | format_geo" +} + get_rectangles() { - local active_workspace=$(hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .activeWorkspace.id') - hyprctl monitors -j | jq -r --arg ws "$active_workspace" "${JQ_MONITOR_GEO} .[] | select(.activeWorkspace.id == (\$ws | tonumber)) | format_geo" - hyprctl clients -j | jq -r --arg ws "$active_workspace" '.[] | select(.workspace.id == ($ws | tonumber)) | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"' + monitor_rects + window_rects } focused_monitor_geo() { hyprctl monitors -j | jq -r "${JQ_MONITOR_GEO} .[] | select(.focused == true) | format_geo" } -# Runs slurp; an empty result with the fullscreen marker present means Return -# was pressed, so the focused monitor is the selection. +# slurp highlights the smallest box containing the cursor and keeps the first +# one on a tie, so overlapping rectangles resolve the same way here (e.g. +# floating over tiled). Reads candidates on stdin, exits 1 when none match. +smallest_rect_at() { + local x=$1 y=$2 + local rect area + local smallest="" + local smallest_area=0 + + while IFS= read -r rect; do + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue + ((x >= BASH_REMATCH[1] && x < BASH_REMATCH[1] + BASH_REMATCH[3] && y >= BASH_REMATCH[2] && y < BASH_REMATCH[2] + BASH_REMATCH[4])) || continue + + area=$((BASH_REMATCH[3] * BASH_REMATCH[4])) + if [[ -z $smallest ]] || ((area < smallest_area)); then + smallest=$rect + smallest_area=$area + fi + done + + [[ -n $smallest ]] || return 1 + echo "$smallest" +} + +# Whatever slurp is highlighting under the cursor. It is fed monitor rects as +# well as window rects, so a cursor in a gap or over the bar highlights the +# monitor rather than any window. +geo_at_cursor() { + local pos=$(hyprctl cursorpos) + local x=${pos%,*} + local y=${pos#*, } + + smallest_rect_at "$x" "$y" < <(window_rects) || + smallest_rect_at "$x" "$y" < <(monitor_rects) || + focused_monitor_geo +} + +# Keyboard control while slurp is open: binds scoped to slurp's layer +# surface (default/hypr/bindings/utilities.lua) invoke these modes. The +# --take-* modes flag the intent with a marker file and dismiss slurp. +if [[ ${1:-} == "--take-fullscreen" ]]; then + pgrep -x slurp >/dev/null || exit 0 + touch "$FULLSCREEN_MARKER" + pkill -x slurp + exit 0 +fi + +if [[ ${1:-} == "--take-window" ]]; then + pgrep -x slurp >/dev/null || exit 0 + touch "$WINDOW_MARKER" + pkill -x slurp + exit 0 +fi + +# Warps the cursor to another window's center, so slurp's own hover +# highlight tracks the selection. +if [[ ${1:-} == "--select-window" ]]; then + pgrep -x slurp >/dev/null || exit 0 + + direction=${2:-} + pos=$(hyprctl cursorpos) + origin_x=${pos%,*} + origin_y=${pos#*, } + + candidates=$(window_rects) + + # Reading order: top-to-bottom, then left-to-right. + rects=$(while IFS= read -r rect; do + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue + printf '%d\t%d\t%s\n' "${BASH_REMATCH[2]}" "${BASH_REMATCH[1]}" "$rect" + done <<<"$candidates" | sort -n -k1,1 -k2,2 | cut -f3-) + [[ -n $rects ]] || exit 0 + + # The selection to move from is the one slurp highlights, resolved from the + # same list in the same order as --take-window so navigation and capture + # never disagree. Measure from its center. + current=$(smallest_rect_at "$origin_x" "$origin_y" <<<"$candidates") + + if [[ $current =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]]; then + origin_x=$((BASH_REMATCH[1] + BASH_REMATCH[3] / 2)) + origin_y=$((BASH_REMATCH[2] + BASH_REMATCH[4] / 2)) + fi + + target="" + + case $direction in + next | prev) + mapfile -t ordered <<<"$rects" + count=${#ordered[@]} + current_index=-1 + + for i in "${!ordered[@]}"; do + if [[ -n $current && ${ordered[i]} == "$current" ]]; then + current_index=$i + break + fi + done + + if [[ $direction == next ]]; then + target=${ordered[$(((current_index + 1) % count))]} + elif ((current_index == -1)); then + target=${ordered[count - 1]} + else + target=${ordered[$(((current_index - 1 + count) % count))]} + fi + ;; + left | right | up | down) + best_score="" + + while IFS= read -r rect; do + [[ $rect == "$current" ]] && continue + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue + center_x=$((BASH_REMATCH[1] + BASH_REMATCH[3] / 2)) + center_y=$((BASH_REMATCH[2] + BASH_REMATCH[4] / 2)) + + case $direction in + left) + primary=$((origin_x - center_x)) + perp=$((center_y - origin_y)) + ;; + right) + primary=$((center_x - origin_x)) + perp=$((center_y - origin_y)) + ;; + up) + primary=$((origin_y - center_y)) + perp=$((center_x - origin_x)) + ;; + down) + primary=$((center_y - origin_y)) + perp=$((center_x - origin_x)) + ;; + esac + + ((primary > 0)) || continue + ((perp < 0)) && perp=$((-perp)) + score=$((primary + perp * 2)) + + if [[ -z $best_score ]] || ((score < best_score)); then + best_score=$score + target=$rect + fi + done <<<"$rects" + ;; + *) + exit 1 + ;; + esac + + if [[ $target =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]]; then + target_x=$((BASH_REMATCH[1] + BASH_REMATCH[3] / 2)) + target_y=$((BASH_REMATCH[2] + BASH_REMATCH[4] / 2)) + hyprctl eval "hl.dispatch(hl.dsp.cursor.move({ x = $target_x, y = $target_y }))" >/dev/null + fi + exit 0 +fi + +MODE=smart +KEEP_FREEZE=false +MATCH_MONITOR=false + +for arg in "$@"; do + case $arg in + --keep-freeze) KEEP_FREEZE=true ;; + --match-monitor) MATCH_MONITOR=true ;; + *) MODE=$arg ;; + esac +done + +# Runs slurp; an empty result with a marker present means one of the --take-* +# binds was pressed, so the highlighted rectangle or the monitor is the +# selection. pick() { local selection - rm -f "$FULLSCREEN_MARKER" + rm -f "$FULLSCREEN_MARKER" "$WINDOW_MARKER" selection=$(slurp "$@" 2>/dev/null) if [[ -z $selection && -e $FULLSCREEN_MARKER ]]; then rm -f "$FULLSCREEN_MARKER" selection=$(focused_monitor_geo) + elif [[ -z $selection && -e $WINDOW_MARKER ]]; then + rm -f "$WINDOW_MARKER" + selection=$(geo_at_cursor) fi printf '%s' "$selection" diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 73e0addd..aa800ad6 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -41,16 +41,22 @@ o.bind("SUPER + ALT + code:35", "Make webcam overlay larger", "omarchy-capture-w o.bind("SUPER + PRINT", "Color picker", "pkill hyprpicker || hyprpicker -a") o.bind("SUPER + CTRL + PRINT", "Extract text (OCR) from screenshot", "omarchy-capture-text") --- While the slurp region picker is open, Return captures the entire focused --- monitor. The bind lives exactly as long as a selection layer is on screen --- (slurp opens one per monitor), so it cannot leak or get stuck. +-- Keyboard control for the slurp region picker (see omarchy-capture-region). +-- The binds live exactly as long as a selection layer is on screen (slurp +-- opens one per monitor), so they cannot leak or get stuck. local selection_layers = 0 hl.on("layer.opened", function(layer) if layer.namespace == "selection" then selection_layers = selection_layers + 1 if selection_layers == 1 then - hl.bind("RETURN", hl.dsp.exec_cmd("omarchy-capture-region --take-fullscreen"), { description = "Capture entire screen" }) + hl.bind("RETURN", hl.dsp.exec_cmd("omarchy-capture-region --take-window"), { description = "Capture highlighted window" }) + hl.bind("CTRL + RETURN", hl.dsp.exec_cmd("omarchy-capture-region --take-fullscreen"), { description = "Capture entire screen" }) + hl.bind("TAB", hl.dsp.exec_cmd("omarchy-capture-region --select-window next"), { description = "Select next window to capture" }) + hl.bind("CTRL + TAB", hl.dsp.exec_cmd("omarchy-capture-region --select-window prev"), { description = "Select previous window to capture" }) + for _, direction in ipairs({ "left", "right", "up", "down" }) do + hl.bind(direction:upper(), hl.dsp.exec_cmd("omarchy-capture-region --select-window " .. direction), { description = "Select window to capture" }) + end end end end) @@ -60,6 +66,12 @@ hl.on("layer.closed", function(layer) selection_layers = selection_layers - 1 if selection_layers == 0 then hl.unbind("RETURN") + hl.unbind("CTRL + RETURN") + hl.unbind("TAB") + hl.unbind("CTRL + TAB") + for _, direction in ipairs({ "LEFT", "RIGHT", "UP", "DOWN" }) do + hl.unbind(direction) + end end end end)