Skip to main content

tui_pages/dialog/
ui.rs

1//! Ratatui renderer for [`DialogData`].
2
3use crate::dialog::DialogData;
4use crate::theme::ThemeStyles;
5use ratatui::{
6    Frame,
7    layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
8    style::{Color, Modifier, Style},
9    text::Text,
10    widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
11};
12
13pub type DialogButtonRenderer<'a> =
14    dyn FnMut(&mut Frame, Rect, &str, bool, &DialogTheme) + 'a;
15
16/// Colors used by [`render_dialog`]. `Default` uses terminal-neutral colors so
17/// the dialog looks reasonable on any theme; override fields to match your app.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct DialogTheme {
20    pub background: Color,
21    pub border: Color,
22    pub border_active: Color,
23    pub title: Color,
24    pub text: Color,
25    pub button: Color,
26    pub button_active: Color,
27}
28
29/// Classify a dialog purpose so [`DialogTheme::themed`] can pick semantic colors.
30pub enum DialogPurposeClass {
31    Success,
32    Failure,
33    Neutral,
34}
35
36/// Implement on your purpose type so the dialog theme can derive colors from it.
37///
38/// The default returns [`DialogPurposeClass::Neutral`] for all purposes —
39/// override [`class`](DialogPurposeStyle::class) to return
40/// [`DialogPurposeClass::Failure`] for error states.
41pub trait DialogPurposeStyle {
42    fn class(&self) -> DialogPurposeClass {
43        DialogPurposeClass::Neutral
44    }
45}
46
47impl DialogTheme {
48    /// Build a [`DialogTheme`] with colors driven by the dialog's purpose.
49    ///
50    /// `purpose` is classified via [`DialogPurposeStyle`]: `Failure` gets
51    /// `error_color`, `Success` gets `success_color`, `Neutral` and `None` get
52    /// `text_color`. The chosen foreground is applied to the dialog border,
53    /// title, text, and inactive buttons.
54    pub fn themed<D: DialogPurposeStyle>(
55        text_color: Color,
56        error_color: Color,
57        success_color: Color,
58        background: Color,
59        button_active: Color,
60        purpose: Option<D>,
61    ) -> Self {
62        let fg = match purpose.as_ref().map(|p| p.class()) {
63            Some(DialogPurposeClass::Success) => success_color,
64            Some(DialogPurposeClass::Failure) => error_color,
65            _ => text_color,
66        };
67        Self {
68            background,
69            border: fg,
70            border_active: fg,
71            title: fg,
72            text: fg,
73            button: fg,
74            button_active,
75        }
76    }
77
78    /// Derive dialog colors from typed [`ThemeStyles`].
79    ///
80    /// Each field picks the most semantically appropriate color from the
81    /// theme role cache, falling back to the built-in default when a style
82    /// has no color set.
83    pub fn from_theme_styles(styles: &ThemeStyles) -> Self {
84        let default = Self::default();
85        Self {
86            background: styles.background.bg.unwrap_or(default.background),
87            border: styles
88                .muted
89                .fg
90                .unwrap_or(styles.text.fg.unwrap_or(default.border)),
91            border_active: styles.text_focus.fg.unwrap_or(default.border_active),
92            title: styles.text_focus.fg.unwrap_or(default.title),
93            text: styles.text.fg.unwrap_or(default.text),
94            button: styles.text.fg.unwrap_or(default.button),
95            button_active: styles
96                .selection
97                .bg
98                .unwrap_or(styles.text_focus.fg.unwrap_or(default.button_active)),
99        }
100    }
101
102    /// Build a [`DialogTheme`] styled with the `error` role from [`ThemeStyles`].
103    ///
104    /// The error foreground colour is used for border, title, text, and
105    /// inactive buttons. Background and button-active colours follow the same
106    /// defaults as [`from_theme_styles`](Self::from_theme_styles).
107    pub fn error_styled(styles: &ThemeStyles) -> Self {
108        let default = Self::default();
109        let fg = styles.error.fg.unwrap_or(styles.text.fg.unwrap_or(default.text));
110        Self {
111            background: styles.background.bg.unwrap_or(default.background),
112            border: fg,
113            border_active: fg,
114            title: fg,
115            text: fg,
116            button: fg,
117            button_active: styles
118                .selection
119                .bg
120                .unwrap_or(styles.text_focus.fg.unwrap_or(default.button_active)),
121        }
122    }
123
124    /// Build a [`DialogTheme`] styled with the `success` role from [`ThemeStyles`].
125    ///
126    /// The success foreground colour is used for border, title, text, and
127    /// inactive buttons. Background and button-active colours follow the same
128    /// defaults as [`from_theme_styles`](Self::from_theme_styles).
129    pub fn success_styled(styles: &ThemeStyles) -> Self {
130        let default = Self::default();
131        let fg = styles
132            .success
133            .fg
134            .unwrap_or(styles.text.fg.unwrap_or(default.text));
135        Self {
136            background: styles.background.bg.unwrap_or(default.background),
137            border: fg,
138            border_active: fg,
139            title: fg,
140            text: fg,
141            button: fg,
142            button_active: styles
143                .selection
144                .bg
145                .unwrap_or(styles.text_focus.fg.unwrap_or(default.button_active)),
146        }
147    }
148
149}
150
151impl Default for DialogTheme {
152    fn default() -> Self {
153        Self {
154            background: Color::Reset,
155            border: Color::DarkGray,
156            border_active: Color::Cyan,
157            title: Color::Cyan,
158            text: Color::Reset,
159            button: Color::Gray,
160            button_active: Color::Cyan,
161        }
162    }
163}
164
165/// Draw a centered modal dialog into `area`.
166///
167/// `active_button` is the index of the highlighted button — read it from the
168/// focus manager (see [`crate::dialog::active_button`]). The dialog is centered
169/// within `area`, so pass the full frame area for a true modal.
170pub fn render_dialog<D>(
171    f: &mut Frame,
172    area: Rect,
173    data: &DialogData<D>,
174    active_button: usize,
175    theme: &DialogTheme,
176) {
177    render_dialog_with_button_renderer(
178        f,
179        area,
180        data,
181        active_button,
182        theme,
183        &mut render_default_dialog_button,
184    );
185}
186
187/// Shorthand for [`render_dialog`] that uses the error semantic color from
188/// [`ThemeStyles`] for the dialog chrome.
189pub fn render_dialog_error<D>(
190    f: &mut Frame,
191    area: Rect,
192    data: &DialogData<D>,
193    active_button: usize,
194    styles: &ThemeStyles,
195) {
196    render_dialog(
197        f,
198        area,
199        data,
200        active_button,
201        &DialogTheme::error_styled(styles),
202    );
203}
204
205/// Shorthand for [`render_dialog`] that uses the success semantic color from
206/// [`ThemeStyles`] for the dialog chrome.
207pub fn render_dialog_success<D>(
208    f: &mut Frame,
209    area: Rect,
210    data: &DialogData<D>,
211    active_button: usize,
212    styles: &ThemeStyles,
213) {
214    render_dialog(
215        f,
216        area,
217        data,
218        active_button,
219        &DialogTheme::success_styled(styles),
220    );
221}
222
223pub fn render_dialog_with_button_renderer<D>(
224    f: &mut Frame,
225    area: Rect,
226    data: &DialogData<D>,
227    active_button: usize,
228    theme: &DialogTheme,
229    render_button: &mut DialogButtonRenderer<'_>,
230) {
231    let message_height = data.message.lines().count().max(1) as u16;
232    let button_row_height = if data.buttons.is_empty() { 0 } else { 3 };
233    // borders (2) + inner vertical margin (2) + message + buttons.
234    let total_height = (message_height + button_row_height + 4).min(area.height.max(3));
235
236    let width = (area.width * 60 / 100).clamp(20, area.width);
237    let x = area.x + (area.width.saturating_sub(width)) / 2;
238    let y = area.y + (area.height.saturating_sub(total_height)) / 2;
239    let dialog_area = Rect::new(x, y, width, total_height);
240
241    f.render_widget(Clear, dialog_area);
242    f.render_widget(
243        Block::default()
244            .borders(Borders::ALL)
245            .border_type(BorderType::Rounded)
246            .border_style(Style::default().fg(theme.border_active))
247            .title(format!(" {} ", data.title))
248            .title_style(
249                Style::default()
250                    .fg(theme.title)
251                    .add_modifier(Modifier::BOLD),
252            )
253            .style(Style::default().bg(theme.background)),
254        dialog_area,
255    );
256
257    let inner = dialog_area.inner(Margin {
258        horizontal: 2,
259        vertical: 1,
260    });
261
262    if data.is_loading {
263        f.render_widget(
264            Paragraph::new(data.message.as_str())
265                .style(
266                    Style::default()
267                        .fg(theme.text)
268                        .add_modifier(Modifier::ITALIC),
269                )
270                .alignment(Alignment::Center)
271                .wrap(Wrap { trim: true }),
272            inner,
273        );
274        return;
275    }
276
277    let mut constraints = vec![Constraint::Min(message_height.max(1))];
278    if button_row_height > 0 {
279        constraints.push(Constraint::Length(button_row_height));
280    }
281    let chunks = Layout::default()
282        .direction(Direction::Vertical)
283        .constraints(constraints)
284        .split(inner);
285
286    f.render_widget(
287        Paragraph::new(Text::from(data.message.as_str()))
288            .style(Style::default().fg(theme.text))
289            .alignment(Alignment::Center)
290            .wrap(Wrap { trim: true }),
291        chunks[0],
292    );
293
294    if data.buttons.is_empty() || chunks.len() < 2 {
295        return;
296    }
297
298    let count = data.buttons.len();
299    let button_chunks = Layout::default()
300        .direction(Direction::Horizontal)
301        .constraints(vec![Constraint::Ratio(1, count as u32); count])
302        .horizontal_margin(1)
303        .split(chunks[1]);
304
305    for (i, label) in data.buttons.iter().enumerate() {
306        let active = i == active_button;
307        render_button(f, button_chunks[i], label.as_str(), active, theme);
308    }
309}
310
311/// Shorthand for [`render_dialog_with_button_renderer`] that uses the
312/// error semantic color from [`ThemeStyles`] for the dialog and custom button
313/// renderer theme.
314pub fn render_dialog_error_with_button_renderer<D>(
315    f: &mut Frame,
316    area: Rect,
317    data: &DialogData<D>,
318    active_button: usize,
319    styles: &ThemeStyles,
320    render_button: &mut DialogButtonRenderer<'_>,
321) {
322    render_dialog_with_button_renderer(
323        f,
324        area,
325        data,
326        active_button,
327        &DialogTheme::error_styled(styles),
328        render_button,
329    );
330}
331
332/// Shorthand for [`render_dialog_with_button_renderer`] that uses the
333/// success semantic color from [`ThemeStyles`] for the dialog and custom button
334/// renderer theme.
335pub fn render_dialog_success_with_button_renderer<D>(
336    f: &mut Frame,
337    area: Rect,
338    data: &DialogData<D>,
339    active_button: usize,
340    styles: &ThemeStyles,
341    render_button: &mut DialogButtonRenderer<'_>,
342) {
343    render_dialog_with_button_renderer(
344        f,
345        area,
346        data,
347        active_button,
348        &DialogTheme::success_styled(styles),
349        render_button,
350    );
351}
352
353fn render_default_dialog_button(
354    f: &mut Frame,
355    area: Rect,
356    label: &str,
357    active: bool,
358    theme: &DialogTheme,
359) {
360    let (text_style, border_style) = if active {
361        (
362            Style::default()
363                .fg(theme.button_active)
364                .add_modifier(Modifier::BOLD),
365            Style::default().fg(theme.border_active),
366        )
367    } else {
368        (
369            Style::default().fg(theme.button),
370            Style::default().fg(theme.border),
371        )
372    };
373    f.render_widget(
374        Paragraph::new(label)
375            .alignment(Alignment::Center)
376            .style(text_style)
377            .block(
378                Block::default()
379                    .borders(Borders::ALL)
380                    .border_style(border_style),
381            ),
382        area,
383    );
384}