pmacs/builtin/runtime/zoom.lua

225 lines
8.7 KiB
Lua

-- GUI zoom (QoL Stage 2, framing docs/gui-zoom-framing.md).
--
-- Drives the font preference that already exists: `pmacs.gpu.set_font`
-- writes it, `semantic_render` relays it as `InstanceMessage::FontFacts`
-- at protocol v17, and the GPU frontend owns every pixel consequence.
-- Nothing here knows a metric, an advance, or what resolves --- the
-- no-pixels invariant (src/font_pref.rs) holds through this module.
--
-- NO KEYBINDINGS, deliberately (Q#Z3). `keymap_stack::Scope` is
-- Buffer | Mode | Global and carries no frontend identity, so "bind
-- this on GPU frontends only" is not expressible; and `FrontendEvent`
-- has no command-invocation variant, so the GPU cannot ask for a
-- command by name either. A global binding would capture C-+/C-- in
-- the TUI and take away the terminal's own zoom --- the very thing the
-- user is pressing the key for. Commands are discoverable via M-x and
-- one line to bind in init.lua; capability-aware binding is its own
-- lane.
pmacs.zoom = pmacs.zoom or {}
-- Wire bounds, in logical px: `FONT_SIZE_CENTI_PX_RANGE` is 600..=7200
-- (pmacs-gpu/src/main.rs). Mirrored rather than imported because the
-- Lua range check is a UX courtesy; the frontend re-checks on arrival
-- because that side is deserialized protocol input.
local MIN_PX = 6.0
local MAX_PX = 72.0
local STATE_KEY = "gpu-zoom"
pmacs.config.define {
name = "ui.gpu-font-size-base",
description = "Logical-pixel size the first zoom step starts from when no font size is set (quantized to hundredths).",
type = "number",
default = 16.0,
min = MIN_PX,
max = MAX_PX,
mutability = "live",
}
-- Lower bound is the QUANTIZER, not tidiness: `validate_font_size`
-- rounds to the nearest hundredth, so a step below 0.01 quantizes to
-- zero and "zoom in" silently does nothing forever. A negative step
-- would invert the commands --- zoom-in shrinking is not a malfunction
-- the user can diagnose, because the command still does something
-- coherent. Upper bound is the range span (72 - 6): a larger step can
-- only ever clamp.
pmacs.config.define {
name = "ui.gpu-zoom-step",
description = "Logical pixels added or removed per zoom step (quantized to hundredths).",
type = "number",
default = 1.0,
min = 0.01,
max = 66.0,
mutability = "live",
}
-- Round to centi-pixel, the wire's unit. Doing this here keeps every
-- comparison and every round-trip in the quantized domain, so "n in,
-- n out" is exact addition rather than float drift.
local function quantize(px)
return math.floor(px * 100 + 0.5) / 100
end
-- The configured step and base, QUANTIZED.
--
-- The registry cannot enforce this: `ConfigKind::Number` validates
-- finiteness and bounds and nothing else (src/config_registry.rs), and
-- `on_change` listeners are notified after the fact --- they cannot
-- veto. A wrapper function would not help either, since a direct
-- `pmacs.config.set` bypasses it (the same seam `autosave` documents).
--
-- So quantize where the value is USED. A step of 0.015 is not a
-- meaningful step in this domain: sizes live in integer hundredths of a
-- logical pixel end to end, and `validate_font_size` already
-- range-checks the original and then rounds to the nearest hundredth.
-- Rounding the step is the same operation applied one level up, not a
-- workaround for one.
--
-- It also RESTORES the round-trip contract, which a raw step breaks:
-- with 0.015 the sequence is 16.00 -> 16.02 -> 16.01. Each operation
-- rounds independently, and both intermediates land on an EXACT tie ---
-- 16.015 and 16.005 are 1601.5 and 1600.5 centi-px --- which this
-- half-up quantizer sends UP. Half-up is not symmetric under negation:
-- rounding up on the way in adds half a centi-pixel, and rounding up on
-- the way out adds another, so the two errors accumulate instead of
-- cancelling. Quantizing first makes every step exact addition in the
-- quantized domain, so n in and n out returns to the starting value for
-- ANY accepted step, not only for the ones that happened to be
-- representable.
local function effective_step()
return quantize(pmacs.config.get("ui.gpu-zoom-step"))
end
local function effective_base()
return quantize(pmacs.config.get("ui.gpu-font-size-base"))
end
-- The current size in logical px, or nil when the preference is unset.
-- nil is a REAL state (the frontend's own default), never inferred from
-- silence --- Q#TH7.
local function current_px()
return pmacs.gpu.font().size
end
-- Preserve the configured family on EVERY write. `set_font` replaces
-- both fields unconditionally, so `set_font { size = n }` alone would
-- clear a family the user set in init.lua, and they would get it back
-- only by restarting.
local function write_size(px)
local spec = { size = px }
local family = pmacs.gpu.font().family
if family then spec.family = family end
pmacs.gpu.set_font(spec)
end
local function save(px)
if not pmacs.state.available() then return end
pmacs.state.write(STATE_KEY, string.format("%d\n", math.floor(px * 100 + 0.5)))
end
local function forget()
if not pmacs.state.available() then return end
pmacs.state.write(STATE_KEY, "")
end
-- Step by `delta` logical px. Returns the new size, or nil plus a
-- reason.
local function step(delta)
local base = current_px() or effective_base()
local want = quantize(base + delta)
if want < MIN_PX or want > MAX_PX then
-- Reject the WHOLE step rather than pinning to the boundary. This
-- is what keeps "n steps in, n steps out returns exactly" true at
-- the edges, which is precisely where a user steps back and forth.
-- It also mirrors `apply_font_facts`, which rejects an
-- out-of-range message outright rather than clamping it.
return nil, string.format(
"zoom: %.2f px is outside %.2f-%.2f; size unchanged", want, MIN_PX, MAX_PX)
end
write_size(want)
save(want)
return want
end
-- Named `increase`/`decrease` rather than `in`/`out`: `in` is a Lua
-- keyword, and `in_` reads like a workaround for one.
function pmacs.zoom.increase()
return step(effective_step())
end
function pmacs.zoom.decrease()
return step(-effective_step())
end
-- Reset returns the preference to NIL --- the frontend's own default ---
-- not to `ui.gpu-font-size-base`. The base is only the origin for a
-- first step; resetting to it would ship an explicit size that merely
-- happens to equal the default, making the untouched state unreachable
-- once a user has ever zoomed. Clearing the saved state too, because
-- "reset until restart" is not what the word says.
function pmacs.zoom.reset()
local family = pmacs.gpu.font().family
local spec = {}
if family then spec.family = family end
pmacs.gpu.set_font(spec)
forget()
return nil
end
-- Restore a saved zoom. Called from the Rust side AFTER
-- `install_state_dirs`, never at module load: builtins and init.lua both
-- run before state is wired up, so a read here would return nothing,
-- always. Zoom is this project's first EAGER state consumer --- saveplace
-- and recentf both read lazily inside functions and never meet this.
--
-- Whole-file parse, not a line iterator. `^(%d+)$` would reject the
-- newline-terminated file we write ourselves ($ anchors to end of
-- subject), and saveplace's `gmatch("([^\n]+)")` would accept the FIRST
-- line of a multi-line file --- fine for recentf, where a line is one
-- independent entry, wrong here, where the file IS the value.
function pmacs.zoom.restore()
if not pmacs.state.available() then return nil end
local text = pmacs.state.read(STATE_KEY)
if not text then return nil end
local centi = text:match("^(%d+)\n$")
if not centi then return nil end
centi = tonumber(centi)
-- Range-check before it can reach `set_font`. A syntactically fine
-- but out-of-range value --- hand-edited, or written by a future
-- version with a wider range --- would otherwise be rejected as a
-- whole message, leaving the user with neither the saved zoom nor an
-- explanation.
if centi < MIN_PX * 100 or centi > MAX_PX * 100 then return nil end
local px = centi / 100
write_size(px)
return px
end
pmacs.command.define {
name = "gpu.zoom-in",
description = "Increase the GPU frontend's font size by one step",
fn = function()
local px, why = pmacs.zoom.increase()
pmacs.editor.set_status(why or string.format("zoom: %.2f px", px))
end,
}
pmacs.command.define {
name = "gpu.zoom-out",
description = "Decrease the GPU frontend's font size by one step",
fn = function()
local px, why = pmacs.zoom.decrease()
pmacs.editor.set_status(why or string.format("zoom: %.2f px", px))
end,
}
pmacs.command.define {
name = "gpu.zoom-reset",
description = "Return the GPU frontend to its own default font size",
fn = function()
pmacs.zoom.reset()
pmacs.editor.set_status("zoom: reset to the frontend default")
end,
}