diff --git a/default/hypr/qconsole.lua b/default/hypr/qconsole.lua index d3a36ae6..a512f8ae 100644 --- a/default/hypr/qconsole.lua +++ b/default/hypr/qconsole.lua @@ -5,6 +5,14 @@ -- How much of the usable screen the console covers, measured from the top. local share = 0.5 +-- A console holding a single window is boxed into a centered panel this many +-- times wider than it is tall, rather than stretched the width of the screen. +-- A second app on the scratchpad gets the full width back: two windows splitting +-- a half-width column is worse than the band this replaced. +local box = 2 + +local SCRATCHPAD = "special:scratchpad" + -- Seed the console with the default agent the first time it opens, rather than -- at boot, so nothing is running until it is wanted. The exec rule has to pin -- the workspace itself: Hyprland only tags a spawn with the workspace it came @@ -23,22 +31,25 @@ hl.config({ }, }) +-- The panel is always flush with the top and always centered, so two numbers +-- describe it: the gap down each side and the gap underneath. +-- -- Refitting replaces the rule in place rather than stacking a new one, but it -- still schedules a monitor and window state refresh, and monitor.focused fires --- on every hop between screens. Most of those hops do not change the number, so +-- on every hop between screens. Most of those hops do not change the gaps, so -- only write the rule when it actually moves. -local covering = nil +local beside, below = nil, nil -local function cover(bottom) - if covering == bottom then - return +local function cover(side, bottom) + if beside == side and below == bottom then + return false end - covering = bottom + beside, below = side, bottom hl.workspace_rule({ - workspace = "special:scratchpad", + workspace = SCRATCHPAD, gaps_in = 0, - gaps_out = { top = 0, right = 0, bottom = bottom, left = 0 }, + gaps_out = { top = 0, right = side, bottom = bottom, left = side }, -- Nothing to highlight in a console that is only ever focused when it is -- open, and the active border reads as a stray frame around a panel that @@ -47,39 +58,136 @@ local function cover(bottom) on_created_empty = seed, }) + + return true +end + +-- One window reads as a console and gets the panel. A second app has turned the +-- scratchpad into a workspace, and a workspace wants the whole width. Only tiled +-- windows count: the gaps are what size the panel, and a floating window on top +-- of the console is not laid out by them. +local function alone() + local tiled = 0 + for _, window in ipairs(hl.get_workspace_windows(SCRATCHPAD)) do + if not window.floating then + tiled = tiled + 1 + end + end + return tiled <= 1 end -- Sizing the console with a window rule would freeze it at whatever the screen -- measured when it first opened, because Hyprland resolves those expressions -- once, as the window maps. Rescaling the monitor afterwards would leave a -- console that is no longer half of anything. Gaps are re-applied by the layout --- instead, so the console is sized by the gap left underneath it and that gap --- is recomputed whenever the monitor layout changes. -local function fit() - local monitor = hl.get_active_monitor() - +-- instead, so the console is sized by the area left around it, and that area is +-- recomputed whenever the monitor it opens on changes. +local function fit(monitor) -- A monitor handle whose output has gone away answers nil to every field, and -- layout changes are exactly when that happens, so this also covers reading - -- height and reserved below. + -- width, height and reserved below. if not monitor or not monitor.scale or monitor.scale <= 0 then - return + return false + end + + -- Width and height are the panel's own pixels, so a monitor turned on its + -- side still reports them the way the panel is built. The odd transforms are + -- the quarter turns, and those are the ones that swap the work area. + local width, height = monitor.width, monitor.height + if monitor.transform % 2 == 1 then + width, height = height, width end -- Monitor dimensions are in physical pixels; gaps are logical, so the scale -- has to come out before the reserved area (already logical) comes off. local reserved = monitor.reserved - local usable = monitor.height / monitor.scale - reserved.top - reserved.bottom + height = height / monitor.scale - reserved.top - reserved.bottom + width = width / monitor.scale - reserved.left - reserved.right - cover(math.max(0, math.floor(usable * (1 - share)))) + local tall = math.floor(height * share) + local wide = width + if alone() then + wide = math.min(width, tall * box) + end + + return cover(math.floor((width - wide) / 2), math.floor(height - tall)) +end + +-- The console keeps the geometry of the output it is open on: a follow_mouse hop +-- onto another screen must not resize a console that is already showing. While +-- it is hidden there is nothing to size but the output that will show it next. +local function console_monitor() + local ws = hl.get_workspace(SCRATCHPAD) + local mon = ws and ws.visible and ws.monitor + + if mon and mon.scale and mon.scale > 0 then + return mon + end + + return hl.get_active_monitor() +end + +local function refit(monitor) + if fit(monitor or console_monitor()) then + -- Land the new gaps in this pass rather than a frame later, so the console + -- does not visibly resize itself once it has already dropped down. + hl.exec_scheduled_prop_refresh_immediately() + end end -- Until a monitor can be read, cover the whole work area rather than leaving --- the console unruled, so it is never seeded without its placement. -cover(0) -fit() +-- the console unruled, so it is never seeded without its placement. A reload +-- runs this again with the console already on screen, so it starts from the +-- output the console is on rather than whichever one the pointer is over. +cover(0, 0) +fit(console_monitor()) -hl.on("monitor.layout_changed", fit) -hl.on("monitor.focused", fit) +hl.on("monitor.layout_changed", function() + refit() +end) + +hl.on("monitor.focused", function() + refit() +end) + +-- Special workspaces open on the monitor they are toggled on, not on whichever +-- output last happened to be focused when the rule was written, so these two +-- take the monitor they are handed rather than looking one up. +hl.on("workspace.special_active", function(ws, mon) + if ws and ws.name == SCRATCHPAD then + refit(mon) + end +end) + +hl.on("workspace.move_to_monitor", function(ws, mon) + if ws and ws.name == SCRATCHPAD then + refit(mon) + end +end) + +-- The panel is only centered while the console holds one tiled window, so the +-- count has to be rechecked as apps come and go: opened and closed, moved on or +-- off (Super+Alt+S, Super+Shift+1), and floated or tiled (Super+T, Super+O). +-- window.close is left out, since it still counts the window on its way out and +-- window.destroy follows it anyway. Measured on Hyprland 0.56.2, a move is +-- trailed by several window.update_rules, as is a float toggle, and the last of +-- those always reads the settled count; the earlier ones refit to what the rule +-- already is, which cover() skips. +-- +-- Only while it is on screen, though. A hidden console is refitted on its way in +-- by workspace.special_active, and every window opened anywhere on the desktop +-- would otherwise rewrite the rule. +local function recount() + local ws = hl.get_workspace(SCRATCHPAD) + if ws and ws.visible then + refit() + end +end + +hl.on("window.open", recount) +hl.on("window.destroy", recount) +hl.on("window.move_to_workspace", recount) +hl.on("window.update_rules", recount) -- The direction names the edge the offset is measured from, not where the -- workspace goes: "slide top" drops it down into view, and "slide bottom" diff --git a/manual/04-navigation.md b/manual/04-navigation.md index 549833bf..0b3404ff 100644 --- a/manual/04-navigation.md +++ b/manual/04-navigation.md @@ -66,6 +66,8 @@ Finally, there's a special scratchpad workspace that drops down over whatever wo It works especially well for a terminal running an agent, or for controls you want to interact with quickly without leaving the current workspace. To move a window off the scratchpad, send it directly to another workspace with something like `Super + Shift + 1`. +While the scratchpad holds a single window, it drops down as a centered panel rather than spanning the screen. Put a second app on it and it goes back to the full width, so the two have room to sit side by side. + ### It takes some getting used to! It takes a little while to get used to navigating your desktop like this, but once you do, it'll be hard to go back to a traditional mouse-driven desktop experience! diff --git a/test/shell.d/hyprland-qconsole-test.sh b/test/shell.d/hyprland-qconsole-test.sh index 57bdea8d..57feb176 100755 --- a/test/shell.d/hyprland-qconsole-test.sh +++ b/test/shell.d/hyprland-qconsole-test.sh @@ -4,15 +4,16 @@ source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" require_command lua -# The console is sized by the gap underneath it, recomputed from the monitor, +# The console is sized by the gaps around it, recomputed from the monitor, # because a window rule's size would freeze at whatever the screen measured when -# the console first opened. The arithmetic is what keeps it half a screen on a -# scaled display, so it is worth pinning down. +# the console first opened. The arithmetic is what keeps it a half-height panel +# on a scaled display, so it is worth pinning down. # base-test.sh does not set -e, so the assertions have to fail the file # themselves rather than leaving the pass below to run regardless. -OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console covers half the work area at any monitor scale" +OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console is a centered panel until a second app joins it" local rules, handlers = {}, {} local monitor = nil +local workspace = nil hl = { config = function() end, @@ -20,6 +21,16 @@ hl = { workspace_rule = function(rule) table.insert(rules, rule) end, on = function(event, callback) handlers[event] = callback end, get_active_monitor = function() return monitor end, + get_workspace = function() return workspace end, + -- workspace.windows is the tiled count here and workspace.floats the floating + -- one, so the fixtures can say which kind of window is on the console. + get_workspace_windows = function() + local list = {} + for _ = 1, workspace and workspace.windows or 0 do table.insert(list, { floating = false }) end + for _ = 1, workspace and workspace.floats or 0 do table.insert(list, { floating = true }) end + return list + end, + exec_scheduled_prop_refresh_immediately = function() end, } dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") @@ -29,6 +40,11 @@ local function current() return rules[#rules] end +local function gaps() + local g = current().gaps_out + return g.top, g.right, g.bottom, g.left +end + -- Config loads before the outputs are up, so the first pass has no monitor to -- read. It still has to leave a rule behind, or the console would open unseeded. assert(#rules > 0, "console is ruled even before a monitor can be read") @@ -38,7 +54,7 @@ assert(current().on_created_empty:find("^%[workspace special:scratchpad silent%] assert(current().workspace == "special:scratchpad") local function rescale(height, scale, bar) - monitor = { height = height, scale = scale, reserved = { top = bar, bottom = 0, left = 0, right = 0 } } + monitor = { width = 1920, height = height, scale = scale, transform = 0, reserved = { top = bar, bottom = 0, left = 0, right = 0 } } handlers["monitor.layout_changed"]() return current().gaps_out.bottom end @@ -52,11 +68,9 @@ assert(rescale(2160, 1.5, 40) == 700, "and at a fractional scale") -- console short. assert(rescale(1440, 1, 0) == 720, "a monitor with nothing reserved") --- The console stays flush with the top and the sides, the way a Quake console --- drops in, and keeps its seed across every refit. local final = current() -assert(final.gaps_out.top == 0 and final.gaps_out.left == 0 and final.gaps_out.right == 0, - "the console is flush to the top and sides") +assert(final.gaps_out.top == 0, "the console stays flush with the top, the way a drop-down arrives") +assert(final.gaps_out.left == final.gaps_out.right, "the panel is centered") assert(final.on_created_empty:find("omarchy%-agent"), "refitting keeps the console seeded") assert(final.no_border == true, "the console drops the active window border") @@ -69,22 +83,197 @@ assert(current().gaps_out.bottom == before, "an absent monitor leaves the consol -- A monitor handle outliving its output answers nil to everything, which is -- what a layout change looks like mid-flight. Reading height or reserved off -- that would throw, so the scale guard has to catch it first. -monitor = setmetatable({}, { __index = function() return nil end }) +local expired = setmetatable({}, { __index = function() return nil end }) +monitor = expired handlers["monitor.layout_changed"]() assert(current().gaps_out.bottom == before, "an expired monitor handle is not read to pieces") -- Refitting to the size it already is would still cost a state refresh, and -- monitor.focused fires on every hop between screens. -monitor = { height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +monitor = { width = 2560, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } handlers["monitor.layout_changed"]() local written = #rules handlers["monitor.focused"]() handlers["monitor.layout_changed"]() assert(#rules == written, "refitting to the same size does not rewrite the rule") -monitor.scale = 2 +-- A centered 2:1 panel, not a full-width drop-down. +monitor = { width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } handlers["monitor.layout_changed"]() -assert(#rules == written + 1, "a real change still rewrites it") -assert(current().gaps_out.bottom == 360, "and lands on half the rescaled screen") +local top, right, bottom, left = gaps() +assert(top == 0 and left == 420 and right == 420 and bottom == 540, "16:9 leaves a 1080x540 panel") + +local dell = { name = "DP-1", width = 6144, height = 2560, scale = 1, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +monitor = dell +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 1807 and right == 1807 and bottom == 1265, "the same 2:1 panel on 6K") + +-- Same logical box at scale 2x (physical 12288x5120): scale does not change the +-- panel's logical size. +monitor = { name = "DP-1", width = 12288, height = 5120, scale = 2, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 1807 and right == 1807 and bottom == 1265, "the 6K box is in logical pixels") + +-- Same height, different width: the sides have to move even though the bottom +-- gap is identical, so the cache cannot key on height alone. +monitor = { width = 2560, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +written = #rules +monitor = { width = 3440, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +assert(#rules == written + 1, "a same-height ultrawide hop still rewrites the sides") +top, right, bottom, left = gaps() +assert(left == 1000 and right == 1000 and bottom == 720, "3440x1440 leaves a 1440x720 box") + +-- A panel wider than the screen is just the screen: a portrait monitor has no +-- room for a 2:1 box and falls back to the full width rather than a negative gap. +monitor = { width = 1080, height = 1920, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 960, "a portrait monitor keeps the full width") +assert(left >= 0 and right >= 0 and bottom >= 0, "gaps are never negative") + +-- A monitor turned on its side still reports the panel's own pixels, so the +-- work area has to be turned with it. Measured against Hyprland 0.56.2: a +-- rotated 1920x1080 lays its windows out in 1080x1920 while width and height +-- still read 1920 and 1080. Quarter turns are the odd transforms; a half turn +-- leaves the shape alone. +monitor = { width = 1920, height = 1080, scale = 1, transform = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 945, "a quarter-turned monitor is sized portrait") + +monitor = { width = 1920, height = 1080, scale = 1, transform = 3, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 945, "and so is the other quarter turn") + +monitor = { width = 1920, height = 1080, scale = 1, transform = 2, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435 and bottom == 525, "a half turn is still landscape") + +-- Special workspaces open on the monitor they are toggled on, not on whichever +-- output was focused when the rule was last written. Opening on 1080p after a +-- 6K fit has to resize the box. +local acer = { name = "HDMI-A-1", width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +monitor = dell +handlers["monitor.layout_changed"]() +handlers["workspace.special_active"]({ name = "special:scratchpad" }, acer) +top, right, bottom, left = gaps() +assert(left == 435 and right == 435 and bottom == 525, "opening on 1080p after a 6K fit resizes the box") +assert(1920 - left - right > 0 and 1080 - 30 - bottom > 0, "1080p leftover is never negative") + +-- follow_mouse onto the 6K while the console is already showing on 1080p must +-- not steal the rule; that is what oversized the Dell after a hop. +workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 1 } +monitor = dell +written = #rules +handlers["monitor.focused"](dell) +assert(#rules == written, "focus on another output does not rewrite an open console") + +-- The output the console is showing on can go away mid-layout-change. Its +-- handle then answers nil to everything, and preferring it blindly would leave +-- the console stranded at the gaps of the monitor that is gone. The rule is +-- still the 1080p one here, so only refitting on the Dell can satisfy this. +workspace = { name = "special:scratchpad", visible = true, monitor = expired, windows = 1 } +monitor = dell +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 1807 and right == 1807 and bottom == 1265, + "a console whose output vanished refits on the monitor that is still there") + +-- Back onto the 1080p panel for the window-count checks below. +workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 1 } +monitor = acer +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435 and bottom == 525, "and refits again once it is back on a live output") + +-- One window reads as a console and keeps the panel. A second app has turned +-- the scratchpad into a workspace, and a workspace wants the whole width. +workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 2 } +handlers["window.open"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0, "a second app on the scratchpad restores the full width") +assert(bottom == 525, "and the console keeps its half-height drop") + +workspace.windows = 3 +written = #rules +handlers["window.open"]() +assert(#rules == written, "a third app changes nothing that is already full width") + +workspace.windows = 1 +handlers["window.destroy"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "closing back down to one window recenters the panel") + +-- An empty scratchpad is about to be seeded with a single agent, so it is sized +-- as a console rather than as a workspace. +workspace.windows = 0 +handlers["window.destroy"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "an empty console is still a console") + +-- A hidden console is refitted on its way back in, so the count does not have to +-- be chased while it is off screen; every window on the desktop would otherwise +-- rewrite the rule. +workspace = { name = "special:scratchpad", visible = false, monitor = acer, windows = 4 } +monitor = dell +written = #rules +handlers["window.open"]() +assert(#rules == written, "a window opening elsewhere does not rewrite a hidden console") + +-- A scratchpad nothing has opened yet has no workspace to read at all, and is +-- sized as the console the seed is about to put a single agent into. +workspace = nil +monitor = { width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 420 and right == 420 and bottom == 540, "a scratchpad that does not exist yet is sized as a console") + +-- Back to a console on screen for the move and float checks. +workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 1 } +monitor = acer +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "a single tiled window is a console") + +-- A floating window on top of the console is not laid out by the gaps, so it +-- must not stretch the panel out from under the agent. +workspace.floats = 1 +handlers["window.open"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "a floating window on the console keeps the panel") + +-- Tiling that float (Super+T) makes it a second app, and floating it again +-- gives the panel back. Both arrive as window.update_rules. +workspace.floats, workspace.windows = 0, 2 +handlers["window.update_rules"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0, "tiling a float on the console restores the full width") + +workspace.floats, workspace.windows = 1, 1 +handlers["window.update_rules"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "floating it again recenters the panel") + +-- Sending an app onto the scratchpad (Super+Alt+S) or off it (Super+Shift+1) +-- does not open or destroy anything, so the move itself has to refit. +workspace.floats, workspace.windows = 0, 2 +handlers["window.move_to_workspace"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0, "moving a second app onto the console restores the full width") + +workspace.windows = 1 +handlers["window.move_to_workspace"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "moving it back off recenters the panel") + +-- window.close still counts the window on its way out, and window.destroy +-- follows it with the settled count, so only destroy is hooked. +assert(handlers["window.close"] == nil, "window.close counts the window on its way out") LUA -pass "the console covers half the work area at any monitor scale" +pass "the console is a centered panel until a second app joins it"