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:
- Storage uses logical indices. The rope always contains characters in the order you typed them.
- 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
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 correctTop-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.