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"