Skip to main content

ftui_layout/
direction.rs

1#![forbid(unsafe_code)]
2
3//! RTL layout mirroring and logical direction support (bd-ic6i.3).
4//!
5//! Provides types for text-direction–aware layout: [`FlowDirection`] controls
6//! whether horizontal content flows left-to-right or right-to-left,
7//! [`LogicalSides`] maps logical start/end to physical left/right, and
8//! [`LogicalAlignment`] resolves Start/End alignment relative to flow.
9//!
10//! # Invariants
11//!
12//! 1. **Idempotent mirroring**: resolving the same logical values with the same
13//!    direction always produces the same physical values.
14//! 2. **RTL↔LTR symmetry**: `resolve(Rtl)` is the mirror of `resolve(Ltr)`.
15//! 3. **Vertical invariance**: RTL only affects the horizontal axis.
16//! 4. **Composable**: logical values can be nested; each subtree resolves
17//!    independently.
18
19use crate::{Alignment, Sides};
20
21/// Horizontal text flow direction.
22///
23/// Controls whether children of a horizontal flex layout are placed
24/// left-to-right or right-to-left.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
26pub enum FlowDirection {
27    /// Left-to-right (default for Latin, Cyrillic, etc.).
28    #[default]
29    Ltr,
30    /// Right-to-left (Arabic, Hebrew, etc.).
31    Rtl,
32}
33
34impl FlowDirection {
35    /// Whether this direction is right-to-left.
36    pub const fn is_rtl(self) -> bool {
37        matches!(self, FlowDirection::Rtl)
38    }
39
40    /// Whether this direction is left-to-right.
41    pub const fn is_ltr(self) -> bool {
42        matches!(self, FlowDirection::Ltr)
43    }
44
45    /// Return `true` if a locale tag (e.g. `"ar"`, `"he"`, `"fa"`) is
46    /// typically RTL. Checks the primary language subtag only.
47    pub fn locale_is_rtl(locale: &str) -> bool {
48        let lang = locale
49            .split(['-', '_'])
50            .next()
51            .unwrap_or("")
52            .to_ascii_lowercase();
53        matches!(
54            lang.as_str(),
55            "ar" | "he"
56                | "fa"
57                | "ur"
58                | "ps"
59                | "sd"
60                | "yi"
61                | "ku"
62                | "dv"
63                | "ks"
64                | "ckb"
65                | "syr"
66                | "arc"
67                | "nqo"
68                | "man"
69                | "sam"
70        )
71    }
72
73    /// Detect flow direction from a locale tag.
74    pub fn from_locale(locale: &str) -> Self {
75        if Self::locale_is_rtl(locale) {
76            FlowDirection::Rtl
77        } else {
78            FlowDirection::Ltr
79        }
80    }
81}
82
83// ---------------------------------------------------------------------------
84// LogicalAlignment
85// ---------------------------------------------------------------------------
86
87/// Alignment in logical (direction-aware) terms.
88///
89/// `Start` and `End` resolve to physical left/right (or top/bottom) based
90/// on the active [`FlowDirection`].
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92pub enum LogicalAlignment {
93    /// Start edge: left in LTR, right in RTL.
94    #[default]
95    Start,
96    /// End edge: right in LTR, left in RTL.
97    End,
98    /// Center (direction-independent).
99    Center,
100}
101
102impl LogicalAlignment {
103    /// Resolve to a physical [`Alignment`] given the flow direction.
104    ///
105    /// For horizontal layouts:
106    /// - `Start` → `Alignment::Start` (LTR) or `Alignment::End` (RTL)
107    /// - `End`   → `Alignment::End` (LTR) or `Alignment::Start` (RTL)
108    /// - `Center` → `Alignment::Center` (always)
109    pub const fn resolve(self, flow: FlowDirection) -> Alignment {
110        match (self, flow) {
111            (LogicalAlignment::Start, FlowDirection::Ltr) => Alignment::Start,
112            (LogicalAlignment::Start, FlowDirection::Rtl) => Alignment::End,
113            (LogicalAlignment::End, FlowDirection::Ltr) => Alignment::End,
114            (LogicalAlignment::End, FlowDirection::Rtl) => Alignment::Start,
115            (LogicalAlignment::Center, _) => Alignment::Center,
116        }
117    }
118}
119
120// ---------------------------------------------------------------------------
121// LogicalSides
122// ---------------------------------------------------------------------------
123
124/// Padding or margin expressed in logical (direction-aware) terms.
125///
126/// `start` and `end` resolve to physical `left` and `right` (swapped in RTL).
127/// `top` and `bottom` are direction-independent.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub struct LogicalSides {
130    pub top: u16,
131    pub bottom: u16,
132    /// Inline start: left in LTR, right in RTL.
133    pub start: u16,
134    /// Inline end: right in LTR, left in RTL.
135    pub end: u16,
136}
137
138impl LogicalSides {
139    /// All sides equal.
140    pub const fn all(val: u16) -> Self {
141        Self {
142            top: val,
143            bottom: val,
144            start: val,
145            end: val,
146        }
147    }
148
149    /// Inline (start/end) sides equal, block (top/bottom) sides equal.
150    pub const fn symmetric(block: u16, inline: u16) -> Self {
151        Self {
152            top: block,
153            bottom: block,
154            start: inline,
155            end: inline,
156        }
157    }
158
159    /// Set only inline (start/end) sides.
160    pub const fn inline(start: u16, end: u16) -> Self {
161        Self {
162            top: 0,
163            bottom: 0,
164            start,
165            end,
166        }
167    }
168
169    /// Set only block (top/bottom) sides.
170    pub const fn block(top: u16, bottom: u16) -> Self {
171        Self {
172            top,
173            bottom,
174            start: 0,
175            end: 0,
176        }
177    }
178
179    /// Resolve to physical [`Sides`] given the flow direction.
180    ///
181    /// - LTR: start → left, end → right
182    /// - RTL: start → right, end → left
183    pub const fn resolve(self, flow: FlowDirection) -> Sides {
184        match flow {
185            FlowDirection::Ltr => Sides {
186                top: self.top,
187                right: self.end,
188                bottom: self.bottom,
189                left: self.start,
190            },
191            FlowDirection::Rtl => Sides {
192                top: self.top,
193                right: self.start,
194                bottom: self.bottom,
195                left: self.end,
196            },
197        }
198    }
199
200    /// The sum of inline (start + end) sides.
201    pub const fn inline_sum(self) -> u16 {
202        self.start + self.end
203    }
204
205    /// The sum of block (top + bottom) sides.
206    pub const fn block_sum(self) -> u16 {
207        self.top + self.bottom
208    }
209}
210
211/// Create [`LogicalSides`] from physical [`Sides`] under a given direction.
212///
213/// Inverse of [`LogicalSides::resolve`].
214impl LogicalSides {
215    pub const fn from_physical(sides: Sides, flow: FlowDirection) -> Self {
216        match flow {
217            FlowDirection::Ltr => Self {
218                top: sides.top,
219                bottom: sides.bottom,
220                start: sides.left,
221                end: sides.right,
222            },
223            FlowDirection::Rtl => Self {
224                top: sides.top,
225                bottom: sides.bottom,
226                start: sides.right,
227                end: sides.left,
228            },
229        }
230    }
231}
232
233// ---------------------------------------------------------------------------
234// Flex extension: mirror_horizontal
235// ---------------------------------------------------------------------------
236
237/// Mirror a sequence of [`Rect`](crate::Rect)s horizontally within a
238/// containing area.
239///
240/// Each rect's x-position is reflected: `new_x = area.right() - (old_x - area.x) - width`.
241/// This preserves the left-to-right size sequence but flips their positions.
242pub fn mirror_rects_horizontal(
243    rects: &mut [ftui_core::geometry::Rect],
244    area: ftui_core::geometry::Rect,
245) {
246    for rect in rects.iter_mut() {
247        // new_x = right - (rect.x - area.x) - rect.width
248        //       = right - rect.x + area.x - rect.width
249        //       = area.x + area.width - rect.x + area.x - rect.width
250        // Simplified: new_x = right - (rect.x - area.x + rect.width) + area.x
251        //           = right - rect.x - rect.width + area.x... nah, simpler:
252        let offset_from_left = rect.x.saturating_sub(area.x);
253        let new_offset = area
254            .width
255            .saturating_sub(offset_from_left)
256            .saturating_sub(rect.width);
257        rect.x = area.x.saturating_add(new_offset);
258    }
259}
260
261// ===========================================================================
262// Tests
263// ===========================================================================
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    // --- FlowDirection ---
270
271    #[test]
272    fn flow_direction_default_is_ltr() {
273        assert_eq!(FlowDirection::default(), FlowDirection::Ltr);
274        assert!(FlowDirection::Ltr.is_ltr());
275        assert!(!FlowDirection::Ltr.is_rtl());
276        assert!(FlowDirection::Rtl.is_rtl());
277        assert!(!FlowDirection::Rtl.is_ltr());
278    }
279
280    #[test]
281    fn flow_direction_from_locale() {
282        assert_eq!(FlowDirection::from_locale("en"), FlowDirection::Ltr);
283        assert_eq!(FlowDirection::from_locale("en-US"), FlowDirection::Ltr);
284        assert_eq!(FlowDirection::from_locale("fr"), FlowDirection::Ltr);
285        assert_eq!(FlowDirection::from_locale("ja"), FlowDirection::Ltr);
286        assert_eq!(FlowDirection::from_locale("ar"), FlowDirection::Rtl);
287        assert_eq!(FlowDirection::from_locale("ar-SA"), FlowDirection::Rtl);
288        assert_eq!(FlowDirection::from_locale("he"), FlowDirection::Rtl);
289        assert_eq!(FlowDirection::from_locale("fa"), FlowDirection::Rtl);
290        assert_eq!(FlowDirection::from_locale("ur"), FlowDirection::Rtl);
291        assert_eq!(FlowDirection::from_locale("yi"), FlowDirection::Rtl);
292    }
293
294    #[test]
295    fn flow_direction_locale_case_insensitive() {
296        assert_eq!(FlowDirection::from_locale("AR"), FlowDirection::Rtl);
297        assert_eq!(FlowDirection::from_locale("He"), FlowDirection::Rtl);
298        assert_eq!(FlowDirection::from_locale("EN"), FlowDirection::Ltr);
299    }
300
301    // --- LogicalAlignment ---
302
303    #[test]
304    fn logical_alignment_ltr_resolution() {
305        assert_eq!(
306            LogicalAlignment::Start.resolve(FlowDirection::Ltr),
307            Alignment::Start
308        );
309        assert_eq!(
310            LogicalAlignment::End.resolve(FlowDirection::Ltr),
311            Alignment::End
312        );
313        assert_eq!(
314            LogicalAlignment::Center.resolve(FlowDirection::Ltr),
315            Alignment::Center
316        );
317    }
318
319    #[test]
320    fn logical_alignment_rtl_resolution() {
321        assert_eq!(
322            LogicalAlignment::Start.resolve(FlowDirection::Rtl),
323            Alignment::End
324        );
325        assert_eq!(
326            LogicalAlignment::End.resolve(FlowDirection::Rtl),
327            Alignment::Start
328        );
329        assert_eq!(
330            LogicalAlignment::Center.resolve(FlowDirection::Rtl),
331            Alignment::Center
332        );
333    }
334
335    // --- LogicalSides ---
336
337    #[test]
338    fn logical_sides_ltr_resolution() {
339        let logical = LogicalSides {
340            top: 1,
341            bottom: 2,
342            start: 3,
343            end: 4,
344        };
345        let physical = logical.resolve(FlowDirection::Ltr);
346        assert_eq!(physical.top, 1);
347        assert_eq!(physical.bottom, 2);
348        assert_eq!(physical.left, 3); // start → left
349        assert_eq!(physical.right, 4); // end → right
350    }
351
352    #[test]
353    fn logical_sides_rtl_resolution() {
354        let logical = LogicalSides {
355            top: 1,
356            bottom: 2,
357            start: 3,
358            end: 4,
359        };
360        let physical = logical.resolve(FlowDirection::Rtl);
361        assert_eq!(physical.top, 1);
362        assert_eq!(physical.bottom, 2);
363        assert_eq!(physical.left, 4); // end → left in RTL
364        assert_eq!(physical.right, 3); // start → right in RTL
365    }
366
367    #[test]
368    fn logical_sides_symmetry() {
369        // Symmetric sides should be identical regardless of direction.
370        let logical = LogicalSides::all(5);
371        let ltr = logical.resolve(FlowDirection::Ltr);
372        let rtl = logical.resolve(FlowDirection::Rtl);
373        assert_eq!(ltr, rtl);
374    }
375
376    #[test]
377    fn logical_sides_roundtrip() {
378        // from_physical(resolve(dir), dir) should return original.
379        let original = LogicalSides {
380            top: 1,
381            bottom: 2,
382            start: 3,
383            end: 4,
384        };
385
386        let ltr_physical = original.resolve(FlowDirection::Ltr);
387        let roundtrip = LogicalSides::from_physical(ltr_physical, FlowDirection::Ltr);
388        assert_eq!(original, roundtrip);
389
390        let rtl_physical = original.resolve(FlowDirection::Rtl);
391        let roundtrip = LogicalSides::from_physical(rtl_physical, FlowDirection::Rtl);
392        assert_eq!(original, roundtrip);
393    }
394
395    #[test]
396    fn logical_sides_constructors() {
397        let all = LogicalSides::all(5);
398        assert_eq!(all.top, 5);
399        assert_eq!(all.bottom, 5);
400        assert_eq!(all.start, 5);
401        assert_eq!(all.end, 5);
402
403        let sym = LogicalSides::symmetric(2, 4);
404        assert_eq!(sym.top, 2);
405        assert_eq!(sym.bottom, 2);
406        assert_eq!(sym.start, 4);
407        assert_eq!(sym.end, 4);
408
409        let inline = LogicalSides::inline(3, 7);
410        assert_eq!(inline.top, 0);
411        assert_eq!(inline.bottom, 0);
412        assert_eq!(inline.start, 3);
413        assert_eq!(inline.end, 7);
414
415        let block = LogicalSides::block(1, 9);
416        assert_eq!(block.top, 1);
417        assert_eq!(block.bottom, 9);
418        assert_eq!(block.start, 0);
419        assert_eq!(block.end, 0);
420    }
421
422    #[test]
423    fn logical_sides_sums() {
424        let s = LogicalSides {
425            top: 1,
426            bottom: 2,
427            start: 3,
428            end: 4,
429        };
430        assert_eq!(s.inline_sum(), 7);
431        assert_eq!(s.block_sum(), 3);
432    }
433
434    // --- mirror_rects_horizontal ---
435
436    #[test]
437    fn mirror_rects_simple() {
438        use ftui_core::geometry::Rect;
439
440        let area = Rect::new(0, 0, 100, 20);
441        let mut rects = vec![
442            Rect::new(0, 0, 30, 20),
443            Rect::new(30, 0, 40, 20),
444            Rect::new(70, 0, 30, 20),
445        ];
446
447        mirror_rects_horizontal(&mut rects, area);
448
449        // [0..30], [30..70], [70..100] → [70..100], [30..70], [0..30]
450        assert_eq!(rects[0].x, 70);
451        assert_eq!(rects[0].width, 30);
452        assert_eq!(rects[1].x, 30);
453        assert_eq!(rects[1].width, 40);
454        assert_eq!(rects[2].x, 0);
455        assert_eq!(rects[2].width, 30);
456    }
457
458    #[test]
459    fn mirror_rects_with_offset() {
460        use ftui_core::geometry::Rect;
461
462        let area = Rect::new(10, 5, 80, 20);
463        let mut rects = vec![
464            Rect::new(10, 5, 20, 20), // offset 0 from area start
465            Rect::new(30, 5, 60, 20), // offset 20 from area start
466        ];
467
468        mirror_rects_horizontal(&mut rects, area);
469
470        // rect[0]: offset_from_left=0, new_offset=80-0-20=60, new_x=10+60=70
471        // rect[1]: offset_from_left=20, new_offset=80-20-60=0, new_x=10+0=10
472        assert_eq!(rects[0].x, 70);
473        assert_eq!(rects[0].width, 20);
474        assert_eq!(rects[1].x, 10);
475        assert_eq!(rects[1].width, 60);
476    }
477
478    #[test]
479    fn mirror_rects_empty() {
480        use ftui_core::geometry::Rect;
481
482        let area = Rect::new(0, 0, 100, 20);
483        let mut rects: Vec<Rect> = vec![];
484        mirror_rects_horizontal(&mut rects, area); // should not panic
485        assert!(rects.is_empty());
486    }
487
488    #[test]
489    fn mirror_rects_idempotent_double_mirror() {
490        use ftui_core::geometry::Rect;
491
492        let area = Rect::new(5, 0, 90, 20);
493        let original = vec![
494            Rect::new(5, 0, 30, 20),
495            Rect::new(35, 0, 25, 20),
496            Rect::new(60, 0, 35, 20),
497        ];
498
499        let mut rects = original.clone();
500        mirror_rects_horizontal(&mut rects, area);
501        mirror_rects_horizontal(&mut rects, area);
502
503        // Double mirror should restore original.
504        assert_eq!(rects, original);
505    }
506
507    // --- Flex RTL split ---
508
509    #[test]
510    fn flex_horizontal_rtl_reverses_order() {
511        use crate::{Constraint, Flex};
512        use ftui_core::geometry::Rect;
513
514        let area = Rect::new(0, 0, 100, 10);
515
516        let ltr_rects = Flex::horizontal()
517            .constraints([Constraint::Fixed(30), Constraint::Fixed(70)])
518            .split(area);
519
520        let rtl_rects = Flex::horizontal()
521            .constraints([Constraint::Fixed(30), Constraint::Fixed(70)])
522            .flow_direction(FlowDirection::Rtl)
523            .split(area);
524
525        // LTR: [0..30] [30..100]
526        assert_eq!(ltr_rects[0].x, 0);
527        assert_eq!(ltr_rects[1].x, 30);
528
529        // RTL: [70..100] [0..70] — same sizes, mirrored positions
530        assert_eq!(rtl_rects[0].x, 70);
531        assert_eq!(rtl_rects[0].width, 30);
532        assert_eq!(rtl_rects[1].x, 0);
533        assert_eq!(rtl_rects[1].width, 70);
534    }
535
536    #[test]
537    fn flex_vertical_rtl_no_change() {
538        use crate::{Constraint, Flex};
539        use ftui_core::geometry::Rect;
540
541        let area = Rect::new(0, 0, 80, 40);
542
543        let ltr_rects = Flex::vertical()
544            .constraints([Constraint::Fixed(10), Constraint::Fixed(30)])
545            .split(area);
546
547        let rtl_rects = Flex::vertical()
548            .constraints([Constraint::Fixed(10), Constraint::Fixed(30)])
549            .flow_direction(FlowDirection::Rtl)
550            .split(area);
551
552        // Vertical layout is not affected by flow direction.
553        assert_eq!(ltr_rects, rtl_rects);
554    }
555
556    #[test]
557    fn flex_horizontal_rtl_with_gap() {
558        use crate::{Constraint, Flex};
559        use ftui_core::geometry::Rect;
560
561        let area = Rect::new(0, 0, 100, 10);
562
563        let rtl_rects = Flex::horizontal()
564            .constraints([
565                Constraint::Fixed(20),
566                Constraint::Fixed(30),
567                Constraint::Fixed(40),
568            ])
569            .gap(5)
570            .flow_direction(FlowDirection::Rtl)
571            .split(area);
572
573        // Total used: 20 + 5 + 30 + 5 + 40 = 100
574        // LTR would be: [0..20] [25..55] [60..100]
575        // RTL mirrors: [80..100] [45..75] [0..40]
576        assert_eq!(rtl_rects[0].x, 80);
577        assert_eq!(rtl_rects[0].width, 20);
578        assert_eq!(rtl_rects[1].x, 45);
579        assert_eq!(rtl_rects[1].width, 30);
580        assert_eq!(rtl_rects[2].x, 0);
581        assert_eq!(rtl_rects[2].width, 40);
582    }
583
584    #[test]
585    fn flex_ltr_default_unchanged() {
586        use crate::{Constraint, Flex};
587        use ftui_core::geometry::Rect;
588
589        let area = Rect::new(0, 0, 100, 10);
590
591        // Default (no flow_direction set) should behave as LTR.
592        let default_rects = Flex::horizontal()
593            .constraints([Constraint::Fixed(30), Constraint::Fixed(70)])
594            .split(area);
595
596        let explicit_ltr = Flex::horizontal()
597            .constraints([Constraint::Fixed(30), Constraint::Fixed(70)])
598            .flow_direction(FlowDirection::Ltr)
599            .split(area);
600
601        assert_eq!(default_rects, explicit_ltr);
602    }
603
604    #[test]
605    fn flex_mixed_direction_nested() {
606        use crate::{Constraint, Flex};
607        use ftui_core::geometry::Rect;
608
609        // Simulate nested layout: RTL outer, content inside.
610        let outer = Rect::new(0, 0, 100, 20);
611
612        let rtl_cols = Flex::horizontal()
613            .constraints([Constraint::Fixed(40), Constraint::Fixed(60)])
614            .flow_direction(FlowDirection::Rtl)
615            .split(outer);
616
617        // RTL: first item (40w) goes to right side.
618        assert_eq!(rtl_cols[0].x, 60);
619        assert_eq!(rtl_cols[0].width, 40);
620        assert_eq!(rtl_cols[1].x, 0);
621        assert_eq!(rtl_cols[1].width, 60);
622
623        // Inner LTR layout within the RTL-positioned first panel.
624        let inner_ltr = Flex::vertical()
625            .constraints([Constraint::Fixed(10), Constraint::Fill])
626            .split(rtl_cols[0]);
627
628        // Vertical layout within is unaffected.
629        assert_eq!(inner_ltr[0].x, rtl_cols[0].x);
630        assert_eq!(inner_ltr[0].y, rtl_cols[0].y);
631        assert_eq!(inner_ltr[0].height, 10);
632    }
633
634    #[test]
635    fn logical_alignment_in_flex() {
636        use crate::{Constraint, Flex};
637        use ftui_core::geometry::Rect;
638
639        let area = Rect::new(0, 0, 100, 10);
640
641        // LogicalAlignment::Start in RTL → Alignment::End → items pushed right.
642        let alignment = LogicalAlignment::Start.resolve(FlowDirection::Rtl);
643        let rects = Flex::horizontal()
644            .constraints([Constraint::Fixed(20)])
645            .alignment(alignment)
646            .split(area);
647
648        // With End alignment, single 20-wide item goes to x=80.
649        assert_eq!(rects[0].x, 80);
650        assert_eq!(rects[0].width, 20);
651    }
652}