Skip to main content

kimun_notes/components/dialogs/
info_dialog.rs

1use ratatui::Frame;
2use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent};
3use ratatui::layout::{Constraint, Direction, Layout, Rect};
4use ratatui::style::{Modifier, Style};
5use ratatui::widgets::{Paragraph, Wrap};
6use unicode_width::UnicodeWidthStr;
7
8use super::ModalShell;
9use crate::components::Component;
10use crate::components::event_state::EventState;
11use crate::components::events::{AppEvent, AppTx};
12use crate::components::hint_row::HintRow;
13use crate::components::panel::{ModalSpec, modal_chrome};
14use crate::settings::themes::Theme;
15
16/// Popup width, border included.
17const WIDTH: u16 = 58;
18/// Horizontal margin between the popup border and its text.
19const MARGIN: u16 = 2;
20
21/// Read-only message dialog: an accent headline, a wrapped body, and
22/// `[Esc] Close`. Esc or a click outside/on the hint closes; everything else
23/// is swallowed. Height follows the wrapped body.
24///
25/// ```text
26/// ┌─ Server Update ──────────────────────────────────────┐
27/// │                                                      │
28/// │  server 0.18.0 available                             │
29/// │                                                      │
30/// │  Update it the same way you installed it (install    │
31/// │  script, Docker image, package manager, ...).        │
32/// │                                                      │
33/// │  [Esc] Close                                         │
34/// └──────────────────────────────────────────────────────┘
35/// ```
36pub struct InfoDialog {
37    title: String,
38    headline: String,
39    body: String,
40    /// Dismiss-on-outside-press and hint-chip clicks (see [`ModalShell`]).
41    shell: ModalShell,
42    close_hint: HintRow,
43}
44
45impl InfoDialog {
46    pub fn new(
47        title: impl Into<String>,
48        headline: impl Into<String>,
49        body: impl Into<String>,
50    ) -> Self {
51        Self {
52            title: format!(" {} ", title.into()),
53            headline: headline.into(),
54            body: body.into(),
55            shell: ModalShell::default(),
56            close_hint: HintRow::new(&[(KeyCode::Esc, "Esc", "Close")]),
57        }
58    }
59
60    /// Rows the body takes once wrapped to the popup's text width.
61    fn body_rows(&self) -> u16 {
62        let text_w = (WIDTH - 2 - 2 * MARGIN) as usize;
63        self.body
64            .lines()
65            .map(|l| l.width().div_ceil(text_w).max(1))
66            .sum::<usize>() as u16
67    }
68
69    /// Modal: every mouse event is consumed; a press outside (or on the
70    /// close hint) closes.
71    pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
72        if let Some(key) = self.shell.pointer_key(m, &[&self.close_hint]) {
73            return self.handle_key(key, tx);
74        }
75        EventState::Consumed
76    }
77
78    pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
79        if key.code == KeyCode::Esc {
80            tx.send(AppEvent::CloseOverlay).ok();
81        }
82        EventState::Consumed // swallow other keys while open
83    }
84}
85
86impl Component for InfoDialog {
87    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
88        let body_rows = self.body_rows();
89        // border(2) + spacer, headline, spacer, body, spacer, hint
90        let popup_area = super::fixed_centered_rect(WIDTH, 2 + 5 + body_rows, rect);
91        self.shell.set(popup_area);
92
93        let inner = modal_chrome(
94            f,
95            popup_area,
96            theme,
97            ModalSpec {
98                title: Some(self.title.as_str()),
99                border: Some(Style::default().fg(theme.accent.to_ratatui())),
100                ..Default::default()
101            },
102        );
103
104        let rows = Layout::default()
105            .direction(Direction::Vertical)
106            .horizontal_margin(MARGIN)
107            .constraints([
108                Constraint::Length(1),         // spacer
109                Constraint::Length(1),         // headline
110                Constraint::Length(1),         // spacer
111                Constraint::Length(body_rows), // body
112                Constraint::Length(1),         // spacer
113                Constraint::Length(1),         // Esc hint
114                Constraint::Min(0),
115            ])
116            .split(inner);
117
118        let bg = theme.bg_panel.to_ratatui();
119        f.render_widget(
120            Paragraph::new(self.headline.as_str()).style(
121                Style::default()
122                    .fg(theme.accent.to_ratatui())
123                    .bg(bg)
124                    .add_modifier(Modifier::BOLD),
125            ),
126            rows[1],
127        );
128        f.render_widget(
129            Paragraph::new(self.body.as_str())
130                .wrap(Wrap { trim: false })
131                .style(Style::default().fg(theme.fg.to_ratatui()).bg(bg)),
132            rows[3],
133        );
134        self.close_hint.render(
135            f,
136            rows[5],
137            Style::default().fg(theme.gray.to_ratatui()).bg(bg),
138            theme,
139        );
140    }
141}
142
143impl_dialog!(InfoDialog);
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use tokio::sync::mpsc;
149
150    #[test]
151    fn esc_closes_and_other_keys_are_swallowed() {
152        let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
153        let mut d = InfoDialog::new("T", "head", "body");
154        assert_eq!(
155            d.handle_key(KeyEvent::from(KeyCode::Char('x')), &tx),
156            EventState::Consumed
157        );
158        assert!(rx.try_recv().is_err());
159        d.handle_key(KeyEvent::from(KeyCode::Esc), &tx);
160        assert!(matches!(rx.try_recv(), Ok(AppEvent::CloseOverlay)));
161    }
162
163    #[test]
164    fn height_follows_wrapped_body() {
165        let short = InfoDialog::new("T", "h", "one line");
166        let long = InfoDialog::new("T", "h", "x".repeat(200));
167        let multi = InfoDialog::new("T", "h", "a\nb");
168        assert_eq!(short.body_rows(), 1);
169        assert_eq!(long.body_rows(), 4); // 200 / 50
170        assert_eq!(multi.body_rows(), 2);
171    }
172}