Skip to main content

tui_pages/dialog/
mod.rs

1//! Built-in modal dialog system (feature = `dialog`).
2//!
3//! The crate's focus manager already tracks an open dialog overlay, the active
4//! button index, and button navigation. This module adds the missing pieces of
5//! a turnkey dialog: a [`DialogData`] content type, a [`DialogResult`], and a
6//! ratatui [`render_dialog`] renderer.
7//!
8//! Usage with [`TuiPages`](crate::TuiPages) (with the dialog content as the
9//! runtime's modal payload `M`, e.g.
10//! `TuiPages<View, Action, State, Pages, Handler, (), DialogData<MyPurpose>>`):
11//!
12//! - **Show** — return `TuiEffect::Focus(data.show_intent())` from your handler.
13//! - **Navigate** — `FocusIntent::Next` / `Prev` move between buttons (handled
14//!   by the focus manager automatically).
15//! - **Confirm** — on your activate key, read [`selection`] to get the chosen
16//!   button + purpose, act on it, then return
17//!   `TuiEffect::Focus(FocusIntent::ClearOverlay)` to close.
18//! - **Render** — `render_dialog(frame, area, data, active_button, &theme)`,
19//!   pulling `data`/`active_button` from [`current_dialog`] / [`active_button`].
20
21mod state;
22mod ui;
23
24pub use state::{DialogData, DialogResult};
25pub use ui::{render_dialog, DialogTheme};
26
27use crate::focus::{FocusController, FocusIntent, FocusManager, OverlayFocus};
28use crossterm::event::{KeyCode, KeyEvent};
29
30impl<D> DialogData<D> {
31    /// The focus intent that opens this dialog as a modal overlay. Wrap it in
32    /// [`TuiEffect::Focus`](crate::TuiEffect::Focus) (or apply it directly to a
33    /// [`FocusManager`]). `O` is the app's overlay type and is inferred from
34    /// the surrounding runtime.
35    pub fn show_intent<O>(self) -> FocusIntent<O, DialogData<D>> {
36        let buttons = self.buttons.len();
37        FocusIntent::ShowModal {
38            data: self,
39            count: buttons,
40        }
41    }
42}
43
44/// The dialog currently shown by the focus manager, if any.
45pub fn current_dialog<O, D>(focus: &FocusManager<O, DialogData<D>>) -> Option<&DialogData<D>> {
46    match focus.overlay() {
47        Some(OverlayFocus::Modal { data, .. }) => Some(data),
48        _ => None,
49    }
50}
51
52/// The active (highlighted) button index of the shown dialog, if any.
53pub fn active_button<O, D>(focus: &FocusManager<O, DialogData<D>>) -> Option<usize> {
54    match focus.overlay() {
55        Some(OverlayFocus::Modal { index, .. }) => Some(*index),
56        _ => None,
57    }
58}
59
60/// Resolve the current dialog into a [`DialogResult`] describing the selected
61/// button and the dialog's purpose. Returns `None` when no dialog is open.
62pub fn selection<O, D: Clone>(focus: &FocusManager<O, DialogData<D>>) -> Option<DialogResult<D>> {
63    match focus.overlay() {
64        Some(OverlayFocus::Modal { data, index, .. }) => Some(DialogResult::Selected {
65            purpose: data.purpose.clone(),
66            index: *index,
67        }),
68        _ => None,
69    }
70}
71
72/// What [`handle_key`] did with a key event.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum DialogKey<D> {
75    /// No dialog was open, so the key was left untouched. Forward it to the
76    /// rest of your input handling (e.g. `tui.handle_key(key, state)`).
77    Ignored,
78    /// The key moved the active button. The dialog stays open; redraw and wait
79    /// for the next key.
80    Consumed,
81    /// The dialog was answered and has been closed for you. Act on the result.
82    Resolved(DialogResult<D>),
83}
84
85/// Drive an open modal dialog from a raw key event, using the conventional
86/// bindings so you don't have to hand-roll them:
87///
88/// - `Tab` / `Right` move to the next button, `Shift+Tab` / `Left` to the
89///   previous (clamped, no wrap — matching the focus manager).
90/// - `Enter` selects the active button and closes the dialog, returning
91///   [`DialogKey::Resolved`] with [`DialogResult::Selected`].
92/// - `Esc` dismisses the dialog and returns [`DialogResult::Dismissed`].
93///
94/// When no dialog is open it returns [`DialogKey::Ignored`] and does nothing,
95/// so the typical event loop is:
96///
97/// ```ignore
98/// match dialog::handle_key(&mut tui.focus, key) {
99///     DialogKey::Ignored => { tui.handle_key(key, state)?; }
100///     DialogKey::Consumed => {}
101///     DialogKey::Resolved(result) => apply(result, state),
102/// }
103/// ```
104///
105/// For non-conventional bindings, drive the dialog yourself with
106/// [`current_dialog`], [`active_button`], [`selection`], and
107/// [`FocusIntent`](crate::FocusIntent) — this helper is just the common path.
108pub fn handle_key<O: Clone + PartialEq, D: Clone>(
109    focus: &mut FocusManager<O, DialogData<D>>,
110    key: KeyEvent,
111) -> DialogKey<D> {
112    if current_dialog(focus).is_none() {
113        return DialogKey::Ignored;
114    }
115
116    match key.code {
117        KeyCode::Tab | KeyCode::Right => {
118            focus.apply_focus_intent(FocusIntent::Next);
119            DialogKey::Consumed
120        }
121        KeyCode::BackTab | KeyCode::Left => {
122            focus.apply_focus_intent(FocusIntent::Prev);
123            DialogKey::Consumed
124        }
125        KeyCode::Enter => {
126            let result = selection(focus).unwrap_or(DialogResult::Dismissed);
127            focus.apply_focus_intent(FocusIntent::ClearOverlay);
128            DialogKey::Resolved(result)
129        }
130        KeyCode::Esc => {
131            focus.apply_focus_intent(FocusIntent::ClearOverlay);
132            DialogKey::Resolved(DialogResult::Dismissed)
133        }
134        // A modal swallows everything else while it is open.
135        _ => DialogKey::Consumed,
136    }
137}