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}