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"