Add per-monitor workspace history cycling

This commit is contained in:
2026-04-30 00:37:49 -07:00
parent 6f489d14ab
commit 3cb0301f9a
2 changed files with 147 additions and 18 deletions

View File

@@ -43,13 +43,13 @@ Required behavior:
- Moving the focused window to the next empty workspace and following it is a
first-class operation.
- Normal workspaces are bounded to `1..9`.
- Workspace history is tracked per monitor.
- Last-workspace toggle uses the current monitor's workspace history.
- Workspace cycling works on the current monitor within the bounded workspace
set.
Important behavior:
- Workspace history is tracked per monitor.
- Last-workspace toggle uses the current monitor's workspace history.
- Workspace history cycling works on the current monitor within the bounded
workspace set.
- Swapping the current workspace contents with another workspace is available.
- Moving a window to an empty workspace on another monitor is available.
- Moving the focused window to another monitor without following keeps keyboard
@@ -62,6 +62,30 @@ Important behavior:
- Hidden/special workspaces are excluded from the status bar's normal workspace
list.
### Workspace History Cycling
Important behavior:
- The model is most-recently-used workspace switching, scoped to the monitor
where the action starts.
- Each monitor has its own ordered workspace history. The focused monitor's
history is not shared with other monitors.
- Only ordinary bounded workspaces are candidates. Special, scratchpad,
minimized, hidden, and out-of-range workspaces are excluded.
- Starting a cycle freezes the candidate list for that cycle. Previewing
workspaces while the cycle is active must not rewrite the history order.
- Starting a cycle previews the previous workspace for the current monitor.
- Repeating the forward cycle action continues farther back through that
monitor's frozen history.
- A reverse cycle action moves through the same frozen history in the opposite
direction.
- Releasing the initiating modifier key commits the currently previewed
workspace and updates history exactly once.
- A cancel path may return to the workspace where the cycle started.
This behavior is important for workflow continuity, but it is not a hard
requirement for a minimal daily-driver window manager.
## Directional Navigation
Required behavior:
@@ -282,7 +306,6 @@ Required behavior:
- `Super+g` opens the go-to-window picker.
- `Super+b` opens the bring-window picker.
- `Super+Shift+b` opens the replace-window picker.
- `Super+\` toggles to the previous workspace on the current monitor.
- `Super+Shift+e` moves the focused window to the next empty workspace and
follows it. This is the target replacement for the older `Super+Shift+h`
binding.
@@ -290,6 +313,13 @@ Required behavior:
- `Hyper+5` swaps the current workspace with a selected workspace.
- `Hyper+g` gathers windows of the focused class onto the current workspace.
Important behavior:
- `Super+\` starts or advances current-monitor workspace history cycling.
- `Super+/` reverses current-monitor workspace history cycling while the
initiating `Super` key is held.
- Releasing the initiating `Super` key commits the workspace history cycle.
### Directional Navigation Bindings
Required behavior:

View File

@@ -26,6 +26,7 @@ local stack_update_timer = nil
local monocle_notice = nil
local scratchpad_pending = {}
local monitor_workspace_history = {}
local workspace_history_cycle = nil
local scratchpads = {
htop = {
@@ -570,29 +571,120 @@ local function monitor_key(monitor)
return tostring(monitor.name or monitor.id or "unknown")
end
local function workspace_history_workspace_id(workspace)
if not workspace or not workspace.id or workspace.id < 1 or workspace.id > max_workspace then
return nil
end
return workspace.id
end
local function workspace_history_monitor_key(workspace)
return monitor_key(workspace and workspace.monitor or hl.get_active_monitor())
end
local function remove_workspace_history_id(history, workspace_id)
for index = #history, 1, -1 do
if history[index] == workspace_id then
table.remove(history, index)
end
end
end
local function remember_workspace_for_monitor(workspace)
workspace = workspace or active_workspace()
if not workspace or not workspace.id or workspace.id < 1 then
if workspace_history_cycle then
return
end
local key = monitor_key(workspace.monitor or hl.get_active_monitor())
local workspace_id = workspace_history_workspace_id(workspace)
if not workspace_id then
return
end
local key = workspace_history_monitor_key(workspace)
local history = monitor_workspace_history[key] or {}
if history.current ~= workspace.id then
history.previous = history.current
history.current = workspace.id
if history[1] ~= workspace_id then
remove_workspace_history_id(history, workspace_id)
table.insert(history, 1, workspace_id)
end
monitor_workspace_history[key] = history
end
local function focus_previous_workspace_for_monitor()
local key = monitor_key(hl.get_active_monitor())
local history = monitor_workspace_history[key]
if history and history.previous then
focus_workspace(history.previous)
else
hl.dsp.focus({ workspace = "previous_per_monitor" })()
local function clone_workspace_history(history)
local clone = {}
for index, workspace_id in ipairs(history or {}) do
clone[index] = workspace_id
end
return clone
end
local function workspace_history_cycle_start()
if workspace_history_cycle then
return workspace_history_cycle
end
remember_workspace_for_monitor()
local workspace = active_workspace()
local workspace_id = workspace_history_workspace_id(workspace)
if not workspace_id then
return nil
end
local key = workspace_history_monitor_key(workspace)
local history = clone_workspace_history(monitor_workspace_history[key])
if history[1] ~= workspace_id then
remove_workspace_history_id(history, workspace_id)
table.insert(history, 1, workspace_id)
end
if #history < 2 then
return nil
end
workspace_history_cycle = {
monitor_key = key,
original_workspace = workspace_id,
history = history,
index = 2,
}
return workspace_history_cycle
end
local function cycle_workspace_history(direction)
local cycle = workspace_history_cycle_start()
if not cycle then
hl.dsp.focus({ workspace = "previous_per_monitor" })()
return
end
local workspace_id = cycle.history[cycle.index]
if not workspace_id then
return
end
focus_workspace(workspace_id)
cycle.index = ((cycle.index - 1 + direction) % #cycle.history) + 1
end
local function commit_workspace_history_cycle()
if not workspace_history_cycle then
return
end
workspace_history_cycle = nil
remember_workspace_for_monitor()
end
local function cancel_workspace_history_cycle()
local cycle = workspace_history_cycle
if not cycle then
return
end
workspace_history_cycle = nil
focus_workspace(cycle.original_workspace)
end
local function move_window_to_workspace(workspace_id, follow, window)
@@ -1765,7 +1857,14 @@ for i = 1, 9 do
end)
end
bind(main_mod .. " + backslash", focus_previous_workspace_for_monitor)
bind(main_mod .. " + backslash", function()
cycle_workspace_history(1)
end)
bind(main_mod .. " + slash", function()
cycle_workspace_history(-1)
end)
bind(main_mod .. " + Super_L", commit_workspace_history_cycle, { release = true })
bind(main_mod .. " + Escape", cancel_workspace_history_cycle)
bind(main_mod .. " + Z", hl.dsp.focus({ monitor = "+1" }))
bind(main_mod .. " + SHIFT + Z", hl.dsp.window.move({ monitor = "+1" }))
bind(main_mod .. " + mouse_down", function()