Skip to Content

Bidirectional Text (BiDi)

Most terminals draw characters in the order they are written to the cell buffer. Unicode does not work that way for scripts like Hebrew and Arabic — the logical order (the order you type) is opposite from the visual order (the order glyphs appear on screen). Mixing left- to-right and right-to-left text in the same paragraph makes this into a full algorithm, codified in UAX #9 (the Unicode Bidirectional Algorithm).

ftui_text::bidi implements UAX #9 via the unicode_bidi crate and exposes the pieces a text surface needs: runs, direction, and index mappings in both directions.

The two orders

Logical (typed): H e l l o ע ב ר י ת Visual (shown): H e l l o ת י ר ב ע ────── LTR ─────── ─ RTL (reversed) ─

visual[i] and logical[i] are different characters when any RTL content is present. Every cursor operation has to know which order it’s working in.

Paragraph direction — who wins on tie?

pub enum Direction { Ltr, Rtl } pub enum ParagraphDirection { Auto, // derive from first strong character Ltr, Rtl, }

Auto is almost always what you want: the algorithm inspects the text and picks the direction of the first strong (non-neutral) character. If there are no strong characters (all whitespace and numbers), the paragraph defaults to LTR.

BidiSegment — the precomputed structure

pub struct BidiRun { pub start: usize, // logical character index pub end: usize, pub level: Level, // UAX #9 embedding level (even = LTR, odd = RTL) pub direction: Direction, } pub struct BidiSegment { /* runs + index maps */ } impl BidiSegment { pub fn new(text: &str, base: Option<Direction>) -> Self; pub fn visual_pos(&self, logical: usize) -> usize; pub fn logical_pos(&self, visual: usize) -> usize; pub fn is_rtl(&self, logical: usize) -> bool; pub fn visual_cursor_pos(&self, logical: usize) -> usize; pub fn logical_cursor_pos(&self, visual: usize) -> usize; pub fn move_right(&self, logical: usize) -> usize; // visual right pub fn move_left(&self, logical: usize) -> usize; // visual left pub fn base_direction(&self) -> Direction; pub fn visual_string(&self) -> String; pub fn char_at_visual(&self, visual: usize) -> Option<char>; }

A BidiSegment builds the index maps once; subsequent queries are O(1) per lookup and O(n) per run iteration.

The key insight — motion in visual space, storage in logical space

Two simple rules keep BiDi sane at the widget layer:

  1. Storage uses logical indices. The rope always contains characters in the order you typed them.
  2. Motion uses visual indices. move_right() moves the cursor one cell to the right on screen, even if that means a logical index decrease (when you’re inside an RTL run).

BidiSegment::move_right(logical) and ::move_left(logical) handle the translation.

Visual ↔ logical, at a glance

logical: [H][e][l][l][o][ ][ע][ב][ר][י][ת] index: 0 1 2 3 4 5 6 7 8 9 10 visual: [H][e][l][l][o][ ][ת][י][ר][ב][ע] index: 0 1 2 3 4 5 6 7 8 9 10 visual_pos(6) → 10 (ע is drawn at visual position 10) logical_pos(10) → 6 (visual position 10 corresponds to logical 6)

Both maps are bijections; calling one and then the other is the identity.

Worked example

mixed_direction.rs
use ftui_text::bidi::{BidiSegment, Direction}; let text = "Hello עברית!"; let seg = BidiSegment::new(text, None); // auto-detect assert_eq!(seg.base_direction(), Direction::Ltr); // The "ע" is at logical index 6. let logical = 6; let visual = seg.visual_pos(logical); assert!(visual > logical); // pushed right in visual order // Cursor motion: pressing right from logical 6 moves deeper into the // Hebrew run, which means *decreasing* the logical index. let next = seg.move_right(logical); assert!(next < logical); // counterintuitive but correct

Top-level helpers for quick use

pub fn reorder(text: &str, direction: ParagraphDirection) -> String; pub fn resolve_levels(text: &str, direction: ParagraphDirection) -> Vec<Level>; pub fn has_rtl(text: &str) -> bool; pub fn paragraph_level(text: &str) -> ParagraphDirection;

Use reorder when you just need “give me the visually reordered string for dumb rendering,” and has_rtl as a cheap early-out to skip the full machinery on pure-LTR text.

Integration with shaping

BidiSegment produces runs with a direction; each run is then handed to the shaper (see shaping) which positions glyphs in visual order. The shaper does not know about logical indices — it trusts the BiDi layer to have sliced the text into monodirectional runs.

Pitfalls

Don’t reorder before storing. The rope stores logical text. If you reorder on the way in, your user’s next edit will be in the wrong place. Reorder only at render time, and only when needed.

Arrow keys in mixed-direction text are a design choice. Visual motion (move_right decreases logical index inside an RTL run) is what most users expect. Logical motion (always increases logical index) is consistent but confusing. Pick one and stick with it; FrankenTUI widgets use visual motion.

paragraph_level returns ParagraphDirection, not Direction. If the paragraph has no strong characters, you get ParagraphDirection::Auto back, not an Ltr/Rtl choice. Decide the fallback at the widget layer.

Where to go next