Skip to content

feat: text wrapping, alignment and line height - #20

Merged
LucasAntunesdeAlmeida merged 1 commit into
masterfrom
wrap
Sep 29, 2026
Merged

LucasAntunesdeAlmeida merged 1 commit into
masterfrom
wrap

Conversation

@LucasAntunesdeAlmeida

Copy link
Copy Markdown
Owner

What

Three chainable calls for translated paragraphs (card effects, codex entries, dialog):

  • .Wrap(width float64) breaks the text into lines no wider than width. 0 turns it off.
  • .TextAlign(a Align), with engine.AlignCenter (the default), engine.AlignLeft and engine.AlignRight, sets how the lines line up in the text's box.
  • .LineHeight(px float64) is the distance from one baseline to the next. The default is the fonts' own line height.

Break rules

The rules are a small, predictable subset of UAX #14, implemented as a pure function in wrap.go (wrapLines):

  • Break at spaces (Latin, Cyrillic). Non-breaking spaces never break.
  • Break between any two CJK characters, and after a hyphen inside a word (sci-/fi).
  • Basic kinsoku: never before closing punctuation (,。、!?)」』】》” and ASCII , . ! ? ) ] }, small kana, ー, 々, …), and never after opening punctuation (「『(【《“ and ( [ {).
  • A space before French ! ? : ; » %, or after «, does not break, so "Bonjour !" keeps its "!".
  • "\n" always breaks. A word wider than the whole line breaks between characters, and never between a letter and its combining marks. Spaces at the end of a wrapped line are dropped; a paragraph's leading indent is kept.

Design choices

  • The wrapped box is the column. The box is exactly width wide, not the longest line, and as tall as its lines, and it stays centered on the text's position like every other object. With a shrink-wrapped box, a left-aligned paragraph's left edge would move with its content, and games cannot read a text's size. With the column, TextAlign(AlignLeft) puts every line at X - width/2 whatever the translation says. The same trick anchors a one-line label by its left or right edge, which a translated HUD needs now that len(str) * size no longer measures it. Clicks and agents' w use the column, and agents still read the unwrapped string.
  • Alignment is needed. Endless Night, the game being translated, wraps card names and effects as centered lines and codex entries as left-aligned lines. Today it does that with fixed character counts per line, which breaks for CJK and Cyrillic. Centered is the default because text is anchored at its center today, so a short text with Wrap looks exactly like one without. The name is TextAlign because it sits next to TextSize and TextColor; Align alone would read like it moves the object.
  • Line height is needed too. The fonts' line height is ascent plus descent, and pixel fonts have no line gap, so Press Start 2P lines touch. The game spaces 8px lines 12px apart. LineHeight is the one number that fixes it. A single line is unaffected.
  • A fixed text area needs no new call: Size(w, h) plus Wrap(w) gives a box whose lines start at its top. That keeps a dialog's first line in place while a typewriter adds more.
  • "\n" now lays out real lines. It used to draw every line on top of the first (Ebitengine's line spacing was left at 0) and measure one line tall, so no working game relied on it.
  • Buttons wrap and align their label the same way, and the plate is the column plus padding.
  • Lines are placed on whole pixels, so pixel fonts stay sharp. The layout is cached on the object and redone only when the text, the face (font or size), Wrap or LineHeight changes. Lines are measured piece by piece, so a typewriter calling SetText every frame hits Ebitengine's shaping cache.

Proof

  • wrap_test.go:
    • a line-breaking table with a monospace measure: spaces, exact fit, dropped and leading spaces, newlines, CRLF, long words, Cyrillic, combining accents, hyphens, NBSP, French spacing and guillemets, CJK, kinsoku on both sides, ellipsis, mixed CJK and Latin;
    • a property test (widths 1 to 30, mixed scripts): no line is wider than the width unless it is a single character, no line ends in a space, and no text is lost;
    • box size, LineHeight, unwrapping, empty text, "\n" lines, alignX, and Size with Wrap;
    • Headless: observed W/H is the column, a click beside a short line hits, a wrapped button, SetText re-wraps, Restart;
    • CJK wrapping with the fallback font.
  • examples/dialog wraps its speech in the dialog box, left-aligned in a fixed text area, with the speaker's name aligned to it (the column trick). It works in English, Portuguese and Chinese (one Chinese line is now long enough to wrap), and demo.gif is re-recorded.
  • README cheat-sheet rows, contract item 12, dialog README.

.Wrap(width) breaks a text into lines no wider than width:
- at spaces (Latin, Cyrillic); non-breaking spaces never break;
- between any two CJK characters, and after a hyphen inside a word;
- basic kinsoku: never before closing punctuation (,。!?」 , . ! ? and
  small kana, ー, …), never after opening punctuation (「( ( [);
- a space before French ! ? : ; » % or after « does not break;
- "\n" always breaks; a word wider than the line alone breaks between
  characters (never between a letter and its combining marks); spaces
  at the end of a wrapped line are dropped.
The rules live in wrap.go as a pure function (wrapLines) with a
table test.

The wrapped text's box is a column exactly width wide and as tall as
its lines, centered on the position like every object, so a
left-aligned paragraph starts at X - width/2 whatever it says. Agents
still read the unwrapped string. Size fixes a text area (a dialog box)
whose lines start at its top.

.TextAlign(engine.AlignCenter|AlignLeft|AlignRight) lines up the lines
in the box (centered by default, like the single-line text anchored at
its center today), on whole pixels. .LineHeight(px) sets the baseline
distance; by default the fonts' own line height.

"\n" now lays out real lines everywhere: it used to draw every line on
top of the first (line spacing 0) and measure one line tall. Buttons
wrap and align their label the same way. The layout is cached on the
object and redone only when the text, the face, Wrap or LineHeight
changes.

Contract item 12. The dialog example wraps its speech, left-aligned in
a fixed text area, in English, Portuguese and Chinese; demo re-recorded.
@LucasAntunesdeAlmeida
LucasAntunesdeAlmeida merged commit b2b8927 into master Sep 29, 2026
1 check passed
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