Skip to content

fix(font): prefer the face width for non-ASCII fallback - #481

Merged
eval-exec merged 2 commits into
eval-exec:mainfrom
thanosapollo:fix/non-ascii-face-width
Oct 7, 2026
Merged

eval-exec merged 2 commits into
eval-exec:mainfrom
thanosapollo:fix/non-ascii-face-width

Conversation

@thanosapollo

@thanosapollo thanosapollo commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Why

With a font collection such as Iosevka (Iosevka-Regular.ttc), Neomacs drew Greek, Cyrillic, accented Latin and punctuation such as — with the collection's Extended face (width 125), while ASCII used the normal-width Regular face. Text like abc αβγ ἀλήθεια é — therefore looked visibly wider for every non-ASCII character. GNU Emacs uses the normal-width face for all of them.

Character fallback scored candidates by the fontset spec's explicit width only. Fontset entries such as (nil . "iso10646-1") leave the width unset, so every width tied and Fontconfig discovery order decided, and Iosevka lists its Extended face first. GNU's font_select_entity (src/font.c) fills an unset preferred width from the face (its font, else its :width), as it does for weight and slant, before font_sort_entities.

Change

  • FontResolver::resolve_from_policy scores width against the captured effective width (the spec's width, or the requested face width when the spec has none), as it already does for weight and slant.
  • Remove the now-unused explicit_width field from the captured character policy.

Primary-font selection, fontset ordering and explicit spec widths are unchanged.

Out of scope: the layout caller still passes a normal width for every face (the primary-font path does the same), so a face with a non-normal :width or an Extended primary font is not yet matched by fallback. Fixing that means passing the face width through to both paths, which is a separate change.

Verification

Regression test (fails on main at 7764ef7 with (Some(Expanded), 3) instead of (Some(Normal), 0), passes with this change):

cargo nextest run -p neomacs-layout-engine --lib -E 'test(=font::resolver::tests::character_fallback_prefers_the_requested_face_width)'

It lists an Extended collection face before a Normal one and checks that both a normal and an expanded requested width select the matching face, so the face width decides rather than discovery order.

explicit_fontset_width_beats_the_face_width_on_live_and_frozen_paths (9a52a76) checks the other direction: an explicit :width 'expanded in the fontset spec still beats a normal face width, on the live and frozen worker paths, fresh and cached. It fails if either path lets the face width override the spec width.

Focused selection cargo nextest run -p neomacs-layout-engine --lib -E 'test(~font::)' on Linux: 213 run, 209 passed. The same 4 tests fail identically on unmodified main (212 run, 208 passed) on the test machine, because of missing or different system fonts (DejaVu Sans Mono not installed, static rather than variable Noto Sans Mono, and a host-dependent primary-face fixture). cargo fmt --check and cargo clippy -p neomacs-layout-engine --lib --tests report nothing new in the changed files.

Isolated GUI comparison with the system Iosevka-Regular.ttc (headless Weston, -Q, :family "Iosevka" :height 165, 22 px), font-at on abc αβγ ἀλήθεια é —:

Editor ASCII Non-ASCII (α β γ ἀ λ ή θ ε ι é —)
GNU Emacs 32.0.50 (pgtk) Iosevka, normal Iosevka, normal
Neomacs main Iosevka, normal Iosevka, expanded
Neomacs with this PR Iosevka, normal Iosevka, normal

Compositor screenshots of that line show the same difference: on main the non-ASCII glyphs are visibly wider than GNU's, and with this PR they match.

This PR is agent-assisted.

Character fallback scored candidates by the fontset spec's width
only.  Specs such as (nil . "iso10646-1") leave it unset, so every
width tied and discovery order decided: with Iosevka-Regular.ttc the
Extended face (width 125) won for Greek, accented Latin and
punctuation while ASCII used the normal Regular face.

GNU font_select_entity fills an unset preferred width from the face,
as it does for weight and slant.  Score against the captured
effective width instead of the explicit spec width.
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 1c6cff56-af25-4296-a629-3fc023a1941f
📥 Commits

Reviewing files that changed from the base of the PR and between 13ad539 and 9a52a76.

📒 Files selected for processing (1)
  • crates/neomacs-layout-engine/src/font/resolver/tests/mod.rs

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Character fallback candidate scoring now uses the captured effective width, including the requested width when the fontset specification leaves width unset. Tests cover face selection by requested width and explicit-width precedence in live and frozen-worker lookups, including cache hits.

Changes

Character Fallback Width

Layer / File(s) Summary
Effective width in fallback scoring
crates/neomacs-layout-engine/src/font/resolver.rs, crates/neomacs-layout-engine/src/font/resolver/worker_policy.rs, crates/neomacs-layout-engine/src/font/resolver/tests/mod.rs
The captured policy uses its effective width for candidate scoring and no longer stores a separate explicit width. Tests cover selection by requested width and explicit-width precedence across live and frozen-worker lookups, including cache hits.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 9a52a

No actionable merge-blocking issue remains after normal checks.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 2 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: character fallback now prefers the requested face width.
Description check ✅ Passed The description explains the fallback-width change, its motivation, scope, and verification. It is directly related to the changeset.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Although I found no blocking issues, this change touches font-selection behavior affecting all non-ASCII text rendering, so final human verification of the visual/selection outcome is warranted before approval.

Review effort: Balanced
Findings: None

What changed in this PR

This PR fixes a font-fallback regression where font collections like Iosevka drew non-ASCII characters (Greek, Cyrillic, accented Latin, punctuation such as —) with a wider face than ASCII, diverging from GNU Emacs. The root cause was that character fallback scored candidate width only against the fontset spec's explicit width; fontset entries such as (nil . "iso10646-1") leave width unset, so every candidate tied and Fontconfig discovery order (which lists Iosevka's Extended face first) decided the winner. The change makes resolve_from_policy score width against the captured effective width (spec width, or the requested face width when unset), mirroring how GNU's font_select_entity fills an unset preferred width from the face, and how the resolver already handles weight and slant.

