From 0bec60f6e4012d95e6d0bea24ac05dbc017691b8 Mon Sep 17 00:00:00 2001 From: ogarza Date: Sun, 30 Aug 2026 05:26:46 -0400 Subject: [PATCH] Center the Quake console in a 2:1 panel instead of spanning the screen. Size it from leftover workspace gaps so it refits per monitor, and let omarchy_qconsole_ratio change the width. --- config/hypr/hyprland.lua | 5 + default/hypr/qconsole.lua | 161 ++++++++++++++++++++++--- manual/04-navigation.md | 2 + test/shell.d/hyprland-qconsole-test.sh | 88 +++++++++++++- 4 files changed, 232 insertions(+), 24 deletions(-) diff --git a/config/hypr/hyprland.lua b/config/hypr/hyprland.lua index 45c9b664..354e079c 100644 --- a/config/hypr/hyprland.lua +++ b/config/hypr/hyprland.lua @@ -10,6 +10,11 @@ dofile((os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") .. "/default/hypr/boo -- keeping core window-manager bindings: -- omarchy_preinstalled_bindings = false +-- Center the quake console in a tiling box. The number is how many times +-- wider the box is than its height. 1 is a square. Comment this out for a +-- full-width drop-down. +omarchy_qconsole_ratio = 2 + -- Load Omarchy defaults. require("default.hypr.omarchy") diff --git a/default/hypr/qconsole.lua b/default/hypr/qconsole.lua index d3a36ae6..235b4675 100644 --- a/default/hypr/qconsole.lua +++ b/default/hypr/qconsole.lua @@ -4,6 +4,9 @@ -- How much of the usable screen the console covers, measured from the top. local share = 0.5 +local min_size = 64 + +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 @@ -25,20 +28,33 @@ hl.config({ -- 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 function cover(bottom) - if covering == bottom then - return +local function same_gaps(a, b) + return a + and b + and a.top == b.top + and a.right == b.right + and a.bottom == b.bottom + and a.left == b.left +end + +local function cover(gaps_out) + if type(gaps_out) == "number" then + gaps_out = { top = 0, right = 0, bottom = gaps_out, left = 0 } end - covering = bottom + + if same_gaps(covering, gaps_out) then + return false + end + covering = gaps_out hl.workspace_rule({ - workspace = "special:scratchpad", + workspace = SCRATCHPAD, gaps_in = 0, - gaps_out = { top = 0, right = 0, bottom = bottom, left = 0 }, + gaps_out = gaps_out, -- 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 +63,148 @@ local function cover(bottom) on_created_empty = seed, }) + + return true +end + +local function reserved_edges(monitor) + local reserved = monitor.reserved + if type(reserved) ~= "table" then + return { top = 0, right = 0, bottom = 0, left = 0 } + end + + return { + top = reserved.top or 0, + right = reserved.right or 0, + bottom = reserved.bottom or 0, + left = reserved.left or 0, + } +end + +-- A positive omarchy_qconsole_ratio (set in hyprland.lua before defaults load) +-- centers the console in a tiling box that many times wider than it is tall. +-- Values below 1 clamp to a square. Unset keeps the full-width drop-down. +local function box_ratio() + local ratio = _G.omarchy_qconsole_ratio + if type(ratio) == "number" and ratio > 0 then + return math.max(1, ratio) + end + return nil +end + +local function is_scratchpad(ws) + return ws and (ws.name == SCRATCHPAD or ws.name == "scratchpad") 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 leftover area and that area is +-- recomputed whenever the monitor it is opening on changes. +local function fit(monitor) + monitor = monitor or hl.get_active_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. if not monitor or not monitor.scale or monitor.scale <= 0 then - return + return false 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 + local reserved = reserved_edges(monitor) + local usable_h = monitor.height / monitor.scale - reserved.top - reserved.bottom + local usable_w = nil + if monitor.width then + usable_w = monitor.width / monitor.scale - reserved.left - reserved.right + end - cover(math.max(0, math.floor(usable * (1 - share)))) + local ratio = box_ratio() + local h = math.max(min_size, math.floor(usable_h * share)) + local side = 0 + local bottom = math.max(0, math.floor(usable_h - h)) + + if ratio and usable_w then + local w = math.floor(h * ratio) + if w > usable_w then + w = math.floor(usable_w) + end + side = math.max(0, math.floor((usable_w - w) / 2)) + if usable_w - (side * 2) < min_size then + side = math.max(0, math.floor((usable_w - min_size) / 2)) + end + end + + if usable_h - bottom < min_size then + bottom = math.max(0, math.floor(usable_h - min_size)) + end + + return cover({ top = 0, right = side, bottom = bottom, left = side }) +end + +local function apply_now() + if hl.exec_scheduled_prop_refresh_immediately then + hl.exec_scheduled_prop_refresh_immediately() + end +end + +local function scratchpad_on_other_monitor(mon) + if not hl.get_workspace or not mon then + return false + end + + local ws = hl.get_workspace(SCRATCHPAD) + if not is_scratchpad(ws) or not ws.visible or not ws.monitor or not ws.monitor.name or not mon.name then + return false + end + + return ws.monitor.name ~= mon.name 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) +cover({ top = 0, right = 0, bottom = 0, left = 0 }) fit() -hl.on("monitor.layout_changed", fit) -hl.on("monitor.focused", fit) +hl.on("monitor.layout_changed", function() + local ws = hl.get_workspace and hl.get_workspace(SCRATCHPAD) + if is_scratchpad(ws) and ws.visible and ws.monitor then + fit(ws.monitor) + else + fit() + end +end) + +-- follow_mouse hops fire this; do not rewrite an open console to a different +-- output's gaps (that is what zeroed the window on the 1080p screen). +hl.on("monitor.focused", function(mon) + if scratchpad_on_other_monitor(mon) then + return + end + fit(mon) +end) + +-- Special workspaces toggle on the monitor they open on, not whichever output +-- last happened to be focused when the rule was written. +hl.on("workspace.special_active", function(ws, mon) + if not is_scratchpad(ws) then + return + end + if fit(mon) then + apply_now() + end +end) + +hl.on("workspace.move_to_monitor", function(ws, mon) + if not is_scratchpad(ws) then + return + end + if fit(mon) then + apply_now() + end +end) -- 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..1b62b0df 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`. +By default the scratchpad drops down as a centered panel. Open `~/.config/hypr/hyprland.lua` if you want to change how wide that panel is (`omarchy_qconsole_ratio` is near the top). `1` makes a square; `2` makes a panel twice as wide as it is tall; `3` or `4` is wider still. Anything smaller than `1` still gives you a square. Comment the line out if you'd rather have it span the full width of the screen. + ### 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..9360e3c8 100755 --- a/test/shell.d/hyprland-qconsole-test.sh +++ b/test/shell.d/hyprland-qconsole-test.sh @@ -10,9 +10,10 @@ require_command lua # 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 half-height 2:1 panel by default" local rules, handlers = {}, {} local monitor = nil +local workspace = nil hl = { config = function() end, @@ -20,8 +21,14 @@ 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, + exec_scheduled_prop_refresh_immediately = function() end, } +-- Same default as config/hypr/hyprland.lua: a centered panel twice as wide as +-- it is tall. Commenting that assignment out is what restores full width. +omarchy_qconsole_ratio = 2 + dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") require("default.hypr.qconsole") @@ -29,6 +36,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") @@ -44,6 +56,7 @@ local function rescale(height, scale, bar) end -- Same panel, same logical size, different scale: the console must not care. +-- These fixtures omit width, so the ratio cannot inset the sides yet. assert(rescale(1080, 1, 40) == 520, "half of a 1080p work area, unscaled") assert(rescale(2160, 2, 40) == 520, "the same half once the monitor is scaled 2x") assert(rescale(2160, 1.5, 40) == 700, "and at a fractional scale") @@ -52,11 +65,10 @@ 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") +assert(final.gaps_out.left == 0 and final.gaps_out.right == 0, + "without a width the sides stay flush") 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") @@ -86,5 +98,69 @@ monitor.scale = 2 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") + +-- Default ratio: 16:9 is a centered 2:1 panel, not a full-width drop-down. +monitor = { width = 1920, height = 1080, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +local top, right, bottom, left = gaps() +assert(top == 0 and left == 420 and right == 420 and bottom == 540, "16:9 default is a 1080x540 panel") + +local dell = { name = "DP-1", width = 6144, height = 2560, scale = 1, 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). +-- It checks that scale does not change the panel's logical size. +monitor = { name = "DP-1", width = 12288, height = 5120, scale = 2, 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") + +-- Acer 1920x1080, bar 30, same ratio. Opening here after a 6K fit must rewrite; +-- leaving 6K side gaps would make leftover width negative. +local acer = { name = "HDMI-A-1", width = 1920, height = 1080, scale = 1, 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 2:1 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 global rule (that is what oversized the Dell after a hop). +workspace = { name = "special:scratchpad", visible = true, monitor = acer } +written = #rules +handlers["monitor.focused"](dell) +assert(#rules == written, "focus on another output does not rewrite an open console") +workspace = nil + +-- Cache: same 1440p height, 16:9 vs 21:9. Sides change even when bottom does not. +monitor = { width = 2560, height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +written = #rules +monitor = { width = 3440, height = 1440, scale = 1, 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 at ratio 2 is a 2:1 box") + +omarchy_qconsole_ratio = 1 +monitor = { width = 1920, height = 1080, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 690 and right == 690 and bottom == 540, "ratio 1 is a square") + +omarchy_qconsole_ratio = 0.5 +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 690 and right == 690 and bottom == 540, "a ratio below 1 clamps to a square") + +omarchy_qconsole_ratio = nil +monitor = dell +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 1265, "clearing the ratio restores full width") LUA -pass "the console covers half the work area at any monitor scale" +pass "the console is a half-height 2:1 panel by default"