kimun_notes/components/dialogs/
info_dialog.rs1use 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
16const WIDTH: u16 = 58;
18const MARGIN: u16 = 2;
20
21pub struct InfoDialog {
37 title: String,
38 headline: String,
39 body: String,
40 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 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 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 }
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 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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(body_rows), Constraint::Length(1), Constraint::Length(1), 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); assert_eq!(multi.body_rows(), 2);
171 }
172}