Merge pull request #9140 from ogarza/center-qconsole

Center the Quake console in a 2:1 panel instead of spanning the screen.
This commit is contained in:
David Heinemeier Hansson authored and GitHub committed 2026-09-13 12:38:41 +02:00
commit 692c02cad5
3 files changed
+336 -37

No files matched your search

+130 -22
View File
@@ -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"
+2
View File
@@ -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!
+204 -15
View File
@@ -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"