Skip to main content

tui_pages/focus/
manager.rs

1use crate::focus::{FocusIntent, FocusQuery, FocusTarget};
2
3/// How navigation behaves at the ends of a list — the single policy shared by
4/// page focus, modal items, buffer switching, and pane switching.
5///
6/// The crate does not hardcode a policy — you choose, and it applies
7/// uniformly. The default is [`Clamp`](FocusWrap::Clamp), which stops at the
8/// first/last element. Set it on the builder with
9/// [`focus_wrap`](crate::TuiPagesBuilder::focus_wrap) or at runtime with
10/// [`FocusManager::set_focus_wrap`]; the runtime reads it back for buffer and
11/// pane cycling as well.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
14pub enum FocusWrap {
15    /// Stop at the first/last element; `Next` on the last (or `Prev` on the
16    /// first) is a no-op.
17    #[default]
18    Clamp,
19    /// Wrap around: `Next` on the last element moves to the first, and `Prev`
20    /// on the first moves to the last.
21    Wrap,
22}
23
24impl FocusWrap {
25    /// Step `index` one position within `0..len` in the given direction,
26    /// applying this policy. `forward == true` advances, `false` retreats.
27    ///
28    /// This is the single shared definition of "what happens at the ends of a
29    /// list", used for page focus, modal items, buffers, and panes alike. `len`
30    /// must be greater than zero (callers guard empty lists).
31    pub fn step(self, index: usize, len: usize, forward: bool) -> usize {
32        match (self, forward) {
33            (FocusWrap::Wrap, true) => (index + 1) % len,
34            (FocusWrap::Wrap, false) => (index + len - 1) % len,
35            (FocusWrap::Clamp, true) => (index + 1).min(len - 1),
36            (FocusWrap::Clamp, false) => index.saturating_sub(1),
37        }
38    }
39}
40
41/// The overlay currently holding focus.
42///
43/// The runtime knows only two shapes: a [`Simple`](OverlayFocus::Simple)
44/// overlay identified by the app's own type `O`, and a generic
45/// [`Modal`](OverlayFocus::Modal) carrying an arbitrary payload `M` plus a
46/// cursor over `count` items. The crate names no dialogs or pickers — those are
47/// conventions a feature (e.g. `dialog`) layers on top of `Modal`.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum OverlayFocus<O = (), M = ()> {
50    Simple(O),
51    Modal { data: M, index: usize, count: usize },
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55struct EnteredSection {
56    section_id: usize,
57    item_index: usize,
58    item_count: usize,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct FocusManager<O = (), M = ()> {
63    targets: Vec<FocusTarget<O>>,
64    index: usize,
65    overlay: Option<OverlayFocus<O, M>>,
66    entered_section: Option<EnteredSection>,
67    /// `(section_id, item_count)` for sections the runtime may enter on its
68    /// own via [`activate`](Self::activate). Refreshed alongside the page's
69    /// focus targets.
70    section_items: Vec<(usize, usize)>,
71    wrap: FocusWrap,
72}
73
74impl<O, M> Default for FocusManager<O, M> {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80pub trait FocusController<O = (), M = ()> {
81    fn apply_focus_intent(&mut self, intent: FocusIntent<O, M>);
82}
83
84// Operations that don't inspect the overlay identity `O`.
85impl<O, M> FocusManager<O, M> {
86    pub fn new() -> Self {
87        Self {
88            targets: Vec::new(),
89            index: 0,
90            overlay: None,
91            entered_section: None,
92            section_items: Vec::new(),
93            wrap: FocusWrap::Clamp,
94        }
95    }
96
97    /// The current end-of-list navigation policy.
98    pub fn focus_wrap(&self) -> FocusWrap {
99        self.wrap
100    }
101
102    /// Set the end-of-list navigation policy (clamp vs. wrap-around).
103    pub fn set_focus_wrap(&mut self, wrap: FocusWrap) {
104        self.wrap = wrap;
105    }
106
107    pub fn targets(&self) -> &[FocusTarget<O>] {
108        &self.targets
109    }
110
111    pub fn overlay(&self) -> Option<&OverlayFocus<O, M>> {
112        self.overlay.as_ref()
113    }
114
115    pub fn overlay_mut(&mut self) -> Option<&mut OverlayFocus<O, M>> {
116        self.overlay.as_mut()
117    }
118
119    pub fn register_page(&mut self, targets: Vec<FocusTarget<O>>) {
120        self.targets = targets;
121        self.index = 0;
122        self.entered_section = None;
123        self.section_items.clear();
124    }
125
126    /// Record the `(section_id, item_count)` pairs for the current page so the
127    /// runtime can enter a section on its own (see [`activate`](Self::activate)).
128    /// The runtime calls this from `refresh_page`; applications rarely need to.
129    pub fn set_section_items(&mut self, section_items: Vec<(usize, usize)>) {
130        self.section_items = section_items;
131        if let Some(section) = &mut self.entered_section {
132            match self
133                .section_items
134                .iter()
135                .find(|(id, _)| *id == section.section_id)
136                .map(|(_, count)| *count)
137            {
138                Some(0) | None => self.entered_section = None,
139                Some(count) => {
140                    section.item_count = count;
141                    section.item_index = section.item_index.min(count - 1);
142                }
143            }
144        }
145    }
146
147    /// The recorded item count for `section_id`, if any.
148    fn section_item_count(&self, section_id: usize) -> Option<usize> {
149        self.section_items
150            .iter()
151            .find(|(id, _)| *id == section_id)
152            .map(|(_, count)| *count)
153    }
154
155    pub fn has_overlay(&self) -> bool {
156        self.overlay.is_some()
157    }
158
159    pub fn next(&mut self) {
160        let wrap = self.wrap;
161
162        if let Some(OverlayFocus::Modal { index, count, .. }) = &mut self.overlay {
163            if *count > 0 {
164                *index = wrap.step(*index, *count, true);
165            }
166            return;
167        }
168
169        if self.overlay.is_some() {
170            return;
171        }
172
173        if let Some(section) = &self.entered_section {
174            if section.item_index + 1 < section.item_count {
175                if let Some(section) = &mut self.entered_section {
176                    section.item_index += 1;
177                }
178                return;
179            }
180            // At the section's last item: leave the section and continue to the
181            // adjacent top-level target. `self.index` still points at the
182            // `Section` target, so the scan below steps past it.
183            self.entered_section = None;
184        }
185
186        if self
187            .targets
188            .get(self.index)
189            .map(FocusTarget::is_canvas)
190            .unwrap_or(false)
191        {
192            return;
193        }
194
195        for index in (self.index + 1)..self.targets.len() {
196            if self.targets[index].is_top_level_navigable() {
197                self.index = index;
198                return;
199            }
200        }
201
202        // No navigable target after the current one: wrap to the first.
203        if matches!(wrap, FocusWrap::Wrap) {
204            for index in 0..self.index {
205                if self.targets[index].is_top_level_navigable() {
206                    self.index = index;
207                    return;
208                }
209            }
210        }
211    }
212
213    pub fn prev(&mut self) {
214        let wrap = self.wrap;
215
216        if let Some(OverlayFocus::Modal { index, count, .. }) = &mut self.overlay {
217            if *count > 0 {
218                *index = wrap.step(*index, *count, false);
219            }
220            return;
221        }
222
223        if self.overlay.is_some() {
224            return;
225        }
226
227        if let Some(section) = &self.entered_section {
228            if section.item_index > 0 {
229                if let Some(section) = &mut self.entered_section {
230                    section.item_index -= 1;
231                }
232                return;
233            }
234            // At the section's first item: leave the section and continue to
235            // the adjacent top-level target before it.
236            self.entered_section = None;
237        }
238
239        if self
240            .targets
241            .get(self.index)
242            .map(FocusTarget::is_canvas)
243            .unwrap_or(false)
244        {
245            return;
246        }
247
248        for index in (0..self.index).rev() {
249            if self.targets[index].is_top_level_navigable() {
250                self.index = index;
251                return;
252            }
253        }
254
255        // No navigable target before the current one: wrap to the last.
256        if matches!(wrap, FocusWrap::Wrap) {
257            for index in ((self.index + 1)..self.targets.len()).rev() {
258                if self.targets[index].is_top_level_navigable() {
259                    self.index = index;
260                    return;
261                }
262            }
263        }
264    }
265
266    /// Open a generic modal overlay carrying `data` with `count` selectable
267    /// items. Pass `count == 0` for a non-interactive modal (e.g. a loading
268    /// dialog). Higher-level conventions (dialogs, pickers) build on this.
269    pub fn show_modal(&mut self, data: M, count: usize) {
270        self.overlay = Some(OverlayFocus::Modal {
271            data,
272            index: 0,
273            count,
274        });
275    }
276
277    pub fn clear_overlay(&mut self) {
278        self.overlay = None;
279    }
280
281    pub fn exit_canvas_forward(&mut self) {
282        for index in (self.index + 1)..self.targets.len() {
283            if self.targets[index].is_top_level_navigable() && !self.targets[index].is_canvas() {
284                self.index = index;
285                return;
286            }
287        }
288    }
289
290    pub fn exit_canvas_backward(&mut self) {
291        for index in (0..self.index).rev() {
292            if self.targets[index].is_top_level_navigable() && !self.targets[index].is_canvas() {
293                self.index = index;
294                return;
295            }
296        }
297    }
298
299    pub fn enter_section(&mut self, item_count: usize) {
300        if item_count == 0 {
301            return;
302        }
303
304        if let Some(FocusTarget::Section(section_id)) = self.targets.get(self.index) {
305            self.entered_section = Some(EnteredSection {
306                section_id: *section_id,
307                item_index: 0,
308                item_count,
309            });
310        }
311    }
312
313    /// Act on the currently focused target without the application inspecting
314    /// focus: if it is a [`Section`](FocusTarget::Section) registered with an
315    /// item count (see
316    /// [`section_with_items`](crate::PageFocusBuilder::section_with_items)),
317    /// enter it. Anything else — a button, an already-entered section, an open
318    /// overlay — is left untouched, so the application's own activation logic
319    /// (navigation, selection) stays its own concern.
320    pub fn activate(&mut self) {
321        if self.overlay.is_some() || self.entered_section.is_some() {
322            return;
323        }
324        if let Some(FocusTarget::Section(section_id)) = self.targets.get(self.index) {
325            if let Some(item_count) = self.section_item_count(*section_id) {
326                self.enter_section(item_count);
327            }
328        }
329    }
330
331    pub fn enter_section_at(&mut self, section_id: usize, item_count: usize, item_index: usize) {
332        if item_count == 0 {
333            return;
334        }
335
336        if let Some(position) = self
337            .targets
338            .iter()
339            .position(|target| matches!(target, FocusTarget::Section(id) if *id == section_id))
340        {
341            self.index = position;
342            self.overlay = None;
343            self.entered_section = Some(EnteredSection {
344                section_id,
345                item_index: item_index.min(item_count.saturating_sub(1)),
346                item_count,
347            });
348        }
349    }
350
351    pub fn leave_section(&mut self) {
352        self.entered_section = None;
353    }
354}
355
356// Operations that read the current focus (and therefore clone `O`).
357impl<O: Clone, M> FocusManager<O, M> {
358    pub fn current(&self) -> Option<FocusTarget<O>> {
359        if let Some(overlay) = &self.overlay {
360            return Some(match overlay {
361                OverlayFocus::Simple(kind) => FocusTarget::Overlay(kind.clone()),
362                OverlayFocus::Modal { index, .. } => FocusTarget::ModalItem(*index),
363            });
364        }
365
366        if let Some(section) = &self.entered_section {
367            return Some(FocusTarget::SectionItem {
368                section: section.section_id,
369                item: section.item_index,
370            });
371        }
372
373        self.targets.get(self.index).cloned()
374    }
375
376    pub fn query(&self) -> FocusQuery<O> {
377        FocusQuery {
378            current: self.current(),
379        }
380    }
381}
382
383// Operations that compare overlays / targets by identity.
384impl<O: Clone + PartialEq, M> FocusManager<O, M> {
385    pub fn is_focused(&self, target: &FocusTarget<O>) -> bool {
386        self.current().as_ref() == Some(target)
387    }
388
389    pub fn add_target(&mut self, target: FocusTarget<O>) {
390        if !self.targets.contains(&target) {
391            self.targets.push(target);
392        }
393    }
394
395    pub fn remove_target(&mut self, target: &FocusTarget<O>) {
396        if let Some(position) = self
397            .targets
398            .iter()
399            .position(|candidate| candidate == target)
400        {
401            self.targets.remove(position);
402            if self.index >= self.targets.len() && !self.targets.is_empty() {
403                self.index = self.targets.len() - 1;
404            }
405        }
406    }
407
408    pub fn set_focus(&mut self, target: FocusTarget<O>) {
409        if let Some(kind) = target.to_overlay() {
410            self.overlay = Some(OverlayFocus::Simple(kind));
411            return;
412        }
413
414        if let FocusTarget::ModalItem(next_index) = target {
415            if let Some(OverlayFocus::Modal { index, count, .. }) = &mut self.overlay {
416                if next_index < *count {
417                    *index = next_index;
418                }
419            }
420            return;
421        }
422
423        if let FocusTarget::Section(section_id) = target {
424            if let Some(position) = self.targets.iter().position(
425                |candidate| matches!(candidate, FocusTarget::Section(id) if *id == section_id),
426            ) {
427                self.index = position;
428                self.overlay = None;
429                self.entered_section = None;
430            }
431            return;
432        }
433
434        if let Some(position) = self
435            .targets
436            .iter()
437            .position(|candidate| candidate == &target)
438        {
439            self.index = position;
440            self.overlay = None;
441            self.entered_section = None;
442        }
443    }
444
445    pub fn open_overlay(&mut self, target: FocusTarget<O>) {
446        if let Some(kind) = target.to_overlay() {
447            self.overlay = Some(OverlayFocus::Simple(kind));
448        }
449    }
450
451    pub fn close_overlay(&mut self, target: FocusTarget<O>) {
452        let should_close = match (&self.overlay, target.to_overlay()) {
453            (Some(OverlayFocus::Simple(current)), Some(requested)) => current == &requested,
454            _ => false,
455        };
456
457        if should_close {
458            self.overlay = None;
459        }
460    }
461
462    pub fn toggle_overlay(&mut self, target: FocusTarget<O>) {
463        if self.is_overlay_open(&target) {
464            self.close_overlay(target);
465        } else {
466            self.open_overlay(target);
467        }
468    }
469
470    pub fn is_overlay_open(&self, target: &FocusTarget<O>) -> bool {
471        match (&self.overlay, target.to_overlay()) {
472            (Some(OverlayFocus::Simple(current)), Some(requested)) => current == &requested,
473            _ => false,
474        }
475    }
476}
477
478impl<O: Clone + PartialEq, M> FocusController<O, M> for FocusManager<O, M> {
479    fn apply_focus_intent(&mut self, intent: FocusIntent<O, M>) {
480        match intent {
481            FocusIntent::Next => self.next(),
482            FocusIntent::Prev => self.prev(),
483            FocusIntent::Set(target) => self.set_focus(target),
484            FocusIntent::Open(target) => self.open_overlay(target),
485            FocusIntent::Close(target) => self.close_overlay(target),
486            FocusIntent::Toggle(target) => self.toggle_overlay(target),
487            FocusIntent::RegisterPage(targets) => self.register_page(targets),
488            FocusIntent::RegisterPageAndEnterSection {
489                targets,
490                section,
491                item_count,
492                item,
493            } => {
494                self.register_page(targets);
495                self.enter_section_at(section, item_count, item);
496            }
497            FocusIntent::ShowModal { data, count } => self.show_modal(data, count),
498            FocusIntent::UpdateModal { data, count } => {
499                if let Some(OverlayFocus::Modal {
500                    data: current_data,
501                    count: current_count,
502                    ..
503                }) = &mut self.overlay
504                {
505                    *current_data = data;
506                    *current_count = count;
507                }
508            }
509            FocusIntent::ClearOverlay => self.clear_overlay(),
510            FocusIntent::ExitCanvasForward => self.exit_canvas_forward(),
511            FocusIntent::ExitCanvasBackward => self.exit_canvas_backward(),
512            FocusIntent::EnterSection { item_count } => self.enter_section(item_count),
513            FocusIntent::LeaveSection => self.leave_section(),
514            FocusIntent::Activate => self.activate(),
515        }
516    }
517}