Skip to content

Full RTL support: native right-to-left layout and bidirectional text - #8836

Open
ahmedhosnypro wants to merge 243 commits into
emilk:mainfrom
ahmedhosnypro:craft/rtl-native
Open

ahmedhosnypro wants to merge 243 commits into
emilk:mainfrom
ahmedhosnypro:craft/rtl-native

Conversation

@ahmedhosnypro

@ahmedhosnypro ahmedhosnypro commented Nov 4, 2026 •

Copy link
Copy Markdown

Adds full right-to-left support to egui: one switch mirrors the entire native UI, and text becomes a real bidirectional pipeline. Style::right_to_left / Context::set_right_to_left flip the reading direction of every core container, widget, panel and menu; epaint shapes text in bidi runs (UAX #9), and caret, selection and IME handling become level-correct in Arabic, Hebrew and other RTL text.

What

  • Direction core — Style::right_to_left, Context::set_right_to_left and Ui direction queries; reading-direction vocabulary in emath (ReadingDirection, Align::Start/End, reading-order Rect helpers); LayoutJob::direction/language with per-paragraph UAX Fix master #9 direction inference (or an explicit override).
  • Layout mirroring — Grid, ScrollArea, Window (title bar, grip), side panels and Sides, MenuBar, menus, popups and tooltips, collapsing headers, sliders/progress bars/toggle, columns, Area pivots and fit_pos, TextEdit, StripLayout. The sizing pass runs in logical order so two-pass widgets measure and place correctly; indent follows the reading edge; a root-cause fix lets resizable panes shrink again.
  • Bidi text — text shaped per bidi run via unicode-bidi; rows reordered visually (UAX Fix master #9 rules L1/L2); RTL paragraphs start on the right; the elision character lands at the visual end; a line-breaker seam with dictionary-based word breaking for Thai, Lao, Khmer and Myanmar (dictionaries included).
  • Editing — caret geometry mapped by the local bidi level, so Home/End and hit-testing behave in mixed-direction text; selection and IME underlines painted per run; byte-counted IME delete and surrounding-text reporting so IMEs stay correct in RTL runs; typed IME events.
  • Accessibility — AccessKit TextRuns carry bidi levels and direction; the inverse-L2 replay is shared with the selection bridge.
  • Platform — eframe text-direction plumbing (native and web text agent), egui-winit IME forwarding, keyboard-layout-aware shortcuts, and app language/locale persistence (Context::set_language).
  • Tests — new *_rtl.rs integration tests in egui, kittest RTL suites with snapshot PNGs, egui-wgpu renderer tests, and tests pinning plain LTR rendering byte-identical.

The raw diff is dominated by four word-break dictionary data files, Cargo.lock and git-LFS snapshot PNGs — the rest is code and tests. ./scripts/check.sh is green (fmt, clippy, workspace tests, wasm32 check, MSRV 1.95).

Closes #1016. Related: this branch reworks the scope of the earlier narrower drafts #8818, #8822, #8825 and #8828 into one consistent implementation.

  • I have followed the instructions in the PR template

craft-agent added 30 commits November 3, 2026 04:29
…andidate G4)

ImeEvent gains DeleteSurroundingBytes { before_bytes, after_bytes }, the
unit IMEs and windowing systems actually report (winit >= 0.31
Ime::DeleteSurrounding, Wayland zwp_text_input_v3). TextEdit converts the
byte counts around the selection's byte offsets to char counts, saturating
at the text bounds and flooring to UTF-8 char boundaries, and reuses the
char-counted deletion path so the delete joins the accompanying Commit in
one undo step. The existing char-counted DeleteSurrounding variant keeps
its units and behaviour.
A focused, mutable, interactive TextEdit that owns IME events reports the
unmasked buffer text and the byte-indexed cursor/selection every focused
frame through IMEOutput::surrounding_text (new public ImeSurroundingText,
UTF-8 byte offsets, empty range = caret). The report is cached in
TextEditState and rebuilt only when the buffer or cursor/selection changed
or an ImeEvent::RequestSurroundingText arrived, so an integration can
answer OS-initiated queries; password fields report None. egui-winit gets
forwarding TODOs in a separate commit.

Semver-visible change: IMEOutput no longer implements Copy (the new field
holds a String); it stays Clone + Debug + PartialEq + Eq with serde. The
three in-repo sites that relied on Copy are fixed (PlatformOutput::append,
eframe web text_agent and app_runner).
winit 0.30.13 has neither Ime::DeleteSurrounding nor a surrounding-text
report/request API, so the egui-side ImeEvent::DeleteSurroundingBytes and
ImeEvent::RequestSurroundingText / IMEOutput::surrounding_text cannot be
forwarded yet. Comment-only TODOs at the two seams (on_ime and
handle_platform_output_inner) so the winit-0.31 migration finds them.
impl-bidioob and others added 22 commits November 6, 2026 03:38
The logical wrap path (line_break_logically) needs per-glyph cluster
byte offsets for cluster-exact row byte ranges, but tracking was gated
solely on the breaker condition (a custom line breaker, or the bundled
sea_dictionary and possibly-SEA text), so plain wrapped-RTL jobs
recorded nothing.

Split the gate in two: cluster tracking now also engages when
text_may_record_bidi_runs sees a character that can make a segment
record a directional run (the Hebrew/Arabic and presentation-form
blocks, RLM, the isolates, and the explicit embedding/override marks —
RLE/RLO force an RTL run even in an LTR-base paragraph). The breaker
consult (word_ranges) keeps today's exact reach: it is gated on the
breaker condition only, so break candidates are unchanged. A predicate
false negative (rare modern RTL blocks) degrades gracefully via the
existing fallback attribution and place_row, never a panic.
logical_blocks reconstructed each block's byte range by walking a
cursor and summing glyph.chr.len_utf8() — but every glyph is stamped
with its shaping cluster's first char, so a cluster that shapes to
multiple glyphs (font-side decomposition, e.g. a dotted Arabic letter
emitting a zero-advance dot plus its base) counted its char once per
glyph. On the 100-byte backend run-mode label the sum drifted to 132,
pushing every later block past the paragraph span until the final row
clamped to an end-equal range — and place_row's visual_runs call
panicked inside unicode-bidi (index out of bounds: the len is 100 but
the index is 100). The drifted ranges could also misattribute blocks
to directional runs in mixed-direction paragraphs.

With cluster tracking on (every glyph carries its cluster's first byte
— always the case in the logical wrap path now), derive the ranges
cluster-exactly per the HarfBuzz cluster model: a block's bytes.start
is its leading glyph's cluster start; its end is the first strictly
greater distinct cluster start scanning forward over subsequent blocks'
leading glyphs, else the paragraph span's end; ranges are clamped into
the span. The chr.len_utf8() walk stays as the fallback for texts the
tracking scan misses, with a records-runs-implies-tracked debug assert
guarding the gate wiring.

Byte-identity: the tofu-font wrapped-row reorder oracles are unchanged
(the NOTDEF path emits at most one glyph per cluster plus per-char
continuations carrying their own chars, so the cluster-derived ranges
equal the old sums there), and pure-LTR paragraphs record no runs and
never take this path. Pinned by T1a (the 100-byte label synthetic,
plus gapped-cluster-start and RTL-descending variants) and T1b (the
exact wrap no-panic/no-drop case).
place_row handed the row's clamped relative byte range straight to
unicode-bidi's ParagraphBidiInfo::visual_runs. That crate asserts
line.start <= levels.len() (note <=) and then indexes
levels[line.start] unchecked, so an end-equal range for a row carrying
real glyphs panicked with "index out of bounds: the len is N but the
index is N" — the observed crash for any wrapped RTL paragraph laid
out with a decomposing font. Returning early instead would drop the
row's glyphs (the panicking rows carried 4-17 real glyphs in the
probe), so resolve runs only for a non-empty in-span range and let the
existing unclaimed-blocks fallback place the row in logical order:
never a panic, never a dropped row, LTR-neutral (pure-LTR paragraphs
record no runs and never take this path).

