From 14987189695a8ae1f2270262fdf77446fafb1ba3 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 11 Oct 2026 02:20:45 -0400 Subject: [PATCH] Add a floating mode with titlebars to the workspace layout cycle (#14482) * Let the workspace layout test's Lua assertions fail the suite Lua 5.5's standalone interpreter exits 0 when the script that raised the error came in on stdin, and exits 1 for the same error in a script file. The block here was piped in through a heredoc, so every assertion in it reported as passing however wrong the value was: changing the expected layout to one the toggle never writes still left the file green. * Add a floating mode to the workspace layout cycle Super + L cycled a workspace between dwindle and scrolling. It now carries on into floating, where every window on the workspace floats. Floating cannot be a third `layout` on the workspace rule. Hyprland has no layout by that name and accepts one anyway: `hl.workspace_rule({ layout = "floating" })` returns ok and silently leaves the workspace on dwindle, exactly as a misspelt name does. So the mode floats the windows themselves, through the same `float` dispatcher SUPER + T uses. Ending the mode has to tile the windows the mode floated and only those, so each one is tagged with `omarchy-mode-floated`. A window already floating when the mode reaches it -- an app with a float rule of its own, a dialog, one popped out with SUPER + T -- is left unclaimed and is still floating when the mode ends. The tag lives on the window rather than in a table here: it dies with the window, follows it between workspaces, and survives the config reload that plugging in a monitor performs, none of which a table on this side would do. A pinned window is left alone entirely, because tiling one unpins it as well. Windows arrive on a workspace three ways and a window rule only covers one of them, so the mode watches events: window.open for what opens there, window.move_to_workspace for what is carried in, and the same event for what is carried back out, which gives up the floating the mode gave it rather than staying floating on a workspace that tiles. Every tiled window is claimed before any of them is floated, because floating one member of a group floats the rest, and claiming as it went would find those already floating and leave them stranded. The mode is read back from the saved file rather than from the compositor. Hyprland reports a workspace's tiled layout and floating is not one, so a floating workspace still answers dwindle or scrolling; trusting that answer would skip a step of the cycle. A file saved before floating existed names a layout rather than a mode and falls through to what Hyprland reports, so the cycle keeps working and the next press rewrites it. Two faults in the old script had to go first, because saving the mode makes both persistent. Named workspaces report a negative id, which passed the `^-?[0-9]+$` guard, and Hyprland reads a leading "-" as a selector relative to the current workspace: pressing Super + L on a named workspace reconfigured workspace 1 instead, and would now save a file that did it again at every login. And the saved state was written under $HOME/.local/state while the Hyprland side reads XDG_STATE_HOME, which also had to reach package.path: the loader lists that directory but loads the files by module name, so a state home anywhere else listed the saved layouts and then loaded same-named ones from the home directory. Saving is checked too. The mode reaching the compositor and the mode reaching disk are separate things that can fail separately, and a save that failed silently would leave the next press reading a file that never got written, cycling between two modes and never reaching the third. The guard against named workspaces stops new files being written, but an earlier version already wrote them, and they go on being loaded at every login. A migration removes the saved layouts named for a negative id. The keyword fallback is gone. `hyprctl keyword` refuses a Lua configuration outright -- "keyword can't work with non-legacy parsers. Use eval." -- and exits 0 while doing so, so as a fallback it neither worked nor reported that it had not. A failed apply now says so and saves nothing, rather than announcing a layout the screen does not show and restoring it at the next login. Co-Authored-By: Codex XHigh * Add titlebars, edge snapping, and a window Shelf to floating workspaces Floating workspaces become a traditional desktop that works with every theme. Windows get themed titlebars from the omarchy-hyprland-titlebars plugin, which is loaded only while something floats. Dragging a titlebar to the left or right edge snaps the window to that half, and dragging it to the top or double-clicking maximizes it. The window floating mode places is sized, centered, and cascaded, and floating workspaces focus on click. Super + M, or the minus button, sets a window aside on the Shelf, which works from any workspace and restores the window onto the current one in that workspace's layout. Float All Workspaces makes floating the default while Super + L still sets a single workspace's layout. Co-Authored-By: Claude Opus 5.5 (1M context) * Bring resize on border to tiling too * Fix floating mode cleanup, fullscreen windows, and failed Shelf notes Turning Float All Workspaces off with no workspace layouts left behind started nothing to give the floated windows back. A window fullscreen when its workspace began floating now floats once it leaves fullscreen. A window whose Shelf note cannot be saved stays where it is, and the titlebars read the theme from where omarchy-theme-set writes it. Co-Authored-By: Claude Opus 5.5 (1M context) * Place the Shelf on the bar once, instead of on every minimize Putting it back on each minimize kept a Shelf the user moved where they put it, but re-added one they had removed. A migration places it once for existing bars; Super + Alt + M opens the Shelf either way. Co-Authored-By: Claude Opus 5.5 * Raise titlebars off the window background A titlebar in the theme's background color ran into the window under it. It takes the theme's lighter background, the raised surface every theme defines, and the selection color on themes that make the two the same. Co-Authored-By: Claude Opus 5.5 * Drop minimizing and edge snapping from floating workspaces A floating workspace is now titlebars with maximize and close, and nothing else. The Shelf (its bar widget, panel, commands, notes, Super + M and Super + Alt + M) goes with the minimize button, and the titlebar plugin's edge snapping is left off, along with the shell's snap preview. Co-Authored-By: Claude Opus 5.5 * Stop describing the titlebar package as snapping Co-Authored-By: Claude Opus 5.5 * Name web app windows in their launchers Chromium names a web app window after its URL, and titles it with the page title or just the domain. StartupWMClass ties the window to its launcher, so the titlebar plugin can show the launcher's Name instead: WhatsApp rather than web.whatsapp.com. New web apps, the shipped ones, and existing launchers (through a migration) all carry it. Co-Authored-By: Claude Opus 5.5 * Drop the titlebar package comment from the base list Co-Authored-By: Claude Opus 5.5 * Show the workspace layout in the bar and cycle it on click The focused workspace's mark is a square for dwindle, a triangle for scrolling, and a circle for floating, and clicking it cycles the layout as Super + L does. Both layout toggles ask the bar to read the new mode. Co-Authored-By: Claude Opus 5.5 * Show scrolling as a circle and floating as a triangle Co-Authored-By: Claude Opus 5.5 * Move the look of the desktop to Trigger > Appearance Menu Bar, Battery Percentage, Workspace Layout, Window Gaps, Animations, and 1-Window Ratio change how the desktop looks rather than switching a behavior, so they leave Trigger > Toggle for their own menu. Co-Authored-By: Claude Opus 5.5 * Choose a default workspace layout instead of floating everything Trigger > Appearance > Workspace Layout sets the layout of every workspace without one of its own: Tiling, Scrolling, or Floating. A tiled default is Hyprland's own layout, and a floating one is the floating mode. It replaces Float All Workspaces, which also overrode and set aside the layouts workspaces had been given with Super + L. Co-Authored-By: Claude Opus 5.5 * Title the workspace layout menu Default Workspace Layout Co-Authored-By: Claude Opus 5.5 * Use the animation icon for Appearance > Animations Co-Authored-By: Claude Opus 5.5 * Open clipboard history from Trigger Co-Authored-By: Claude Opus 5.5 * Leave edge resizing to looknfeel on floating workspaces looknfeel now resizes from borders everywhere, so floating workspaces only need to switch pointer focus to click. Co-Authored-By: Claude Opus 5.5 * Convert saved workspace layout rules to modes Released versions saved a workspace's layout as a plain workspace rule, which names no mode, so a floating default would override a workspace set to scrolling, and the bar and Super + L would misread it. A migration rewrites those files as modes. Co-Authored-By: Claude Opus 5.5 * Cycle workspace layouts backwards with Super + Alt + L Co-Authored-By: Claude Opus 5.5 * Test the Trigger > Appearance split and Clipboard History placement Co-Authored-By: Claude Opus 5.5 * Decode escaped web app URLs before naming their windows Co-Authored-By: Claude Opus 5.5 * Describe the workspace widget's layout mark and click Co-Authored-By: Claude Opus 5.5 * Let a workspace cycled onto the default follow it again Once Super + L gave a workspace a layout of its own, it kept it for good, so a later change of the default never reached it. Cycling onto the default layout now forgets the workspace's own instead of saving it. Without a saved default, the default is Hyprland's own layout, which the user's looknfeel may have set. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Codex XHigh Co-authored-by: Claude Opus 5.5 (1M context) --- applications/Basecamp.desktop | 1 + applications/Discord.desktop | 1 + applications/Google Contacts.desktop | 1 + applications/Google Maps.desktop | 1 + applications/Google Messages.desktop | 1 + applications/Google Photos.desktop | 1 + applications/WhatsApp.desktop | 1 + applications/X.desktop | 1 + applications/YouTube.desktop | 1 + bin/omarchy-hyprland-workspace-layout-current | 28 + bin/omarchy-hyprland-workspace-layout-default | 35 ++ bin/omarchy-hyprland-workspace-layout-toggle | 76 ++- bin/omarchy-webapp-install | 5 + bin/omarchy-webapp-window-class | 24 + default/hypr/bindings/tiling.lua | 3 +- default/hypr/looknfeel.lua | 4 +- default/hypr/workspace-layouts.lua | 13 + default/hypr/workspace-modes.lua | 385 +++++++++++++ default/omarchy/omarchy-menu.jsonc | 5 +- install/omarchy-base.packages | 1 + manual/04-navigation.md | 10 +- manual/07-hotkeys.md | 3 +- manual/13-toggles-idle-screensaver.md | 2 +- migrations/1787679775.sh | 31 ++ migrations/1791403310.sh | 3 + migrations/1791696584.sh | 27 + migrations/1791698594.sh | 14 + shell/plugins/bar/README.md | 2 +- shell/plugins/bar/widgets/Workspaces.qml | 63 ++- .../hyprland-floating-workspaces-test.sh | 457 ++++++++++++++++ .../shell.d/hyprland-workspace-layout-test.sh | 504 +++++++++++++++++- test/shell.d/webapp-install-test.sh | 18 +- .../webapp-window-class-migration-test.sh | 47 ++ .../workspace-layout-mode-migration-test.sh | 34 ++ 34 files changed, 1758 insertions(+), 45 deletions(-) create mode 100755 bin/omarchy-hyprland-workspace-layout-current create mode 100755 bin/omarchy-hyprland-workspace-layout-default create mode 100755 bin/omarchy-webapp-window-class create mode 100644 default/hypr/workspace-modes.lua create mode 100644 migrations/1787679775.sh create mode 100644 migrations/1791403310.sh create mode 100644 migrations/1791696584.sh create mode 100644 migrations/1791698594.sh create mode 100755 test/shell.d/hyprland-floating-workspaces-test.sh create mode 100755 test/shell.d/webapp-window-class-migration-test.sh create mode 100755 test/shell.d/workspace-layout-mode-migration-test.sh diff --git a/applications/Basecamp.desktop b/applications/Basecamp.desktop index 7c518ba4..fa27f499 100644 --- a/applications/Basecamp.desktop +++ b/applications/Basecamp.desktop @@ -2,6 +2,7 @@ Version=1.0 Name=Basecamp Exec=omarchy-launch-webapp https://launchpad.37signals.com +StartupWMClass=chrome-launchpad.37signals.com__-Default Terminal=false Type=Application Icon=basecamp diff --git a/applications/Discord.desktop b/applications/Discord.desktop index 461867d6..6ade2aa5 100644 --- a/applications/Discord.desktop +++ b/applications/Discord.desktop @@ -2,6 +2,7 @@ Version=1.0 Name=Discord Exec=omarchy-launch-webapp https://discord.com/channels/@me +StartupWMClass=chrome-discord.com__channels_@me-Default Terminal=false Type=Application Icon=omarchy-discord diff --git a/applications/Google Contacts.desktop b/applications/Google Contacts.desktop index 8f92c882..e1b5a864 100644 --- a/applications/Google Contacts.desktop +++ b/applications/Google Contacts.desktop @@ -2,6 +2,7 @@ Version=1.0 Name=Google Contacts Exec=omarchy-launch-webapp https://contacts.google.com/ +StartupWMClass=chrome-contacts.google.com__-Default Terminal=false Type=Application Icon=google-contacts diff --git a/applications/Google Maps.desktop b/applications/Google Maps.desktop index 3cf546b6..a1c6ffad 100644 --- a/applications/Google Maps.desktop +++ b/applications/Google Maps.desktop @@ -2,6 +2,7 @@ Version=1.0 Name=Google Maps Exec=omarchy-launch-webapp https://maps.google.com +StartupWMClass=chrome-maps.google.com__-Default Terminal=false Type=Application Icon=google-maps diff --git a/applications/Google Messages.desktop b/applications/Google Messages.desktop index 18474555..69d40782 100644 --- a/applications/Google Messages.desktop +++ b/applications/Google Messages.desktop @@ -2,6 +2,7 @@ Version=1.0 Name=Google Messages Exec=omarchy-launch-webapp https://messages.google.com/web/conversations +StartupWMClass=chrome-messages.google.com__web_conversations-Default Terminal=false Type=Application Icon=google-messages diff --git a/applications/Google Photos.desktop b/applications/Google Photos.desktop index 44ccd254..ed566ec2 100644 --- a/applications/Google Photos.desktop +++ b/applications/Google Photos.desktop @@ -2,6 +2,7 @@ Version=1.0 Name=Google Photos Exec=omarchy-launch-webapp https://photos.google.com/ +StartupWMClass=chrome-photos.google.com__-Default Terminal=false Type=Application Icon=google-photos diff --git a/applications/WhatsApp.desktop b/applications/WhatsApp.desktop index f193616d..ce02e4f0 100644 --- a/applications/WhatsApp.desktop +++ b/applications/WhatsApp.desktop @@ -2,6 +2,7 @@ Version=1.0 Name=WhatsApp Exec=omarchy-launch-webapp https://web.whatsapp.com/ +StartupWMClass=chrome-web.whatsapp.com__-Default Terminal=false Type=Application Icon=whatsapp diff --git a/applications/X.desktop b/applications/X.desktop index 02fe0424..2027a693 100644 --- a/applications/X.desktop +++ b/applications/X.desktop @@ -2,6 +2,7 @@ Version=1.0 Name=X Exec=omarchy-launch-webapp https://x.com/ +StartupWMClass=chrome-x.com__-Default Terminal=false Type=Application Icon=x diff --git a/applications/YouTube.desktop b/applications/YouTube.desktop index d6659d75..767eb10f 100644 --- a/applications/YouTube.desktop +++ b/applications/YouTube.desktop @@ -2,6 +2,7 @@ Version=1.0 Name=YouTube Exec=omarchy-launch-webapp https://youtube.com/ +StartupWMClass=chrome-youtube.com__-Default Terminal=false Type=Application Icon=youtube diff --git a/bin/omarchy-hyprland-workspace-layout-current b/bin/omarchy-hyprland-workspace-layout-current new file mode 100755 index 00000000..45627fed --- /dev/null +++ b/bin/omarchy-hyprland-workspace-layout-current @@ -0,0 +1,28 @@ +#!/bin/bash + +# omarchy:summary=Print the layout of a workspace: dwindle, scrolling, or floating +# omarchy:args=[workspace-id] + +if [[ -n $1 ]]; then + WORKSPACE_ID=$1 + TILED_LAYOUT=$(hyprctl workspaces -j | jq -r --arg id "$WORKSPACE_ID" 'first(.[] | select((.id | tostring) == $id) | .tiledLayout) // empty') +else + read -r WORKSPACE_ID TILED_LAYOUT < <(hyprctl activeworkspace -j | jq -r '"\(.id) \(.tiledLayout // "")"') +fi + +MODES_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/workspace-layouts" + +# Hyprland only reports the tiled layout, and floating is not one, so the mode is +# read back from the file omarchy-hyprland-workspace-layout-toggle wrote. Files +# saved before floating existed name no mode, and a workspace without a mode of +# its own follows the default layout. +if [[ $WORKSPACE_ID =~ ^[0-9]+$ ]] && (( WORKSPACE_ID >= 1 )); then + MODE=$(sed -n 's/.*mode = "\([a-z]*\)".*/\1/p' "$MODES_DIR/$WORKSPACE_ID.lua" 2>/dev/null) + + if [[ -z $MODE && -f $MODES_DIR/default.lua ]]; then + MODE=$(omarchy-hyprland-workspace-layout-default) + fi +fi + +MODE=${MODE:-$TILED_LAYOUT} +echo "${MODE:-dwindle}" diff --git a/bin/omarchy-hyprland-workspace-layout-default b/bin/omarchy-hyprland-workspace-layout-default new file mode 100755 index 00000000..94012b6b --- /dev/null +++ b/bin/omarchy-hyprland-workspace-layout-default @@ -0,0 +1,35 @@ +#!/bin/bash + +# omarchy:summary=Show or set the layout of workspaces without one of their own: dwindle, scrolling, or floating +# omarchy:args=[dwindle|scrolling|floating] +# omarchy:examples=omarchy hyprland workspace layout default | omarchy hyprland workspace layout default scrolling + +MODES_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/workspace-layouts" +DEFAULT_FILE="$MODES_DIR/default.lua" + +# Without a saved default, Hyprland's own layout is the default, which the +# user's looknfeel may have set. +if (( $# == 0 )); then + MODE=$(sed -n 's/.*default = "\([a-z]*\)".*/\1/p' "$DEFAULT_FILE" 2>/dev/null) + [[ -n $MODE ]] || MODE=$(hyprctl getoption general.layout -j 2>/dev/null | jq -r '.str // empty') + echo "${MODE:-dwindle}" + exit 0 +fi + +case $1 in + dwindle) LABEL=tiling ;; + scrolling | floating) LABEL=$1 ;; + *) + echo "Usage: omarchy-hyprland-workspace-layout-default [dwindle|scrolling|floating]" >&2 + exit 1 + ;; +esac + +# Saved even for dwindle, so the choice also wins over a layout set in the +# user's own looknfeel. Workspaces given a layout with Super + L keep theirs. +mkdir -p "$MODES_DIR" +printf 'o.workspace_mode({ default = "%s" })\n' "$1" >"$DEFAULT_FILE" + +hyprctl reload >/dev/null +omarchy-shell -q omarchy.workspaces refresh +omarchy-notification-send -g 󱂬 "New workspaces use the $LABEL layout" diff --git a/bin/omarchy-hyprland-workspace-layout-toggle b/bin/omarchy-hyprland-workspace-layout-toggle index 29b0c10e..7bb41912 100755 --- a/bin/omarchy-hyprland-workspace-layout-toggle +++ b/bin/omarchy-hyprland-workspace-layout-toggle @@ -1,21 +1,69 @@ #!/bin/bash -# omarchy:summary=Toggle the layout on the current active workspace between dwindle and scrolling +# omarchy:summary=Cycle the current active workspace through the dwindle, scrolling, and floating layouts +# omarchy:args=[back] + +DIRECTION=${1:-next} +if [[ $DIRECTION != "next" && $DIRECTION != "back" ]]; then + echo "Usage: omarchy-hyprland-workspace-layout-toggle [back]" >&2 + exit 1 +fi ACTIVE_WORKSPACE=$(hyprctl activeworkspace -j | jq -r '.id') -[[ $ACTIVE_WORKSPACE =~ ^-?[0-9]+$ ]] || exit 1 -CURRENT_LAYOUT=$(hyprctl activeworkspace -j | jq -r '.tiledLayout') -LAYOUTS_DIR="$HOME/.local/state/omarchy/workspace-layouts" -LAYOUT_FILE="$LAYOUTS_DIR/$ACTIVE_WORKSPACE.lua" +# Named workspaces report a negative id, and Hyprland reads a leading "-" as a +# selector relative to the current workspace: targeting one lands on workspace 1 +# and reconfigures that instead. +[[ $ACTIVE_WORKSPACE =~ ^[0-9]+$ ]] || exit 1 +MODES_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/workspace-layouts" +MODE_FILE="$MODES_DIR/$ACTIVE_WORKSPACE.lua" -case "$CURRENT_LAYOUT" in - dwindle) NEW_LAYOUT=scrolling ;; - *) NEW_LAYOUT=dwindle ;; -esac +CURRENT_MODE=$(omarchy-hyprland-workspace-layout-current) -mkdir -p "$LAYOUTS_DIR" -printf 'hl.workspace_rule({ workspace = "%s", layout = "%s" })\n' "$ACTIVE_WORKSPACE" "$NEW_LAYOUT" >"$LAYOUT_FILE" +if [[ $DIRECTION == "back" ]]; then + case "$CURRENT_MODE" in + dwindle) NEW_MODE=floating ;; + floating) NEW_MODE=scrolling ;; + *) NEW_MODE=dwindle ;; + esac +else + case "$CURRENT_MODE" in + dwindle) NEW_MODE=scrolling ;; + scrolling) NEW_MODE=floating ;; + *) NEW_MODE=dwindle ;; + esac +fi -hyprctl eval "hl.workspace_rule({ workspace = \"$ACTIVE_WORKSPACE\", layout = \"$NEW_LAYOUT\" })" >/dev/null 2>&1 || \ - hyprctl keyword workspace "$ACTIVE_WORKSPACE, layout:$NEW_LAYOUT" -omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_LAYOUT" +APPLY="o.workspace_mode({ workspace = \"$ACTIVE_WORKSPACE\", mode = \"$NEW_MODE\" })" + +# Saving a mode that did not apply would leave the file naming one layout while +# the screen shows another, and restore it at the next login. +APPLIED=0 +if hyprctl eval "$APPLY" >/dev/null 2>&1; then + APPLIED=1 +fi + +if (( ! APPLIED )); then + omarchy-notification-send -g 󱂬 "Workspace layout needs a Hyprland reload" +elif [[ $NEW_MODE == "$(omarchy-hyprland-workspace-layout-default)" ]] && rm -f "$MODE_FILE"; then + # Cycled onto the default, a workspace goes back to following it, so a later + # change of the default reaches it too. + omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_MODE" +elif mkdir -p "$MODES_DIR" && printf '%s\n' "$APPLY" >"$MODE_FILE"; then + omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_MODE" +else + # The mode is on screen but nothing will bring it back, and the next press + # reads the mode from this file, so say so rather than counting it as set. + omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_MODE but could not be saved" +fi + +# The bar shows the focused workspace's layout. +if (( APPLIED )); then + omarchy-shell -q omarchy.workspaces refresh +fi + +# Titlebars and shadows are set up while the config loads, and only +# when something floats, so the first floating workspace needs a reload to get +# them. Once the titlebar plugin is in, later ones have them already. +if (( APPLIED )) && [[ $NEW_MODE == "floating" ]] && ! hyprctl plugin list -j 2>/dev/null | jq -e 'any(.[]?; .name == "hyprbars")' >/dev/null 2>&1; then + hyprctl reload >/dev/null +fi diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index e07354ba..7a9b31ac 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -234,6 +234,11 @@ if [[ -n $MIME_TYPES ]]; then printf 'MimeType=%s\n' "$(desktop_string_escape "$MIME_TYPES")" >>"$DESKTOP_FILE" fi +# Floating titlebars name the window after this entry rather than its page title. +if [[ -z $CUSTOM_EXEC ]]; then + printf 'StartupWMClass=%s\n' "$(desktop_string_escape "$(omarchy-webapp-window-class "$APP_URL")")" >>"$DESKTOP_FILE" +fi + chmod +x "$DESKTOP_FILE" if [[ $INTERACTIVE_MODE == "true" ]]; then diff --git a/bin/omarchy-webapp-window-class b/bin/omarchy-webapp-window-class new file mode 100755 index 00000000..99d2562a --- /dev/null +++ b/bin/omarchy-webapp-window-class @@ -0,0 +1,24 @@ +#!/bin/bash + +# omarchy:summary=Print the window class Chromium gives a web app opened at a URL +# omarchy:args= +# omarchy:hidden=true + +# Chromium names an --app window after the URL's host and path, so a web app's +# launcher can name its window in StartupWMClass, which titlebars use to show +# the app's name rather than the page title. + +url=${1:-} +if [[ -z $url ]]; then + echo "Usage: omarchy-webapp-window-class " >&2 + exit 1 +fi + +address=${url#*://} +address=${address%%[?#]*} +host=${address%%/*} +path=${address:${#host}} +host=${host##*@} +host=${host%%:*} + +printf 'chrome-%s_%s-Default\n' "${host,,}" "${path:-/}" | sed 's|/|_|g' diff --git a/default/hypr/bindings/tiling.lua b/default/hypr/bindings/tiling.lua index 72721b51..2ebacf26 100644 --- a/default/hypr/bindings/tiling.lua +++ b/default/hypr/bindings/tiling.lua @@ -11,7 +11,8 @@ o.bind("SUPER + ALT + F", "Full width", hl.dsp.window.fullscreen({ mode = "maxim o.bind("SUPER + O", "Pop window out (float & pin)", "omarchy-hyprland-window-pop") o.bind("SUPER + ALT + Home", "Save window width", "omarchy-hyprland-window-width save") o.bind("SUPER + Home", "Restore window width", "omarchy-hyprland-window-width restore") -o.bind("SUPER + L", "Toggle workspace layout", "omarchy-hyprland-workspace-layout-toggle") +o.bind("SUPER + L", "Cycle workspace layout", "omarchy-hyprland-workspace-layout-toggle") +o.bind("SUPER + ALT + L", "Cycle workspace layout back", "omarchy-hyprland-workspace-layout-toggle back") o.bind("SUPER + LEFT", "Focus on left window", hl.dsp.focus({ direction = "l" })) o.bind("SUPER + RIGHT", "Focus on right window", hl.dsp.focus({ direction = "r" })) diff --git a/default/hypr/looknfeel.lua b/default/hypr/looknfeel.lua index 4a0bd42a..a2f3c12f 100644 --- a/default/hypr/looknfeel.lua +++ b/default/hypr/looknfeel.lua @@ -14,7 +14,9 @@ hl.config({ inactive_border = inactive_border_color, }, - resize_on_border = false, + resize_on_border = true, + extend_border_grab_area = 8, + hover_icon_on_border = true, allow_tearing = false, layout = "dwindle", }, diff --git a/default/hypr/workspace-layouts.lua b/default/hypr/workspace-layouts.lua index 6c33184e..27fec5ba 100644 --- a/default/hypr/workspace-layouts.lua +++ b/default/hypr/workspace-layouts.lua @@ -3,6 +3,19 @@ local paths = require("default.hypr.paths") local require_all = require("default.hypr.require_all") +-- The saved files call o.workspace_mode, so it has to exist before they load. +local workspace_modes = require("default.hypr.workspace-modes") + local layouts_dir = paths.state_home .. "/omarchy/workspace-layouts" +-- The directory is found through XDG_STATE_HOME but the files are loaded by +-- module name, and bootstrap only puts ~/.local/state on the search path. A +-- state home anywhere else would list the saved layouts and then fail to require +-- them, or load same-named ones from under the home directory instead. +package.path = paths.state_home .. "/?.lua;" .. package.path + require_all.files(layouts_dir, "omarchy.workspace-layouts", { reload = true }) + +-- With every saved mode known, bring the open windows in line and set up what a +-- floating workspace needs. +workspace_modes.apply() diff --git a/default/hypr/workspace-modes.lua b/default/hypr/workspace-modes.lua new file mode 100644 index 00000000..aa6aaa3c --- /dev/null +++ b/default/hypr/workspace-modes.lua @@ -0,0 +1,385 @@ +-- Workspace modes for omarchy-hyprland-workspace-layout-toggle. +-- +-- Hyprland has dwindle and scrolling as tiled layouts, but nothing that floats a +-- whole workspace: it reports a workspace's `tiledLayout` and takes an unknown +-- layout name in silence, falling back to dwindle. So floating is applied to the +-- windows themselves. +-- +-- Ending the mode has to tile the windows the mode floated and only those, so +-- each one is tagged. The tag lives on the window: it dies with it, follows it +-- between workspaces, and survives the config reload that a monitor being +-- plugged in performs, none of which a table on this side would do. +-- +-- Every window on a floating workspace also carries a second tag, whether the +-- mode floated it or it was floating already. Titlebars, shadows, and full +-- opacity follow that tag, so a dialog on a floating workspace can be dragged by +-- its titlebar too, and a window carried out to a tiled one loses it. + +require("default.hypr.helpers") +local paths = require("default.hypr.paths") + +local FLOATED_TAG = "omarchy-mode-floated" +local WORKSPACE_TAG = "omarchy-floating-workspace" +local TITLEBARS = "/usr/lib/omarchy-hyprland-titlebars/titlebars.so" + +local M = {} + +local modes = {} +local default_mode = nil +local watching = false +local cascade = 0 + +local function set_floating(window, floating) + hl.dispatch(hl.dsp.window.float({ action = floating and "on" or "off", window = window })) +end + +local function set_claimed(window, claimed) + hl.dispatch(hl.dsp.window.tag({ tag = (claimed and "+" or "-") .. FLOATED_TAG, window = window })) +end + +local function set_on_floating_workspace(window, on) + hl.dispatch(hl.dsp.window.tag({ tag = (on and "+" or "-") .. WORKSPACE_TAG, window = window })) +end + +local function claimed() + local addresses = {} + + for _, window in ipairs(hl.get_windows({ tag = FLOATED_TAG })) do + addresses[window.address] = true + end + + return addresses +end + +-- Special workspaces, such as the scratchpad, and named ones, which report a +-- negative id, keep whatever state a window arrived with. +local function floating_workspace(id) + id = tonumber(id) + + if not id or id < 1 then + return false + end + + local mode = modes[tostring(id)] + if mode then + return mode == "floating" + end + + return default_mode == "floating" +end + +local function anything_floats() + if default_mode == "floating" then + return true + end + + for _, mode in pairs(modes) do + if mode == "floating" then + return true + end + end + + return false +end + +-- A window the mode floats opens at a comfortable size in the middle of its +-- monitor, each one stepped from the last so a workspace floated all at once does +-- not leave every window stacked exactly on the one beneath it. +local function place(window) + local monitor = window.monitor + if not monitor or not monitor.width then + return + end + + local width = math.floor(monitor.width / monitor.scale) + local height = math.floor(monitor.height / monitor.scale) + + if (monitor.transform or 0) % 2 == 1 then + width, height = height, width + end + + local reserved = monitor.reserved or {} + width = width - (reserved.left or 0) - (reserved.right or 0) + height = height - (reserved.top or 0) - (reserved.bottom or 0) + + hl.dispatch(hl.dsp.window.resize({ + x = math.min(1100, math.floor(width * 0.64)), + y = math.min(760, math.floor(height * 0.68)), + relative = false, + window = window, + })) + hl.dispatch(hl.dsp.window.center({ window = window })) + + cascade = (cascade + 1) % 5 + hl.dispatch(hl.dsp.window.move({ x = cascade * 24 - 48, y = cascade * 24 - 48, relative = true, window = window })) +end + +-- A window already floating on its own account -- an app with a float rule of +-- its own, a dialog, one popped out with SUPER + T -- is left unclaimed, so it +-- is still floating when the mode ends. A fullscreen window is left as it is. +-- +-- Every tiled window is claimed before any of them is floated: floating one +-- member of a group floats the rest, and a single pass would then find those +-- already floating and leave them to be stranded when the mode ends. +local function float_for_mode(windows) + local claiming = {} + + for _, window in ipairs(windows) do + set_on_floating_workspace(window, true) + + if not window.floating and (window.fullscreen or 0) == 0 then + set_claimed(window, true) + table.insert(claiming, window) + end + end + + for _, window in ipairs(claiming) do + set_floating(window, true) + place(window) + end +end + +-- A pinned window is one the user asked to keep above everything, and tiling it +-- would unpin it as well, so the mode leaves it be. +local function tile_for_mode(window, mine) + set_on_floating_workspace(window, false) + + if not mine[window.address] or window.pinned then + return + end + + set_claimed(window, false) + set_floating(window, false) +end + +-- The workspace is passed in when a window is still on its way to it. +local function arrange(windows, workspace) + local floating = {} + local mine = claimed() + + for _, window in ipairs(windows) do + local id = tonumber(workspace and workspace.id or (window.workspace and window.workspace.id)) + + if id and id >= 1 then + if floating_workspace(id) then + table.insert(floating, window) + else + tile_for_mode(window, mine) + end + end + end + + float_for_mode(floating) +end + +-- Overlapping windows are placed with the pointer, so a floating workspace +-- focuses on click rather than on hover. Every other workspace keeps the user's +-- own setting. +local input = {} + +local function update_input() + local workspace = hl.get_active_workspace() + local active = workspace ~= nil and floating_workspace(workspace.id) + + if active == input.floating then + return + end + + input.floating = active + + hl.config({ input = { follow_mouse = active and 0 or input.follow_mouse } }) +end + +-- Windows arrive by opening and by being carried in, and a window rule only ever +-- fires for the first. One carried back out gives up the floating the mode gave +-- it rather than staying floating somewhere that tiles. +local function watch() + if watching then + return + end + + watching = true + + input.follow_mouse = hl.get_config("input.follow_mouse") + + hl.on("window.open", function(window) + arrange({ window }) + end) + + -- A move is still being committed when this fires, and changing the window's + -- floating state underneath it loses the move. Let it land first. + hl.on("window.move_to_workspace", function(window, workspace) + hl.timer(function() + arrange({ window }, workspace) + end, { type = "oneshot", timeout = 1 }) + end) + + -- A fullscreen window is left as it is, so it is arranged once it comes back + -- out: a tiled one leaving fullscreen on a floating workspace floats then. + hl.on("window.fullscreen", function(window) + hl.timer(function() + arrange({ window }) + end, { type = "oneshot", timeout = 1 }) + end) + + hl.on("workspace.active", update_input) + hl.on("monitor.focused", update_input) +end + +function o.workspace_mode(spec) + watch() + + -- The default for every workspace that has no mode of its own, chosen under + -- Trigger > Appearance > Workspace Layout. A tiled default is Hyprland's own + -- layout; a floating one is arranged once the saved modes have all loaded. + if spec.default then + default_mode = spec.default + if spec.default ~= "floating" then + hl.config({ general = { layout = spec.default } }) + end + return + end + + local workspace = tostring(spec.workspace) + + -- Floating keeps whatever tiled layout the workspace had, so nothing has to be + -- chosen for it on the way back out. + if spec.mode ~= "floating" then + hl.workspace_rule({ workspace = workspace, layout = spec.mode }) + end + + modes[workspace] = spec.mode + + arrange(hl.get_workspace_windows(workspace)) + update_input() +end + +local function theme_colors() + local colors = {} + -- omarchy-theme-set writes the current theme under ~/.local/state whatever + -- XDG_STATE_HOME says, and Hyprland loads the theme's own config from there. + local file = io.open(paths.home .. "/.local/state/omarchy/current/theme/colors.toml", "r") + + if file then + for line in file:lines() do + local key, value = line:match('^%s*([%w_]+)%s*=%s*"#(%x%x%x%x%x%x)"') + if key then + colors[key] = "rgb(" .. value .. ")" + end + end + + file:close() + end + + local background = colors.background or "rgb(1a1b26)" + -- A titlebar in the window's own background color runs into the window, so it + -- takes the theme's raised surface. Themes that make that the background too + -- fall back to their selection color. + local titlebar = colors.lighter_background + if not titlebar or titlebar == background then + titlebar = colors.selection or background + end + + return { + background = background, + foreground = colors.foreground or "rgb(c0caf5)", + titlebar = titlebar, + } +end + +-- Titlebars come from omarchy-hyprland-titlebars. Hyprland loads a plugin only +-- while the config asks for it, and unloads it at the first reload that does +-- not, so asking only while something floats keeps it out of tiling sessions. +-- The plugin reloads the config once it is in, which is the pass that finds its +-- options here. +local function titlebars() + local file = io.open(TITLEBARS, "r") + if not file then + return + end + file:close() + + hl.plugin.load(TITLEBARS) + + if not hl.plugin.hyprbars then + return + end + + local colors = theme_colors() + local window = [[window = "address:%WINDOW%"]] + + hl.config({ + plugin = { + hyprbars = { + enabled = true, + workspace_tag = WORKSPACE_TAG, + bar_height = 30, + bar_color = colors.titlebar, + col = { text = colors.foreground }, + bar_text_font = "monospace", + bar_text_size = 12, + bar_text_weight = "normal", + bar_text_align = "left", + bar_buttons_alignment = "right", + bar_part_of_window = true, + bar_precedence_over_border = true, + bar_padding = 10, + bar_button_padding = 8, + button_rounding = 0, + icon_on_hover = false, + inactive_button_color = colors.titlebar, + on_double_click = "hyprctl dispatch 'hl.dsp.window.fullscreen({ mode = \"maximized\", action = \"toggle\", " .. window .. " })'", + }, + }, + }) + + for _, button in ipairs({ + { icon = "×", action = "hyprctl dispatch 'hl.dsp.window.close({ " .. window .. " })'" }, + { icon = "□", action = "hyprctl dispatch 'hl.dsp.window.fullscreen({ mode = \"maximized\", action = \"toggle\", " .. window .. " })'" }, + }) do + hl.plugin.hyprbars.add_button({ + bg_color = colors.titlebar, + fg_color = colors.foreground, + size = 20, + icon = button.icon, + action = button.action, + }) + end +end + +-- Runs once every saved mode has loaded. A reload is a fresh start on this side +-- but not for the windows, so each one is brought in line with the modes as they +-- now stand: moving the default away from floating has to give back what it +-- floated, even though nothing names those workspaces any more. +function M.apply() + -- A default that no longer floats can leave no saved mode at all to start the + -- watchers, while the windows it floated still carry its tags. + local leftovers = #hl.get_windows({ tag = FLOATED_TAG }) > 0 or #hl.get_windows({ tag = WORKSPACE_TAG }) > 0 + + if anything_floats() or leftovers then + watch() + end + + if watching then + arrange(hl.get_windows()) + update_input() + end + + if not anything_floats() then + return + end + + -- Overlapping windows need depth to tell apart, and a translucent window over + -- another is hard to read. + if not hl.get_config("decoration.shadow.enabled") then + hl.config({ decoration = { shadow = { enabled = true, range = 16, render_power = 3, color = "rgba(00000035)" } } }) + o.window(".*", { no_shadow = true }) + o.window({ tag = WORKSPACE_TAG }, { no_shadow = false }) + end + + o.window({ tag = WORKSPACE_TAG }, { opacity = "1.0 override 1.0 override" }) + + titlebars() +end + +return M diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 710dfb90..0823c184 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -97,10 +97,13 @@ "trigger.toggle.nightlight": {"icon":"󰔎","label":"Nightlight","action":"omarchy-toggle-nightlight"}, "trigger.appearance.top-bar": {"icon":"󰍜","label":"Menu Bar","action":"omarchy-toggle-bar"}, "trigger.appearance.battery-percentage": {"icon":"󰁹","label":"Battery Percentage","when":"omarchy-hw-laptop","action":"omarchy-shell omarchy.power togglePercentage"}, - "trigger.appearance.workspace-layout": {"icon":"󱂬","label":"Workspace Layout","action":"omarchy-hyprland-workspace-layout-toggle"}, + "trigger.appearance.workspace-layout": {"icon":"󱂬","label":"Workspace Layout","title":"Default Workspace Layout"}, "trigger.appearance.window-gaps": {"icon":"","label":"Window Gaps","action":"omarchy-hyprland-window-gaps-toggle"}, "trigger.appearance.animations": {"icon":"󰗘","label":"Animations","action":"omarchy-toggle-animations"}, "trigger.appearance.one-window-ratio": {"icon":"","label":"1-Window Ratio","action":"omarchy-hyprland-window-single-square-aspect-toggle"}, + "trigger.appearance.workspace-layout.dwindle": {"icon":"󱓻","label":"Tiling","checked":"[[ $(omarchy-hyprland-workspace-layout-default) == \"dwindle\" ]]","action":"omarchy-hyprland-workspace-layout-default dwindle"}, + "trigger.appearance.workspace-layout.scrolling": {"icon":"󰝥","label":"Scrolling","checked":"[[ $(omarchy-hyprland-workspace-layout-default) == \"scrolling\" ]]","action":"omarchy-hyprland-workspace-layout-default scrolling"}, + "trigger.appearance.workspace-layout.floating": {"icon":"󰔶","label":"Floating","checked":"[[ $(omarchy-hyprland-workspace-layout-default) == \"floating\" ]]","action":"omarchy-hyprland-workspace-layout-default floating"}, "trigger.tests.network-speedtest": {"icon":"󰓅","label":"Network Speed Test","action":"omarchy-shell shell summon omarchy.speedtest"}, "trigger.tests.disk-speedtest": {"icon":"󰋊","label":"Disk Speed Test","action":"omarchy-shell shell summon omarchy.disk-speedtest"}, diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 99324316..ef6cd9f5 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -99,6 +99,7 @@ omacalc omacut omawrite omarchy-nvim +omarchy-hyprland-titlebars omasnap # OWE owns desktop video backgrounds and feeds the lock screen its frames. owe diff --git a/manual/04-navigation.md b/manual/04-navigation.md index 5a939825..8217cb3c 100644 --- a/manual/04-navigation.md +++ b/manual/04-navigation.md @@ -34,9 +34,11 @@ Omarchy's default layout is called dwindle. It keeps all the windows you open on But you can also choose to turn a workspace into the scrolling layout where windows are lined up side-by-side, beyond the visible edge of the display. You turn a single workspace into this layout via `Super + L`. +Pressing `Super + L` again turns the workspace floating, where windows overlap freely and you place them yourself, and a third press returns it to dwindle. `Super + Alt + L` goes through them the other way. A window that floats anyway — one you popped out with `Super + T`, or an app that always opens floating — keeps floating when the workspace goes back to tiling. The top bar marks the workspace you're on with its layout: a square for dwindle, a circle for scrolling, and a triangle for floating. Clicking that mark cycles the layout too. + ![navigation-scrolling-layout](images/navigation-scrolling-layout.webp) -The choice is per workspace, and it sticks. So you can keep workspace 1 on dwindle for browsing and workspace 2 on scrolling for code, and they'll come back that way after a restart. (The same toggle is under _Trigger > Appearance > Workspace Layout_ in the Omarchy menu). +The choice is per workspace, and it sticks. So you can keep workspace 1 on dwindle for browsing and workspace 2 on scrolling for code, and they'll come back that way after a restart. If you wish to use the scrolling layout as the default, you can set that in `~/.config/hypr/looknfeel.lua`: @@ -48,6 +50,12 @@ hl.config({ }) ``` +### Floating workspaces + +A floating workspace works like a traditional desktop: windows overlap, and you move and size them yourself. Each window gets a titlebar in your theme's colors, with buttons to maximize and close. Click a window to bring it forward. Resize it from any edge or corner. Double-click a titlebar to maximize the window while keeping the top bar in view, and again to get its old size back. + +New workspaces, and any you haven't given a layout of their own with `Super + L`, use the default layout. Pick it under _Trigger > Appearance > Workspace Layout_: Tiling, Scrolling, or Floating. `omarchy-hyprland-workspace-layout-default scrolling` sets it from a terminal. + ### Grouping windows Windows can be grouped using `Super + G`. Once you're in a group, every window you start while that's active will belong to the group. You can move between these grouped windows using `Super + Ctrl + Arrow Left/Right` or `Super + Alt + 1/2/3/4` to go directly to grouped window in order. diff --git a/manual/07-hotkeys.md b/manual/07-hotkeys.md index d9c9fedd..ed779c5d 100644 --- a/manual/07-hotkeys.md +++ b/manual/07-hotkeys.md @@ -15,7 +15,8 @@ You can see all the main keyboard bindings with `Super + K` (Tmux bindings with | `Super + T` | Toggle window between tiling/floating | | `Super + J` | Toggle window position (horizontal/vertical) | | `Super + O` | Toggle popping window into sticky'n'floating | -| `Super + L` | Toggle between dwindle and scrolling layout | +| `Super + L` | Cycle through the dwindle, scrolling, and floating layouts | +| `Super + Alt + L` | Cycle through the layouts in reverse | | `Super + P` | Toggle pseudo window style (natural v stretch) | | `Super + F` | Go full screen | | `Super + Alt + F` | Go full width | diff --git a/manual/13-toggles-idle-screensaver.md b/manual/13-toggles-idle-screensaver.md index 6c481ce6..7c996789 100644 --- a/manual/13-toggles-idle-screensaver.md +++ b/manual/13-toggles-idle-screensaver.md @@ -24,7 +24,7 @@ From the terminal, the same switches are `omarchy toggle `. Run `omarchy The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled device's name is saved to a small state file that Hyprland reads on startup to disable it again. -_Trigger > Appearance_ holds the menu bar and animations, plus a few things that aren't `omarchy toggle` commands but behave the same: battery percentage in the bar, workspace layout (`Super + L`), window gaps (`Super + Shift + Backspace`), and the 1-window square aspect (`Super + Ctrl + Backspace`). +_Trigger > Appearance_ holds the menu bar and animations, plus a few things that aren't `omarchy toggle` commands but behave the same: battery percentage in the bar, the default workspace layout, window gaps (`Super + Shift + Backspace`), and the 1-window square aspect (`Super + Ctrl + Backspace`). Most of these are just a flag file under `~/.local/state/omarchy/toggles/`. If you want to branch on one in a script, `omarchy-toggle-enabled` gives you an exit code instead of making you go looking: diff --git a/migrations/1787679775.sh b/migrations/1787679775.sh new file mode 100644 index 00000000..3a521ff1 --- /dev/null +++ b/migrations/1787679775.sh @@ -0,0 +1,31 @@ +echo "Drop saved workspace layouts belonging to named workspaces" + +# omarchy-hyprland-workspace-layout-toggle acted on whatever id activeworkspace +# reported, and a named workspace reports a negative one. Hyprland reads a +# leading "-" as a selector relative to the current workspace, so a layout saved +# for one is applied to workspace 1 at every login instead. The command no longer +# writes these; this clears the ones already on disk. It wrote to ~/.local/state +# whatever XDG_STATE_HOME said, so both are cleared. +removed=0 + +for state_home in "$HOME/.local/state" "${XDG_STATE_HOME:-}"; do + [[ -n $state_home ]] || continue + + layouts_dir="$state_home/omarchy/workspace-layouts" + [[ -d $layouts_dir ]] || continue + + for saved in "$layouts_dir"/-*.lua; do + if [[ -f $saved ]]; then + rm -f -- "$saved" + removed=1 + fi + done +done + +# The package hook reloads Hyprland before migrations run, so the rule from the +# file just deleted is already applied to workspace 1. Hyprland watches the files +# it required and picks the deletion up on its own, but not for a user who has +# turned autoreload off, so ask for one. +if (( removed )); then + hyprctl reload >/dev/null 2>&1 || true +fi diff --git a/migrations/1791403310.sh b/migrations/1791403310.sh new file mode 100644 index 00000000..bc30bb73 --- /dev/null +++ b/migrations/1791403310.sh @@ -0,0 +1,3 @@ +echo "Install titlebars for floating workspaces" + +omarchy-pkg-add omarchy-hyprland-titlebars diff --git a/migrations/1791696584.sh b/migrations/1791696584.sh new file mode 100644 index 00000000..e6e85614 --- /dev/null +++ b/migrations/1791696584.sh @@ -0,0 +1,27 @@ +echo "Name web app windows in their launchers, so titlebars show the app's name" + +# Chromium names a web app window after its URL, and the launcher's +# StartupWMClass is how titlebars find the launcher's Name for it. Launchers +# that already name a class, and those with custom commands, are left alone. +for desktop in "${XDG_DATA_HOME:-$HOME/.local/share}"/applications/*.desktop; do + [[ -f $desktop ]] || continue + grep -q '^StartupWMClass=' "$desktop" && continue + + exec_line=$(grep -m1 '^Exec=omarchy-launch-webapp ' "$desktop") || continue + + # The installer escaped the URL twice: as one Exec argument, then as desktop + # file text. Undo the file layer, then the argument quoting. + url=${exec_line#Exec=omarchy-launch-webapp } + url=${url//\\\\/\\} + if [[ $url == \"*\" ]]; then + url=$(sed 's/\\\(.\)/\1/g' <<<"${url:1:${#url}-2}") + fi + url=${url//%%/%} + [[ $url =~ ^https?:// && $url != *[[:space:]]* ]] || continue + + class=$(omarchy-webapp-window-class "$url") + target=$(realpath "$desktop") + CLASS=${class//\\/\\\\} awk '!done && /^Exec=/ { print; print "StartupWMClass=" ENVIRON["CLASS"]; done = 1; next } { print }' "$target" >"$target.omarchy-migrate" && + cat "$target.omarchy-migrate" >"$target" + rm -f "$target.omarchy-migrate" +done diff --git a/migrations/1791698594.sh b/migrations/1791698594.sh new file mode 100644 index 00000000..69081288 --- /dev/null +++ b/migrations/1791698594.sh @@ -0,0 +1,14 @@ +echo "Save workspace layouts as modes, so a default layout keeps them" + +# Workspace layouts were saved as plain Hyprland rules. Floating and the default +# layout read the mode a workspace was given, and a rule names none, so a +# workspace set to scrolling would follow a floating default instead. The old +# toggle wrote to ~/.local/state whatever XDG_STATE_HOME said, so both are read. +for state_home in "$HOME/.local/state" "${XDG_STATE_HOME:-}"; do + [[ -n $state_home ]] || continue + + for saved in "$state_home"/omarchy/workspace-layouts/*.lua; do + [[ -f $saved ]] || continue + sed -i --follow-symlinks 's/^hl\.workspace_rule({ workspace = "\([0-9]*\)", layout = "\([a-z]*\)" })$/o.workspace_mode({ workspace = "\1", mode = "\2" })/' "$saved" + done +done diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index d0934f11..255c4bc6 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -55,7 +55,7 @@ Example `shell.json` (bar subtree only shown): | Name | What it does | Interactions | |---|---|---| | `omarchy.menu` | Omarchy menu launcher | left = menu · right = terminal | -| `omarchy.workspaces` | Hyprland workspace switcher | left = focus workspace | +| `omarchy.workspaces` | Hyprland workspace switcher; the focused workspace shows its layout | left = focus workspace, or cycle the focused one's layout | | `omarchy.clock` | Date/time label + popup with a month grid, ISO week numbers, and month stepping | left = popup · right = cycle label format · middle = timezone selector | | `omarchy.media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup | | `omarchy.indicators` | Manual state indicators | left = indicator action | diff --git a/shell/plugins/bar/widgets/Workspaces.qml b/shell/plugins/bar/widgets/Workspaces.qml index ec0482a5..4d808baf 100644 --- a/shell/plugins/bar/widgets/Workspaces.qml +++ b/shell/plugins/bar/widgets/Workspaces.qml @@ -1,6 +1,8 @@ import QtQuick import QtQuick.Layouts +import Quickshell import Quickshell.Hyprland +import Quickshell.Io import qs.Commons import qs.Ui @@ -35,6 +37,60 @@ BarWidget { root.bar.run("hyprctl dispatch " + Util.shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })")) } + // The focused workspace shows its layout: a square tiles, a circle scrolls, + // and a triangle floats. Clicking it cycles to the next one, as Super + L does. + property string focusedMode: "dwindle" + property bool modeStale: false + + function modeGlyph(mode) { + if (mode === "scrolling") return "\uDB81\uDF65" + if (mode === "floating") return "\uDB81\uDD36" + return "\uDB85\uDCFB" + } + + function refresh() { + if (modeProc.running) { + root.modeStale = true + return + } + modeProc.running = true + } + + function cycleLayout() { + if (root.bar) root.bar.run("omarchy-hyprland-workspace-layout-toggle") + } + + Component.onCompleted: refresh() + + Connections { + target: Hyprland + function onFocusedWorkspaceChanged() { root.refresh() } + } + + Process { + id: modeProc + command: ["omarchy-hyprland-workspace-layout-current"] + stdout: StdioCollector { + onStreamFinished: { + var mode = text.trim() + if (mode) root.focusedMode = mode + } + } + onExited: { + if (!root.modeStale) return + root.modeStale = false + root.refresh() + } + } + + // Super + L and the default layout change the mode without a focus change, + // so they ask every bar to read it again. + ShellIpc { + target: "omarchy.workspaces" + + function refresh(): void { root.broadcast("refresh") } + } + readonly property real trailingGap: root.vertical ? 0 : Style.spaceReal(1.5) implicitWidth: grid.implicitWidth + trailingGap @@ -59,13 +115,16 @@ BarWidget { readonly property bool focused: Hyprland.focusedWorkspace !== null && Hyprland.focusedWorkspace.id === modelData bar: root.bar - text: focused ? "\uDB85\uDCFB" : (modelData === 10 ? "0" : String(modelData)) + text: focused ? root.modeGlyph(root.focusedMode) : (modelData === 10 ? "0" : String(modelData)) opacity: occupied || focused ? 1 : 0.5 horizontalMargin: 6 verticalPadding: 6 fixedWidth: root.vertical ? root.barSize : Style.space(20) fixedHeight: root.barSize - onPressed: function() { root.focusWorkspace(modelData) } + onPressed: function() { + if (focused) root.cycleLayout() + else root.focusWorkspace(modelData) + } } } } diff --git a/test/shell.d/hyprland-floating-workspaces-test.sh b/test/shell.d/hyprland-floating-workspaces-test.sh new file mode 100755 index 00000000..67f6d3ce --- /dev/null +++ b/test/shell.d/hyprland-floating-workspaces-test.sh @@ -0,0 +1,457 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua +require_command jq + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +stub_dir="$tmpdir/bin" +fake="$tmpdir/hyprland" +home_dir="$tmpdir/home" +state_home="$home_dir/.local/state" +runtime_dir="$tmpdir/runtime" +modes_dir="$state_home/omarchy/workspace-layouts" +mkdir -p "$stub_dir" "$fake" "$home_dir" "$runtime_dir" + +# Answers come from files the cases write, and everything else is logged. +cat >"$stub_dir/hyprctl" <<'EOF' +#!/bin/bash + +case "$1" in + activeworkspace) cat "$FAKE/activeworkspace.json" ;; + activewindow) cat "$FAKE/activewindow.json" ;; + clients) cat "$FAKE/clients.json" ;; + monitors) cat "$FAKE/monitors.json" ;; + workspaces) cat "$FAKE/workspaces.json" ;; + plugin) printf '[]\n' ;; + getoption) printf '{"option": "%s", "str": "%s"}\n' "$2" "${HYPRCTL_LAYOUT:-dwindle}" ;; + *) printf '%s\n' "$*" >>"$FAKE/hyprctl.log" ;; +esac +EOF + +for command in omarchy-notification-send omarchy-bar omarchy-shell; do + printf '#!/bin/bash\nprintf "%%s\\n" "%s $*" >>"$FAKE/commands.log"\n' "$command" >"$stub_dir/$command" +done +chmod +x "$stub_dir"/* + +run() { + env HOME="$home_dir" XDG_STATE_HOME="$state_home" XDG_RUNTIME_DIR="$runtime_dir" FAKE="$fake" \ + PATH="$stub_dir:$ROOT/bin:$PATH" "$@" +} + +dispatched() { + grep -F "dispatch hl.dsp.$1" "$fake/hyprctl.log" >/dev/null +} + +reset_logs() { + : >"$fake/hyprctl.log" + : >"$fake/commands.log" +} + +cat >"$fake/monitors.json" <<'EOF' +[{ "id": 0, "name": "DP-1", "x": 0, "y": 0, "width": 2560, "height": 1440, "scale": 1, "transform": 0, "reserved": [0, 30, 0, 0] }] +EOF +printf '{ "id": 2, "name": "2", "tiledLayout": "dwindle" }\n' >"$fake/activeworkspace.json" +printf '[{ "id": 2, "name": "2", "tiledLayout": "dwindle" }, { "id": 4, "name": "4", "tiledLayout": "scrolling" }]\n' >"$fake/workspaces.json" +reset_logs + +# --- Which layout a workspace is in ------------------------------------------- + +[[ $(run omarchy-hyprland-workspace-layout-current) == "dwindle" ]] || + fail "a workspace with nothing saved reports Hyprland's tiled layout" +[[ $(run omarchy-hyprland-workspace-layout-current 4) == "scrolling" ]] || + fail "another workspace reports its own tiled layout" + +mkdir -p "$modes_dir" +printf 'o.workspace_mode({ workspace = "4", mode = "floating" })\n' >"$modes_dir/4.lua" +[[ $(run omarchy-hyprland-workspace-layout-current 4) == "floating" ]] || + fail "a saved floating mode is reported, though Hyprland cannot report it" +pass "the layout of a workspace comes from its saved mode before Hyprland's" + +# --- The default layout ------------------------------------------------------- + +[[ $(run omarchy-hyprland-workspace-layout-default) == "dwindle" ]] || fail "the default layout starts as tiling" +[[ $(HYPRCTL_LAYOUT=scrolling run omarchy-hyprland-workspace-layout-default) == "scrolling" ]] || + fail "without a saved default, Hyprland's own layout is the default" +run omarchy-hyprland-workspace-layout-default scrolling +grep -qxF 'o.workspace_mode({ default = "scrolling" })' "$modes_dir/default.lua" || + fail "the default layout is saved" "$(cat "$modes_dir/default.lua" 2>&1)" +[[ $(run omarchy-hyprland-workspace-layout-default) == "scrolling" ]] || fail "the saved default is reported" +grep -qx 'reload' "$fake/hyprctl.log" || fail "a new default reloads Hyprland" +grep -qF 'omarchy-shell -q omarchy.workspaces refresh' "$fake/commands.log" || fail "a new default refreshes the bar" +pass "the default layout is saved, applied, and shown" + +run omarchy-hyprland-workspace-layout-default floating +[[ $(run omarchy-hyprland-workspace-layout-current 2) == "floating" ]] || + fail "a workspace without a layout of its own follows the default" +[[ $(run omarchy-hyprland-workspace-layout-current 4) == "floating" ]] || + fail "a saved floating workspace still reports floating" +printf 'o.workspace_mode({ workspace = "4", mode = "dwindle" })\n' >"$modes_dir/4.lua" +[[ $(run omarchy-hyprland-workspace-layout-current 4) == "dwindle" ]] || + fail "a workspace given its own layout keeps it under a floating default" +pass "workspaces without a layout of their own follow the default, others keep theirs" + +if run omarchy-hyprland-workspace-layout-default sideways 2>/dev/null; then + fail "an unknown default layout is refused" +fi +[[ $(run omarchy-hyprland-workspace-layout-default) == "floating" ]] || fail "a refused default leaves the saved one alone" +pass "an unknown default layout is refused" +rm -f "$modes_dir"/*.lua + +# --- The workspace mode in Hyprland -------------------------------------------- + +mkdir -p "$state_home/omarchy/current/theme" +cat >"$state_home/omarchy/current/theme/colors.toml" <<'EOF' +background = "#112233" +lighter_background = "#223344" +selection = "#334455" +foreground = "#ddeeff" +accent = "#ff8800" +EOF + +lua_harness="$tmpdir/harness.lua" +cat >"$lua_harness" <<'LUA' +local all = {} +local subscriptions = {} +local plugin_loads = 0 +local titlebar_config = nil +local buttons = {} +local config = { ["input.follow_mouse"] = 1, ["decoration.shadow.enabled"] = false } +local rules = {} +local placed = {} +local active = { id = 1 } +local titlebars_installed = os.getenv("TITLEBARS_INSTALLED") == "1" + +local monitor = { width = 2560, height = 1440, scale = 1, transform = 0, reserved = { left = 0, right = 0, top = 30, bottom = 0 } } + +function window(address, workspace, options) + options = options or {} + local created = { + address = address, + floating = options.floating or false, + fullscreen = options.fullscreen or 0, + pinned = false, + tags = options.tags or {}, + workspace = { id = workspace }, + monitor = monitor, + } + table.insert(all, created) + return created +end + +function has_tag(target, tag) + for _, held in ipairs(target.tags) do + if held == tag then + return true + end + end + return false +end + +local real_open = io.open +io.open = function(path, mode) + if path == "/usr/lib/omarchy-hyprland-titlebars/titlebars.so" then + return titlebars_installed and { close = function() end } or nil + end + return real_open(path, mode) +end + +hl = { + workspace_rule = function() end, + window_rule = function(rule) table.insert(rules, rule) end, + get_workspace_windows = function(selector) + local found = {} + for _, candidate in ipairs(all) do + if tostring(candidate.workspace.id) == tostring(selector) then + table.insert(found, candidate) + end + end + return found + end, + get_windows = function(filter) + local found = {} + for _, candidate in ipairs(all) do + if not filter or (filter.tag and has_tag(candidate, filter.tag)) then + table.insert(found, candidate) + end + end + return found + end, + dispatch = function(action) + local target = action.window + if action.kind == "place" then + placed[target.address] = true + elseif action.tag then + local name = action.tag:sub(2) + if action.tag:sub(1, 1) == "+" then + if not has_tag(target, name) then + table.insert(target.tags, name) + end + else + for index, held in ipairs(target.tags) do + if held == name then + table.remove(target.tags, index) + break + end + end + end + else + target.floating = action.action == "on" + end + end, + dsp = { + window = { + float = function(spec) return spec end, + tag = function(spec) return spec end, + resize = function(spec) spec.kind = "place" return spec end, + center = function(spec) spec.kind = "place" return spec end, + move = function(spec) spec.kind = "place" return spec end, + }, + }, + on = function(event, callback) + subscriptions[event] = subscriptions[event] or {} + table.insert(subscriptions[event], callback) + end, + timer = function(callback) callback() end, + get_active_workspace = function() return active end, + get_config = function(key) return config[key] end, + config = function(values) + if values.plugin then + titlebar_config = values.plugin.hyprbars + return + end + local function flatten(prefix, options) + for key, value in pairs(options) do + if type(value) == "table" then + flatten(prefix .. key .. ".", value) + else + config[prefix .. key] = value + end + end + end + flatten("", values) + end, + plugin = { + load = function(path) + plugin_loads = plugin_loads + 1 + assert(path == "/usr/lib/omarchy-hyprland-titlebars/titlebars.so", "the titlebars were loaded from the wrong place") + if os.getenv("TITLEBARS_LOADED") == "1" then + hl.plugin.hyprbars = { add_button = function(button) table.insert(buttons, button) end } + end + end, + }, +} + +function state() + return { + plugin_loads = plugin_loads, + titlebar_config = titlebar_config, + buttons = buttons, + config = config, + rules = rules, + placed = placed, + subscriptions = subscriptions, + active = active, + } +end + +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.helpers") +LUA + +run_lua() { + local script=$1 + shift + printf 'dofile("%s")\n%s\n' "$lua_harness" "$script" >"$tmpdir/case.lua" + env HOME="$home_dir" XDG_STATE_HOME="$state_home" OMARCHY_PATH="$ROOT" "$@" lua "$tmpdir/case.lua" +} + +rm -rf "$modes_dir" + +# Nothing floats: the titlebar plugin stays out and nothing changes. +if ! run_lua ' +local tiled = window("0x1", 1) +require("default.hypr.workspace-layouts") +local s = state() +assert(s.plugin_loads == 0, "the titlebar plugin was loaded with nothing floating") +assert(not tiled.floating, "a window floated with nothing floating") +assert(#s.rules == 0, "window rules were added with nothing floating") +' TITLEBARS_INSTALLED=1; then + fail "nothing changes for a session where no workspace floats" +fi +pass "nothing changes for a session where no workspace floats" + +# A floating default floats every regular workspace without a mode of its own, +# and leaves special workspaces and those with their own layout alone. +mkdir -p "$modes_dir" +printf 'o.workspace_mode({ default = "floating" })\n' >"$modes_dir/default.lua" +printf 'o.workspace_mode({ workspace = "2", mode = "dwindle" })\n' >"$modes_dir/2.lua" + +if ! run_lua ' +local first = window("0x1", 1) +local dialog = window("0x2", 1, { floating = true }) +local own_layout = window("0x3", 2) +local scratch = window("0x4", -98) +local fullscreen = window("0x5", 3, { fullscreen = 1 }) +require("default.hypr.workspace-layouts") +local s = state() + +assert(first.floating and has_tag(first, "omarchy-mode-floated"), "a window on an unsaved workspace was not floated") +assert(s.placed["0x1"], "a window the mode floated was not given a place") +assert(has_tag(first, "omarchy-floating-workspace"), "a floated window got no titlebar tag") +assert(has_tag(dialog, "omarchy-floating-workspace"), "a window floating already got no titlebar tag") +assert(not has_tag(dialog, "omarchy-mode-floated"), "a window floating already was claimed") +assert(not s.placed["0x2"], "a window floating already was moved") +assert(not own_layout.floating, "a workspace with its own layout was floated") +assert(not has_tag(own_layout, "omarchy-floating-workspace"), "a tiled window got a titlebar tag") +assert(not scratch.floating and #scratch.tags == 0, "a window on a special workspace was touched") +assert(not fullscreen.floating, "a fullscreen window was floated") +assert(has_tag(fullscreen, "omarchy-floating-workspace"), "a fullscreen window on a floating workspace got no titlebar tag") + +-- Pointer focus follows the active workspace. +assert(s.config["input.follow_mouse"] == 0, "a floating workspace focused on hover") +s.active.id = 2 +s.subscriptions["workspace.active"][1]() +assert(s.config["input.follow_mouse"] == 1, "a tiled workspace lost the user focus setting") + +-- Shadows only for windows on floating workspaces, when the user has none. +assert(s.config["decoration.shadow.enabled"] == true, "floating windows got no shadows to tell them apart") +assert(#s.rules >= 3, "the floating window rules were not added") +' TITLEBARS_INSTALLED=1; then + fail "a floating default floats every regular workspace without a layout of its own" +fi +pass "a floating default floats every regular workspace without a layout of its own" + +# A tiled default is Hyprland's own layout for workspaces without a rule. +rm -f "$modes_dir"/*.lua +printf 'o.workspace_mode({ default = "scrolling" })\n' >"$modes_dir/default.lua" +if ! run_lua ' +require("default.hypr.workspace-layouts") +assert(state().config["general.layout"] == "scrolling", "a scrolling default did not set the layout: " .. tostring(state().config["general.layout"])) +'; then + fail "a scrolling default is Hyprland's default layout" +fi +pass "a scrolling default is Hyprland's default layout" + +# Moving the default away from floating is a fresh start, and the windows it floated +# have to be given back even though nothing names their workspace any more. With +# no workspace given a layout of its own, no saved mode is left to start the mode. +rm -f "$modes_dir"/*.lua +if ! run_lua ' +local floated = window("0x1", 1, { floating = true, tags = { "omarchy-mode-floated", "omarchy-floating-workspace" } }) +local dialog = window("0x2", 1, { floating = true, tags = { "omarchy-floating-workspace" } }) +local scratch = window("0x3", -98, { floating = true, tags = { "omarchy-mode-floated" } }) +require("default.hypr.workspace-layouts") +assert(not floated.floating, "a window the mode floated stayed floating once floating was off") +assert(not has_tag(floated, "omarchy-mode-floated"), "a window given back is still claimed") +assert(not has_tag(floated, "omarchy-floating-workspace"), "a tiled window kept its titlebar tag") +assert(dialog.floating, "a dialog was tiled when floating was turned off") +assert(not has_tag(dialog, "omarchy-floating-workspace"), "a dialog on a tiled workspace kept its titlebar tag") +assert(scratch.floating and has_tag(scratch, "omarchy-mode-floated"), "a window on the scratchpad was tiled") +'; then + fail "turning floating off gives back what it floated, but not what is on the scratchpad" +fi +pass "turning floating off gives back what it floated, but not what is on the scratchpad" + +# A tiled window that was fullscreen when its workspace started floating is +# left as it is, and floats once it comes out of fullscreen. +printf 'o.workspace_mode({ workspace = "1", mode = "floating" })\n' >"$modes_dir/1.lua" +if ! run_lua ' +local video = window("0x1", 1, { fullscreen = 2 }) +require("default.hypr.workspace-layouts") +assert(not video.floating, "a fullscreen window was floated") +video.fullscreen = 0 +state().subscriptions["window.fullscreen"][1](video) +assert(video.floating and has_tag(video, "omarchy-mode-floated"), "a window leaving fullscreen on a floating workspace stayed tiled") +'; then + fail "a window leaving fullscreen on a floating workspace floats" +fi +pass "a window leaving fullscreen on a floating workspace floats" + +# A window moved to a special workspace, like the scratchpad, is left alone. +printf 'o.workspace_mode({ workspace = "1", mode = "floating" })\n' >"$modes_dir/1.lua" +if ! run_lua ' +local moving = window("0x1", 1) +require("default.hypr.workspace-layouts") +local moved = state().subscriptions["window.move_to_workspace"][1] +assert(moving.floating, "the window was not floated to begin with") + +moved(moving, { id = -98 }) +assert(moving.floating and has_tag(moving, "omarchy-mode-floated"), "a window was tiled on its way to the scratchpad") + +moved(moving, { id = 2 }) +assert(not moving.floating, "a window carried to a tiled workspace kept floating") +'; then + fail "the mode leaves windows on their way to a special workspace alone" +fi +pass "the mode leaves windows on their way to a special workspace alone" + +# Titlebars: asked for only when installed, and themed once loaded. +if ! run_lua ' +window("0x1", 1) +require("default.hypr.workspace-layouts") +assert(state().plugin_loads == 0, "titlebars were asked for without being installed") +'; then + fail "a missing titlebar package leaves floating without titlebars" +fi + +if ! run_lua ' +window("0x1", 1) +require("default.hypr.workspace-layouts") +local s = state() +assert(s.plugin_loads == 1, "the titlebar plugin was not loaded with a workspace floating") +local bar = s.titlebar_config +assert(bar, "the titlebars were not configured once loaded") +assert(bar.workspace_tag == "omarchy-floating-workspace", "titlebars are not limited to floating workspaces") +assert(bar.bar_color == "rgb(223344)", "titlebars do not stand out from the window background: " .. tostring(bar.bar_color)) +assert(bar.inactive_button_color == "rgb(223344)", "titlebar buttons do not match the titlebar") +for _, button in ipairs(s.buttons) do + assert(button.bg_color == "rgb(223344)", "a titlebar button does not match the titlebar") +end +assert(bar.col.text == "rgb(ddeeff)", "titlebar text does not follow the theme foreground") +assert(not bar.edge_snap, "dragged windows snap to screen edges") +assert(#s.buttons == 2, "the titlebars do not have exactly close and maximize") +local actions = s.buttons[1].action .. s.buttons[2].action +assert(actions:find("window.close", 1, true), "no button closes the window") +assert(actions:find("maximized", 1, true), "no button maximizes the window") +assert(bar.on_double_click:find("maximized", 1, true), "double-clicking a titlebar does not maximize") +' TITLEBARS_INSTALLED=1 TITLEBARS_LOADED=1; then + fail "titlebars load when a workspace floats and follow the theme" +fi +pass "titlebars load when a workspace floats and follow the theme" + +# A theme whose raised surface is its background would hide the titlebar again, +# so it takes the selection color instead. +theme="$state_home/omarchy/current/theme/colors.toml" +cp "$theme" "$theme.saved" +sed -i 's/^lighter_background = .*/lighter_background = "#112233"/' "$theme" +if ! run_lua ' +window("0x1", 1) +require("default.hypr.workspace-layouts") +local bar = state().titlebar_config +assert(bar and bar.bar_color == "rgb(334455)", "a flat theme does not fall back to its selection color: " .. tostring(bar and bar.bar_color)) +' TITLEBARS_INSTALLED=1 TITLEBARS_LOADED=1; then + fail "titlebars stand out on a theme whose raised surface is its background" +fi +mv "$theme.saved" "$theme" +pass "titlebars stand out on a theme whose raised surface is its background" + +# omarchy-theme-set writes the current theme under ~/.local/state even when +# XDG_STATE_HOME points elsewhere, so that is where the titlebars find it. +other_state="$tmpdir/other-state" +mkdir -p "$other_state/omarchy/workspace-layouts" +cp "$modes_dir/1.lua" "$other_state/omarchy/workspace-layouts/" +if ! run_lua ' +window("0x1", 1) +require("default.hypr.workspace-layouts") +local bar = state().titlebar_config +assert(bar and bar.bar_color == "rgb(223344)", "titlebars missed the theme with XDG_STATE_HOME elsewhere: " .. tostring(bar and bar.bar_color)) +' TITLEBARS_INSTALLED=1 TITLEBARS_LOADED=1 XDG_STATE_HOME="$other_state"; then + fail "titlebars follow the theme when XDG_STATE_HOME points elsewhere" +fi +pass "titlebars follow the theme when XDG_STATE_HOME points elsewhere" diff --git a/test/shell.d/hyprland-workspace-layout-test.sh b/test/shell.d/hyprland-workspace-layout-test.sh index ef8b72fe..71fa8301 100755 --- a/test/shell.d/hyprland-workspace-layout-test.sh +++ b/test/shell.d/hyprland-workspace-layout-test.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -euo pipefail + source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" require_command lua @@ -18,52 +20,512 @@ cat >"$stub_dir/hyprctl" <<'EOF' if [[ $1 == "activeworkspace" && -n $HYPRCTL_BROKEN ]]; then printf '{}\n' elif [[ $1 == "activeworkspace" ]]; then - printf '{"id":3,"tiledLayout":"dwindle"}\n' + printf '{"id":%s,"tiledLayout":"%s"}\n' "${HYPRCTL_WS_ID:-3}" "${HYPRCTL_TILED_LAYOUT:-dwindle}" else printf '%s\n' "$*" >>"$HYPRCTL_LOG" + + if [[ $1 == "eval" && -n $HYPRCTL_EVAL_FAILS ]]; then + exit 7 + fi fi EOF chmod +x "$stub_dir/hyprctl" cat >"$stub_dir/omarchy-notification-send" <<'EOF' #!/bin/bash -: +printf '%s\n' "$*" >>"$NOTIFICATION_LOG" EOF chmod +x "$stub_dir/omarchy-notification-send" -HOME="$home_dir" HYPRCTL_LOG="$log_file" PATH="$stub_dir:$PATH" \ - "$ROOT/bin/omarchy-hyprland-workspace-layout-toggle" +# The toggle asks the bar to show the new layout; never reach a live shell. +cat >"$stub_dir/omarchy-shell" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >>"$NOTIFICATION_LOG" +EOF +chmod +x "$stub_dir/omarchy-shell" layout_file="$home_dir/.local/state/omarchy/workspace-layouts/3.lua" -[[ -f $layout_file ]] || fail "workspace layout toggle saves a workspace rule" -grep -Fx 'hl.workspace_rule({ workspace = "3", layout = "scrolling" })' "$layout_file" >/dev/null || - fail "workspace layout toggle saves the selected layout" -grep -Fx 'eval hl.workspace_rule({ workspace = "3", layout = "scrolling" })' "$log_file" >/dev/null || - fail "workspace layout toggle applies the selected layout immediately" -pass "workspace layout toggle persists and applies the selected layout" +notification_log="$tmpdir/notifications.log" -if HOME="$home_dir" HYPRCTL_LOG="$log_file" HYPRCTL_BROKEN=1 PATH="$stub_dir:$PATH" \ - "$ROOT/bin/omarchy-hyprland-workspace-layout-toggle" 2>/dev/null; then +# XDG_STATE_HOME is named explicitly rather than left to be inherited: a machine +# that sets it would otherwise have its real saved layouts written over by these +# runs. It is the value the command would derive from this HOME anyway, and a +# case that needs another one passes its own, which env applies last. +toggle() { + env HOME="$home_dir" XDG_STATE_HOME="$home_dir/.local/state" \ + HYPRCTL_LOG="$log_file" NOTIFICATION_LOG="$notification_log" \ + PATH="$stub_dir:$ROOT/bin:$PATH" "$@" "$ROOT/bin/omarchy-hyprland-workspace-layout-toggle" +} + +toggle_back() { + env HOME="$home_dir" XDG_STATE_HOME="$home_dir/.local/state" \ + HYPRCTL_LOG="$log_file" NOTIFICATION_LOG="$notification_log" \ + PATH="$stub_dir:$ROOT/bin:$PATH" "$ROOT/bin/omarchy-hyprland-workspace-layout-toggle" "$@" +} + +saved_mode() { + sed -n 's/.*mode = "\([a-z]*\)".*/\1/p' "$layout_file" 2>/dev/null +} + +applied() { + grep -Fx "eval o.workspace_mode({ workspace = \"3\", mode = \"$1\" })" "$log_file" >/dev/null +} + +# Nothing saved yet, so the cycle starts from the layout Hyprland reports. +: >"$notification_log" +toggle +[[ -f $layout_file ]] || fail "workspace layout toggle saves the selected mode" +[[ $(saved_mode) == "scrolling" ]] || fail "dwindle cycles to scrolling" "saved: $(cat "$layout_file")" +applied scrolling || fail "scrolling is applied immediately" "$(cat "$log_file")" +grep -q "set to scrolling" "$notification_log" || + fail "the new mode is announced" "$(cat "$notification_log")" +grep -qx -- "-q omarchy.workspaces refresh" "$notification_log" || + fail "the bar is asked to show the new layout" "$(cat "$notification_log")" +if grep -q "could not be saved" "$notification_log"; then + fail "a saved mode is announced as saved" "$(cat "$notification_log")" +fi +pass "dwindle cycles to scrolling" + +# The saved file is what carries the mode from here: floating is not a tiled +# layout, so Hyprland could never report it back. +toggle +[[ $(saved_mode) == "floating" ]] || fail "scrolling cycles to floating" "saved: $(cat "$layout_file")" +applied floating || fail "floating is applied immediately" "$(cat "$log_file")" +pass "scrolling cycles to floating" + +# Hyprland still reports a tiled layout underneath a floating workspace, so a +# toggle that trusted the compositor here would cycle back to scrolling and skip +# dwindle entirely. The apply matters as much as the file: without it the saved +# mode says dwindle while the screen stays floating. +toggle +[[ ! -e $layout_file ]] || fail "floating cycles back to dwindle, the default, which the workspace follows again" "saved: $(cat "$layout_file")" +applied dwindle || fail "dwindle is applied immediately" "$(cat "$log_file")" +pass "floating cycles back to dwindle" + +# Super + Alt + L walks the same cycle the other way. +toggle_back back +[[ $(saved_mode) == "floating" ]] || fail "back from dwindle is floating" "saved: $(cat "$layout_file")" +toggle_back back +[[ $(saved_mode) == "scrolling" ]] || fail "back from floating is scrolling" "saved: $(cat "$layout_file")" +toggle_back back +[[ ! -e $layout_file ]] || fail "back from scrolling is dwindle, the default" "saved: $(cat "$layout_file")" +if toggle_back sideways 2>/dev/null; then + fail "an unknown direction is refused" +fi +[[ ! -e $layout_file ]] || fail "a refused direction changes nothing" "saved: $(cat "$layout_file")" +pass "the layout cycles backwards too" + +# Cycling onto the default forgets the workspace's own layout, so it follows a +# later change of the default too; any other layout is saved as its own. +printf 'o.workspace_mode({ default = "floating" })\n' >"${layout_file%/*}/default.lua" +printf 'o.workspace_mode({ workspace = "3", mode = "scrolling" })\n' >"$layout_file" +: >"$log_file" +toggle +[[ ! -e $layout_file ]] || fail "cycling onto the default forgets the workspace's own layout" "saved: $(cat "$layout_file")" +applied floating || fail "the default layout is still applied at once" "$(cat "$log_file")" +toggle +[[ $(saved_mode) == "dwindle" ]] || fail "cycling on from the default saves the next layout" "saved: $(cat "$layout_file" 2>&1)" +rm -f "${layout_file%/*}/default.lua" +pass "a workspace cycled onto the default follows it again" + +# Files written before floating existed name a layout rather than a mode. +: >"$log_file" +printf 'hl.workspace_rule({ workspace = "3", layout = "scrolling" })\n' >"$layout_file" +toggle HYPRCTL_TILED_LAYOUT=scrolling +[[ $(saved_mode) == "floating" ]] || + fail "a saved layout from before floating cycles on into floating" "saved: $(cat "$layout_file")" +applied floating || fail "the mode after a legacy layout is applied" "$(cat "$log_file")" +pass "a saved layout from before floating cycles on into floating" + +# A mode that did not apply must not be restored at the next login, and must not +# be announced as though it had been. +printf 'o.workspace_mode({ workspace = "3", mode = "scrolling" })\n' >"$layout_file" +: >"$notification_log" +toggle HYPRCTL_EVAL_FAILS=1 +[[ $(saved_mode) == "scrolling" ]] || + fail "a mode that failed to apply is not saved" "saved: $(cat "$layout_file")" +grep -q "needs a Hyprland reload" "$notification_log" || + fail "a mode that failed to apply says so" "$(cat "$notification_log")" +if grep -q "set to" "$notification_log"; then + fail "a mode that failed to apply is announced as set" "$(cat "$notification_log")" +fi +pass "a mode that failed to apply is neither saved nor announced as set" + +# The mode is on screen but nothing will bring it back, and the next press reads +# the mode from the file that was not written, so it cannot count as set. A +# regular file where the state directory belongs fails mkdir for any user, +# including root. +blocked="$tmpdir/blocked" +printf 'not a directory\n' >"$blocked" +: >"$log_file" +: >"$notification_log" +toggle XDG_STATE_HOME="$blocked" 2>/dev/null +applied scrolling || fail "the mode is still applied when it cannot be saved" "$(cat "$log_file")" +grep -q "could not be saved" "$notification_log" || + fail "a mode that could not be saved says so" "$(cat "$notification_log")" +pass "a mode that applied but could not be saved says so" + +# A named workspace reports a negative id, and Hyprland reads a leading "-" as a +# selector relative to the current workspace: acting on one reconfigures +# workspace 1 rather than the workspace the user is looking at. +: >"$log_file" +if toggle HYPRCTL_WS_ID=-1337 2>/dev/null; then + fail "workspace layout toggle exits nonzero on a named workspace" +fi +if grep -q "workspace_mode" "$log_file"; then + fail "workspace layout toggle applies nothing for a named workspace" "$(cat "$log_file")" +fi +if [[ -f $home_dir/.local/state/omarchy/workspace-layouts/-1337.lua ]]; then + fail "workspace layout toggle does not persist a mode for a named workspace" +fi +pass "workspace layout toggle leaves a named workspace alone" + +# The guard stops new ones being written; the ones an earlier version already +# wrote go on reconfiguring workspace 1 at every login until they are removed. +migration=$(grep -rl 'Drop saved workspace layouts belonging to named workspaces' "$ROOT/migrations" | head -n 1 || true) +[[ -n $migration ]] || fail "a migration removes saved layouts for named workspaces" + +migration_home="$tmpdir/migration" +migration_layouts="$migration_home/.local/state/omarchy/workspace-layouts" +mkdir -p "$migration_layouts" +printf 'o.workspace_mode({ workspace = "-1337", mode = "scrolling" })\n' >"$migration_layouts/-1337.lua" +printf 'o.workspace_mode({ workspace = "3", mode = "scrolling" })\n' >"$migration_layouts/3.lua" + +# omarchy-migrate runs each migration as `bash -euo pipefail` and only records it +# as done if it exits 0, so run it the same way here: under those flags an +# unmatched glob is the difference between a migration that finishes and one that +# stops the whole run and repeats it at every login. +# The stub is on the path because the migration asks Hyprland to reload once it +# has removed something, and the real one would reload the compositor of whoever +# is running the suite. +run_migration() { + env HOME="$migration_home" XDG_STATE_HOME="$migration_home/.local/state" \ + HYPRCTL_LOG="$tmpdir/migration-hyprctl.log" PATH="$stub_dir:$PATH" \ + bash -euo pipefail "$migration" >/dev/null +} + +migration_log="$tmpdir/migration-hyprctl.log" +: >"$migration_log" +run_migration || fail "the migration exits cleanly with a layout to remove" + +if [[ -f $migration_layouts/-1337.lua ]]; then + fail "the migration removes a saved layout for a named workspace" +fi +[[ -f $migration_layouts/3.lua ]] || + fail "the migration keeps saved layouts for numbered workspaces" + +# The bad rule is already applied in the running session, and a user who has +# turned autoreload off would keep it until their next login. +grep -q "^reload" "$migration_log" || + fail "the migration asks Hyprland to reload what it removed" "$(cat "$migration_log")" + +# The ordinary case is a machine with nothing to remove, where the glob matches +# no file at all. +: >"$migration_log" +run_migration || fail "the migration exits cleanly with nothing to remove" +[[ -f $migration_layouts/3.lua ]] || + fail "the migration with nothing to remove keeps what is there" +if grep -q "^reload" "$migration_log"; then + fail "the migration reloads Hyprland having removed nothing" "$(cat "$migration_log")" +fi +pass "the migration removes saved layouts for named workspaces" + +if toggle HYPRCTL_BROKEN=1 2>/dev/null; then fail "workspace layout toggle exits nonzero without a workspace id" fi -[[ -f "$home_dir/.local/state/omarchy/workspace-layouts/null.lua" ]] && - fail "workspace layout toggle does not persist a rule without a workspace id" +if [[ -f $home_dir/.local/state/omarchy/workspace-layouts/null.lua ]]; then + fail "workspace layout toggle does not persist a mode without a workspace id" +fi pass "workspace layout toggle ignores broken hyprctl output" -HOME="$home_dir" OMARCHY_PATH="$ROOT" lua <<'LUA' -local rules = {} +# A state home somewhere else has to work end to end: the shell writes there and +# Hyprland loads from there. Saving to one place and restoring from another is a +# mode that silently never comes back. +state_home="$tmpdir/state" +toggle XDG_STATE_HOME="$state_home" +[[ -f $state_home/omarchy/workspace-layouts/3.lua ]] || + fail "workspace layout toggle honours XDG_STATE_HOME" + +# A decoy of the same name under the home directory, saying something else. The +# loader lists the custom state home but loads by module name, so without the +# search path it finds this one and the mode restored is the wrong one. +printf 'hl.workspace_rule({ workspace = "3", layout = "dwindle" })\n' >"$layout_file" + +xdg_probe="$tmpdir/xdg-probe.lua" +cat >"$xdg_probe" <<'LUA' +local applied = {} hl = { workspace_rule = function(rule) - table.insert(rules, rule) + table.insert(applied, rule) end, + get_workspace_windows = function() + return {} + end, + get_windows = function() + return {} + end, + dispatch = function() end, + dsp = { window = { float = function(spec) return spec end, tag = function(spec) return spec end } }, + on = function() end, + get_active_workspace = function() return { id = 3 } end, + get_config = function() end, + config = function() end, } dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.helpers") require("default.hypr.workspace-layouts") -assert(#rules == 1) -assert(rules[1].workspace == "3") -assert(rules[1].layout == "scrolling") +assert(#applied == 1, "a mode saved under XDG_STATE_HOME was not restored") +assert(applied[1].workspace == "3", "the restored mode named the wrong workspace") +assert(applied[1].layout == "scrolling", "the restored mode was not the one saved") LUA -pass "saved workspace layouts load into Hyprland configuration" + +if ! HOME="$home_dir" XDG_STATE_HOME="$state_home" OMARCHY_PATH="$ROOT" lua "$xdg_probe"; then + fail "a mode saved under XDG_STATE_HOME is restored from there" +fi +pass "workspace layout toggle saves and restores through XDG_STATE_HOME" + +printf 'o.workspace_mode({ workspace = "3", mode = "floating" })\n' >"$layout_file" + +lua_script="$tmpdir/workspace-modes.lua" + +cat >"$lua_script" <<'LUA' +local workspace_rules = {} +local subscriptions = {} +local queried = {} +local all = {} +local placed = {} +local config = { ["input.follow_mouse"] = 1, ["general.resize_on_border"] = false, ["decoration.shadow.enabled"] = true } + +local function window(address, options) + options = options or {} + + local created = { + address = address, + floating = options.floating or false, + pinned = options.pinned or false, + group = options.group, + tags = {}, + workspace = { id = options.workspace or 3 }, + } + + table.insert(all, created) + + return created +end + +local function has_tag(target, tag) + for _, held in ipairs(target.tags) do + if held == tag then + return true + end + end + + return false +end + +-- Two plain windows, one already floating on its own account, and a group whose +-- members float together the way Hyprland floats a group target. +local tiled = window("0xA") +local already = window("0xB", { floating = true }) +local grouped = window("0xC", { group = "g" }) +local partner = window("0xD", { group = "g" }) +local pinned = window("0x12") + +hl = { + workspace_rule = function(rule) + table.insert(workspace_rules, rule) + end, + get_workspace_windows = function(selector) + table.insert(queried, tostring(selector)) + + local found = {} + for _, candidate in ipairs(all) do + if tostring(candidate.workspace.id) == tostring(selector) then + table.insert(found, candidate) + end + end + + return found + end, + get_windows = function(filter) + local found = {} + for _, candidate in ipairs(all) do + if filter and filter.tag and has_tag(candidate, filter.tag) then + table.insert(found, candidate) + end + end + + return found + end, + dispatch = function(action) + local target = action.window + + if action.kind == "place" then + table.insert(placed, target) + elseif action.tag then + local name = action.tag:sub(2) + + if action.tag:sub(1, 1) == "+" then + if not has_tag(target, name) then + table.insert(target.tags, name) + end + else + for index, held in ipairs(target.tags) do + if held == name then + table.remove(target.tags, index) + break + end + end + end + else + -- Floating a grouped window takes the rest of its group with it. + for _, candidate in ipairs(all) do + if candidate == target or (target.group and candidate.group == target.group) then + candidate.floating = action.action == "on" + end + end + end + end, + dsp = { + window = { + float = function(spec) return spec end, + tag = function(spec) return spec end, + resize = function(spec) spec.kind = "place" return spec end, + center = function(spec) spec.kind = "place" return spec end, + move = function(spec) spec.kind = "place" return spec end, + }, + }, + on = function(event, callback) + subscriptions[event] = subscriptions[event] or {} + table.insert(subscriptions[event], callback) + end, + -- The move handler waits for the move to land; here it lands at once. + timer = function(callback) callback() end, + get_active_workspace = function() return { id = 3 } end, + get_config = function(key) return config[key] end, + config = function(values) + for section, options in pairs(values) do + for key, value in pairs(options) do + config[section .. "." .. key] = value + end + end + end, + window_rule = function() end, + plugin = { load = function() end }, +} + +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.helpers") +require("default.hypr.workspace-layouts") + +local CLAIMED = "omarchy-mode-floated" + +-- Floating is not a layout, so the saved file must not have asked Hyprland for +-- one by that name: Hyprland takes an unknown layout in silence and falls back +-- to dwindle. +assert(#workspace_rules == 0, "floating asked Hyprland for a layout") + +-- The windows of the workspace the file named, not of some other one. +assert(#queried == 1 and queried[1] == "3", "the wrong workspace was queried: " .. table.concat(queried, ",")) + +assert(tiled.floating == true, "the tiled window was not floated") +assert(has_tag(tiled, CLAIMED), "the window the mode floated was not claimed") +assert(already.floating == true, "the already floating window changed") +assert(not has_tag(already, CLAIMED), "a window floating on its own account was claimed") + +-- Every member of a group is claimed. Floating one floats them all, so claiming +-- as it goes would find the rest already floating and leave them unclaimed. +assert(grouped.floating == true and partner.floating == true, "the group was not floated") +assert(has_tag(grouped, CLAIMED) and has_tag(partner, CLAIMED), "a group member went unclaimed") + +-- One handler each: subscribing on every call would float a window as many +-- times as the mode has ever been set. +assert(#subscriptions["window.open"] == 1, "window.open subscribed more than once") +assert(#subscriptions["window.move_to_workspace"] == 1, "moves subscribed more than once") + +local opened = subscriptions["window.open"][1] +local moved = subscriptions["window.move_to_workspace"][1] + +-- A window opening on a floating workspace floats; one that arrives floating +-- already is left alone, so the mode never claims it. +local fresh = window("0xE") +opened(fresh) +assert(fresh.floating == true, "a window opened on a floating workspace stayed tiled") +assert(has_tag(fresh, CLAIMED), "a window opened into the mode was not claimed") + +local own_rule = window("0xF", { floating = true }) +opened(own_rule) +assert(not has_tag(own_rule, CLAIMED), "a window with its own float rule was claimed") + +-- A window carried onto a floating workspace never had that workspace's mode +-- applied to it. +local carried = window("0x10", { workspace = 4 }) +moved(carried, { id = 3 }) +assert(carried.floating == true, "a window carried onto a floating workspace stayed tiled") + +-- Carried on to a tiled workspace, the mode gives back what it took. +carried.workspace.id = 5 +moved(carried, { id = 5 }) +assert(carried.floating == false, "a window the mode floated stayed floating on a tiled workspace") +assert(not has_tag(carried, CLAIMED), "a window the mode gave back is still claimed") + +-- A window floating on its own account keeps floating wherever it goes. +moved(own_rule, { id = 5 }) +assert(own_rule.floating == true, "a window floating on its own account was tiled by a move") + +-- Carried between two floating workspaces it stays claimed, so the workspace it +-- ends on can still give it back. +o.workspace_mode({ workspace = "6", mode = "floating" }) +tiled.workspace.id = 6 +moved(tiled, { id = 6 }) +assert(tiled.floating == true, "a window moved between floating workspaces was tiled") +assert(has_tag(tiled, CLAIMED), "a window moved between floating workspaces lost its claim") + +o.workspace_mode({ workspace = "6", mode = "dwindle" }) +assert(tiled.floating == false, "the workspace a claimed window moved to could not give it back") + +-- A pinned window was put above everything on purpose, and tiling it would unpin +-- it too, so the mode leaves it where the user put it. +assert(has_tag(pinned, CLAIMED), "the window to be pinned was never claimed") +pinned.pinned = true + +-- Ending the mode tiles what the mode claimed, and only that. +o.workspace_mode({ workspace = "3", mode = "dwindle" }) +assert(#workspace_rules == 2, "leaving floating did not set a layout") +assert(workspace_rules[2].layout == "dwindle") +assert(workspace_rules[2].workspace == "3", "the layout was applied to the wrong workspace") +assert(grouped.floating == false, "the window the mode floated was left floating") +assert(already.floating == true, "a window floating on its own account was tiled by the mode ending") +assert(partner.floating == false, "a group member the mode floated was left floating") +assert(pinned.floating == true, "a pinned window was tiled by the mode ending") +assert(has_tag(pinned, CLAIMED), "a pinned window lost the claim that returns it once unpinned") + +-- Still one handler each after a second mode: the count above was taken before +-- this call, so only a check on this side catches subscribing every time. +assert(#subscriptions["window.open"] == 1, "window.open subscribed again on the next mode") +assert(#subscriptions["window.move_to_workspace"] == 1, "moves subscribed again on the next mode") + +-- The workspace is tiled now, so nothing opening on it should float. +local after = window("0x11") +opened(after) +assert(after.floating == false, "a window opened after the mode ended was floated") + +-- Scrolling is a layout like dwindle, and has to reach Hyprland as one. +o.workspace_mode({ workspace = "3", mode = "scrolling" }) +assert(workspace_rules[#workspace_rules].layout == "scrolling", "scrolling was not applied as a layout") +assert(workspace_rules[#workspace_rules].workspace == "3") +LUA + +# Lua 5.5 exits 0 when a script that failed came in on stdin, so a heredoc piped +# straight into lua reports every assertion above as passing. Run it as a file. +# XDG_STATE_HOME is named here for the same reason the toggle helper names it: +# the loader reads the saved modes from paths.state_home, so a machine that +# exports it would load its own saved layouts instead of the fixture below. +if ! HOME="$home_dir" XDG_STATE_HOME="$home_dir/.local/state" OMARCHY_PATH="$ROOT" \ + lua "$lua_script"; then + fail "a saved floating mode floats the workspace without inventing a layout" +fi +pass "a saved floating mode floats the workspace without inventing a layout" diff --git a/test/shell.d/webapp-install-test.sh b/test/shell.d/webapp-install-test.sh index 2db32353..f6fb3144 100644 --- a/test/shell.d/webapp-install-test.sh +++ b/test/shell.d/webapp-install-test.sh @@ -11,7 +11,7 @@ home="$tmpdir/home" mkdir -p "$home/.local/share/applications" install_webapp() { - HOME="$home" "$ROOT/bin/omarchy-webapp-install" "$@" + HOME="$home" PATH="$ROOT/bin:$PATH" "$ROOT/bin/omarchy-webapp-install" "$@" } desktop_for() { @@ -31,6 +31,20 @@ grep -Fxq 'Exec=omarchy-launch-webapp "https://example.com"' "$desktop" || fail "webapp install launches the https URL" "$(cat "$desktop")" pass "webapp install writes an https desktop entry" +grep -Fxq 'StartupWMClass=chrome-example.com__-Default' "$desktop" || + fail "webapp install names the window Chromium opens" "$(cat "$desktop")" +pass "webapp install names the window Chromium opens, so titlebars can show the app's name" + +for case in \ + "https://web.whatsapp.com/|chrome-web.whatsapp.com__-Default" \ + "https://maps.google.com|chrome-maps.google.com__-Default" \ + "https://discord.com/channels/@me|chrome-discord.com__channels_@me-Default" \ + "https://Example.com:8443/a/b?x=1#y|chrome-example.com__a_b-Default"; do + [[ $("$ROOT/bin/omarchy-webapp-window-class" "${case%%|*}") == "${case#*|}" ]] || + fail "the window class of ${case%%|*} is ${case#*|}" "$("$ROOT/bin/omarchy-webapp-window-class" "${case%%|*}")" +done +pass "web app window classes follow Chromium's host and path naming" + if install_webapp "Plain" "example.org/app" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then : else @@ -47,6 +61,8 @@ else fi grep -Fxq 'Exec=omarchy-launch-webapp https://localhost:47990 --ignore-certificate-errors' "$(desktop_for Local)" || fail "webapp install writes the custom exec" "$(cat "$(desktop_for Local)")" +! grep -q '^StartupWMClass=' "$(desktop_for Local)" || + fail "a custom exec does not get a guessed window class" "$(cat "$(desktop_for Local)")" pass "webapp install keeps a custom https exec" for url in "javascript:alert(1)" "file:///etc/passwd" "data:text/html,hi" "ftp://example.com" "ext://x"; do diff --git a/test/shell.d/webapp-window-class-migration-test.sh b/test/shell.d/webapp-window-class-migration-test.sh new file mode 100755 index 00000000..a6e77251 --- /dev/null +++ b/test/shell.d/webapp-window-class-migration-test.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1791696584.sh" +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT +apps="$tmpdir/home/.local/share/applications" +mkdir -p "$apps" "$tmpdir/dotfiles" + +entry() { + printf '[Desktop Entry]\nName=%s\nExec=%s\nType=Application\n' "$1" "$2" +} + +entry WhatsApp 'omarchy-launch-webapp https://web.whatsapp.com/' >"$apps/WhatsApp.desktop" +entry Outlook 'omarchy-launch-webapp "https://outlook.office.com/mail/"' >"$apps/Outlook.desktop" +entry Local 'omarchy-launch-webapp https://localhost:47990 --ignore-certificate-errors' >"$apps/Local.desktop" +# What the installer writes for https://example.com/a$b%c: the URL quoted as an +# Exec argument, then escaped again as desktop file text. +entry Escaped 'omarchy-launch-webapp "https://example.com/a\\$b%%c"' >"$apps/Escaped.desktop" +{ entry Named 'omarchy-launch-webapp https://named.example/'; echo 'StartupWMClass=mine'; } >"$apps/Named.desktop" +entry Linked 'omarchy-launch-webapp https://linked.example/' >"$tmpdir/dotfiles/Linked.desktop" +ln -s "$tmpdir/dotfiles/Linked.desktop" "$apps/Linked.desktop" + +# Twice, as a retried update would. +for run in 1 2; do + env -u XDG_DATA_HOME HOME="$tmpdir/home" PATH="$ROOT/bin:$PATH" bash -euo pipefail "$migration" >/dev/null +done + +[[ $(sed -n 3,4p "$apps/WhatsApp.desktop") == $'Exec=omarchy-launch-webapp https://web.whatsapp.com/\nStartupWMClass=chrome-web.whatsapp.com__-Default' ]] || + fail "a web app launcher names its window right after Exec" "$(cat "$apps/WhatsApp.desktop")" +grep -Fxq 'StartupWMClass=chrome-outlook.office.com__mail_-Default' "$apps/Outlook.desktop" || + fail "a quoted web app URL names its window" "$(cat "$apps/Outlook.desktop")" +[[ $(grep -c '^StartupWMClass=' "$apps/WhatsApp.desktop") == 1 ]] || fail "a second run adds nothing" +grep -Fxq 'StartupWMClass=chrome-example.com__a$b%c-Default' "$apps/Escaped.desktop" || + fail "an escaped URL is decoded before naming its window" "$(cat "$apps/Escaped.desktop")" +pass "web app launchers name their windows once" + +! grep -q '^StartupWMClass=' "$apps/Local.desktop" || fail "a custom command is left alone" "$(cat "$apps/Local.desktop")" +[[ $(grep '^StartupWMClass=' "$apps/Named.desktop") == "StartupWMClass=mine" ]] || fail "a class the launcher already names is kept" +pass "custom commands and existing classes are left alone" + +[[ -L $apps/Linked.desktop ]] && grep -Fxq 'StartupWMClass=chrome-linked.example__-Default' "$tmpdir/dotfiles/Linked.desktop" || + fail "a symlinked launcher stays linked and its target is updated" "$(ls -l "$apps/Linked.desktop")" +pass "a symlinked launcher stays linked and its target is updated" diff --git a/test/shell.d/workspace-layout-mode-migration-test.sh b/test/shell.d/workspace-layout-mode-migration-test.sh new file mode 100755 index 00000000..fb34bdf0 --- /dev/null +++ b/test/shell.d/workspace-layout-mode-migration-test.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1791698594.sh" +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT +layouts="$tmpdir/home/.local/state/omarchy/workspace-layouts" +mkdir -p "$layouts" "$tmpdir/dotfiles" + +printf 'hl.workspace_rule({ workspace = "2", layout = "scrolling" })\n' >"$layouts/2.lua" +printf 'o.workspace_mode({ workspace = "3", mode = "floating" })\n' >"$layouts/3.lua" +printf 'o.workspace_mode({ default = "floating" })\n' >"$layouts/default.lua" +printf 'hl.workspace_rule({ workspace = "4", layout = "dwindle" })\n' >"$tmpdir/dotfiles/4.lua" +ln -s "$tmpdir/dotfiles/4.lua" "$layouts/4.lua" + +# Twice, as a retried update would. +for run in 1 2; do + env -u XDG_STATE_HOME HOME="$tmpdir/home" bash -euo pipefail "$migration" >/dev/null +done + +[[ $(<"$layouts/2.lua") == 'o.workspace_mode({ workspace = "2", mode = "scrolling" })' ]] || + fail "a saved layout rule becomes a mode" "$(cat "$layouts/2.lua")" +[[ $(<"$layouts/3.lua") == 'o.workspace_mode({ workspace = "3", mode = "floating" })' ]] || + fail "a saved mode is left alone" "$(cat "$layouts/3.lua")" +[[ $(<"$layouts/default.lua") == 'o.workspace_mode({ default = "floating" })' ]] || + fail "the default layout is left alone" "$(cat "$layouts/default.lua")" +pass "saved layout rules become modes, and modes are left alone" + +[[ -L $layouts/4.lua && $(<"$tmpdir/dotfiles/4.lua") == 'o.workspace_mode({ workspace = "4", mode = "dwindle" })' ]] || + fail "a symlinked layout stays linked and its target is converted" "$(ls -l "$layouts/4.lua"; cat "$tmpdir/dotfiles/4.lua")" +pass "a symlinked layout stays linked and its target is converted"