Changes:

  • Score SelectionRequest.width using Some(policy.width) (the effective captured width) instead of the explicit-only spec width in FontResolver::resolve_from_policy.
  • Remove the now-unused explicit_width field from CapturedCharacterPolicy (definition and both capture sites).
  • Add a regression test asserting the requested face width, not discovery order, decides among collection faces of differing widths.
File Description
crates/​neomacs-layout-engine/​src/​font/​resolver.rs Scores width on the effective captured width and drops the explicit_width field from the struct and its capture site.
crates/​neomacs-layout-engine/​src/​font/​resolver/​worker_policy.rs Drops the explicit_width field from the worker capture_bounded path.
crates/​neomacs-layout-engine/​src/​font/​resolver/​tests/​mod.rs Adds a test verifying the requested face width is preferred over Fontconfig discovery order.

I reviewed the width-scoring change, confirmed the explicit_width field is fully removed with no dangling references, traced the scoring through candidate_selection_score (so Some(Normal) correctly penalizes wider faces while explicit-width specs are unchanged), and verified the new test exercises the generic-fallback (unset-width) path that the fix targets. The change is minimal, correct, and consistent with the stated scope; the two capture paths' differing width defaults (sync uses the requested width, worker hardcodes Normal and keys its cache on Normal) have no functional effect today since callers always pass Normal, matching the PR's acknowledged out-of-scope note. I found no issues to flag.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

The face width only fills an unset fontset width.  Check that an
explicit Expanded fontset spec still wins over a Normal face request,
on the live path and on the frozen worker path, on a fresh lookup and
on a cache hit.
thanosapollo added a commit to thanosapollo/neomacs that referenced this pull request Oct 6, 2026
thanos already carries the content of these heads, verbatim or as the
personal variant it ships; this merge keeps the tree and records the
heads so later upstream merges of them need no re-resolution.

