Skip to main content

snora_core/
focus.rs

1//! Frame-level keyboard zone navigation — pure, iced-free decision
2//! vocabulary (RFC-060).
3//!
4//! Snora owns the frame (the four skeleton slots [`crate::AppLayout`]
5//! composes); applications own what is inside a pane. This module
6//! supplies snora's half of keyboard navigation between those slots:
7//! given the current zone, a cycle direction, and which optional slots
8//! are populated, which zone is next.
9//!
10//! # What this is not
11//!
12//! * **Not a key binding.** Snora does not claim Tab or Shift+Tab — Tab
13//!   already means "next control" to iced and to every application with
14//!   a form. [`next_zone`] takes a [`Cycle`] direction, not a key; the
15//!   recommended binding is **F6 / Shift+F6**, via `snora::keyboard`'s
16//!   companion helper.
17//! * **Not event capture.** No subscription is installed by this crate
18//!   or by `snora`. The application wires `iced::keyboard::listen()`
19//!   itself and calls this pure function — the same shape as
20//!   `snora::keyboard::dismiss_on_escape`.
21//! * **Not modal focus trapping.** [`next_zone`] reports when cycling is
22//!   suspended because a modal is open; it does not enumerate or bound
23//!   the modal's contents. Trapping is a separate, staged decision — see
24//!   `docs/src/contributing/design-decisions.md`.
25//! * **Not state.** The current zone lives in the application's own
26//!   state, alongside `toasts` and the overlay flags — this module is
27//!   pure *of* that state, not a holder of it.
28
29/// The four skeleton-level navigation zones, in logical cycle order.
30///
31/// Order is **`Header → SideBar → Body → Footer`**, wrapping — and it is
32/// *logical* order, not visual: under RTL the sidebar renders on the
33/// opposite physical edge, but it is still the start-edge rail
34/// immediately following the header, so this order needs no
35/// direction-dependent mirroring (unlike `ToastPosition`'s anchor
36/// corner). That is a deliberate ABDD decision, not an omission.
37///
38/// Tabs and breadcrumbs are **not** zones — [`crate::Tab`] and
39/// [`crate::Crumb`] are content an application places *inside* a zone.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41pub enum FocusZone {
42    /// The header slot (`AppLayout::header`).
43    Header,
44    /// The side navigation rail (`AppLayout::side_bar`).
45    SideBar,
46    /// The main content area (`AppLayout::body`). Always present — it is
47    /// the one slot `AppLayout` requires.
48    Body,
49    /// The footer / status bar slot (`AppLayout::footer`).
50    Footer,
51}
52
53/// Direction to cycle [`FocusZone`]s in.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum Cycle {
56    /// Next zone in logical order.
57    Forward,
58    /// Previous zone in logical order.
59    Backward,
60}
61
62/// Which optional skeleton slots the current layout has populated.
63///
64/// `body` is required by [`crate::AppLayout`] and is always present, so
65/// it has no field here — it cannot be expressed as absent.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
67pub struct ZonePresence {
68    /// Whether `AppLayout::header` is `Some`.
69    pub header: bool,
70    /// Whether `AppLayout::side_bar` is `Some`.
71    pub side_bar: bool,
72    /// Whether `AppLayout::footer` is `Some`.
73    pub footer: bool,
74}
75
76impl ZonePresence {
77    /// All optional slots absent — the body-only degenerate case every
78    /// `AppLayout::new` application starts from.
79    #[must_use]
80    pub fn none() -> Self {
81        Self::default()
82    }
83
84    /// All optional slots present.
85    #[must_use]
86    pub fn all() -> Self {
87        Self {
88            header: true,
89            side_bar: true,
90            footer: true,
91        }
92    }
93
94    /// Set whether the header slot is present.
95    #[must_use]
96    pub fn header(mut self, present: bool) -> Self {
97        self.header = present;
98        self
99    }
100
101    /// Set whether the side-bar slot is present.
102    #[must_use]
103    pub fn side_bar(mut self, present: bool) -> Self {
104        self.side_bar = present;
105        self
106    }
107
108    /// Set whether the footer slot is present.
109    #[must_use]
110    pub fn footer(mut self, present: bool) -> Self {
111        self.footer = present;
112        self
113    }
114
115    fn is_present(self, zone: FocusZone) -> bool {
116        match zone {
117            FocusZone::Header => self.header,
118            FocusZone::SideBar => self.side_bar,
119            FocusZone::Body => true,
120            FocusZone::Footer => self.footer,
121        }
122    }
123}
124
125const ORDER: [FocusZone; 4] = [
126    FocusZone::Header,
127    FocusZone::SideBar,
128    FocusZone::Body,
129    FocusZone::Footer,
130];
131
132/// The next zone in logical cycle order, or `None` if cycling is
133/// suspended.
134///
135/// Absent optional zones are skipped. In the body-only degenerate case
136/// (`ZonePresence::none()`), the only present zone is `Body`, so the
137/// result is always `Some(FocusZone::Body)` — cycling has nowhere else
138/// to go, which is the correct behaviour rather than a special case.
139///
140/// **Overlay containment**, mirroring
141/// `snora::keyboard::dismiss_on_escape`'s modal-before-menu precedence
142/// exactly — same two flags, same priority:
143/// * `has_modal == true` — cycling is **suspended** (`None`), regardless
144///   of `has_menu`. Focus belongs inside the modal, whose contents are
145///   an application-supplied `Node` this function cannot enumerate; it
146///   reports the suspension rather than silently returning `Body`.
147/// * `has_modal == false && has_menu == true` — cycling proceeds
148///   **unaffected**. Menus are light-weight, dismissible on outside
149///   click, and do not own focus, so `has_menu` never changes the
150///   result — it is accepted only to mirror `dismiss_on_escape`'s shape
151///   and to make that non-effect directly testable.
152///
153/// # Example
154///
155/// ```
156/// use snora_core::focus::{Cycle, FocusZone, ZonePresence, next_zone};
157///
158/// // Body-only layout: cycling always lands back on Body.
159/// let result = next_zone(FocusZone::Body, Cycle::Forward, ZonePresence::none(), false, false);
160/// assert_eq!(result, Some(FocusZone::Body));
161///
162/// // A modal open suspends cycling, even with a full layout.
163/// let result = next_zone(FocusZone::Header, Cycle::Forward, ZonePresence::all(), true, false);
164/// assert_eq!(result, None);
165///
166/// // A menu alone does not affect cycling.
167/// let result = next_zone(FocusZone::Header, Cycle::Forward, ZonePresence::all(), false, true);
168/// assert_eq!(result, Some(FocusZone::SideBar));
169/// ```
170#[must_use]
171pub fn next_zone(
172    current: FocusZone,
173    cycle: Cycle,
174    present: ZonePresence,
175    has_modal: bool,
176    has_menu: bool,
177) -> Option<FocusZone> {
178    // Deliberately does not affect the result — see the doc comment's
179    // containment section. Accepted only to mirror `dismiss_on_escape`'s
180    // shape and to make "menu alone is unaffected" directly testable.
181    let _ = has_menu;
182
183    if has_modal {
184        return None;
185    }
186
187    // Exhaustive rather than a search over ORDER: if a fifth FocusZone is
188    // ever added and not added to ORDER, this fails to compile instead of
189    // panicking at runtime inside a pure function.
190    let start = match current {
191        FocusZone::Header => 0,
192        FocusZone::SideBar => 1,
193        FocusZone::Body => 2,
194        FocusZone::Footer => 3,
195    };
196    let len = ORDER.len();
197
198    (1..=len)
199        .map(|step| match cycle {
200            Cycle::Forward => ORDER[(start + step) % len],
201            Cycle::Backward => ORDER[(start + len - step) % len],
202        })
203        .find(|&zone| present.is_present(zone))
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    /// `next_zone`'s exhaustive index match must agree with `ORDER`. The
211    /// match is exhaustive so a new variant fails to compile, but nothing
212    /// otherwise stops the two from drifting apart.
213    #[test]
214    fn index_match_agrees_with_order() {
215        for (i, &zone) in ORDER.iter().enumerate() {
216            let via_match = match zone {
217                FocusZone::Header => 0,
218                FocusZone::SideBar => 1,
219                FocusZone::Body => 2,
220                FocusZone::Footer => 3,
221            };
222            assert_eq!(via_match, i, "{zone:?} index disagrees with ORDER");
223        }
224    }
225
226    #[test]
227    fn forward_cycles_through_all_four_zones_in_logical_order() {
228        let present = ZonePresence::all();
229        assert_eq!(
230            next_zone(FocusZone::Header, Cycle::Forward, present, false, false),
231            Some(FocusZone::SideBar)
232        );
233        assert_eq!(
234            next_zone(FocusZone::SideBar, Cycle::Forward, present, false, false),
235            Some(FocusZone::Body)
236        );
237        assert_eq!(
238            next_zone(FocusZone::Body, Cycle::Forward, present, false, false),
239            Some(FocusZone::Footer)
240        );
241        assert_eq!(
242            next_zone(FocusZone::Footer, Cycle::Forward, present, false, false),
243            Some(FocusZone::Header)
244        );
245    }
246
247    #[test]
248    fn backward_cycles_through_all_four_zones_in_reverse_logical_order() {
249        let present = ZonePresence::all();
250        assert_eq!(
251            next_zone(FocusZone::Header, Cycle::Backward, present, false, false),
252            Some(FocusZone::Footer)
253        );
254        assert_eq!(
255            next_zone(FocusZone::Footer, Cycle::Backward, present, false, false),
256            Some(FocusZone::Body)
257        );
258        assert_eq!(
259            next_zone(FocusZone::Body, Cycle::Backward, present, false, false),
260            Some(FocusZone::SideBar)
261        );
262        assert_eq!(
263            next_zone(FocusZone::SideBar, Cycle::Backward, present, false, false),
264            Some(FocusZone::Header)
265        );
266    }
267
268    #[test]
269    fn forward_wraps_around_from_footer_to_header() {
270        assert_eq!(
271            next_zone(
272                FocusZone::Footer,
273                Cycle::Forward,
274                ZonePresence::all(),
275                false,
276                false
277            ),
278            Some(FocusZone::Header)
279        );
280    }
281
282    #[test]
283    fn backward_wraps_around_from_header_to_footer() {
284        assert_eq!(
285            next_zone(
286                FocusZone::Header,
287                Cycle::Backward,
288                ZonePresence::all(),
289                false,
290                false
291            ),
292            Some(FocusZone::Footer)
293        );
294    }
295
296    #[test]
297    fn body_only_layout_always_lands_on_body() {
298        let present = ZonePresence::none();
299        for (start, cycle) in [
300            (FocusZone::Body, Cycle::Forward),
301            (FocusZone::Body, Cycle::Backward),
302            (FocusZone::Header, Cycle::Forward),
303            (FocusZone::Footer, Cycle::Backward),
304        ] {
305            assert_eq!(
306                next_zone(start, cycle, present, false, false),
307                Some(FocusZone::Body),
308                "start={start:?} cycle={cycle:?}"
309            );
310        }
311    }
312
313    #[test]
314    fn absent_header_is_skipped_going_forward() {
315        // header absent, side_bar + footer present.
316        let present = ZonePresence::none().side_bar(true).footer(true);
317        // From Footer, forward wraps past absent Header straight to SideBar.
318        assert_eq!(
319            next_zone(FocusZone::Footer, Cycle::Forward, present, false, false),
320            Some(FocusZone::SideBar)
321        );
322    }
323
324    #[test]
325    fn absent_footer_is_skipped_going_backward() {
326        // footer absent, header + side_bar present.
327        let present = ZonePresence::none().header(true).side_bar(true);
328        // From Body, backward would land on Footer if present; skips to SideBar.
329        assert_eq!(
330            next_zone(FocusZone::Body, Cycle::Backward, present, false, false),
331            Some(FocusZone::SideBar)
332        );
333    }
334
335    #[test]
336    fn every_combination_of_absent_optional_slots_stays_on_body_or_a_present_zone() {
337        for header in [false, true] {
338            for side_bar in [false, true] {
339                for footer in [false, true] {
340                    let present = ZonePresence::none()
341                        .header(header)
342                        .side_bar(side_bar)
343                        .footer(footer);
344                    for start in [
345                        FocusZone::Header,
346                        FocusZone::SideBar,
347                        FocusZone::Body,
348                        FocusZone::Footer,
349                    ] {
350                        for cycle in [Cycle::Forward, Cycle::Backward] {
351                            let next = next_zone(start, cycle, present, false, false)
352                                .expect("Body is always present, so a next zone always exists");
353                            assert!(
354                                present.is_present(next),
355                                "present={present:?} start={start:?} cycle={cycle:?} \
356                                 produced absent zone {next:?}"
357                            );
358                        }
359                    }
360                }
361            }
362        }
363    }
364
365    #[test]
366    fn modal_open_suspends_cycling() {
367        assert_eq!(
368            next_zone(
369                FocusZone::Header,
370                Cycle::Forward,
371                ZonePresence::all(),
372                true,
373                false
374            ),
375            None
376        );
377    }
378
379    #[test]
380    fn menu_alone_does_not_affect_cycling() {
381        assert_eq!(
382            next_zone(
383                FocusZone::Header,
384                Cycle::Forward,
385                ZonePresence::all(),
386                false,
387                true
388            ),
389            Some(FocusZone::SideBar)
390        );
391    }
392
393    #[test]
394    fn both_modal_and_menu_open_modal_wins() {
395        // Mirrors dismiss_on_escape's both_open_modal_takes_priority.
396        assert_eq!(
397            next_zone(
398                FocusZone::Header,
399                Cycle::Forward,
400                ZonePresence::all(),
401                true,
402                true
403            ),
404            None
405        );
406    }
407}