Skip to content

feat: configurable per-slot font weight for bundled Cascadia Code#1628

Open
nikicat wants to merge 5 commits into
raphamorim:mainfrom
nikicat:feat/per-slot-font-weight
Open

feat: configurable per-slot font weight for bundled Cascadia Code#1628
nikicat wants to merge 5 commits into
raphamorim:mainfrom
nikicat:feat/per-slot-font-weight

Conversation

@nikicat
Copy link
Copy Markdown

@nikicat nikicat commented May 30, 2026

Summary

Adds an optional weight = <number> field to each per-slot font section
(fonts.regular, fonts.bold, fonts.italic, fonts.bold-italic). When the
slot is served by the bundled Cascadia Code variable font, the value is baked
into the wght axis at shape and rasterize time — so a user who wants a lighter
regular face can write weight = 350 instead of installing a system "Cascadia
Code Light". Refs #1577.

Details

  • from_static_slice_with_wght takes the wght axis value and the logical
    CSS-style weight as separate parameters, decoupling the rendered axis from the
    cascade-match weight — so is_bold() and bold-fallback routing still see
    Weight::BOLD for bold slots even when the user picks a lighter axis (e.g. 500).
  • The terminal grid path (grid_emit::shape_run_swash / rasterize_glyph_native)
    now applies the wght variation via a per-rasterizer cache mirroring
    sugarloaf::text; macOS gets it through the CTFont handle.
  • Config live-reload propagates a weight change: the grid rasterizer and per-panel
    glyph atlases are rebuilt and a redraw is scheduled (the UpdateConfig handler
    previously mutated state without requesting a frame).
  • The 700 bold threshold is centralized on swash::Weight::BOLD.
  • man page: per-slot signatures updated to reflect that weight deserializes, bold
    defaults corrected to WGHT_BOLD (700), and the phantom width column dropped.

Testing

cargo test -p sugarloaf — TOML deserialization, full FontLibrary::load
per-slot wght_variation, and axis/logical-weight decoupling.

Refs #1577

🤖 Generated with Claude Code

nikicat and others added 3 commits May 30, 2026 17:19
Adds an optional `weight = <number>` field to each per-slot section
(`fonts.regular`, `fonts.bold`, `fonts.italic`, `fonts.bold-italic`).
When the slot is served by the bundled Cascadia Code variable font, the
value is baked into the `wght` axis at shape and rasterize time — so a
user who wants a lighter regular face can write `weight = 350` instead
of installing a system "Cascadia Code Light".

Implementation notes:

- `from_static_slice_with_wght` now takes the `wght` axis value and the
  logical CSS-style weight as separate parameters, decoupling the
  rendered axis from the cascade-match weight. `is_bold()` and the
  bold-spec lookup walk see `Weight::BOLD` for the bold slots even when
  the user picks a lighter axis value (e.g. 500). The two were
  conflated in a single argument before, which would have demoted
  `is_bold()` to false and broken bold-fallback routing for any
  sub-700 override.

- `is_bold()` and the synthesis-weight check in `from_data` now read
  `swash::Weight::BOLD` instead of the bare `Weight(700)`. The 700
  threshold lives in one place.

- The terminal grid path (`grid_emit::shape_run_swash` and
  `rasterize_glyph_native`) was not applying any `wght` variation; it
  built swash shape/scale contexts straight from the font bytes. A new
  per-`GridGlyphRasterizer` cache (`wght_variation_cache`) mirrors the
  one in `sugarloaf::text` and feeds the variation into both
  `shaper.variations` and `scaler.variations`.

- Config live-reload now actually propagates a `weight` change:
  - `Screen::update_config` drops `self.grid_rasterizer` (its
    per-font_id caches still pointed at the previous library's faces)
    and clears `self.grids` so each panel's `GridRenderer` rebuilds
    with an empty glyph atlas — otherwise atlas hits would keep
    serving the old outlines.
  - Each panel's `pending_update` gets `TerminalDamage::Full` so the
    renderer's per-panel dirty gate lets the frame through.
  - The `UpdateConfig` event handler in `application.rs` now calls
    `route.request_redraw()`; previously the handler mutated state but
    never scheduled a frame, so the next paint waited for an external
    event (focus, input, blink).

macOS rasterize gets the variation for free via the CTFont handle that
`from_static_slice_with_wght` builds with `with_wght_variation`.

Tests:

- `sugarloaf::font::fonts::toml_tests::weight_field_parses_from_toml`
  pins the TOML→`SugarloafFont` deserialization.
- `sugarloaf::font::alias_tests::load_applies_per_slot_weight_overrides`
  walks the full `SugarloafFonts → FontLibrary::load` path and asserts
  each slot's resulting `wght_variation`.
- `sugarloaf::font::alias_tests::fallback_user_weight_decouples_axis_from_logical_weight`
  pins the decoupling: a user-set bold weight of 500 still satisfies
  `is_bold()` while rendering at the requested axis value.
- The existing `fallback_bold_slot_reports_is_bold` was updated for
  the new `load_fallback_from_memory(slot, weight_override)` signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7d4678e)
`weight` actually deserializes now (previous commit), so the per-slot
signatures should reflect that and the documented bold defaults need to
match `WGHT_BOLD` (700, not 800). Also drop the phantom `width` column
— `SugarloafFont` has no such field; a user setting `width = "..."`
would either be silently dropped or error depending on serde mode.

