Repository navigation
Full RTL support: native right-to-left layout and bidirectional text - #8836
Open
ahmedhosnypro wants to merge 243 commits into
Open
ahmedhosnypro wants to merge 243 commits into
ahmedhosnypro wants to merge 243 commits into
Conversation
added 30 commits
November 3, 2026 04:29
…tion (candidate G18)
…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.
…candidate winit-compat-arms)
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.
This was referenced 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_leftflip the reading direction of every core container, widget, panel and menu;epaintshapes text in bidi runs (UAX #9), and caret, selection and IME handling become level-correct in Arabic, Hebrew and other RTL text.What
Style::right_to_left,Context::set_right_to_leftandUidirection queries; reading-direction vocabulary inemath(ReadingDirection,Align::Start/End, reading-orderRecthelpers);LayoutJob::direction/languagewith per-paragraph UAX Fix master #9 direction inference (or an explicit override).Sides, MenuBar, menus, popups and tooltips, collapsing headers, sliders/progress bars/toggle, columns,Areapivots andfit_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.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).TextRunscarry bidi levels and direction; the inverse-L2 replay is shared with the selection bridge.Context::set_language).*_rtl.rsintegration tests inegui, kittest RTL suites with snapshot PNGs,egui-wgpurenderer tests, and tests pinning plain LTR rendering byte-identical.The raw diff is dominated by four word-break dictionary data files,
Cargo.lockand git-LFS snapshot PNGs — the rest is code and tests../scripts/check.shis 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.