Skip to content

feat(gpui): per-window content zoom, installed by MoonRoot from the theme - #77

Merged
ThusMad merged 4 commits into
masterfrom
feat/window-content-zoom
Sep 21, 2026
Merged

ThusMad merged 4 commits into
masterfrom
feat/window-content-zoom

Conversation

@ThusMad

@ThusMad ThusMad commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

What & why

Browser-style page zoom for a GPUI window, installed from the Moon theme.

Window::set_content_zoom(zoom, cx) folds a zoom into the window's scale factor. Every quad, path, glyph and GPU canvas renders at the combined density, viewport_size() becomes the platform content size divided by the zoom, and pointer input is divided once at the top of dispatch_event. The four platform setters (resize, set_client_inset, show_window_menu, set_traffic_light_position) and the IME geometry in PlatformInputHandler multiply on the way back to the platform, so no platform crate is touched and Windows, macOS, X11 and Wayland get the same conversions.

MoonScale gains zoom; MoonThemeConfig::set_zoom / with_zoom guard it exactly like set_ui_scale; MoonRoot::render installs the theme's zoom on its window the way it already installs the rem size, and MoonTheme::install_config refreshes every window so a change reaches all of them on their next frame.

The consumer motivation: MoonTerminal scales its interface through MoonScale::ui today, which reaches only the geometry that goes through tokens.ui(). A window-level zoom scales every pixel by construction — raw literals, persisted dock sizes and the inherited base widgets included — and keeps the design's proportions at any factor.

Notable decisions

  • The scale_factor field stays the effective value (platform × zoom) and scale_factor() is unchanged, so an upstream direct field read after a re-sync still sees the zoom. One private sync_platform_geometry recomputes it and viewport_size from bounds_changed and from a zoom change.
  • A zoom requested while drawing — a root view's render is the expected caller — is parked in pending_content_zoom and applied at the top of the next draw, which the request itself makes dirty. Applying mid-frame would lay the root out at the old size and paint it at the new density for one frame.
  • unzoom_input is an exhaustive match with no wildcard arm, so a PlatformInput variant that arrives with a re-sync fails to compile instead of passing through unconverted.
  • client_inset is stored in content space because client_inset() feeds layout; only the platform call multiplies.
  • IME geometry converts inside PlatformInputHandler, because macOS first_rect_for_character_range, Windows retrieve_caret_position and X11 get_ime_area call bounds_for_range directly and bypass update_ime_position. ime_candidate_bounds composes the converted call and does not convert again.
  • GpuFrameInfo and GpuCanvasTextContext carry content_zoom beside the combined scale_factor for a canvas that must keep device density (a chart): it sizes its target by bounds × scale_factor and its own geometry by scale_factor / content_zoom.
  • resize stays in the platform's pixels, like bounds() and window_bounds(): a size saved from window_bounds() restores unchanged under zoom. viewport_size() is the content-space counterpart.
  • The accessibility click fallback multiplies the node's content-space centre before it synthesises platform events, since dispatch_event divides them again.
  • An immediate set_content_zoom cancels a zoom parked by an earlier render (otherwise the next draw would take the stale request), and rescales the tracked mouse_position so the frame that applies the zoom hovers the right element.
  • Root::new installs the theme's zoom before the first frame, so a new window never presents one unzoomed frame; render keeps it current afterwards.
  • The macOS native context menu multiplies its position into AppKit view points (native_menu/macos.rs); Windows was already correct by construction.
  • window_border.rs, menu/popup_menu.rs, menu/context_menu.rs and the editor popovers (input/popovers/{hover_popover,completion_menu,code_action_menu}.rs) clamped content-space positions against the platform window size; they now read viewport_size(). The popovers and the macOS menu are TrackedFork drift: menu 3 → 4, input and text_area 16 → 19 in component-manifest.json, with docs/component-mirror-baseline.json regenerated and inspected.
  • The retained text cache key includes content_zoom beside the combined factor, so a zoom change offset by a DPI change still rebuilds a device-density canvas's text.
  • A theme file's impossible zoom (a hand-edited 0) is normalized on install the way set_zoom refuses it, so the tokens never report a zoom no window will take.
  • A zoom makes fractional scale factors the normal case, and layout rounds every authored length to device pixels on its own: a 20px toggle track, its 16px thumb and the 2px gap land as 16, 13 and 2 at 80 %, so the thumb rested on the track's bottom edge; the checkbox mark drifted by up to two pixels the same way, partly because its inset took a flat 1px off for a border that layout draws at a whole number of device pixels. foundation::snap_centered snaps the box and the inset and takes the centred part as what is left between them, and snap_border gives the border its drawn width; the toggle thumb, the checkbox mark and the radio dot go through it. The same drift exists without a zoom on a 125 % or 150 % display. checkbox.rs is a tracked donor file, so the mirror baseline moves with it.

