Skip to main content

cranpose_ui/
layout_direction.rs

1//! Which way the interface reads.
2//!
3//! Layout direction is what turns "start" and "end" into "left" and "right".
4//! It is a composition local rather than a global so a screen can pin a
5//! direction — a code block, a phone number, a language picker — without
6//! reversing the rest of the interface with it.
7
8use cranpose_core::{compositionLocalOf, CompositionLocal, CompositionLocalProvider};
9
10/// Which side of the interface is the start.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
12pub enum LayoutDirection {
13    /// Start is the left edge — Latin, Cyrillic, CJK.
14    #[default]
15    Ltr,
16    /// Start is the right edge — Arabic, Hebrew.
17    Rtl,
18}
19
20impl LayoutDirection {
21    /// Whether the start edge is the right one.
22    pub fn is_rtl(self) -> bool {
23        matches!(self, LayoutDirection::Rtl)
24    }
25
26    /// The direction that reads the other way.
27    pub fn reversed(self) -> Self {
28        match self {
29            LayoutDirection::Ltr => LayoutDirection::Rtl,
30            LayoutDirection::Rtl => LayoutDirection::Ltr,
31        }
32    }
33
34    /// Turns start/end values into physical left/right values.
35    pub fn resolve(self, start: f32, end: f32) -> (f32, f32) {
36        match self {
37            LayoutDirection::Ltr => (start, end),
38            LayoutDirection::Rtl => (end, start),
39        }
40    }
41}
42
43/// The [`CompositionLocal`] carrying the current layout direction.
44pub fn local_layout_direction() -> CompositionLocal<LayoutDirection> {
45    thread_local! {
46        static LOCAL: std::cell::RefCell<Option<CompositionLocal<LayoutDirection>>> =
47            const { std::cell::RefCell::new(None) };
48    }
49    LOCAL.with(|cell| {
50        cell.borrow_mut()
51            .get_or_insert_with(|| compositionLocalOf(LayoutDirection::default))
52            .clone()
53    })
54}
55
56/// The layout direction in force here.
57pub fn layout_direction() -> LayoutDirection {
58    local_layout_direction().current()
59}
60
61/// Runs `content` in `direction`.
62#[allow(non_snake_case)]
63pub fn ProvideLayoutDirection(direction: LayoutDirection, content: impl FnOnce()) {
64    CompositionLocalProvider(vec![local_layout_direction().provides(direction)], content);
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    #[test]
72    fn resolving_start_and_end_follows_the_direction() {
73        assert_eq!(LayoutDirection::Ltr.resolve(4.0, 12.0), (4.0, 12.0));
74        assert_eq!(LayoutDirection::Rtl.resolve(4.0, 12.0), (12.0, 4.0));
75    }
76
77    #[test]
78    fn a_direction_knows_its_opposite() {
79        assert_eq!(LayoutDirection::Ltr.reversed(), LayoutDirection::Rtl);
80        assert!(!LayoutDirection::Ltr.is_rtl());
81        assert!(LayoutDirection::Rtl.is_rtl());
82    }
83
84    #[test]
85    fn a_provided_direction_reaches_the_content_and_ends_with_it() {
86        use cranpose_core::{location_key, Composition, MemoryApplier};
87        use std::cell::Cell;
88        use std::rc::Rc;
89
90        let mut composition = Composition::new(MemoryApplier::new());
91        let outer = Rc::new(Cell::new(LayoutDirection::Rtl));
92        let inside = Rc::new(Cell::new(LayoutDirection::Ltr));
93        let nested = Rc::new(Cell::new(LayoutDirection::Rtl));
94        let after = Rc::new(Cell::new(LayoutDirection::Rtl));
95
96        let key = location_key(file!(), line!(), column!());
97        {
98            let (outer, inside, nested, after) = (
99                Rc::clone(&outer),
100                Rc::clone(&inside),
101                Rc::clone(&nested),
102                Rc::clone(&after),
103            );
104            let mut render = move || {
105                // Nothing provided: the interface reads the default way.
106                outer.set(layout_direction());
107                ProvideLayoutDirection(LayoutDirection::Rtl, || {
108                    inside.set(layout_direction());
109                    // A screen can pin a direction back the other way without
110                    // reversing what surrounds it.
111                    ProvideLayoutDirection(LayoutDirection::Ltr, || nested.set(layout_direction()));
112                });
113                // The provision is scoped to the content, not to what follows.
114                after.set(layout_direction());
115            };
116            composition.render(key, &mut render).expect("render");
117        }
118
119        assert_eq!(outer.get(), LayoutDirection::Ltr);
120        assert_eq!(inside.get(), LayoutDirection::Rtl);
121        assert_eq!(nested.get(), LayoutDirection::Ltr);
122        assert_eq!(after.get(), LayoutDirection::Ltr);
123    }
124
125    #[test]
126    fn the_composition_local_is_one_instance_per_thread() {
127        use cranpose_core::{location_key, Composition, MemoryApplier};
128        use std::cell::Cell;
129        use std::rc::Rc;
130
131        // Two calls that returned different locals would each carry their own
132        // value, and a provision made through one would be invisible to the
133        // other -- which is what a lazily-created local gets wrong.
134        let mut composition = Composition::new(MemoryApplier::new());
135        let seen = Rc::new(Cell::new(LayoutDirection::Ltr));
136        let recorder = Rc::clone(&seen);
137        let key = location_key(file!(), line!(), column!());
138        let mut render = move || {
139            CompositionLocalProvider(
140                vec![local_layout_direction().provides(LayoutDirection::Rtl)],
141                || recorder.set(local_layout_direction().current()),
142            );
143        };
144        composition.render(key, &mut render).expect("render");
145
146        assert_eq!(seen.get(), LayoutDirection::Rtl);
147    }
148}