Adds a one-line note on each slot explaining that `weight` only takes
effect for the bundled Cascadia Code (the axis is what's variable),
and pointing system-font users at `style = "Light"` etc.

Other pre-existing drift in this section (size = 18.0 vs actual 14.0,
style = "Normal" vs actual "default", undocumented absence of `extras`
/ `emoji`) is left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 51774ef)
Bold and bold-italic glyphs could render at the variable font's default
400 weight instead of 700, with *which* glyphs affected varying per
window. The bundled Cascadia variable font carries its slot weight in a
`wght` axis applied at rasterize time, but `rasterize_glyph_native` read
that value from `wght_variation_cache` — a per-font_id side cache that
only `shape_run_swash` populates. Shaping is skipped on a shaped-run
cache hit (`run_cache_get(..).is_some()`), so a glyph could reach
rasterization with no cache entry; the old code then `flatten()`ed the
miss to `None` and rasterized at the default instance. Because the glyph
atlas key carries no weight, that lighter outline was then cached
permanently. Whichever glyphs happened to be shaped-this-frame vs served
from the run cache decided the boldness, hence the per-window variance.

Resolve `wght` from the FontLibrary on a cache miss (the same
`or_insert_with` lookup `shape_run_swash` uses) so the rasterized weight
is a function of font identity, not frame/shape ordering. `font_library`
is threaded through `ensure_glyph_by_id`; the macOS path takes it as
`_font_library` since it bakes the axis into the CTFont handle at load.

Adds `bold_slot_rasterizes_bold_without_prior_shaping`, which rasterizes
a bold-slot glyph with and without a pre-populated side cache and asserts
the outlines match; it fails on the old code (ink 35790 vs 46114).

Regression from 7d4678e ("feat: configurable per-slot font weight").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4734999)
@nikicat
Copy link
Copy Markdown
Author

nikicat commented May 30, 2026

Pushed a follow-up commit fixing a regression in this same feature, found after opening the PR:

fix(font): resolve wght axis at rasterize time, not via shape side-cache

Bold / bold-italic glyphs could render at the variable font's default 400 weight instead of 700, with which glyphs were affected varying per window. rasterize_glyph_native read the wght axis from wght_variation_cache, a per-font_id side cache that only shape_run_swash populates — but shaping is skipped on a shaped-run cache hit, so a glyph could reach rasterization with no cache entry, get flatten()ed to None, and rasterize at the default instance (then cached permanently, since the atlas key carries no weight). The fix resolves wght from the FontLibrary on a cache miss (same lookup shaping uses), making the rasterized weight a function of font identity rather than frame/shape ordering. Adds bold_slot_rasterizes_bold_without_prior_shaping (fails on the old code: ink 35790 vs 46114).

nikicat and others added 2 commits May 30, 2026 18:30
Formatting-only; no behavior change. (CI `cargo fmt --check` gate.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
swash's ScaleContext/ShapeContext keep a single coordinate buffer that
every builder(..).variations(..) reuses. variations() only resize()s and
overwrites the axes it is handed -- it never clears stale entries (unlike
normalized_coords(), which does). So a slot with no variation (wght None
-- italic or regular-at-default) passed an empty variation set, the
resize was a no-op, and the buffer kept the *previous* build's wght.

Because the grid renderer shares one scale_ctx across every font_id, an
unweighted italic glyph rasterized right after a bold (wght 700) glyph
inherited the stale coordinate and rendered bold -- the rare "false bold"
artifact. Rare because it needs a weighted build to immediately precede
an unweighted one; far more frequent under dim text, which fragments runs
and interleaves more weights through the shared context (~30-50% there).

Follow-up to 4734999: that fix made rasterize resolve wght
deterministically per font_id, but the None path still walked into this
swash footgun. Force a clean slate with normalized_coords(empty) before
variations() at both the rasterize and shape sites.

Regression test unweighted_slot_unaffected_by_prior_bold_rasterize drives
a bold then an unweighted glyph through one rasterizer and pins that the
unweighted glyph matches its no-prior-bold baseline (ink 35790, not bold's
46114). Fails before the fix, passes after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nikicat
Copy link
Copy Markdown
Author

nikicat commented May 30, 2026

Pushed a follow-up fix (f87cd223bb): clear swash's shared coordinate buffer before applying wght.

The earlier resolve wght at rasterize time commit made weight resolution deterministic per font_id, but a residual rare "false bold" remained. Root cause is in swash's ScaleContext/ShapeContext: they keep a single coordinate buffer that every builder(..).variations(..) reuses, and variations() only resize()s + overwrites the axes it's given — it never clears stale entries (unlike normalized_coords()). So an unweighted slot (wght: None) passed an empty variation set, the resize was a no-op, and the glyph inherited the previous build's wght. Since the grid renderer shares one scale_ctx across every font_id, an unweighted italic glyph rasterized right after a bold one rendered bold.

It's rare because it needs a weighted build to immediately precede an unweighted one — and much more frequent under dim text (~30–50%), which fragments runs and interleaves more weights through the shared context.

Fix: normalized_coords(empty) (which clears) before variations() at both the rasterize and shape sites. Added a regression test that drives a bold then an unweighted glyph through one rasterizer and pins that the unweighted glyph matches its no-prior-bold baseline (ink 35790, not bold's 46114) — fails before, passes after.

nikicat added a commit to nikicat/rio that referenced this pull request May 30, 2026
Format the wght-rasterize regression tests to match rustfmt, aligning
nb/fixes with the same change on the feat/per-slot-font-weight PR branch
(raphamorim#1628). No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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