* 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 <noreply@openai.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* Stop describing the titlebar package as snapping
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* Drop the titlebar package comment from the base list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* Show scrolling as a circle and floating as a triangle
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* Title the workspace layout menu Default Workspace Layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Use the animation icon for Appearance > Animations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Open clipboard history from Trigger
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* Cycle workspace layouts backwards with Super + Alt + L
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test the Trigger > Appearance split and Clipboard History placement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Decode escaped web app URLs before naming their windows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Describe the workspace widget's layout mark and click
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
532 lines
22 KiB
Bash
Executable File
532 lines
22 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
set -euo pipefail
|
|
|
|
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
|
|
|
require_command lua
|
|
|
|
tmpdir=$(mktemp -d)
|
|
trap 'rm -rf "$tmpdir"' EXIT
|
|
|
|
stub_dir="$tmpdir/bin"
|
|
home_dir="$tmpdir/home"
|
|
log_file="$tmpdir/hyprctl.log"
|
|
mkdir -p "$stub_dir" "$home_dir"
|
|
|
|
cat >"$stub_dir/hyprctl" <<'EOF'
|
|
#!/bin/bash
|
|
|
|
if [[ $1 == "activeworkspace" && -n $HYPRCTL_BROKEN ]]; then
|
|
printf '{}\n'
|
|
elif [[ $1 == "activeworkspace" ]]; then
|
|
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"
|
|
|
|
# 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"
|
|
notification_log="$tmpdir/notifications.log"
|
|
|
|
# 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
|
|
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"
|
|
|
|
# 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(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(#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
|
|
|
|
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"
|