Known limitations

  • Nothing in the gallery sets a zoom yet; the consumer validation is MoonTerminal built against this branch at 100 % and 150 % (FireTest chart-smoke on the fixture bench).
  • Reviewed by two clean-context agents (correctness; cross-platform and consumer impact); every finding above the nit level is addressed in the second commit.

How to verify

cargo fmt --all -- --check
cargo test -p moon-gpui window::tests          # 11 passed
cargo test -p moon-ui-components               # 560 passed
powershell -ExecutionPolicy Bypass -File tools\run-component-guardrails.ps1   # all steps ok
cargo xtask component-api --check-baseline     # baseline refreshed: content_zoom, set_zoom, with_zoom, zoom

The nine window tests each name the edit they catch (dropping a term of sync_platform_geometry, a re-sync restoring the bare bounds_changed reads, a missing unzoom_input arm, scaling Lines deltas, removing the zoom guard, dropping the bounds-observer call or the no-op check, applying a zoom mid-frame, dropping either IME conversion, dropping the frame-info assignment).

Three component sweeps (the_thumb_keeps_one_gap_on_every_side_at_any_scale_factor, test_checked_mark_stays_centred_at_any_scale_factor, the_dot_stays_centred_at_any_scale_factor) walk every 5 % scale factor from 50 % to 300 % and assert equal space on every side; each was proven red against the unsnapped code.

…heme

Browser-style page zoom for a GPUI window. `Window::set_content_zoom` folds a
zoom into the window's scale factor: every quad, path, glyph and GPU canvas
renders at the combined density, `viewport_size` becomes the platform content
size divided by the zoom, pointer input is divided once at the top of
`dispatch_event`, and the four platform setters plus the IME geometry in
`PlatformInputHandler` multiply on the way back — so no platform crate is
touched. A zoom requested from a root view's render is parked and applied at
the top of the next frame, which the request itself guarantees.

`MoonScale` gains `zoom`; `MoonThemeConfig::set_zoom` guards it like
`set_ui_scale`; `MoonRoot::render` installs the theme's zoom on its window the
way it already installs the rem size, and `install_config` refreshes every
window so a change reaches them all. `GpuFrameInfo` and `GpuCanvasTextContext`
carry the zoom beside the combined factor for a canvas that keeps device
density. Inherited widgets that clamped content positions against the
platform window size now read `viewport_size()`.

Documented in MOON_PATCH_QUEUE.md with what a re-sync drops; nine window tests
and four theme/root tests pin the contracts.
Two clean-context reviews of the zoom patch, all findings above nit level:

- the accessibility click fallback multiplies the node's content-space
  centre before it synthesises platform events, which `dispatch_event`
  divides again;
- an immediate `set_content_zoom` cancels a zoom parked by an earlier render
  and rescales the tracked `mouse_position`, so the frame that applies the
  zoom hovers the right element;
- `resize` stays in the platform's pixels like `bounds()`, so a size saved
  from `window_bounds()` restores unchanged;
- `Root::new` installs the theme's zoom before the first frame;
- the macOS native context menu multiplies its position into AppKit view
  points, and the editor popovers clamp against `viewport_size()` (TrackedFork
  drift: menu 3 -> 4, input and text_area 16 -> 19, mirror baseline
  regenerated);
- the retained text cache key includes `content_zoom`;
- a theme file's impossible zoom is normalized on install.

Three more window tests and a theme test pin them; the patch-queue entry now
lists everything a re-sync drops.
@Alena-Selezneva

Copy link
Copy Markdown

Two notes from the consumer side (MoonTerminal), both about the follow-up rather than this branch.

A canvas that opts into device density also has to unzoom its cursor. GpuFrameInfo::content_zoom invites a chart to size its geometry by scale_factor / content_zoom, but pointer input arrives in content space and the chart converts it with the combined factor: panels/chart/render_input.rs does let sf = window.scale_factor() and chartdx/input.rs documents its stored coordinates as (position - slot_origin) * scale_factor, device pixels. Today that is self-consistent — chart geometry and cursor both use scale_factor, so the chart simply zooms with the interface. The moment its geometry switches to scale_factor / content_zoom and the cursor conversion does not, crosshair and geometry drift apart by the zoom, and what drifts on that chart is the price readout under the cursor. Worth one sentence in the content_zoom doc comment: the divide is for geometry and for anything compared against pointer positions. (Also: the new field makes every literal GpuFrameInfo { .. } in a consumer fail to compile — chartdx/render_state/tests.rs has one.)

