From 534196af948dad74f64f423089c51e244dfaaf2d Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sat, 11 Jul 2026 15:42:05 -0700 Subject: [PATCH] Make scratchpad exclusivity stateful --- docs/tiling-wm-experience.md | 11 ++- dotfiles/config/hypr/hyprland/scratchpads.lua | 70 +++++++++++++++---- dotfiles/config/hypr/hyprland/windows.lua | 3 +- 3 files changed, 66 insertions(+), 18 deletions(-) diff --git a/docs/tiling-wm-experience.md b/docs/tiling-wm-experience.md index 7ca9e884..ec64df3e 100644 --- a/docs/tiling-wm-experience.md +++ b/docs/tiling-wm-experience.md @@ -260,8 +260,15 @@ Required behavior: - A named scratchpad exists for volume. - A named scratchpad exists for x.com. - Scratchpads appear near-fullscreen and centered by default. -- Regular scratchpads can be tiled into the normal workspace when desired, - while retaining their summon/dismiss toggles. +- Floating scratchpad behavior is a state of a window, not a property of its + application class. Floating any window at scratchpad geometry enters that + state; tiling it leaves that state. +- Invoking a scratchpad replaces only another window currently in floating + scratchpad mode. Other floating windows are left alone. +- Regular scratchpad targets can be tiled into the normal workspace when + desired, while retaining their shortcut association. +- Summoning a scratchpad target with its shortcut always restores floating + scratchpad mode, even if the window was previously tiled. - Toggling a scratchpad deactivates fullscreen/tabbed state first. - Floating scratchpads are hidden from normal workspace and window listings. diff --git a/dotfiles/config/hypr/hyprland/scratchpads.lua b/dotfiles/config/hypr/hyprland/scratchpads.lua index 8a47eb3b..9c0869d5 100644 --- a/dotfiles/config/hypr/hyprland/scratchpads.lua +++ b/dotfiles/config/hypr/hyprland/scratchpads.lua @@ -8,6 +8,7 @@ function M.setup(ctx) dropdown_animation_frames = 18 dropdown_animation_frame_ms = 16 scratchpad_pending = {} + scratchpad_mode_tag = "scratchpad" monitor_reserved_cache_path = (os.getenv("XDG_RUNTIME_DIR") or "/tmp") .. "/hyprland-monitor-reserved.tsv" scratchpad_fallback_reserved_top = 60 @@ -100,15 +101,23 @@ function M.setup(ctx) return def.allow_tiling ~= false and window and window.floating == false end - local function is_scratchpad_window(window) - for _, def in pairs(scratchpads) do - if scratchpad_window_matches(window, def) and not tiled_scratchpad_is_normal_window(window, def) then + local function window_has_scratchpad_tag(window) + for _, tag in ipairs((window and window.tags) or {}) do + if tag == scratchpad_mode_tag then return true end end return false end + local function window_is_floating_scratchpad(window) + return window and window.floating == true and window_has_scratchpad_tag(window) + end + + local function is_scratchpad_window(window) + return window_is_floating_scratchpad(window) + end + local function matching_scratchpad_name(window) for name, def in pairs(scratchpads) do if scratchpad_window_matches(window, def) then @@ -122,6 +131,31 @@ function M.setup(ctx) return "name:scratch-hidden-" .. name end + local function mark_window_as_floating_scratchpad(window, name) + local selector = window_selector(window) + if not selector then + return + end + + dispatch(hl.dsp.window.float({ action = "enable", window = selector })) + dispatch(hl.dsp.window.tag({ tag = "+" .. scratchpad_mode_tag, window = selector })) + if name then + dispatch(hl.dsp.window.tag({ tag = "+scratchpad-" .. name, window = selector })) + end + end + + local function clear_floating_scratchpad_state(window) + local selector = window_selector(window) + if not selector then + return + end + + dispatch(hl.dsp.window.tag({ tag = "-" .. scratchpad_mode_tag, window = selector })) + for name in pairs(scratchpads) do + dispatch(hl.dsp.window.tag({ tag = "-scratchpad-" .. name, window = selector })) + end + end + local function as_number(value, default) local number = tonumber(value) if number == nil then @@ -337,9 +371,7 @@ function M.setup(ctx) end local selector = window_selector(window) - dispatch(hl.dsp.window.float({ action = "enable", window = selector })) - dispatch(hl.dsp.window.tag({ tag = "+scratchpad", window = selector })) - dispatch(hl.dsp.window.tag({ tag = "+scratchpad-" .. name, window = selector })) + mark_window_as_floating_scratchpad(window, name) dispatch(hl.dsp.window.resize({ x = geometry.width, y = geometry.height, relative = false, window = selector })) dispatch(hl.dsp.window.move({ x = geometry.x, y = geometry.y, relative = false, window = selector })) if def.dropdown then @@ -382,7 +414,8 @@ function M.setup(ctx) local function hide_scratchpad_window(name, window) remove_minimized_window(window) - move_window_to_workspace(scratchpad_workspace(name), false, window) + name = name or matching_scratchpad_name(window) + move_window_to_workspace(name and scratchpad_workspace(name) or "special:NSP", false, window) end local function scratchpad_show_workspace(workspace) @@ -401,6 +434,7 @@ function M.setup(ctx) end remove_minimized_window(window) + mark_window_as_floating_scratchpad(window, name) if scratchpads[name] and scratchpads[name].dropdown then apply_scratchpad_geometry(name, window, target_monitor or hl.get_active_monitor(), "above") end @@ -408,8 +442,10 @@ function M.setup(ctx) dispatch(hl.dsp.focus({ window = window_selector(window) })) if scratchpads[name] and scratchpads[name].dropdown then animate_dropdown_scratchpad_down(name, window, target_monitor or hl.get_active_monitor()) - elseif should_apply_scratchpad_geometry(name, window, opts) then - schedule_scratchpad_geometry(name, window, target_monitor or hl.get_active_monitor(), nil, nil, opts) + else + schedule_scratchpad_geometry(name, window, target_monitor or hl.get_active_monitor(), nil, nil, { + force_geometry = true, + }) end end @@ -418,17 +454,17 @@ function M.setup(ctx) return workspace and window and same_workspace(window.workspace, workspace) end - -- Active scratchpads are scratchpad windows visible on the active workspace. - -- Invoking a different scratchpad replaces that active set. + -- Scratchpad exclusivity belongs to a window's current presentation state, + -- not its class. Invoking a named shortcut replaces any other window that is + -- visibly in floating scratchpad mode, including an otherwise ordinary app. local function active_scratchpad_windows(except_name) local windows = {} for _, window in ipairs(hl.get_windows()) do local name = matching_scratchpad_name(window) if - name - and name ~= except_name + (except_name == nil or name ~= except_name) and scratchpad_is_visible(window) - and not tiled_scratchpad_is_normal_window(window, scratchpads[name]) + and window_is_floating_scratchpad(window) then windows[#windows + 1] = { name = name, @@ -474,7 +510,7 @@ function M.setup(ctx) show_scratchpad_window(name, window, pending.workspace or active_workspace(), pending.monitor or hl.get_active_monitor(), { force_geometry = true, }) - elseif scratchpad_is_visible(window) then + elseif scratchpad_is_visible(window) and window_is_floating_scratchpad(window) then schedule_scratchpad_geometry(name, window, hl.get_active_monitor()) end end @@ -584,9 +620,13 @@ function M.setup(ctx) ctx.lower_contains_any = lower_contains_any ctx.scratchpad_window_matches = scratchpad_window_matches ctx.tiled_scratchpad_is_normal_window = tiled_scratchpad_is_normal_window + ctx.window_has_scratchpad_tag = window_has_scratchpad_tag + ctx.window_is_floating_scratchpad = window_is_floating_scratchpad ctx.is_scratchpad_window = is_scratchpad_window ctx.matching_scratchpad_name = matching_scratchpad_name ctx.scratchpad_workspace = scratchpad_workspace + ctx.mark_window_as_floating_scratchpad = mark_window_as_floating_scratchpad + ctx.clear_floating_scratchpad_state = clear_floating_scratchpad_state ctx.as_number = as_number ctx.logical_monitor_dimension = logical_monitor_dimension ctx.split_tsv = split_tsv diff --git a/dotfiles/config/hypr/hyprland/windows.lua b/dotfiles/config/hypr/hyprland/windows.lua index ffdd47ef..6f838baf 100644 --- a/dotfiles/config/hypr/hyprland/windows.lua +++ b/dotfiles/config/hypr/hyprland/windows.lua @@ -140,7 +140,7 @@ function M.setup(ctx) action = "set", window = selector, })) - dispatch(hl.dsp.window.float({ action = "enable", window = selector })) + mark_window_as_floating_scratchpad(window, matching_scratchpad_name(window)) dispatch(hl.dsp.window.resize({ x = geometry.width, y = geometry.height, relative = false, window = selector })) dispatch(hl.dsp.window.move({ x = geometry.x, y = geometry.y, relative = false, window = selector })) end @@ -153,6 +153,7 @@ function M.setup(ctx) end if window.floating then + clear_floating_scratchpad_state(window) dispatch(hl.dsp.window.float({ action = "disable", window = selector })) return end