kimun_notes/components/dialogs/
update_dialog.rs1use ratatui::Frame;
2use ratatui::crossterm::event::{KeyCode, MouseEvent};
3use ratatui::layout::{Constraint, Direction, Layout, Rect};
4use ratatui::style::{Modifier, Style};
5use ratatui::widgets::Paragraph;
6
7use super::ModalShell;
8use crate::components::Component;
9use crate::components::clickable::key_at;
10use crate::components::event_state::EventState;
11use crate::components::events::{AppEvent, AppTx, UpdateFlow};
12use crate::components::hint_row::HintRow;
13use crate::components::panel::{ModalSpec, modal_chrome};
14use crate::settings::themes::Theme;
15use crate::update::UpdateStatus;
16use unicode_width::UnicodeWidthStr;
17
18pub struct UpdateAvailableDialog {
34 current: String,
35 latest: String,
36 eligible: bool,
38 upgrade_hint: Option<String>,
40 shell: ModalShell,
42 action_rects: Vec<(Rect, KeyCode)>,
44 close_hint: HintRow,
45}
46
47impl UpdateAvailableDialog {
48 pub fn new(status: &UpdateStatus) -> Self {
49 Self {
50 current: status.current.clone(),
51 latest: status.latest.clone(),
52 eligible: status.channel.self_update_eligible(),
53 upgrade_hint: status.channel.upgrade_hint().map(str::to_string),
54 shell: ModalShell::default(),
55 action_rects: Vec::new(),
56 close_hint: HintRow::new(&[(KeyCode::Esc, "Esc", "Close")]),
57 }
58 }
59
60 pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
63 if let Some(key) = self.shell.pointer_key(m, &[&self.close_hint]) {
64 return self.handle_key(key, tx);
65 }
66 if let Some(key) = key_at(&self.action_rects, m) {
67 return self.handle_key(key, tx);
68 }
69 EventState::Consumed
70 }
71
72 pub fn handle_key(
73 &mut self,
74 key: ratatui::crossterm::event::KeyEvent,
75 tx: &AppTx,
76 ) -> EventState {
77 match key.code {
78 KeyCode::Char('u') | KeyCode::Char('U') if self.eligible => {
79 tx.send(AppEvent::Update(UpdateFlow::Apply)).ok();
80 tx.send(AppEvent::CloseOverlay).ok();
81 EventState::Consumed
82 }
83 KeyCode::Char('s') | KeyCode::Char('S') => {
84 tx.send(AppEvent::Update(UpdateFlow::Dismiss(self.latest.clone())))
85 .ok();
86 tx.send(AppEvent::CloseOverlay).ok();
87 EventState::Consumed
88 }
89 KeyCode::Esc => {
90 tx.send(AppEvent::CloseOverlay).ok();
91 EventState::Consumed
92 }
93 _ => EventState::Consumed, }
95 }
96}
97
98impl Component for UpdateAvailableDialog {
99 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
100 let popup_area = super::fixed_centered_rect(58, 11, rect);
101 self.shell.set(popup_area);
102 self.action_rects.clear();
103
104 let inner = modal_chrome(
105 f,
106 popup_area,
107 theme,
108 ModalSpec {
109 title: Some(" Update Available "),
110 border: Some(Style::default().fg(theme.accent.to_ratatui())),
111 ..Default::default()
112 },
113 );
114
115 let rows = Layout::default()
116 .direction(Direction::Vertical)
117 .constraints([
118 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
127 .split(inner);
128
129 let bg = theme.bg_panel.to_ratatui();
130 let fg = theme.fg.to_ratatui();
131 let gray = theme.gray.to_ratatui();
132 let accent = theme.accent.to_ratatui();
133
134 f.render_widget(
136 Paragraph::new(format!(" kimün {} → {}", self.current, self.latest)).style(
137 Style::default()
138 .fg(accent)
139 .bg(bg)
140 .add_modifier(Modifier::BOLD),
141 ),
142 rows[1],
143 );
144
145 super::render_separator(f, rows[2], gray, bg);
147
148 let key_style = Style::default()
150 .bg(bg)
151 .patch(theme.action())
152 .add_modifier(Modifier::BOLD);
153 let label_style = Style::default().fg(fg).bg(bg);
154 if self.eligible {
155 let cols = Layout::default()
156 .direction(Direction::Horizontal)
157 .constraints([Constraint::Length(24), Constraint::Min(1)])
158 .split(rows[3]);
159 let r = render_action(f, cols[0], " [U]", " Update now", key_style, label_style);
160 self.action_rects.push((r, KeyCode::Char('u')));
161 let r = render_action(
162 f,
163 cols[1],
164 "[S]",
165 " Skip this version",
166 key_style,
167 label_style,
168 );
169 self.action_rects.push((r, KeyCode::Char('s')));
170 } else {
171 let hint = self
173 .upgrade_hint
174 .clone()
175 .unwrap_or_else(|| "Download the latest release manually.".to_string());
176 f.render_widget(
177 Paragraph::new(format!(" Run: {hint}")).style(label_style),
178 rows[3],
179 );
180 let r = render_action(
181 f,
182 rows[4],
183 " [S]",
184 " Skip this version",
185 key_style,
186 label_style,
187 );
188 self.action_rects.push((r, KeyCode::Char('s')));
189 }
190
191 for (r, _) in &self.action_rects {
192 crate::components::clickable::register(*r);
193 }
194
195 f.render_widget(
197 Paragraph::new(format!(" Releases: {}", crate::update::releases_url()))
198 .style(Style::default().fg(gray).bg(bg)),
199 rows[5],
200 );
201
202 self.close_hint
204 .render(f, rows[6], Style::default().fg(gray).bg(bg), theme);
205 }
206}
207
208fn render_action(
211 f: &mut Frame,
212 area: Rect,
213 key: &str,
214 label: &str,
215 key_style: Style,
216 label_style: Style,
217) -> Rect {
218 let chunks = Layout::default()
219 .direction(Direction::Horizontal)
220 .constraints([Constraint::Length(key.len() as u16), Constraint::Min(1)])
221 .split(area);
222 f.render_widget(Paragraph::new(key.to_string()).style(key_style), chunks[0]);
223 f.render_widget(
224 Paragraph::new(label.to_string()).style(label_style),
225 chunks[1],
226 );
227 let indent = (key.len() - key.trim_start().len()) as u16;
228 let x = area.x.saturating_add(indent);
229 let text_w = (key.trim_start().width() + label.trim_end().width()) as u16;
230 Rect {
231 x,
232 y: area.y,
233 width: text_w.min(area.right().saturating_sub(x)),
234 height: 1,
235 }
236}
237
238impl_dialog!(UpdateAvailableDialog);
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::update::InstallChannel;
244 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
245 use tokio::sync::mpsc;
246
247 fn status(channel: InstallChannel) -> UpdateStatus {
248 UpdateStatus {
249 current: "0.17.0".into(),
250 latest: "0.18.0".into(),
251 channel,
252 update_available: true,
253 dismissed: false,
254 }
255 }
256
257 #[test]
258 fn skip_sends_dismiss_and_close() {
259 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
260 let mut d = UpdateAvailableDialog::new(&status(InstallChannel::Direct));
261 let state = d.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), &tx);
262 assert_eq!(state, EventState::Consumed);
263 assert!(
264 matches!(rx.try_recv(), Ok(AppEvent::Update(UpdateFlow::Dismiss(v))) if v == "0.18.0")
265 );
266 assert!(matches!(rx.try_recv(), Ok(AppEvent::CloseOverlay)));
267 }
268
269 #[test]
270 fn update_now_only_on_eligible_channel() {
271 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
272 let mut d = UpdateAvailableDialog::new(&status(InstallChannel::Script));
274 d.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE), &tx);
275 assert!(matches!(
276 rx.try_recv(),
277 Ok(AppEvent::Update(UpdateFlow::Apply))
278 ));
279
280 let (tx2, mut rx2) = mpsc::unbounded_channel::<AppEvent>();
282 let mut d2 = UpdateAvailableDialog::new(&status(InstallChannel::Brew));
283 let state = d2.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE), &tx2);
284 assert_eq!(state, EventState::Consumed);
285 assert!(rx2.try_recv().is_err());
286 }
287}