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}