This no-op-but-keep-the-row semantics is the cross-framework
convention: typst guards the same call caller-side ("The bidi crate
panics for empty lines"), and FriBidi's fribidi_reorder_line treats
len == 0 as a success no-op. The guard is unreachable for well-formed
cluster-exact rows; it exists for hostile drift and for texts the
tracking scan misses.
T2 exercises the exact crash stack — FontsView::layout_job at
pixels_per_point 2.0 over the two observed user-site labels: the
100-byte backend run-mode label and a 400+ byte two-paragraph label
(mirroring the crash's 400-byte ID Test paragraph) — across wrap
widths 20-150 with a real decomposing Arabic font, asserting no panic
and that every input char keeps a visible glyph.

The font comes from KITTEST_RTL_FONT, the established kittest fixture
convention (~/ expansion, skipped silently when unset or unreadable);
fonts are never committed, and the test is cfg'd out on wasm.
…uns gate

Composition fix: itemE's leading-advance pin
(line_break_logically_threads_the_leading_advance_onto_the_reading_first_row,
landed in 148306f) hand-builds its Paragraph with a recorded bidi run but
without cluster tracking, and calls line_break_logically directly. bidi's
rebase (ccfb8e4) added the records-runs-implies-tracked debug_assert
there: a paragraph that records runs while the tracking predicate
recognizes its text must be tracked, because the Sigma chr.len_utf8()
attribution fallback drifts on decomposed glyphs. In production the
invariant holds (layout's gate turns tracking on before any
line_break_logically call); the test is a new class of direct caller the
assert did not anticipate, so the assert fired on the composed tree.

Fix the test, not the assert: emit the glyphs and their cluster byte
starts in tandem the way track_cluster_bytes records them, walking the
logical text ("أول ثاني") in reverse — the emission order of a pure-RTL
run — so the synthetic is exactly the tracked paragraph production
shapes (cluster starts descend in stream order: 13,11,9,7,6,4,2,0).
This also upgrades the pin to exercise the cluster-exact attribution
path in logical_blocks instead of the Sigma fallback.

Two details worth recording:
- The hand-written visual string ("يانث لوا") had the alef/noon glyphs
  transposed relative to the strict char reversal of the logical text.
  Building the stream from the logical text removes the discrepancy; no
  assertion depended on it (every glyph keeps advance 10.0 at the same
  x, and nothing on this path reads glyph chars for geometry).
- No existing assertion depended on the fallback's byte attribution:
  break candidates are classified from the base glyph's char
  (RowBreakCandidates::add_block), block widths/origins/glyph grouping
  are byte-independent, and the test passes an empty word_ranges so the
  byte-consuming word walk is skipped. The full epaint suite (131
  tests) and every assertion in this pin pass unchanged.

Gates: cargo test -p epaint 131/0; cargo test -p egui --lib 145/0 +
--test text_edit_rtl 15/15; cargo fmt --all --check; cargo clippy
-p egui -p epaint --all-targets -- -D warnings; cargo check -p egui
-p epaint --target wasm32-unknown-unknown.
paint_text_selection treated a row's logical column range as one
contiguous visual glyph-slot span (mirrored whole under a
right-to-left base), and paint_underlines drew one line segment per
row from the same assumption. On a bidirectionally reordered row the
glyphs are stored in visual order while cursor columns are logical,
so a logical range crossing a run boundary maps to a visually
disjoint set of glyph slots: the single span recolored and
highlighted unselected glyphs and missed selected ones, independent
of the base direction.

Decompose the per-row selection into visually-contiguous run groups:
cluster the row's glyphs into cells (new cell at glyph 0 and at every
positive-advance glyph, the same rule epaint's bidi_cluster_cells and
the accessibility bridge's logical_glyphs use), walk the cells in
reading order via epaint's Row::logical_cell_order (the shared UAX storytold#9
L2 replay — the same schedule the caret stepper uses internally),
assign logical columns in cell-size steps, select whole cells whose
column range intersects the row's half-open selected range, and merge
adjacent slot ranges into maximal groups. Each group gets its own
highlight rect (group box edges, with the same row-kind anchor
extensions the single span used), its own recolor range (the same
per-glyph vertex convention), and its own underline segment (group
box edges with the physical end anchors). One RowVertexIndices entry
per rect, so the label selection glitch-hider consumes them
unchanged. Level-uniform rows keep the single-span code path
unchanged, and row column ranges are clamped into the row before use
so hostile cursors degrade to that fast path instead of misbehaving.

Row::logical_cell_order becomes pub in epaint so the new code
consumes the shared replay instead of re-implementing it.

New tests in text_edit_rtl.rs: mixed_row_selection_recolors_per_run
and mixed_row_selection_rects_are_per_run (LTR base, selection
crossing into the embedded Arabic run), rtl_base_mixed_row_
selection_recolors_per_run (RTL base, selection crossing into the
embedded Latin run), ime_preedit_underline_follows_bidi_runs (IME
preedit underline split per group), and the regression pins
mixed_row_run_boundary_edges (run-aligned selection keeps the
single-span geometry), uniform_rows_keep_single_rect (select-all on
uniform rows paints exactly one rect with the single-span anchors)
and out_of_range_selections_take_the_fast_path_without_panicking.

Gates: cargo fmt --all -- --check; cargo clippy -p egui -p epaint
--all-targets -- -D warnings; cargo test -p epaint --lib 131/0;
cargo test -p egui --lib 145/0; --test text_edit_rtl 22/22;
--test label_selection_rtl 6/6; cargo test -p egui_kittest (zero
snapshot changes); cargo check -p egui -p epaint --target
wasm32-unknown-unknown; RUSTDOCFLAGS="-D warnings" cargo doc -p
epaint --lib --no-deps.
Row::x_offset and Row::char_at dispatched by base parity alone, so
inside any run that counteracts the base direction the caret columns
were mirrored: on "ملف PDF" (RTL base) the embedded Latin run's carets
painted right-to-left (decreasing x) while its glyphs paint left-to-
right, the reading-exit caret sat at the visual left edge instead of
the last-read glyph's exit edge, and hit-testing a Latin glyph's box
returned the mirrored column.

Route both through the caret-boundary tables task 467 landed for
stepping (visual_caret_boundaries, gated on the same fast-path
predicate is_bidi_homogeneous_with_base):

- x_offset returns boundary_x(boundary_of_column[column]) on
  non-homogeneous rows, falling back to the untouched historic arms on
  a table miss (degenerate streams keep their documented fallback).
- char_at gains a mixed-direction scan: per-cell parity (the cell's
  own bidi_level, not the row's base) over the cluster cells, each
  cell's first-read column taken from the tables, and candidates
  selected across cells by nearest caret x (ties keep the last; a
  click no cell catches falls back to the reading exit, as before).
- visual_caret_boundaries widens to pub(super) so the sibling
  text_layout tests can pin the painted walk against the tables.

Scope consequence, stated plainly: the tables map EVERY non-homogeneous
row, which un-mirrors the paint on all-even-in-RTL rows (e.g. the
"Hello"/"3.14" values every Latin field in an RTL UI stamps) and
all-odd-in-LTR rows too — there the old base-parity paint disagreed
with the already-landed local-level stepping; no test pinned the old
paint on those rows. Uniform base-parity-matching rows (pure LTR, pure
RTL, empty, elision-only) never build the tables and keep today's
exact instructions, so English/LTR rendering stays byte-identical.

The four stale "approximate in-run mapping" doc fences (Row::glyphs,
the bidi-run emission comment in text_layout.rs, the
mixed_rtl_base_caret_edges doc, and the cursor_range collapse comment)
now describe the exact table mapping.

Test delta (epaint): new mixed_row_caret_geometry_maps_by_local_level
(per-level offsets, boundary-order x-monotonicity, char_at/x_offset
round-trips, the run-boundary fallback, and uniform fast-path guards;
RED before this change — the Latin run's carets walked mirrored);
bidi_cursor_hit_test_follows_logical_order's full-row monotonicity loop
re-aimed to the per-run walk (descending across the Arabic run, the
teleport drop, ascending across the digit run); the hostile-input
corpus now also probes char_at over on-ink/off-ink/center x values and
x_offset over every column on unwrapped and wrapped+elided layouts.
Values derived from the boundary-table probe at the planning base
(fc2173e); relational pins are font-independent.
The bidi-boundaries test encoded the pre-467 visual model: it expected
the End caret at the visual right edge of the row. Under the logical
model Home (column 0) anchors at the reading start's entry edge (the
visual right edge of this RTL row) and End sits at the last-read Latin
glyph's exit edge — LEFT of Home — so the old end_x > home_x assert is
unsatisfiable, not merely failing. Re-aim it to end_x < home_x, which
holds at the base too (233.0 < 283.8) and pins the model ordering.

The selection across the direction boundary is re-aimed the same way:
visual-right from the reading start is a no-op under the 467 stepping,
so 5x shift+ArrowRight could never reach (0,5); five shift+ArrowLeft
presses now walk 0 -> 1 -> 2 -> 3 -> 4 -> 8 across the bidi reading
teleport and span the whole mixed row (asserted (0, 8)).

With the epaint caret-geometry fix the hit test now resolves the
far-left click to the Latin run's first-read column (the fixture font
shapes 8 glyph slots: ف carries a zero-advance continuation, so P is
column 5), flipping this test from its recorded known-red-with-fixture
state (home 283.8, end 233.0) to 3/3 with the fixture — the known-red
fence is closed. Without the fixture the printed-skip path is
untouched and stays 3/3.

The module doc's "reading end = the visual left edge" sentence is
reworded to the per-cell exit-edge model this test now pins.
Reword the new caret-geometry comments to plain technical prose (no
change-history or review vocabulary), drop a plan-era test-number prefix
from a doc block the change rewrote anyway, destructure the cluster cell
instead of rebuilding its range (clippy
unnecessary_struct_initialization), and run cargo fmt over the new
tests.
Region gains per-axis seed_x/seed_y state set by region_from_max_rect: the zero-size min_rect anchor at the next widget position (for a vertical Align::Center body, the center of the offered rect) is a placeholder for the empty body, not content. The first measured rect on an axis replaces the seed's contribution instead of unioning with it (expand_to_include_rect), and the first scalar expand on an axis spans from the seed to the new value - the same shape extending the seed-point min_rect produced, so every existing single-expand caller behaves bit-identically - after which unions behave as before. force_set_min_rect spends both seeds (the forced rect is content), and the main-wrap region rebuild carries the two fields through. Empty bodies keep reporting the zero-size seed point.

A centered body whose content hugs one edge (a nested row, a grid, an add_space advance, a nested cross-Center allocate_ui_with_layout) used to measure from its content to the offered center - exactly half the offered width, in both directions - and autosized windows sized from such a body jumped between the tight sizing-pass measurement and the seed-widened steady one. New crates/egui/tests/centered_seed.rs pins the row, grid, add_space, nested-body and window cases in both directions and in two frames, with the mirrored spans, plus the unchanged cases (seed inside content, the empty-body point, width-setter claims). crates/egui/tests/layout_min_rect.rs re-pins vertical_max_and_min_mirror_with_centered_child to the content bounding box: the Min/Max parent's own seed edge used to union with the centered child's content span (175 for a 50-wide widget); the measurement is now the child's content box, identically for both cross alignments, and an empty indent child no longer widens its parent's span. crates/egui/tests/reading_edge_width.rs pins that a bare set_min_width claim in a centered body keeps its layout-anchored span. The updated tests fail on the parent commit (red-at-base/centered-seed-impl-redbase-overlay.log: the 312-wide centered bodies, the 163-wide steady window bodies, exit 101, fresh-target git-archive overlay); the field addition alone does not compile until the main-wrap destructure/reconstruct carries the fields (red-at-base/centered-seed-impl-compile-fail.log, E0027/E0063).
Rewrite the mirrored-row window test's doc comment as behavioral prose
and rename its window id to reflect what the test exercises.
The baseline predated two reviewed rtl landings that change what this
test renders:

- popup: resolve menu fallback placements by reading direction
  (8cf2093): when the styled sub-submenu popup does not fit its
  preferred anchor, the fallback align is now picked from the
  reading-direction menu table, which moves the popup ~104 px to the
  reading-end side (bisect-verified first change of this render between
  the baseline commit and the current tip; ~6.7k px).
- menu: mirror the submenu arrow triangle in rtl (7d0af73): the two
  submenu arrow triangles now point at the reading end (88 px).

The sides mirror contributes no pixels here: the render measured just
before it is identical to the current one (6787 failing px at the linux
threshold in both). The earlier rtl menu snapshot regeneration only
covered the menu_rtl/* snapshots, so this file stayed stale.

Regenerated with UPDATE_SNAPSHOTS=1 on the wgpu renderer; the suite is
green except the two pre-existing environment-dependent failures
(test_scroll_initial, should_wait_for_images), which render identically
at their own baseline commits and are left untouched.
A decomposing font (Noto Sans Arabic and kin) shapes some letters as a
zero-advance component glyph plus its base — one shaping cluster, two
glyphs stamped the same character. The one-glyph-per-char invariant that
cursor and selection code relies on was only maintained in the
fewer-glyphs-than-chars direction, so every caret boundary, hit test and
text-edit insertion past a decomposed letter drifted by one column per
extra glyph, and the caret could sit at a phantom slot inside the
neighboring letter's glyph pair.

Collapse each decomposed cluster back into one cursor-stream glyph in
galley_from_rows, after tessellation: the mesh keeps every glyph's quad
so rendering is unchanged for every input, the merged glyph spans the
group's ink and keeps the group's first mesh vertex so selection recolor
spans still cover the cluster, and multi-character continuations and
invisible zero-width marks survive. Rows without a collapsible component
keep their original glyph vector. The mixed-row shift+ArrowLeft walk
that crossed the direction boundary now spans the row in four presses.
Typing after a mid-row click in an RTL field must insert at the clicked
logical position, leave the cursor after the typed character, and paint
the caret hugging the typed glyph's box — swept over every inner caret
boundary of an Arabic word whose letters decompose into base+component
glyphs, with a Latin twin that runs without the font fixture. The demo
window's field shape (multiline, prefix and custom suffix atoms, explicit
align) keeps the caret at the typed glyph's exit edge when typing at the
reading end, in its RIGHT-aligned, LEFT-aligned and single-row variants,
and backspacing after a mid-row click deletes exactly the character
before the clicked caret.
@ahmedhosnypro ahmedhosnypro changed the title Full RTL Supprt Full RTL support: native right-to-left layout and bidirectional text Nov 6, 2026
A shaped combining mark is a zero-advance glyph that draws ink and
repeats its base glyph's character (the shaper stamps every glyph of a
cluster with the cluster's first character), so an ink+chr test also
consumed real characters' glyphs on fonts with mark positioning:
vocalized Arabic, Hebrew niqqud and Devanagari matras lost their cursor
columns, the row held fewer glyphs than characters, and a text-edit
insertion at the reading end landed mid-word.

The shaper now stamps each cluster's surplus glyphs — the zero-advance
glyphs beyond the cluster's character count (Glyph::is_cluster_surplus)
— and the fold keys on that stamp instead of ink and chr. Marks and
continuations are never stamped (their clusters shape to at most one
glyph per character), and a pass-through glyph no longer flushes a
buffered component, so a decomposed letter's dots still chain across an
intervening mark. Rows without a stamped glyph keep their original
glyph vector.

This branch has not been deployed

No deployments
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.

Bidirectional text support

2 participants