Skip to content

Render and input text in languages other than English on macOS - #2

Open
hard25670559 wants to merge 3 commits into
stevschmid:macos-portfrom
hard25670559:feature/macos-cjk-text
Open

Render and input text in languages other than English on macOS#2
hard25670559 wants to merge 3 commits into
stevschmid:macos-portfrom
hard25670559:feature/macos-cjk-text

Conversation

@hard25670559

Copy link
Copy Markdown

Problem

The bundled bitmap fonts carry only the first 128 codepoints, so every other character is drawn as a [U+XXXX] placeholder. Typing through an input method does nothing visible at all: the composition never reaches the application, keys the IME is about to consume also hit the UI, the candidate window opens in a screen corner, and committed text is turned into ? on the way in.

Together this means build names, notes, account names and item text pasted from a localised client are effectively limited to ASCII, whatever language the user works in. PathOfBuildingCommunity/PathOfBuilding#5632 has been open on this since early 2023; users there resort to third-party rebuilt engine binaries.

Changes

Glyph rendering — a parallel path rather than a replacement. A new macOS module rasterises a codepoint on demand through CoreText; going via CTFontCreateForString uses the system fallback chain, so Han, kana, Cyrillic and emoji all resolve with no font to bundle or curate. r_font caches these per (codepoint, size) as small textures and consults them in the three places that previously produced tofu — width measurement, cursor hit testing and drawing. Latin text still goes through the bitmap fonts untouched, so existing layout is unchanged, and tofu remains the fallback when no glyph exists anywhere.

Glyphs are rasterised at the display's pixel density and scaled back to layout units so they stay sharp on HiDPI, and are anchored to a fixed fraction of the line height: a font's own ascent includes leading and sits noticeably below where the bitmap fonts place their baseline.

Input method support — GLFW's content view conforms to NSTextInputClient but leaves several parts inert, so those are replaced at runtime and chained to the originals rather than forking the library:

  • setMarkedText: forwards the composition to the UI so it can be drawn at the caret; unmarkText clears it.
  • firstRectForCharacterRange: answers with the caret rectangle the UI reports, so the candidate window follows the text.
  • markedRange was one unit short of the text it holds and selectedRange was missing entirely — both are consulted while positioning the candidate window, and a bad answer makes the system give up on it.
  • keyDown: withholds keys from the application while composing. GLFW reports the raw key first, which let Return confirm a dialog before the composed text arrived.
  • insertText: clears the marked text GLFW leaves behind on commit, which otherwise kept every later keystroke away from the application.

UI plumbing — character events carry the codepoint encoded as UTF-8 instead of being looked up in the key name table, which only knows named keys and answered ? for anything above ASCII. Composition state is delivered through a new OnPreedit callback, and SetIMECaretRect lets the focused control report where its caret is.

Dependencies

Verification

Built for arm64-osx and run on a three-display setup.

  • CJK, Latin and mixed text render correctly and stay sharp on a 2x display; Latin-only layout is byte-identical in appearance.
  • Composition appears underlined at the caret, the candidate window follows it, Return commits without confirming the surrounding dialog, and backspace/arrows behave normally afterwards.
  • Verified with the system Bopomofo input method and with a third-party one.

Known limitation

With the system Bopomofo input method the candidate window appears at a stale position for the first character of a session and corrects itself once you move through the candidates. It picks that first position from a cached caret location without querying the application; other applications on the same machine behave identically. Third-party input methods are unaffected.

Jimmy Chang added 3 commits August 10, 2026 20:49
The bundled bitmap fonts only carry the first 128 codepoints, so every
other character was drawn as a "[U+XXXX]" placeholder. Build names,
notes, account names and item text pasted from a localised client are
effectively restricted to ASCII, whatever language the user works in.

Add a parallel glyph path instead of replacing the existing one:

- r_dynfont (macOS/CoreText) rasterises a single codepoint on demand.
  Going through CTFontCreateForString gives us the system fallback
  chain, so Han, kana, Cyrillic and emoji all resolve without shipping
  or hand-picking a font.
- r_font caches those glyphs per (codepoint, size) as small textures and
  consults them in the three places that previously produced tofu:
  width measurement, cursor hit testing, and drawing. Latin text still
  goes through the bitmap fonts untouched, so existing layout is
  unchanged, and tofu remains the fallback when no glyph exists.

Glyphs are rasterised at the display's pixel density and scaled back to
layout units so they stay sharp on HiDPI, and are anchored to a fixed
fraction of the line height: the font's own ascent includes leading and
would sit noticeably below where the bitmap fonts place their baseline.

Also fix r_tex_c's image constructor, which assigned the mip set to its
parameter rather than the member that PerformUpload reads, so building a
texture from memory always dereferenced a null image.
Typing with an IME did nothing visible: the composition never reached the
application, keys the IME was about to consume also hit the UI, and the
candidate window opened in a fixed screen corner. Committed text was
turned into "?" before it arrived.

GLFW's content view conforms to NSTextInputClient but leaves several
parts inert, so replace those at runtime and chain to the originals
rather than forking the library:

- setMarkedText: forwards the composition up to the UI so it can be drawn
  at the caret; unmarkText clears it.
- firstRectForCharacterRange: answers with the caret rectangle the UI
  reports, converted to screen space, so the candidate window follows the
  text instead of sitting in a corner.
- markedRange was one unit short of the text it holds and selectedRange
  was missing entirely; both are consulted while positioning the
  candidate window and a bad answer makes the system give up on it.
- keyDown: withholds keys from the application while a composition is in
  progress. GLFW reports the raw key first, which let Return confirm a
  dialog before the composed text ever arrived.
- insertText: clears the marked text GLFW leaves behind on commit, which
  otherwise kept every later keystroke away from the application.

On the UI side, character events now carry the codepoint encoded as
UTF-8 instead of being looked up in the key name table, which only knows
named keys and answered "?" for anything above ASCII. Composition state
is delivered through a new OnPreedit callback, and SetIMECaretRect lets
the focused control report where its caret is.
The system caches where it believes the caret is and only re-reads it
when told the coordinates are stale, so a candidate window could open at
a position the caret had already left. Invalidate those coordinates
whenever the reported rectangle changes.

Also answer attributedSubstringForProposedRange with the text being
composed rather than nil, which is what an input method consults for
context around the composition.
@hard25670559

Copy link
Copy Markdown
Author

Also submitted upstream as PathOfBuildingCommunity#114. Same reasoning as #1: the change belongs to the engine, but it was developed and verified on this branch because master does not currently produce a working macOS build there. Happy to close this one if you'd rather it land upstream first.

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