Skip to content

Plugins: a service measures text as its window's canvas does - #1367

Closed
hoangvu12 wants to merge 4 commits into
zeronsh:zeron/zgxfrom
hoangvu12:service-measure-text
Closed

hoangvu12 wants to merge 4 commits into
zeronsh:zeron/zgxfrom
hoangvu12:service-measure-text

Conversation

@hoangvu12

@hoangvu12 hoangvu12 commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

A plugin's service can't measure text. It runs in the engine, which has no ui and no text shaping, and only a window's canvas has c:measure_text. We're moving Zeron Design to a Luau plugin, and its layout has to run in the service, because agents call its tools while Zeron is hidden and plugin windows don't draw then.

Now the service measures text the way its window's canvas does:

local w, h = zeron.measure_text("Find your still point", { font = "Geist", size = 34, weight = 700, width = 320 })
zeron.load_font("fonts/Inter-Regular.ttf") -- { "Inter" }
  • zeron.measure_text(text, { font, size, weight, italic, tabular, line_height, letter_spacing, width }) returns a width and a height. The options are the canvas's c:measure_text options. width wraps the text, and line_height and letter_spacing are in points, as on ui.text.
  • It shapes with zgx's TextServices and makes the same call a canvas makes (Scene::measure_text_with_font). The fonts are the system's plus the app's own (Geist, Geist Mono, Instrument Serif). A family it doesn't have falls back as it does in a window.
  • zeron.load_font(path) adds a font file from the plugin's own folder and returns its family names. zeron.load_font { base64 = … } adds a font from its bytes (64 MiB at most), such as a Google Font fetched with zeron.fetch(url, { binary = true }) (Plugins: zeron.fetch can return a font's or a picture's bytes whole, in base64 #1379). A window that loads the same file with ui.load_font (A script loads a font file and names its family in font zgx#2074) gets the same faces, so both sides measure alike. Each service has its own fonts, so this needs no permission.
  • The engine now depends on zgx and zgx-text: zgx's text shaping and measuring without its renderer (Text shaping and measuring build without the renderer zgx#2094), so no wgpu or winit. Until #2094 merges, both are pinned to its commit on the author's fork (hoangvu12/zgx baf248af). After it merges, the pin moves to zeronsh/zgx's commit, and apps/zgx should move to the same rev so the service and its windows measure alike.

Cost

  • The new crates are zgx, zgx-text and zgx's forks of cosmic-text, harfrust, swash and taffy: 116 Cargo.lock lines. There's no wgpu, winit or present code. An earlier version of this PR linked zgx-gpu, and that failed on Linux, where zgx-gpu needs zgx's patched wgpu-hal.
  • A release program that only measures text this way is 1.96 MB, and no wgpu code is linked into it. The first measure takes 1.5 ms, which includes reading the system's font list, and 1,000 more take 4 ms.
  • The app's fonts add about 1 MB to the engine. Each service's text state is made on its first measure, and the shaper drops the words it has cached once they pass 4 MB.

Testing

Four new tests in plugins::service::vm::tests:

  • a_service_measures_text_as_a_window_does: "9:41" in Geist 16 measures 29.58 by 23, the size Zeron's window gave on a canvas. Eight option sets (weights, italic, Instrument Serif, mono, the default font, a wrap width, line height and letter spacing) equal what a scene with a renderer's text services measures.
  • a_family_it_lacks_falls_back_as_in_a_window: an unknown family measures as the window's fallback, and two unknown families measure the same. A bad width is an error.
  • a_service_measures_in_its_plugins_fonts: loading Geist Bold Italic, which isn't among the app's fonts, changes the width, and the new width matches a window with that font. A file that isn't a font, and a path outside the plugin, are refused.
  • a_service_measures_in_a_font_from_its_bytes: Geist Bold Italic loaded from base64 measures as a window with that font. Bytes that aren't a font, text that isn't base64, and a table without base64 are refused.

cargo test -p zeron-engine --lib plugins::service: 34 passed. cargo check -p zeron-engine --target x86_64-unknown-linux-gnu passes. I have no Linux machine, so the C dependencies' build scripts ran with a stand-in compiler that writes empty objects. cargo check doesn't link, so the Rust code is checked in full. cargo clippy -p zeron-engine shows nothing in the touched files.

On Windows the engine's lib tests don't compile on zeron/zgx as it is: an ignored test in chat_persistence.rs and a helper in source_control.rs call libc::getrusage, which only exists on Unix. I made both Unix-only locally to run the tests and left that out of this PR.

Open questions

  • A service on another device measures with that device's system fonts, so a family installed here but not there falls back differently. Fonts from the plugin's folder and the app's own fonts measure the same everywhere.
  • Left out to keep this small: line-break positions.

Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

A plugin's service had no way to measure text: it runs in the engine,
which linked no text shaping, and only a window's canvas could measure.
Layout that must work while Zeron is hidden (agent tools) couldn't run.

zeron.measure_text(text, opts) shapes with zgx-gpu's TextServices, the
same shaper and font lookup a window's canvas uses, over the system's
fonts and the app's own. zeron.load_font(path) adds a font file from the
plugin's folder, so the service and a window that loads the same file
with ui.load_font measure alike.
A missing file inside the plugin got the same "isn't in the plugin"
message as a path outside it.
zgx-gpu brought wgpu and winit into the engine, and on Linux its present
code needs zgx's patched wgpu-hal, which the root workspace can't take
(it would reach gpui's wgpu): the engine failed to build there. zgx-text
(zeronsh/zgx#2094) is zgx's text shaping and measuring alone.
A font the service downloaded (zeron.fetch with binary = true) had no
file in the plugin to name. { base64 = ... } loads it as a file loads,
64 MiB at most, as a window's ui.load_font.
@hoangvu12

Copy link
Copy Markdown
Contributor Author

Folded into #1392, which combines our plugin pull requests into one for review. This change is its commit 3fe0d99.

@hoangvu12 hoangvu12 closed this Nov 12, 2026
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.

1 participant