- contrib/repeat-backpressure 551220e (eval-exec#459): carried; the rebase only
  moved to FrontendKey, as the main merge did.
- contrib/nested-reader-recovery 91a4022 (eval-exec#460): carried, combined with
  the command-error reporting below.
- fix/command-error-literal-message 271a202 (eval-exec#489): carried as
  56339fa, 98b7742, 0ebd026.
- fix/non-ascii-face-width 9a52a76 (eval-exec#481): carried as 13ad539 and
  7eee365.
- feature/builtin-mcp c9542d3 (eval-exec#480): personal endpoint variant with the
  same reply bound (677591e) and documentation (e0ee6c3).
- fix/text-prop-interval-recycling 38b1e0a (eval-exec#479): personal interval
  recycling passes the same retention tests.
- feature/gui-daemon-publication 1cd96f8 (eval-exec#454): personal deferred GUI
  daemon, a superset.  Kept deliberately: terminal-live-p classifies every
  terminal by its output method (GNU Fterminal_live_p), an unregistered
  frame's native-window wait fails closed, and neomacs-set-frame-opacity
  passes integer percentages through unchanged, since alpha-background
  already reads a fixnum as a percentage (GNU gui_set_alpha_background).

@eval-exec eval-exec left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified against GNU 31.1 built from the emacs-mirror tree at a360712c (source plus a live batch run), and against a private worktree at 9a52a76 with cargo nextest run -p neomacs-layout-engine --lib.

GNU side — the claims hold.
font_select_entity (font.c:3171) builds prefer from the face's font spec fields, falls back to FONT_SET_STYLE (prefer, FONT_WIDTH_INDEX, attrs[LFACE_SWIDTH_INDEX]), then calls font_sort_entities: an unset width is filled from the face, for the same reason as weight and slant. font_score (font.c:2128) scores width as a ranking penalty and rejects only on size (factor-2). candidate_selection_score already has that shape, and GnuStyleScore's field order matches GNU's default face-font-selection-order — so the request is the right lever, in the right place.

Tests — reproduced, including the negative.
Both named tests pass here (2/2). As a control I reverted width: Some(policy.width) to width: None: character_fallback_prefers_the_requested_face_width then fails with exactly the PR body's values — left: (Some(Expanded), 3), right: (Some(Normal), 0), "requested Normal". The test catches the bug rather than passing incidentally.

For the record: query.requested_width was already policy.width before this change. The bug was that select_best_candidate re-ranks from candidate_score plus discovery ordinal, and with width: None every candidate scored 0 on width, so the tie-break discarded the width-aware ordering the backend had already been asked for. Query width-aware, selection width-blind.

GUI table — the non-ASCII row reproduced headlessly.
The real system Iosevka through the real fontconfig backend and the real resolver (font selection needs no display):

build α, é, — (non-ASCII, width-less fontset entry)
pre-fix (width: None) Some(Expanded) → Iosevka-Extended.ttf
this PR Some(Normal) → Iosevka-Regular.ttf

Caveat: here Iosevka ships as per-style files rather than the Iosevka-Regular.ttc collection, so the in-collection face index isn't reproduced (the fixtures cover that synthetically), and I couldn't run GNU or watch pixels on this box — the local GNU build is GTK3, not pgtk. GNU's decision follows from font_select_entity filling the width from the face, so its row should be the Regular face.

Latent: the frozen worker path can't serve a non-Normal request at all.
capture_bounded (worker_policy.rs:108) takes no width and hardcodes spec.width.unwrap_or(FontWidth::Normal) — width is the only one of weight/slant/width the frozen capture doesn't receive from its caller. The sharper half is the key: FrozenCharacterPolicies::capture builds CharCacheKey { …, width: FontWidth::Normal.gnu_numeric(), … } (worker_policy.rs:47), so the snapshot is keyed Normal-only, and a miss in resolve_worker_character (worker_policy.rs:222) sets missing and returns None — no fallback.

Measured with a scratch test — no :width in the spec, Extended face listed first:

request live frozen
Normal Some((Normal, 0)) Some((Normal, 0))
Expanded Some((Expanded, 3)) None + worker_policy_missing()

The two paths agree only while every caller requests Normal, which holds today only because metrics.rs:2828 hardcodes FontWidth::Normal. The scoped follow-up ("pass the face width through to both paths") would therefore make Extended-face text resolve to no font on the worker path while looking correct on the main thread. Threading the width into capture_bounded is necessary but not sufficient — the key has to carry it too, or stop keying on width.

The second test can't catch this: it always sets an explicit spec width, so unwrap_or never fires and both paths agree by construction. The scratch test above would; happy to send it as a patch.

Smaller

  • selection.rs:48-55: GNU skips a non-fixnum width field (&& FIXNUMP (AREF (entity, i))) — no penalty at all — whereas candidate_width.unwrap_or(FontWidth::Normal) scores an unknown-width candidate as if it were Normal. Only visible for non-Normal requests.
  • No .min(127) on the width distance, unlike the weight field below it (selection.rs:67) and unlike GNU, which caps all three style fields. Only changes a winner when two candidates are both >127 units away.
  • A metadata.width == None candidate in the first test would pin the unknown-width treatment.

Follow-ups I'd like to pick up (happy to file these as an issue rather than leaving them only here):

  1. Give the frozen capture the width and key it on that width, then plumb the face width from RealizedFaceFontSelection.
  2. Treat unknown-width candidates the way GNU does — skip, not Normal.
  3. Cap the width distance at 127 like weight.

Fix is correct, lands in the right layer, and the tests are honest. Merging by rebase.

@eval-exec
eval-exec merged commit d69545f into eval-exec:main Oct 7, 2026
35 of 42 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The change is small, well-scoped, verified correct against the width-scoring logic on both capture paths, and backed by targeted regression tests.

Review effort: Balanced
Findings: None

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.

3 participants