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}