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:
- 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.
- 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.Direction—LtrorRtlfor a single run.ParagraphDirection— paragraph-level direction: explicitLtr, explicitRtl, orAuto(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. UseBidiSegment. - 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.
Autouses the first strong character, which can be several bytes in. Leading whitespace, punctuation, and digits do not count. - Do not turn off the
bidifeature without checking. If any shipping locale contains Arabic / Hebrew / etc. text, turning offbidirenders those paragraphs in logical order — visually incorrect, silently.
See also
- Accessibility tree — names are stored in logical order; the tree stays direction-agnostic
- Focus graph — arrow-key semantics adjust visually for RTL
- i18n and locales — where the locale comes from
- Text — bidi — the full UAX #9 implementation details
- Style — color · Widgets — focus · Demo showcase overview