From 83987718cfb0574975eff6c78f1d364c8f27b9e1 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Tue, 18 Aug 2026 22:24:56 +0200 Subject: [PATCH] Give the Quake console its own file, an agent, and half a screen (#7420) * Open the scratchpad with the default agent already in it on_created_empty fires when the special workspace is created empty, so the agent starts the first time the console drops down instead of at boot, and comes back on the next open if you close it. The exec rule pins the workspace rather than trusting the spawn to inherit it: Hyprland only tags a process with its origin workspace while misc.initial_workspace_tracking is on, and we turn that off. Nothing to guard for a missing default agent. Omarchy picks none for you, and omarchy-agent exits without opening a window when none is set, so the scratchpad just opens empty until one is chosen. Co-Authored-By: Claude Opus 5 (1M context) * Move the console into its own file and size it to half the screen The scratchpad's presentation was scattered through looknfeel: a dim in the decoration block, a workspace rule below it, two animation leaves further down again. Gathered into qconsole.lua, where the whole console is one readable thing. Sized to half the screen while it moved. A window rule cannot do that: its size expressions resolve once, when the window maps, so rescaling the monitor afterwards leaves a console that is no longer half of anything. Gaps are re-applied by the layout, so the console is sized by the gap left underneath it, recomputed from the monitor whenever the layout changes. Monitor dimensions come back in physical pixels while gaps are logical, so the scale comes out before the reserved area comes off. That arithmetic is the whole trick, and the test pins it at 1x, 2x and 1.5x. The test runs lua with an explicit "-". Bare `lua < * Only rewrite the console rule when its size actually changes Refitting replaces the rule in place rather than stacking a new one, so there was no leak, but each write still schedules a monitor and window state refresh and monitor.focused fires on every hop between screens. Remember what was last written and skip the write when the number has not moved. Also say out loud that the scale guard is what keeps the arithmetic below it safe: a monitor handle that has outlived its output answers nil to every field, and a layout change is exactly when that happens. Co-Authored-By: Claude Opus 5 (1M context) * Drop the active window border inside the console The gradient border marks which window has focus, which the console does not need: it is only ever focused while it is open, and the dimmed workspace behind it already sets it apart. On a single agent terminal the highlight just reads as a frame around the panel. no_border on the workspace rule pins the border to 0 at workspace-rule priority, so it applies to whatever ends up in there without touching the global border. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Omabot Co-authored-by: Claude Opus 5 (1M context) --- default/hypr/looknfeel.lua | 20 ------ default/hypr/omarchy.lua | 1 + default/hypr/qconsole.lua | 88 +++++++++++++++++++++++++ test/shell.d/hyprland-qconsole-test.sh | 90 ++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 20 deletions(-) create mode 100644 default/hypr/qconsole.lua create mode 100755 test/shell.d/hyprland-qconsole-test.sh diff --git a/default/hypr/looknfeel.lua b/default/hypr/looknfeel.lua index 8d9801d4..f912169d 100644 --- a/default/hypr/looknfeel.lua +++ b/default/hypr/looknfeel.lua @@ -22,11 +22,6 @@ hl.config({ decoration = { rounding = 0, - -- Dimming only kicks in while a special workspace is open, so the - -- scratchpad gets its overlay separation without costing anything the - -- rest of the time. - dim_special = 0.6, - shadow = { enabled = false, }, @@ -69,16 +64,6 @@ hl.config({ }, }) --- Give the scratchpad a floating Quake-console presentation. Shadow and blur --- stay off globally (they cost GPU on every frame for almost no visual gain, --- and windows are near-opaque anyway), so the inset, the dimming, and the --- slide carry the effect. -hl.workspace_rule({ - workspace = "special:scratchpad", - gaps_out = 80, - gaps_in = 40, -}) - -- Default animations, see https://wiki.hypr.land/Configuring/Advanced-and-Cool/Animations/ hl.curve("easeOutQuint", { type = "bezier", points = { { 0.23, 1 }, { 0.32, 1 } } }) hl.curve("easeInOutCubic", { type = "bezier", points = { { 0.65, 0.05 }, { 0.36, 1 } } }) @@ -101,11 +86,6 @@ hl.animation({ leaf = "layersOut", enabled = true, speed = 1.5, bezier = "linear hl.animation({ leaf = "fadeLayersIn", enabled = true, speed = 1.79, bezier = "almostLinear" }) hl.animation({ leaf = "fadeLayersOut", enabled = true, speed = 1.39, bezier = "almostLinear" }) hl.animation({ leaf = "workspaces", enabled = false }) --- 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" --- retracts it back up the way a Quake console does. -hl.animation({ leaf = "specialWorkspaceIn", enabled = true, speed = 3, bezier = "easeOutQuint", style = "slide top" }) -hl.animation({ leaf = "specialWorkspaceOut", enabled = true, speed = 2, bezier = "easeInOutCubic", style = "slide bottom" }) hl.config({ dwindle = { diff --git a/default/hypr/omarchy.lua b/default/hypr/omarchy.lua index 22432001..abc7c4e0 100644 --- a/default/hypr/omarchy.lua +++ b/default/hypr/omarchy.lua @@ -15,6 +15,7 @@ if _G.omarchy_default_bindings ~= false then end require("default.hypr.envs") require("default.hypr.looknfeel") +require("default.hypr.qconsole") require("default.hypr.input") require("default.hypr.windows") diff --git a/default/hypr/qconsole.lua b/default/hypr/qconsole.lua new file mode 100644 index 00000000..ea7d4b0c --- /dev/null +++ b/default/hypr/qconsole.lua @@ -0,0 +1,88 @@ +-- The scratchpad, presented as a Quake console: a dimmed overlay that drops +-- down over whatever workspace you are on. Its bindings live in +-- bindings/tiling.lua, and its slide is animated below. + +-- How much of the usable screen the console covers, measured from the top. +local share = 0.5 + +-- 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 +-- from while misc.initial_workspace_tracking is on, and looknfeel turns it off. +-- Omarchy ships without a default agent, and omarchy-agent exits without +-- opening anything when none is set, so until one is picked this just opens an +-- empty console. +local seed = "[workspace special:scratchpad silent] omarchy-agent" + +-- Dimming only applies while a special workspace is open, so the console gets +-- its separation from the workspace underneath without costing anything the +-- rest of the time. +hl.config({ + decoration = { + dim_special = 0.6, + }, +}) + +-- 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 +-- only write the rule when it actually moves. +local covering = nil + +local function cover(bottom) + if covering == bottom then + return + end + covering = bottom + + hl.workspace_rule({ + workspace = "special:scratchpad", + gaps_in = 10, + gaps_out = { top = 0, right = 0, bottom = bottom, left = 0 }, + + -- 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 + -- is already set apart by the dimming behind it. + no_border = true, + + on_created_empty = seed, + }) +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() + + -- 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 + 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 + + cover(math.max(0, math.floor(usable * (1 - share)))) +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() + +hl.on("monitor.layout_changed", fit) +hl.on("monitor.focused", fit) + +-- 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" +-- retracts it back up the way a Quake console does. +hl.animation({ leaf = "specialWorkspaceIn", enabled = true, speed = 3, bezier = "easeOutQuint", style = "slide top" }) +hl.animation({ leaf = "specialWorkspaceOut", enabled = true, speed = 2, bezier = "easeInOutCubic", style = "slide bottom" }) diff --git a/test/shell.d/hyprland-qconsole-test.sh b/test/shell.d/hyprland-qconsole-test.sh new file mode 100755 index 00000000..57bdea8d --- /dev/null +++ b/test/shell.d/hyprland-qconsole-test.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua + +# The console is sized by the gap underneath 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. +# 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" +local rules, handlers = {}, {} +local monitor = nil + +hl = { + config = function() end, + animation = function() end, + workspace_rule = function(rule) table.insert(rules, rule) end, + on = function(event, callback) handlers[event] = callback end, + get_active_monitor = function() return monitor end, +} + +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.qconsole") + +local function current() + return rules[#rules] +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") +assert(current().on_created_empty:find("omarchy%-agent"), "console is seeded with the default agent") +assert(current().on_created_empty:find("^%[workspace special:scratchpad silent%]"), + "the seed is pinned to the console rather than trusting the spawn to inherit it") +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 } } + handlers["monitor.layout_changed"]() + return current().gaps_out.bottom +end + +-- Same panel, same logical size, different scale: the console must not care. +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") + +-- The bar is already out of the work area; counting it twice would push the +-- 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.on_created_empty:find("omarchy%-agent"), "refitting keeps the console seeded") +assert(final.no_border == true, "the console drops the active window border") + +-- A monitor that cannot be read must not wipe the last good rule. +local before = current().gaps_out.bottom +monitor = nil +handlers["monitor.layout_changed"]() +assert(current().gaps_out.bottom == before, "an absent monitor leaves the console as it was") + +-- 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 }) +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 } } +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 +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") +LUA +pass "the console covers half the work area at any monitor scale"