#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FocusZone {
Header,
SideBar,
Body,
Footer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Cycle {
Forward,
Backward,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct ZonePresence {
pub header: bool,
pub side_bar: bool,
pub footer: bool,
}
impl ZonePresence {
#[must_use]
pub fn none() -> Self {
Self::default()
}
#[must_use]
pub fn all() -> Self {
Self {
header: true,
side_bar: true,
footer: true,
}
}
#[must_use]
pub fn header(mut self, present: bool) -> Self {
self.header = present;
self
}
#[must_use]
pub fn side_bar(mut self, present: bool) -> Self {
self.side_bar = present;
self
}
#[must_use]
pub fn footer(mut self, present: bool) -> Self {
self.footer = present;
self
}
fn is_present(self, zone: FocusZone) -> bool {
match zone {
FocusZone::Header => self.header,
FocusZone::SideBar => self.side_bar,
FocusZone::Body => true,
FocusZone::Footer => self.footer,
}
}
}
const ORDER: [FocusZone; 4] = [
FocusZone::Header,
FocusZone::SideBar,
FocusZone::Body,
FocusZone::Footer,
];
#[must_use]
pub fn next_zone(
current: FocusZone,
cycle: Cycle,
present: ZonePresence,
has_modal: bool,
has_menu: bool,
) -> Option<FocusZone> {
let _ = has_menu;
if has_modal {
return None;
}
let start = match current {
FocusZone::Header => 0,
FocusZone::SideBar => 1,
FocusZone::Body => 2,
FocusZone::Footer => 3,
};
let len = ORDER.len();
(1..=len)
.map(|step| match cycle {
Cycle::Forward => ORDER[(start + step) % len],
Cycle::Backward => ORDER[(start + len - step) % len],
})
.find(|&zone| present.is_present(zone))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_match_agrees_with_order() {
for (i, &zone) in ORDER.iter().enumerate() {
let via_match = match zone {
FocusZone::Header => 0,
FocusZone::SideBar => 1,
FocusZone::Body => 2,
FocusZone::Footer => 3,
};
assert_eq!(via_match, i, "{zone:?} index disagrees with ORDER");
}
}
#[test]
fn forward_cycles_through_all_four_zones_in_logical_order() {
let present = ZonePresence::all();
assert_eq!(
next_zone(FocusZone::Header, Cycle::Forward, present, false, false),
Some(FocusZone::SideBar)
);
assert_eq!(
next_zone(FocusZone::SideBar, Cycle::Forward, present, false, false),
Some(FocusZone::Body)
);
assert_eq!(
next_zone(FocusZone::Body, Cycle::Forward, present, false, false),
Some(FocusZone::Footer)
);
assert_eq!(
next_zone(FocusZone::Footer, Cycle::Forward, present, false, false),
Some(FocusZone::Header)
);
}
#[test]
fn backward_cycles_through_all_four_zones_in_reverse_logical_order() {
let present = ZonePresence::all();
assert_eq!(
next_zone(FocusZone::Header, Cycle::Backward, present, false, false),
Some(FocusZone::Footer)
);
assert_eq!(
next_zone(FocusZone::Footer, Cycle::Backward, present, false, false),
Some(FocusZone::Body)
);
assert_eq!(
next_zone(FocusZone::Body, Cycle::Backward, present, false, false),
Some(FocusZone::SideBar)
);
assert_eq!(
next_zone(FocusZone::SideBar, Cycle::Backward, present, false, false),
Some(FocusZone::Header)
);
}
#[test]
fn forward_wraps_around_from_footer_to_header() {
assert_eq!(
next_zone(
FocusZone::Footer,
Cycle::Forward,
ZonePresence::all(),
false,
false
),
Some(FocusZone::Header)
);
}
#[test]
fn backward_wraps_around_from_header_to_footer() {
assert_eq!(
next_zone(
FocusZone::Header,
Cycle::Backward,
ZonePresence::all(),
false,
false
),
Some(FocusZone::Footer)
);
}
#[test]
fn body_only_layout_always_lands_on_body() {
let present = ZonePresence::none();
for (start, cycle) in [
(FocusZone::Body, Cycle::Forward),
(FocusZone::Body, Cycle::Backward),
(FocusZone::Header, Cycle::Forward),
(FocusZone::Footer, Cycle::Backward),
] {
assert_eq!(
next_zone(start, cycle, present, false, false),
Some(FocusZone::Body),
"start={start:?} cycle={cycle:?}"
);
}
}
#[test]
fn absent_header_is_skipped_going_forward() {
let present = ZonePresence::none().side_bar(true).footer(true);
assert_eq!(
next_zone(FocusZone::Footer, Cycle::Forward, present, false, false),
Some(FocusZone::SideBar)
);
}
#[test]
fn absent_footer_is_skipped_going_backward() {
let present = ZonePresence::none().header(true).side_bar(true);
assert_eq!(
next_zone(FocusZone::Body, Cycle::Backward, present, false, false),
Some(FocusZone::SideBar)
);
}
#[test]
fn every_combination_of_absent_optional_slots_stays_on_body_or_a_present_zone() {
for header in [false, true] {
for side_bar in [false, true] {
for footer in [false, true] {
let present = ZonePresence::none()
.header(header)
.side_bar(side_bar)
.footer(footer);
for start in [
FocusZone::Header,
FocusZone::SideBar,
FocusZone::Body,
FocusZone::Footer,
] {
for cycle in [Cycle::Forward, Cycle::Backward] {
let next = next_zone(start, cycle, present, false, false)
.expect("Body is always present, so a next zone always exists");
assert!(
present.is_present(next),
"present={present:?} start={start:?} cycle={cycle:?} \
produced absent zone {next:?}"
);
}
}
}
}
}
}
#[test]
fn modal_open_suspends_cycling() {
assert_eq!(
next_zone(
FocusZone::Header,
Cycle::Forward,
ZonePresence::all(),
true,
false
),
None
);
}
#[test]
fn menu_alone_does_not_affect_cycling() {
assert_eq!(
next_zone(
FocusZone::Header,
Cycle::Forward,
ZonePresence::all(),
false,
true
),
Some(FocusZone::SideBar)
);
}
#[test]
fn both_modal_and_menu_open_modal_wins() {
assert_eq!(
next_zone(
FocusZone::Header,
Cycle::Forward,
ZonePresence::all(),
true,
true
),
None
);
}
}