Skip to main content

cvkg_layout/
focus.rs

1use cvkg_core::layout::Rect;
2
3/// The current input modality that the layout engine adapts to.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum LayoutModality {
6    /// Precise pointer (mouse, trackpad, stylus).  Use intrinsic sizes.
7    #[default]
8    Pointer,
9    /// Touch input.  Enforce a minimum tap-target size of 44×44 logical pts.
10    Touch,
11    /// Accessibility zoom is active.  Touch rules apply and spacing is doubled.
12    AccessibilityZoom,
13}
14
15impl LayoutModality {
16    /// Minimum tap-target dimension for this modality (logical pixels).
17    pub fn min_tap_target(self) -> f32 {
18        match self {
19            LayoutModality::Pointer => 0.0,
20            LayoutModality::Touch => 44.0,
21            LayoutModality::AccessibilityZoom => 44.0,
22        }
23    }
24
25    /// Spacing multiplier applied on top of the view's configured spacing.
26    pub fn spacing_multiplier(self) -> f32 {
27        match self {
28            LayoutModality::Pointer => 1.0,
29            LayoutModality::Touch => 1.25,
30            LayoutModality::AccessibilityZoom => 2.0,
31        }
32    }
33
34    /// Apply this modality's minimum tap-target constraint to a measured size.
35    pub fn adapt_size(self, size: cvkg_core::Size) -> cvkg_core::Size {
36        let min = self.min_tap_target();
37        cvkg_core::Size {
38            width: size.width.max(min),
39            height: size.height.max(min),
40        }
41    }
42}
43
44/// A focusable element produced by `compute_focus_order`.
45#[derive(Debug, Clone, PartialEq)]
46pub struct FocusCandidate {
47    /// Stable identity — matches `LayoutView::view_hash()`.
48    pub hash: u64,
49    /// Post-layout bounding rect, in the root coordinate space.
50    pub rect: Rect,
51    /// Explicit tab index, if the view has one.  `None` means natural order.
52    pub tab_index: Option<i32>,
53}
54
55/// Compute a deterministic keyboard-focus traversal order for a flat list of candidates.
56pub fn compute_focus_order(mut candidates: Vec<FocusCandidate>) -> Vec<u64> {
57    let mut explicit: Vec<FocusCandidate> = candidates
58        .iter()
59        .filter(|c| c.tab_index.map_or(false, |t| t > 0))
60        .cloned()
61        .collect();
62    candidates.retain(|c| !c.tab_index.map_or(false, |t| t > 0));
63
64    explicit.sort_by(|a, b| {
65        let ta = a.tab_index.unwrap_or(i32::MAX);
66        let tb = b.tab_index.unwrap_or(i32::MAX);
67        ta.cmp(&tb)
68            .then_with(|| a.rect.y.total_cmp(&b.rect.y))
69            .then_with(|| a.rect.x.total_cmp(&b.rect.x))
70    });
71
72    let row_bucket = |r: &Rect| (r.y / 8.0).floor() as i32;
73    candidates.sort_by(|a, b| {
74        row_bucket(&a.rect)
75            .cmp(&row_bucket(&b.rect))
76            .then_with(|| a.rect.x.total_cmp(&b.rect.x))
77    });
78
79    explicit
80        .into_iter()
81        .chain(candidates)
82        .map(|c| c.hash)
83        .collect()
84}
85
86/// Validate that the focus order computed by `compute_focus_order` is consistent with visual reading order.
87pub fn validate_reading_order(order: &[FocusCandidate]) -> Result<(), String> {
88    let natural: Vec<&FocusCandidate> = order
89        .iter()
90        .filter(|c| !c.tab_index.map_or(false, |t| t > 0))
91        .collect();
92
93    let row_bucket = |r: &Rect| (r.y / 8.0).floor() as i32;
94    for window in natural.windows(2) {
95        let a = window[0];
96        let b = window[1];
97        if row_bucket(&b.rect) < row_bucket(&a.rect) {
98            return Err(format!(
99                "reading order violation: view 0x{:X} (y≈{:.1}) precedes view 0x{:X} (y≈{:.1}) visually",
100                b.hash, b.rect.y, a.hash, a.rect.y
101            ));
102        }
103        if row_bucket(&a.rect) == row_bucket(&b.rect) && b.rect.x < a.rect.x - 1.0 {
104            return Err(format!(
105                "reading order violation: view 0x{:X} (x≈{:.1}) precedes view 0x{:X} (x≈{:.1}) on same row",
106                b.hash, b.rect.x, a.hash, a.rect.x
107            ));
108        }
109    }
110    Ok(())
111}