diff --git a/docs/tiling-wm-experience.md b/docs/tiling-wm-experience.md index 29e63534..8f828411 100644 --- a/docs/tiling-wm-experience.md +++ b/docs/tiling-wm-experience.md @@ -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: diff --git a/dotfiles/config/hypr/hyprland.lua b/dotfiles/config/hypr/hyprland.lua index cf5c83b0..a77adbd9 100644 --- a/dotfiles/config/hypr/hyprland.lua +++ b/dotfiles/config/hypr/hyprland.lua @@ -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()