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"