diff --git a/bin/omarchy-menu b/bin/omarchy-menu index 1ffa081c..17b6059b 100755 --- a/bin/omarchy-menu +++ b/bin/omarchy-menu @@ -93,11 +93,12 @@ show_learn_menu() { } show_trigger_menu() { - case $(menu "Trigger" " Capture\n󰧸 Transcode\n Share\n󰔎 Toggle\n Hardware") in + case $(menu "Trigger" " Capture\n󰧸 Transcode\n Share\n󰔎 Toggle\n󰧨 Workspace\n Hardware") in *Capture*) show_capture_menu ;; *Transcode*) show_transcode_menu ;; *Share*) show_share_menu ;; *Toggle*) show_toggle_menu ;; + *Workspace*) show_workspace_menu ;; *Hardware*) show_hardware_menu ;; *) show_main_menu ;; esac @@ -175,6 +176,14 @@ show_transcode_menu() { esac } +show_workspace_menu() { + case $(menu "Workspace" "󰑓 Restore\n󰆓 Save") in + *Restore*) omarchy-workspace-restore ;; + *Save*) omarchy-workspace-save ;; + *) back_to show_trigger_menu ;; + esac +} + show_toggle_menu() { local options="󱄄 Screensaver\n󰔎 Nightlight\n󱫖 Idle Lock\n󰂛 Notifications\n󰍜 Top Bar\n󱂬 Workspace Layout\n Window Gaps\n 1-Window Ratio\n󰍹 Monitor Scaling\n Direct Boot\n󰟵 Passwordless Sudo" @@ -680,6 +689,7 @@ go_to_menu() { *trigger*) show_trigger_menu ;; *toggle*) show_toggle_menu ;; *hardware*) show_hardware_menu ;; + *workspace*) show_workspace_menu ;; *share*) show_share_menu ;; *transcode*) show_transcode_menu ;; *background*) show_background_menu ;; diff --git a/bin/omarchy-workspace-restore b/bin/omarchy-workspace-restore new file mode 100755 index 00000000..862cb6a7 --- /dev/null +++ b/bin/omarchy-workspace-restore @@ -0,0 +1,395 @@ +#!/bin/bash + +# omarchy:summary=Restore the saved layout for the current Hyprland workspace +# omarchy:args= +# omarchy:examples=omarchy workspace restore + +set -euo pipefail + +monitor=$(omarchy-hyprland-monitor-focused) +workspace=$(hyprctl monitors -j | jq -r --arg monitor "$monitor" '.[] | select(.name == $monitor) | .activeWorkspace.id') +file="$HOME/.config/hypr/workspaces/$monitor-$workspace.tsv" + +if [[ ! -f $file ]]; then + notify-send -u low "󱂬 No saved workspace $workspace for $monitor" "$file" -t 3000 + exit 1 +fi + +restore_data=$(grep -v '^#' "$file" || true) + +if [[ -z $restore_data ]]; then + notify-send -u low "󱂬 No windows saved for workspace $workspace" "$file" -t 3000 + exit 1 +fi + +restore_map=$(mktemp) +assigned_addresses=$(mktemp) +trap 'rm -f "$restore_map" "$assigned_addresses"' EXIT + +declare -a classes commands xs ys ws hs focuseds addresses launched +count=0 + +while IFS=$'\t' read -r _ saved_workspace mode class command x y w h focused; do + [[ -n $class ]] || continue + + workspace=$saved_workspace + classes[count]=$class + commands[count]=$command + xs[count]=$x + ys[count]=$y + ws[count]=$w + hs[count]=$h + focuseds[count]=${focused:-false} + addresses[count]="" + launched[count]=false + (( count += 1 )) +done <<< "$restore_data" + +hyprctl dispatch workspace "$workspace" >/dev/null + +for (( i = 0; i < count; i++ )); do + class=${classes[i]} + hyprctl keyword windowrulev2 "workspace $workspace silent,class:^($class)$" >/dev/null + + if [[ ${mode:-tiled} == "floating" ]]; then + hyprctl keyword windowrulev2 "float,class:^($class)$" >/dev/null + else + hyprctl keyword windowrulev2 "tile,class:^($class)$" >/dev/null + fi +done + +existing_unassigned_json() { + local class=$1 + + { + hyprctl clients -j | jq -r --arg class "$class" '.[] | select(.class == $class or .initialClass == $class) | .address' | + grep -Fvx -f "$assigned_addresses" || true + } | jq -R -s -c 'split("\n")[:-1]' +} + +wait_for_address() { + local class=$1 + local existing=${2:-[]} + local tries=0 + local address="" + + while [[ -z $address ]]; do + address=$(hyprctl clients -j | jq -r --arg class "$class" --argjson existing "$existing" \ + '.[] | select((.class == $class or .initialClass == $class) and (.address as $address | $existing | index($address) | not)) | .address' | head -n1) + + if [[ -n $address ]]; then + printf '%s\n' "$address" + return 0 + fi + + sleep 0.2 + (( tries += 1 )) + + # Single-instance apps may focus/reuse an existing window instead of creating a new one. + if (( tries >= 25 )) && [[ $existing != "[]" ]]; then + jq -r '.[0]' <<< "$existing" + return 0 + fi + + if (( tries >= 75 )); then + echo "Timed out waiting for $class" >&2 + return 1 + fi + done +} + +ensure_window() { + local id=$1 + local class=${classes[id]} + local command=${commands[id]} + local existing address + + if [[ ${launched[id]} == "true" ]]; then + last_address=${addresses[id]} + return 0 + fi + + existing=$(existing_unassigned_json "$class") + + if [[ $existing != "[]" && ( $class == chrome-* || $class == chromium* ) ]]; then + address=$(jq -r '.[0]' <<< "$existing") + else + hyprctl dispatch exec "[workspace $workspace silent] $command" >/dev/null + address=$(wait_for_address "$class" "$existing") || return 1 + fi + + hyprctl dispatch movetoworkspacesilent "$workspace,address:$address" >/dev/null || true + + if [[ ${mode:-tiled} == "floating" ]]; then + hyprctl dispatch setfloating "address:$address" >/dev/null || true + hyprctl dispatch resizewindowpixel exact "${ws[id]}" "${hs[id]}",address:"$address" >/dev/null || true + hyprctl dispatch movewindowpixel exact "${xs[id]}" "${ys[id]}",address:"$address" >/dev/null || true + else + hyprctl dispatch settiled "address:$address" >/dev/null || true + hyprctl dispatch focuswindow "address:$address" >/dev/null || true + fi + + addresses[id]=$address + launched[id]=true + printf '%s\n' "$address" >>"$assigned_addresses" + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$address" "$id" "${xs[id]}" "${ys[id]}" "${ws[id]}" "${hs[id]}" >>"$restore_map" + + if [[ ${focuseds[id]} == "true" ]]; then + focused_address=$address + fi + + sleep 0.4 + last_address=$address +} + +choose_rep() { + local best="" + local best_area=-1 + local id area + + for id in "$@"; do + area=$(( ws[id] * hs[id] )) + if (( area > best_area )); then + best=$id + best_area=$area + fi + done + + printf '%s\n' "$best" +} + +find_launched_address() { + local id + + for id in "$@"; do + if [[ ${launched[id]} == "true" ]]; then + printf '%s\n' "${addresses[id]}" + return 0 + fi + done + + return 1 +} + +opposite_direction() { + case $1 in + l) echo r ;; + r) echo l ;; + u) echo d ;; + d) echo u ;; + esac +} + +abs() { + local value=$1 + if (( value < 0 )); then + echo $(( -value )) + else + echo "$value" + fi +} + +find_partition() { + local ids=("$@") + local best_score=9223372036854775807 + local best_a="" + local best_b="" + local best_dir="" + local orientation i j id edge start cut gap right bottom area_a area_b score valid a b + + for orientation in v h; do + for i in "${ids[@]}"; do + for j in "${ids[@]}"; do + if [[ $orientation == "v" ]]; then + edge=$(( xs[i] + ws[i] )) + start=${xs[j]} + [[ $edge -lt $start ]] || continue + else + edge=$(( ys[i] + hs[i] )) + start=${ys[j]} + [[ $edge -lt $start ]] || continue + fi + + cut=$(( (edge + start) / 2 )) + gap=$(( start - edge )) + valid=true + a="" + b="" + area_a=0 + area_b=0 + + for id in "${ids[@]}"; do + if [[ $orientation == "v" ]]; then + right=$(( xs[id] + ws[id] )) + if (( right <= cut )); then + a="$a $id" + area_a=$(( area_a + ws[id] * hs[id] )) + elif (( xs[id] >= cut )); then + b="$b $id" + area_b=$(( area_b + ws[id] * hs[id] )) + else + valid=false + break + fi + else + bottom=$(( ys[id] + hs[id] )) + if (( bottom <= cut )); then + a="$a $id" + area_a=$(( area_a + ws[id] * hs[id] )) + elif (( ys[id] >= cut )); then + b="$b $id" + area_b=$(( area_b + ws[id] * hs[id] )) + else + valid=false + break + fi + fi + done + + [[ $valid == "true" && -n $a && -n $b ]] || continue + + score=$(abs $(( area_a - area_b ))) + score=$(( score * 1000 - gap )) + + if (( score < best_score )); then + best_score=$score + best_a=${a# } + best_b=${b# } + if [[ $orientation == "v" ]]; then + best_dir=r + else + best_dir=d + fi + fi + done + done + done + + if [[ -z $best_a || -z $best_b ]]; then + fallback_partition "${ids[@]}" + return + fi + + read -r -a part_a <<< "$best_a" + read -r -a part_b <<< "$best_b" + part_dir=$best_dir +} + +fallback_partition() { + local ids=("$@") + local sorted=() + local line id bbox_min_x=999999 bbox_min_y=999999 bbox_max_x=0 bbox_max_y=0 bbox_w bbox_h key index + + for id in "${ids[@]}"; do + (( xs[id] < bbox_min_x )) && bbox_min_x=${xs[id]} + (( ys[id] < bbox_min_y )) && bbox_min_y=${ys[id]} + (( xs[id] + ws[id] > bbox_max_x )) && bbox_max_x=$(( xs[id] + ws[id] )) + (( ys[id] + hs[id] > bbox_max_y )) && bbox_max_y=$(( ys[id] + hs[id] )) + done + + bbox_w=$(( bbox_max_x - bbox_min_x )) + bbox_h=$(( bbox_max_y - bbox_min_y )) + + while read -r line; do + sorted+=("${line#* }") + done < <( + for id in "${ids[@]}"; do + if (( bbox_w >= bbox_h )); then + key=${xs[id]} + else + key=${ys[id]} + fi + printf '%s %s\n' "$key" "$id" + done | sort -n + ) + + part_a=() + part_b=() + for index in "${!sorted[@]}"; do + if (( index < ${#sorted[@]} / 2 )); then + part_a+=("${sorted[index]}") + else + part_b+=("${sorted[index]}") + fi + done + + if (( bbox_w >= bbox_h )); then + part_dir=r + else + part_dir=d + fi +} + +materialize_group() { + local ids=("$@") + local rep_a rep_b addr_a addr_b dir opposite + + if (( ${#ids[@]} == 0 )); then + return 0 + fi + + if (( ${#ids[@]} == 1 )); then + ensure_window "${ids[0]}" >/dev/null || true + return 0 + fi + + find_partition "${ids[@]}" + dir=$part_dir + opposite=$(opposite_direction "$dir") + + addr_a=$(find_launched_address "${part_a[@]}" || true) + addr_b=$(find_launched_address "${part_b[@]}" || true) + + if [[ -z $addr_a && -z $addr_b ]]; then + rep_a=$(choose_rep "${part_a[@]}") + ensure_window "$rep_a" || return 0 + addr_a=$last_address + hyprctl dispatch focuswindow "address:$addr_a" >/dev/null || true + hyprctl dispatch layoutmsg preselect "$dir" >/dev/null || true + rep_b=$(choose_rep "${part_b[@]}") + ensure_window "$rep_b" || true + addr_b=${last_address:-} + elif [[ -n $addr_a && -z $addr_b ]]; then + hyprctl dispatch focuswindow "address:$addr_a" >/dev/null || true + hyprctl dispatch layoutmsg preselect "$dir" >/dev/null || true + rep_b=$(choose_rep "${part_b[@]}") + ensure_window "$rep_b" || true + addr_b=${last_address:-} + elif [[ -z $addr_a && -n $addr_b ]]; then + hyprctl dispatch focuswindow "address:$addr_b" >/dev/null || true + hyprctl dispatch layoutmsg preselect "$opposite" >/dev/null || true + rep_a=$(choose_rep "${part_a[@]}") + ensure_window "$rep_a" || true + addr_a=${last_address:-} + fi + + materialize_group "${part_a[@]}" + materialize_group "${part_b[@]}" +} + +ids=() +for (( i = 0; i < count; i++ )); do + ids+=("$i") +done + +if [[ ${mode:-tiled} == "floating" ]]; then + for id in "${ids[@]}"; do + ensure_window "$id" >/dev/null || true + done +else + materialize_group "${ids[@]}" + + for _ in 1 2 3 4; do + while IFS=$'\t' read -r address _ _ _ w h; do + hyprctl dispatch resizewindowpixel exact "$w" "$h",address:"$address" >/dev/null || true + done < <(sort -t $'\t' -k3,3n -k4,4n "$restore_map") + sleep 0.1 + done +fi + +if [[ -n ${focused_address:-} ]]; then + hyprctl dispatch focuswindow "address:$focused_address" >/dev/null || true +fi + +notify-send -u low "󱂬 Restored workspace $workspace for $monitor" -t 3000 +hyprctl dispatch workspace "$workspace" >/dev/null diff --git a/bin/omarchy-workspace-save b/bin/omarchy-workspace-save new file mode 100755 index 00000000..11b6e637 --- /dev/null +++ b/bin/omarchy-workspace-save @@ -0,0 +1,74 @@ +#!/bin/bash + +# omarchy:summary=Save the current Hyprland workspace app layout to ~/.config/hypr/workspaces/-.tsv +# omarchy:args= +# omarchy:examples=omarchy workspace save + +set -euo pipefail + +monitor=$(omarchy-hyprland-monitor-focused) +workspace=$(hyprctl monitors -j | jq -r --arg monitor "$monitor" '.[] | select(.name == $monitor) | .activeWorkspace.id') + +mkdir -p ~/.config/hypr/workspaces +file="$HOME/.config/hypr/workspaces/$monitor-$workspace.tsv" +clients=$(hyprctl clients -j) +active_address=$(hyprctl activewindow -j | jq -r '.address // ""') + +webapp_command() { + local class=$1 + local initial_title=$2 + local encoded host path + + [[ $class == chrome-* ]] || return 1 + + if [[ $initial_title == http://* || $initial_title == https://* ]]; then + printf 'omarchy-launch-webapp %s\n' "${initial_title//_/\/}" + return 0 + fi + + encoded=${class#chrome-} + encoded=${encoded%-Default} + + if [[ $encoded == *__* ]]; then + host=${encoded%%__*} + path=${encoded#*__} + printf 'omarchy-launch-webapp https://%s/%s\n' "$host" "${path//_/\/}" + else + printf 'omarchy-launch-webapp https://%s\n' "${encoded//_/\/}" + fi +} + +launch_command() { + local class=$1 + local initial_title=$2 + local pid=$3 + + if webapp_command "$class" "$initial_title"; then + return 0 + elif [[ -r /proc/$pid/cmdline ]]; then + tr '\0' ' ' <"/proc/$pid/cmdline" | sed 's/[[:space:]]*$//' + else + echo "$class" + fi +} + +{ + printf '# monitor\tworkspace\tmode\tclass\tcommand\tx\ty\tw\th\tfocused\n' + + jq -r --argjson workspace "$workspace" ' + [.[] | select(.workspace.id == $workspace)] + | sort_by(.at[0], .at[1]) + | .[] + | [(.initialClass // .class), .initialTitle, .pid, .at[0], .at[1], .size[0], .size[1], .address] + | @tsv + ' <<< "$clients" | while IFS=$'\t' read -r class initial_title pid x y w h address; do + command=$(launch_command "$class" "$initial_title" "$pid") + focused=false + if [[ $address == "$active_address" ]]; then + focused=true + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$monitor" "$workspace" "tiled" "$class" "$command" "$x" "$y" "$w" "$h" "$focused" + done +} >"$file" + +notify-send -u low "󱂬 Saved workspace $workspace for $monitor" -t 3000 diff --git a/default/hypr/bindings/utilities.conf b/default/hypr/bindings/utilities.conf index 32dc0170..e8ba8309 100644 --- a/default/hypr/bindings/utilities.conf +++ b/default/hypr/bindings/utilities.conf @@ -46,6 +46,10 @@ bindd = SUPER CTRL, S, Share, exec, omarchy-menu share # Transcoding bindd = SUPER CTRL, R, Transcode, exec, omarchy-menu transcode +# Workspace layouts +bindd = SUPER CTRL, HOME, Restore workspace layout, exec, omarchy-workspace-restore +bindd = SUPER CTRL ALT, HOME, Save workspace layout, exec, omarchy-workspace-save + # Waybar-less information bindd = SUPER CTRL ALT, T, Show time, exec, notify-send -u low " $(date +"%A %H:%M · %d %B %Y · Week %V")" bindd = SUPER CTRL ALT, B, Show battery remaining, exec, notify-send -u low "$(omarchy-battery-status)"