221 lines
8.4 KiB
Lua
221 lines
8.4 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, because each
|
|
-- operation rounds independently and 16.015 and 16.005 round in
|
|
-- opposite directions. 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,
|
|
}
|