Skip to Content

Bidi and RTL support

Right-to-left (RTL) languages like Arabic and Hebrew do not just change the reading direction — they interact with embedded left-to-right (LTR) content through a real algorithm, UAX #9, the Unicode Bidirectional Algorithm. FrankenTUI treats that algorithm as a library concern, not a widget concern: ftui-text::bidi processes a paragraph once, and the rest of the system reads the resulting visual runs.

The bidi module lives in ftui-text/src/bidi.rs and is feature-gated behind bidi (on by default for builds that ship locales that need it). The implementation wraps the unicode_bidi crate; the FrankenTUI-side concerns are integration, caching, and propagation of a Locale through the runtime.

What bidirectional text actually is

Consider a simple sentence with mixed content:

The price is ٢٥ SAR.

The logical byte order is T-h-e- -p-r-i-c-e- -i-s- -٢-٥- -S-A-R-. (left-to-right in memory). The visual order, how cells appear on the screen, is:

The price is 25 SAR.

with the Arabic digits flowing right-to-left as a nested run inside a left-to-right paragraph. bidi is the module that computes the transformation.

Two things become true at once:

  1. Logical position and visual position are different integers. A cursor at the end of “price” is at the same logical offset no matter what the paragraph direction is, but it paints in a completely different visual column.
  2. Wrapping respects runs, not code points. You cannot wrap inside a run without flipping direction inside it.

The ftui-text::bidi types

use ftui_text::bidi::{BidiSegment, Direction, ParagraphDirection, reorder}; let seg = BidiSegment::new("The price is ٢٥ SAR.", None);
  • BidiSegment — precomputed analysis of one paragraph. Holds the run list, logical-to-visual mapping, and per-run direction. O(1) cursor mapping after the one-time analysis.
  • BidiRun — a contiguous slice of text that shares one direction. Runs are the unit the renderer composes.
  • DirectionLtr or Rtl for a single run.
  • ParagraphDirection — paragraph-level direction: explicit Ltr, explicit Rtl, or Auto (detect from the first strong character).

reorder(runs) converts logical run order into visual run order, which is what the renderer emits into the cell buffer.

The segmentation module (script_segmentation.rs) keeps a feature-independent Direction enum so script-run partitioning does not force the bidi feature on.

How direction reaches the text

Three layers cooperate:

Application sets the locale

let program = Program::builder(my_app) .with_locale("ar") // or "he", "fa", "ur", "ps" .run();

ProgramConfig::with_locale(tag) writes a Locale into the runtime context. Details on the locale API itself live on i18n and locales.

Runtime threads the locale through widgets

The LocaleContext carries the active Locale alongside the fallback chain. A widget that owns editable text reads the context on each update so it can interpret cursor keys in the reader’s natural direction (Home = start-of-visual-line, regardless of the underlying paragraph direction).

Widget hands text to ftui-text

The widget builds a BidiSegment (or reuses one the rope caches) and emits the ordered runs into the current line. The renderer paints runs in visual order.

No widget contains hand-rolled direction logic. All RTL-specific behavior is either “ask the BidiSegment what the visual column is” or “swap directional semantics based on the Locale.”

Paragraph-level direction

Three modes:

  • Explicit Ltr — force LTR regardless of content. Appropriate for code, URLs, and UI chrome that should not flip.
  • Explicit Rtl — force RTL. Appropriate for Arabic / Hebrew UI strings where the first strong character happens to be Latin.
  • Auto — look at the first strong character. The one that matches most “just works” expectations for mixed strings.

ProgramConfig::with_locale chooses a sensible default per locale: Arabic / Hebrew / Persian / Urdu / Pashto → Rtl, everything else → Ltr. Widgets can override per-string when needed (log lines, code blocks, and so on).

RTL and the accessibility tree

An accessible name is a logical string; the a11y tree stores it exactly as the widget supplied it. Visual reordering is the renderer’s job, not the a11y tree’s — screen readers read logical order. This is the right separation: a blind user reading “The price is ٢٥ SAR.” hears “The price is twenty-five S-A-R period”, in reading order, regardless of how the characters paint on screen.

If your widget builds a localized name from pieces, assemble it in logical order. Do not reverse the byte sequence to “match” what the screen shows.

RTL and the focus graph

Spatial focus (arrow keys) has one direction convention that matters here: “next” and “previous” are visual concepts. In an RTL paragraph, Right-arrow moves the cursor to the logically-preceding character, because that is the cell to the right. Tab order, which is document order, is unaffected — it follows the widget tree, not visual direction.

RTL and the demo

The i18n_demo screen’s RTL Layout panel is the live reference. It cycles through:

  • pure RTL paragraphs (Arabic),
  • mixed RTL paragraphs with embedded Latin numerals,
  • mixed paragraphs with full Latin words embedded in Arabic text,
  • edge cases: punctuation at run boundaries, ZWJ sequences, fonts that synthesize glyphs for combining marks.

The Stress Lab panel adds combining marks, stacked diacritics, CJK width quirks, and emoji. All of those compose with bidi in ways you can only fully see by poking at a live frame.

See i18n and locales for how the demo picker cycles through the supported locales.

What changes with RTL enabled

  • Line wrapping respects run boundaries. The wrapper uses the run list from BidiSegment, not raw code-point indexing.
  • Cursor movement uses the visual-to-logical map. “Move cursor right” maps to “visual column + 1”, then translated back to a logical offset.
  • Selection highlighting spans contiguous visual cells, which may correspond to a non-contiguous logical range in mixed-run paragraphs.
  • Horizontal alignment defaults flip: right-align becomes the leading edge, left-align becomes the trailing edge. Widgets that care about “start” versus “end” should express themselves with those terms, not with “left” / “right.”

Pitfalls

  • Do not hand-roll RTL. str::chars().rev() is not bidi; it is nonsense for any string that contains both directions. Use BidiSegment.
  • Do not store strings in visual order. Always store logical order in your model. Visual order is a rendering output, not a storage format.
  • Do not hardcode “left” and “right” in widget logic for directional actions. Use “leading” and “trailing” — it makes the widget work for both LTR and RTL without duplication.
  • Do not assume paragraph direction from the first bytes. Auto uses the first strong character, which can be several bytes in. Leading whitespace, punctuation, and digits do not count.
  • Do not turn off the bidi feature without checking. If any shipping locale contains Arabic / Hebrew / etc. text, turning off bidi renders those paragraphs in logical order — visually incorrect, silently.

See also