feat(gpui): per-window content zoom, installed by MoonRoot from the theme - #77
Conversation
…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.
|
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. The terminal's existing scale knob must be moved, not doubled. Settings → General already ships «масштаб 75–150 %» since 0.45.4, and |
…-zoom # Conflicts: # docs/component-mirror-baseline.json
|
Two things I'd add to the description, both about consequences outside the zoom path itself.
let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;was reading the restore rectangle, not the current one. On Windows The consumer has call sites that still mix the two spaces. In MoonTerminal
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.
|
Two notes from the consumer side, since MoonTerminal is the motivation here. 1. Sites in the terminal that still read 2. |
e915316 to
89c417c
Compare
…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).
|
New since my last pass is the 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.
The new sweeps do not catch this because both harnesses render a bare control — Cosmetic either way, but worth doing properly: on Windows at 125 %/150 % this lands on users with no zoom involved at all. |
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 ofdispatch_event. The four platform setters (resize,set_client_inset,show_window_menu,set_traffic_light_position) and the IME geometry inPlatformInputHandlermultiply on the way back to the platform, so no platform crate is touched and Windows, macOS, X11 and Wayland get the same conversions.MoonScalegainszoom;MoonThemeConfig::set_zoom/with_zoomguard it exactly likeset_ui_scale;MoonRoot::renderinstalls the theme's zoom on its window the way it already installs the rem size, andMoonTheme::install_configrefreshes every window so a change reaches all of them on their next frame.The consumer motivation: MoonTerminal scales its interface through
MoonScale::uitoday, which reaches only the geometry that goes throughtokens.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
scale_factorfield stays the effective value (platform × zoom) andscale_factor()is unchanged, so an upstream direct field read after a re-sync still sees the zoom. One privatesync_platform_geometryrecomputes it andviewport_sizefrombounds_changedand from a zoom change.renderis the expected caller — is parked inpending_content_zoomand applied at the top of the nextdraw, 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_inputis an exhaustivematchwith no wildcard arm, so aPlatformInputvariant that arrives with a re-sync fails to compile instead of passing through unconverted.client_insetis stored in content space becauseclient_inset()feeds layout; only the platform call multiplies.PlatformInputHandler, because macOSfirst_rect_for_character_range, Windowsretrieve_caret_positionand X11get_ime_areacallbounds_for_rangedirectly and bypassupdate_ime_position.ime_candidate_boundscomposes the converted call and does not convert again.GpuFrameInfoandGpuCanvasTextContextcarrycontent_zoombeside the combinedscale_factorfor a canvas that must keep device density (a chart): it sizes its target bybounds × scale_factorand its own geometry byscale_factor / content_zoom.resizestays in the platform's pixels, likebounds()andwindow_bounds(): a size saved fromwindow_bounds()restores unchanged under zoom.viewport_size()is the content-space counterpart.dispatch_eventdivides them again.set_content_zoomcancels a zoom parked by an earlier render (otherwise the nextdrawwould take the stale request), and rescales the trackedmouse_positionso the frame that applies the zoom hovers the right element.Root::newinstalls the theme's zoom before the first frame, so a new window never presents one unzoomed frame;renderkeeps it current afterwards.native_menu/macos.rs); Windows was already correct by construction.window_border.rs,menu/popup_menu.rs,menu/context_menu.rsand the editor popovers (input/popovers/{hover_popover,completion_menu,code_action_menu}.rs) clamped content-space positions against the platform window size; they now readviewport_size(). The popovers and the macOS menu are TrackedFork drift:menu3 → 4,inputandtext_area16 → 19 incomponent-manifest.json, withdocs/component-mirror-baseline.jsonregenerated and inspected.content_zoombeside the combined factor, so a zoom change offset by a DPI change still rebuilds a device-density canvas's text.zoom(a hand-edited0) is normalized on install the wayset_zoomrefuses it, so the tokens never report a zoom no window will take.foundation::snap_centeredsnaps the box and the inset and takes the centred part as what is left between them, andsnap_bordergives 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.rsis a tracked donor file, so the mirror baseline moves with it.Known limitations
How to verify
The nine window tests each name the edit they catch (dropping a term of
sync_platform_geometry, a re-sync restoring the barebounds_changedreads, a missingunzoom_inputarm, scalingLinesdeltas, 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.