Skip to content

Commit 870376c

Browse files
dhhcodex
andcommitted
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>
1 parent 9b59645 commit 870376c

8 files changed

Lines changed: 615 additions & 40 deletions

File tree

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,39 @@
11
#!/bin/bash
22

3-
# omarchy:summary=Toggle the layout on the current active workspace between dwindle and scrolling
3+
# omarchy:summary=Cycle the current active workspace through the dwindle, scrolling, and floating layouts
44

55
ACTIVE_WORKSPACE=$(hyprctl activeworkspace -j | jq -r '.id')
6-
[[ $ACTIVE_WORKSPACE =~ ^-?[0-9]+$ ]] || exit 1
7-
CURRENT_LAYOUT=$(hyprctl activeworkspace -j | jq -r '.tiledLayout')
8-
LAYOUTS_DIR="$HOME/.local/state/omarchy/workspace-layouts"
9-
LAYOUT_FILE="$LAYOUTS_DIR/$ACTIVE_WORKSPACE.lua"
6+
# Named workspaces report a negative id, and Hyprland reads a leading "-" as a
7+
# selector relative to the current workspace: targeting one lands on workspace 1
8+
# and reconfigures that instead.
9+
[[ $ACTIVE_WORKSPACE =~ ^[0-9]+$ ]] || exit 1
10+
MODES_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/workspace-layouts"
11+
MODE_FILE="$MODES_DIR/$ACTIVE_WORKSPACE.lua"
1012

11-
case "$CURRENT_LAYOUT" in
12-
dwindle) NEW_LAYOUT=scrolling ;;
13-
*) NEW_LAYOUT=dwindle ;;
13+
# Hyprland only reports the tiled layout, and floating is not one, so the mode is
14+
# read back from the file this script wrote. Files saved before floating existed
15+
# name no mode and fall through to what Hyprland reports.
16+
CURRENT_MODE=$(sed -n 's/.*mode = "\([a-z]*\)".*/\1/p' "$MODE_FILE" 2>/dev/null)
17+
if [[ -z $CURRENT_MODE ]]; then
18+
CURRENT_MODE=$(hyprctl activeworkspace -j | jq -r '.tiledLayout')
19+
fi
20+
21+
case "$CURRENT_MODE" in
22+
dwindle) NEW_MODE=scrolling ;;
23+
scrolling) NEW_MODE=floating ;;
24+
*) NEW_MODE=dwindle ;;
1425
esac
1526

16-
mkdir -p "$LAYOUTS_DIR"
17-
printf 'hl.workspace_rule({ workspace = "%s", layout = "%s" })\n' "$ACTIVE_WORKSPACE" "$NEW_LAYOUT" >"$LAYOUT_FILE"
27+
APPLY="o.workspace_mode({ workspace = \"$ACTIVE_WORKSPACE\", mode = \"$NEW_MODE\" })"
1828

19-
hyprctl eval "hl.workspace_rule({ workspace = \"$ACTIVE_WORKSPACE\", layout = \"$NEW_LAYOUT\" })" >/dev/null 2>&1 || \
20-
hyprctl keyword workspace "$ACTIVE_WORKSPACE, layout:$NEW_LAYOUT"
21-
omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_LAYOUT"
29+
# Saving a mode that did not apply would leave the file naming one layout while
30+
# the screen shows another, and restore it at the next login.
31+
if ! hyprctl eval "$APPLY" >/dev/null 2>&1; then
32+
omarchy-notification-send -g 󱂬 "Workspace layout needs a Hyprland reload"
33+
elif mkdir -p "$MODES_DIR" && printf '%s\n' "$APPLY" >"$MODE_FILE"; then
34+
omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_MODE"
35+
else
36+
# The mode is on screen but nothing will bring it back, and the next press
37+
# reads the mode from this file, so say so rather than counting it as set.
38+
omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_MODE but could not be saved"
39+
fi