The terminal's existing scale knob must be moved, not doubled. Settings → General already ships «масштаб 75–150 %» since 0.45.4, and startup.rs spends that one preference on three channels: with_ui_scale(ui_scale), scale.font = ui_scale, with_font_delta(font_delta * ui_scale). If prefs.ui_scale later also feeds set_zoom, a user sitting at 150 % gets 225 % on his next launch, with the settings screen that could undo it scaled along. Two details there that fit this API well: the slider applies on release with a 5 % step (so the deferred apply costs one frame per commit, not per tick), and the terminal already tolerates hand-edited values outside 75–150 % — which is exactly what set_zoom storing any positive value verbatim preserves.

…-zoom

# Conflicts:
#	docs/component-mirror-baseline.json
@Alena-Selezneva

Copy link
Copy Markdown

Two things I'd add to the description, both about consequences outside the zoom path itself.

popup_menu.rs is not a no-op at zoom 1 — it's a fix. The notable-decisions list files this one under "clamped content-space positions against the platform window size", but

let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;

was reading the restore rectangle, not the current one. On Windows window_bounds() is built from GetWindowPlacement(..).rcNormalPosition and WindowBounds::Maximized carries exactly that (moon-gpui-windows/src/window.rs:213-243), so on a maximized window every PopupMenu without an explicit max_height has been capped at half the restored height. viewport_size() makes it half the real viewport. That lands for every user on the next release, not only for whoever turns a zoom on, and a maximized window is the common case — worth a line in the description so it isn't mistaken for a refactor if menu heights visibly change. (window_border.rs has the same shape, but there it's Linux-only and both X11 and Wayland put the current bounds in Maximized, so no change.)

The consumer has call sites that still mix the two spaces. In MoonTerminal master these compose a platform-space window origin/size with content-space element geometry, so they're exact today and drift by the zoom the moment the terminal installs one:

  • panels/chart/render.rs (the FireTest probe): window.window_bounds().get_bounds().origin + the canvas element boundsChartProbe. The Windows storm posts those numbers straight as client coordinates (firetest/storm/win.rs), so at 150 % it would circle a rect at ~2/3 of the chart's real origin and size. Since the 150 % consumer validation in "How to verify" is a FireTest chart-smoke run, this one is worth checking before trusting that signal — the storm may have been stirring the panel next door.
  • window/windowing.rs::responsive_widthwindow.bounds().size.width, fed to the responsive chrome of analytics, strategies, screener, core-expert, assets and the profit monitor. Layout is content space, this number isn't, so at 150 % those surfaces would think the window is 1.5× wider than the box they're laid out in — the same drift this PR fixes in the popovers and menus.
  • panels/chart/render_input.rs::press_count: window.bounds().origin + position. Self-consistent while the zoom is constant, so nothing trades wrongly, but the close-residue proximity radius is then measured in content pixels and silently grows with the zoom.

Nothing here blocks this PR — they're follow-ups on the consumer side. Flagging them because the description names MoonTerminal as the motivation and the 150 % run as the evidence.

…evice pixels

Layout rounds every authored length to device pixels on its own. At a
fractional scale factor a box, the part centred in it and the inset between
them round apart: a 20px track, a 16px thumb and a 2px gap at 80 % land as
16, 13 and 2, so the thumb rests on the bottom edge of its track with all the
space above it. The checkbox mark drifted by up to two pixels the same way,
because its inset also took a flat 1px off for a border that layout draws at
a whole number of device pixels.

`foundation::snap_centered` snaps the box and the inset and takes the part as
what is left between them, so the inset is equal on every side at any factor;
`snap_border` gives the border the width layout draws it at. The toggle sizes
its thumb through it, the checkbox its mark, the radio its dot.

Each control gets a sweep over every 5 % scale factor from 50 % to 300 % that
asserts equal space on every side; each was proven red against the unsnapped
code. The checkbox is a tracked donor file, so the mirror baseline moves.
@Alena-Selezneva

Copy link
Copy Markdown

Two notes from the consumer side, since MoonTerminal is the motivation here.

1. Sites in the terminal that still read window.bounds() for layout. Inside MoonUI the sweep looks complete — after this PR no component reads the platform window size for content-space geometry, and anchored.rs was already on viewport_size(). The terminal is not: responsive_width() (crates/moon-ui-gpui/src/window/windowing.rs:751) returns window.bounds().size.width and drives the responsive chrome breakpoints on five surfaces (rail wrap, settings/login chrome width, trade window). With a zoom of 1.5 that number is 1.5× the content space the chrome is laid out in, so the narrow-mode breakpoints silently stop firing. viewport_size().width keeps the maximized/restore distinction that doc comment is about, so it looks like a direct swap. press_count() (crates/moon-ui-gpui/src/panels/chart/render_input.rs:54) also adds window.bounds().origin (platform) to an event position (content after unzoom_input); it stays self-consistent because every window shares one theme zoom, but the click-dedupe radius it feeds ends up measured in content pixels rather than screen ones.

