Skip to main content

snora_core/
direction.rs

1//! Logical layout direction — the foundation of ABDD (Accessible By
2//! Default and by Design).
3//!
4//! snora expresses layout in terms of **logical edges** ([`Edge::Start`] /
5//! [`Edge::End`]) rather than physical directions (left / right). An
6//! application picks a [`LayoutDirection`] at runtime, and the engine maps
7//! logical edges to physical positions accordingly.
8
9/// Reading direction of the application's layout.
10///
11/// This is a framework-level setting. Individual widgets do not need to be
12/// re-authored for RTL — the engine consumes this value at every point
13/// where "left" or "right" would otherwise be hardcoded (sidebar side, toast
14/// anchor, header end-controls, etc.).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
16pub enum LayoutDirection {
17    /// Left-to-right (e.g. English, Japanese, most languages).
18    #[default]
19    Ltr,
20    /// Right-to-left (e.g. Arabic, Hebrew, Persian).
21    Rtl,
22}
23
24impl LayoutDirection {
25    /// Flip the direction. Useful for a user-facing "Flip LTR / RTL" toggle
26    /// during development or accessibility preference changes.
27    ///
28    /// # Example
29    ///
30    /// ```
31    /// use snora_core::LayoutDirection;
32    ///
33    /// assert_eq!(LayoutDirection::Ltr.flipped(), LayoutDirection::Rtl);
34    /// assert_eq!(LayoutDirection::Rtl.flipped(), LayoutDirection::Ltr);
35    /// // Flipping is its own inverse.
36    /// assert_eq!(LayoutDirection::Ltr.flipped().flipped(), LayoutDirection::Ltr);
37    /// ```
38    #[must_use]
39    pub fn flipped(self) -> Self {
40        match self {
41            LayoutDirection::Ltr => LayoutDirection::Rtl,
42            LayoutDirection::Rtl => LayoutDirection::Ltr,
43        }
44    }
45
46    /// Returns `true` if the logical [`Edge::Start`] maps to the *physical*
47    /// left side of the screen under this direction.
48    ///
49    /// Useful for engines when they need to decide whether a start-anchored
50    /// element should be pushed first or last in a horizontal row.
51    #[must_use]
52    pub fn start_is_left(self) -> bool {
53        matches!(self, LayoutDirection::Ltr)
54    }
55}
56
57/// A logical position along a primary axis.
58///
59/// `Start` is the side a reader's eye begins at; `End` is where it finishes.
60/// In LTR this maps to (Left, Right); in RTL it maps to (Right, Left).
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum Edge {
63    /// The side a reader's eye begins at — physically left under
64    /// [`LayoutDirection::Ltr`], right under [`LayoutDirection::Rtl`].
65    Start,
66    /// The side a reader's eye finishes at — physically right under
67    /// [`LayoutDirection::Ltr`], left under [`LayoutDirection::Rtl`].
68    End,
69}
70
71impl Edge {
72    /// Resolve this logical edge to a physical side for a given direction.
73    ///
74    /// Returns `true` for "left", `false` for "right".
75    #[must_use]
76    pub fn is_left_under(self, direction: LayoutDirection) -> bool {
77        match (direction, self) {
78            (LayoutDirection::Ltr, Edge::Start) => true,
79            (LayoutDirection::Ltr, Edge::End) => false,
80            (LayoutDirection::Rtl, Edge::Start) => false,
81            (LayoutDirection::Rtl, Edge::End) => true,
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn edge_mapping_is_consistent() {
92        assert!(Edge::Start.is_left_under(LayoutDirection::Ltr));
93        assert!(!Edge::End.is_left_under(LayoutDirection::Ltr));
94        assert!(!Edge::Start.is_left_under(LayoutDirection::Rtl));
95        assert!(Edge::End.is_left_under(LayoutDirection::Rtl));
96    }
97
98    #[test]
99    fn flipping_is_idempotent_twice() {
100        let d = LayoutDirection::Ltr;
101        assert_eq!(d, d.flipped().flipped());
102    }
103}