default/hypr/bindings/tiling.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ o.bind("SUPER + ALT + F", "Full width", hl.dsp.window.fullscreen({ mode = "maxim
1111
o.bind("SUPER + O", "Pop window out (float & pin)", "omarchy-hyprland-window-pop")
1212
o.bind("SUPER + ALT + Home", "Save window width", "omarchy-hyprland-window-width save")
1313
o.bind("SUPER + Home", "Restore window width", "omarchy-hyprland-window-width restore")
14-
o.bind("SUPER + L", "Toggle workspace layout", "omarchy-hyprland-workspace-layout-toggle")
14+
o.bind("SUPER + L", "Cycle workspace layout", "omarchy-hyprland-workspace-layout-toggle")
1515

1616
o.bind("SUPER + LEFT", "Focus on left window", hl.dsp.focus({ direction = "l" }))
1717
o.bind("SUPER + RIGHT", "Focus on right window", hl.dsp.focus({ direction = "r" }))

default/hypr/workspace-layouts.lua

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
local paths = require("default.hypr.paths")
44
local require_all = require("default.hypr.require_all")
55

6+
-- The saved files call o.workspace_mode, so it has to exist before they load.
7+
require("default.hypr.workspace-modes")
8+
69
local layouts_dir = paths.state_home .. "/omarchy/workspace-layouts"
710

11+
-- The directory is found through XDG_STATE_HOME but the files are loaded by
12+
-- module name, and bootstrap only puts ~/.local/state on the search path. A
13+
-- state home anywhere else would list the saved layouts and then fail to require
14+
-- them, or load same-named ones from under the home directory instead.
15+
package.path = paths.state_home .. "/?.lua;" .. package.path
16+
817
require_all.files(layouts_dir, "omarchy.workspace-layouts", { reload = true })

default/hypr/workspace-modes.lua

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
-- Workspace modes for omarchy-hyprland-workspace-layout-toggle.
2+
--
3+
-- Hyprland has dwindle and scrolling as tiled layouts, but nothing that floats a
4+
-- whole workspace: it reports a workspace's `tiledLayout` and takes an unknown
5+
-- layout name in silence, falling back to dwindle. So floating is applied to the
6+
-- windows themselves.
7+
--
8+
-- Ending the mode has to tile the windows the mode floated and only those, so
9+
-- each one is tagged. The tag lives on the window: it dies with it, follows it
10+
-- between workspaces, and survives the config reload that a monitor being
11+
-- plugged in performs, none of which a table on this side would do.
12+
13+
require("default.hypr.helpers")
14+
15+
local FLOATED_TAG = "omarchy-mode-floated"
16+
17+
local modes = {}
18+
local watching = false
19+
20+
local function set_floating(window, floating)
21+
hl.dispatch(hl.dsp.window.float({ action = floating and "on" or "off", window = window }))
22+
end
23+
24+
local function set_claimed(window, claimed)
25+
hl.dispatch(hl.dsp.window.tag({ tag = (claimed and "+" or "-") .. FLOATED_TAG, window = window }))
26+
end
27+
28+
local function claimed()
29+
local addresses = {}
30+
31+
for _, window in ipairs(hl.get_windows({ tag = FLOATED_TAG })) do
32+
addresses[window.address] = true
33+
end
34+
35+
return addresses
36+
end
37+
38+
-- A window already floating on its own account -- an app with a float rule of
39+
-- its own, a dialog, one popped out with SUPER + T -- is left unclaimed, so it
40+
-- is still floating when the mode ends.
41+
local function float_for_mode(window)
42+
if window.floating then
43+
return
44+
end
45+
46+
set_claimed(window, true)
47+
set_floating(window, true)
48+
end
49+
50+
-- A pinned window is one the user asked to keep above everything, and tiling it
51+
-- would unpin it as well, so the mode leaves it be.
52+
local function tile_for_mode(window, mine)
53+
if not mine[window.address] or window.pinned then
54+
return
55+
end
56+
57+
set_claimed(window, false)
58+
set_floating(window, false)
59+
end
60+
61+
-- Windows arrive by opening and by being carried in, and a window rule only ever
62+
-- fires for the first. One carried back out gives up the floating the mode gave
63+
-- it rather than staying floating somewhere that tiles.
64+
local function watch()
65+
if watching then
66+
return
67+
end
68+
69+
watching = true
70+
71+
hl.on("window.open", function(window)
72+
if modes[tostring(window.workspace.id)] == "floating" then
73+
float_for_mode(window)
74+
end
75+
end)
76+
77+
hl.on("window.move_to_workspace", function(window, workspace)
78+
if modes[tostring(workspace.id)] == "floating" then
79+
float_for_mode(window)
80+
else
81+
tile_for_mode(window, claimed())
82+
end
83+
end)
84+
end
85+
86+
function o.workspace_mode(spec)
87+
local workspace = tostring(spec.workspace)
88+
local floating = spec.mode == "floating"
89+
90+
watch()
91+
92+
-- Floating keeps whatever tiled layout the workspace had, so nothing has to be
93+
-- chosen for it on the way back out.
94+
if not floating then
95+
hl.workspace_rule({ workspace = workspace, layout = spec.mode })
96+
end
97+
98+
modes[workspace] = spec.mode
99+
100+
local windows = hl.get_workspace_windows(workspace)
101+
102+
if floating then
103+
local claiming = {}
104+
105+
-- Claim every tiled window before floating any of them: floating one member
106+
-- of a group floats the rest, and a single pass would then find those
107+
-- already floating and leave them to be stranded when the mode ends.
108+
for _, window in ipairs(windows) do
109+
if not window.floating then
110+
set_claimed(window, true)
111+
table.insert(claiming, window)
112+
end
113+
end
114+
115+
for _, window in ipairs(claiming) do
116+
set_floating(window, true)
117+
end
118+
else
119+
local mine = claimed()
120+
121+
for _, window in ipairs(windows) do
122+
tile_for_mode(window, mine)
123+
end
124+
end
125+
end

manual/04-navigation.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Omarchy's default layout is called dwindle. It keeps all the windows you open on
3434

3535
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`.
3636

37+
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. 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.
38+
3739
![navigation-scrolling-layout](images/navigation-scrolling-layout.webp)
3840

3941
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 > Toggle > Workspace Layout_ in the Omarchy menu).

manual/07-hotkeys.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ You can see all the main keyboard bindings with `Super + K` (Tmux bindings with
1515
| `Super + T` | Toggle window between tiling/floating |
1616
| `Super + J` | Toggle window position (horizontal/vertical) |
1717
| `Super + O` | Toggle popping window into sticky'n'floating |
18-
| `Super + L` | Toggle between dwindle and scrolling layout |
18+
| `Super + L` | Cycle through the dwindle, scrolling, and floating layouts |
1919
| `Super + P` | Toggle pseudo window style (natural v stretch) |
2020
| `Super + F` | Go full screen |
2121
| `Super + Alt + F` | Go full width |

migrations/1787679775.sh

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
echo "Drop saved workspace layouts belonging to named workspaces"
2+
3+
# omarchy-hyprland-workspace-layout-toggle acted on whatever id activeworkspace
4+
# reported, and a named workspace reports a negative one. Hyprland reads a
5+
# leading "-" as a selector relative to the current workspace, so a layout saved
6+
# for one is applied to workspace 1 at every login instead. The command no longer
7+
# writes these; this clears the ones already on disk. It wrote to ~/.local/state
8+
# whatever XDG_STATE_HOME said, so both are cleared.
9+
removed=0
10+
11+
for state_home in "$HOME/.local/state" "${XDG_STATE_HOME:-}"; do
12+
[[ -n $state_home ]] || continue
13+
14+
layouts_dir="$state_home/omarchy/workspace-layouts"
15+
[[ -d $layouts_dir ]] || continue
16+
17+
for saved in "$layouts_dir"/-*.lua; do
18+
if [[ -f $saved ]]; then
19+
rm -f -- "$saved"
20+
removed=1
21+
fi
22+
done
23+
done
24+
25+
# The package hook reloads Hyprland before migrations run, so the rule from the
26+
# file just deleted is already applied to workspace 1. Hyprland watches the files
27+
# it required and picks the deletion up on its own, but not for a user who has
28+
# turned autoreload off, so ask for one.
29+
if (( removed )); then
30+
hyprctl reload >/dev/null 2>&1 || true
31+
fi

0 commit comments

Comments
 (0)