2. MoonScale::ui and zoom compose. The terminal already ships a user-facing 75–150 % interface scale (v0.45.4, Settings → General, with a live preview) wired to ui_scale, and that value is persisted per user. If that control is moved onto zoom, the two multiply — tokens.ui() still scales geometry and the window scales everything again — so a user who saved 150 % would land at 2.25× unless the persisted ui_scale is migrated back to 1.0 at the same time. Worth calling out in whatever terminal PR follows this one; people have already tuned that slider and a one-release jump in interface size is the kind of thing they notice immediately.

@ThusMad
ThusMad force-pushed the feat/window-content-zoom branch from e915316 to 89c417c Compare September 21, 2026 09:28
ThusMad added a commit to Moonbot-Tech/MoonTerminal that referenced this pull request Sep 21, 2026
…emoved

The Compact / Standard / Large density setting shifted the whole application
one MoonUI tier. MoonUI draws each tier by hand, so a tier step substituted a
different design rather than scaling the reviewed one, and the design's own
mixture of tiers lost its proportions. The UI zoom slider fed the token
multipliers, which reach only the geometry that goes through `tokens.ui()`.

Now `ui_scale` (50-200 %) is installed as `MoonScale::zoom`; every window's
`MoonRoot` applies it as content zoom, so every pixel, glyph and hitbox scales
by construction. The tokens stay at the design's size system, spelled once as
`design::CONTROL_TIER` / `DESIGN_FONT_DELTA` / `BODY_TEXT` / `INPUT_SIZE`;
the tier resolvers, `UiDensity`, its settings row, locale keys and the
density-migration contracts are gone.

The chart keeps device density: `chartdx` sizes its render target by the
combined factor and its own geometry and text by `scale_factor /
content_zoom`, the text layer crosses into content pixels only at the GPUI
boundary, and the overlays over chart geometry divide by the zoom in
`chart_origin_logical`. All of it is shared code above the three GPU
backends. `responsive_width` and the click series read content and screen
space as they should; the FireTest probe is built in platform space and a
`MOON_FIXTURE_SETTINGS` hook lets the bench open at a chosen scale.

Requires MoonUI with `Window::set_content_zoom` (Moonbot-Tech/MoonUI#77).
@Alena-Selezneva

Copy link
Copy Markdown

New since my last pass is the snap_centered / snap_border commit in foundation.rs and its three consumers; the zoom core in window.rs / platform.rs is unchanged, so the two consumer-side notes above still stand for whatever MoonTerminal PR follows this one.

One thing about the snapping: it puts the box's contents on whole device pixels but not the box itself, and the guarantee needs both.

snap_centered derives inset and inner from window.pixel_snap(outer) — from the assumption that the outer box will be drawn at round(box_size × scale_factor) device pixels. Layout does not round sizes, it rounds each edge separately (window.rs:snap_boundsround_to_device_pixel on left and right), so an element's drawn device width is R((x + size)·sf) − R(x·sf). With size·sf = n + f, that is n or n + 1 depending on where the box's own origin falls, and only one of the two matches what snap_centered assumed. When it is the other one, outer_drawn − inner is odd and the mark lands a device pixel off centre — the drift the sweeps exist to catch.

MoonToggle is safe because snapped() (toggle.rs:222-228) writes track.outer back into the metrics, so the track is drawn at the snapped height. The other two are not: checkbox.rs:697 still draws .size(metrics.box_size) and radio.rs:231 .size(choice.box_size), both unsnapped. Drawing them at the Centered::outer that snap_centered already computed would close it.

The new sweeps do not catch this because both harnesses render a bare control — CheckedCheckboxHarness and ProbedRadioHarness { label: None } put the box at an origin that is already device-aligned. With a label the box takes mt(box_offset()) (checkbox.rs:696, radio.rs:230), i.e. (line_height − box_size) × 0.5 unsnapped, which is a fractional device origin by construction — and the labelled checkbox is the one people actually look at. A labelled case in the sweep would pin it.

Cosmetic either way, but worth doing properly: on Windows at 125 %/150 % this lands on users with no zoom involved at all.

@ThusMad
ThusMad merged commit e4f2d4e into master Sep 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants