kimun_notes/components/dialogs/
update_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;
6
7use crate::components::Component;
8use crate::components::clickable::{is_press_outside, key_at};
9use crate::components::event_state::EventState;
10use crate::components::events::{AppEvent, AppTx, UpdateFlow};
11use crate::components::hint_row::HintRow;
12use crate::components::panel::{ModalSpec, modal_chrome};
13use crate::settings::themes::Theme;
14use crate::update::UpdateStatus;
15use unicode_width::UnicodeWidthStr;
16
17pub struct UpdateAvailableDialog {
33 current: String,
34 latest: String,
35 eligible: bool,
37 upgrade_hint: Option<String>,
39 popup_rect: Rect,
41 action_rects: Vec<(Rect, KeyCode)>,
43 close_hint: HintRow,
44}
45
46impl UpdateAvailableDialog {
47 pub fn new(status: &UpdateStatus) -> Self {
48 Self {
49 current: status.current.clone(),
50 latest: status.latest.clone(),
51 eligible: status.channel.self_update_eligible(),
52 upgrade_hint: status.channel.upgrade_hint().map(str::to_string),
53 popup_rect: Rect::default(),
54 action_rects: Vec::new(),
55 close_hint: HintRow::new(&[(KeyCode::Esc, "Esc", "Close")]),
56 }
57 }
58
59 pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
62 if is_press_outside(m, self.popup_rect) {
63 return self.handle_key(KeyEvent::from(KeyCode::Esc), tx);
64 }
65 if let Some(key) = self.close_hint.hit(m) {
66 return self.handle_key(key, tx);
67 }
68 if let Some(key) = key_at(&self.action_rects, m) {
69 return self.handle_key(key, tx);
70 }
71 EventState::Consumed
72 }
73
74 pub fn handle_key(
75 &mut self,
76 key: ratatui::crossterm::event::KeyEvent,
77 tx: &AppTx,
78 ) -> EventState {
79 match key.code {
80 KeyCode::Char('u') | KeyCode::Char('U') if self.eligible => {
81 tx.send(AppEvent::Update(UpdateFlow::Apply)).ok();
82 tx.send(AppEvent::CloseOverlay).ok();
83 EventState::Consumed
84 }
85 KeyCode::Char('s') | KeyCode::Char('S') => {
86 tx.send(AppEvent::Update(UpdateFlow::Dismiss(self.latest.clone())))
87 .ok();
88 tx.send(AppEvent::CloseOverlay).ok();
89 EventState::Consumed
90 }
91 KeyCode::Esc => {
92 tx.send(AppEvent::CloseOverlay).ok();
93 EventState::Consumed
94 }
95 _ => EventState::Consumed, }
97 }
98}
99
100impl Component for UpdateAvailableDialog {
101 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
102 let popup_area = super::fixed_centered_rect(58, 11, rect);
103 self.popup_rect = popup_area;
104 self.action_rects.clear();
105
106 let inner = modal_chrome(
107 f,
108 popup_area,
109 theme,
110 ModalSpec {
111 title: Some(" Update Available "),
112 border: Some(Style::default().fg(theme.accent.to_ratatui())),
113 ..Default::default()
114 },
115 );
116
117 let rows = Layout::default()
118 .direction(Direction::Vertical)
119 .constraints([
120 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
129 .split(inner);
130
131 let bg = theme.bg_panel.to_ratatui();
132 let fg = theme.fg.to_ratatui();
133 let gray = theme.gray.to_ratatui();
134 let accent = theme.accent.to_ratatui();
135
136 f.render_widget(
138 Paragraph::new(format!(" kimün {} → {}", self.current, self.latest)).style(
139 Style::default()
140 .fg(accent)
141 .bg(bg)
142 .add_modifier(Modifier::BOLD),
143 ),
144 rows[1],
145 );
146
147 super::render_separator(f, rows[2], gray, bg);
149
150 let key_style = Style::default()
152 .bg(bg)
153 .patch(theme.action())
154 .add_modifier(Modifier::BOLD);
155 let label_style = Style::default().fg(fg).bg(bg);
156 if self.eligible {
157 let cols = Layout::default()
158 .direction(Direction::Horizontal)
159 .constraints([Constraint::Length(24), Constraint::Min(1)])
160 .split(rows[3]);
161 let r = render_action(f, cols[0], " [U]", " Update now", key_style, label_style);
162 self.action_rects.push((r, KeyCode::Char('u')));
163 let r = render_action(
164 f,
165 cols[1],
166 "[S]",
167 " Skip this version",
168 key_style,
169 label_style,
170 );
171 self.action_rects.push((r, KeyCode::Char('s')));
172 } else {
173 let hint = self
175 .upgrade_hint
176 .clone()
177 .unwrap_or_else(|| "Download the latest release manually.".to_string());
178 f.render_widget(
179 Paragraph::new(format!(" Run: {hint}")).style(label_style),
180 rows[3],
181 );
182 let r = render_action(
183 f,
184 rows[4],
185 " [S]",
186 " Skip this version",
187 key_style,
188 label_style,
189 );
190 self.action_rects.push((r, KeyCode::Char('s')));
191 }
192
193 for (r, _) in &self.action_rects {
194 crate::components::clickable::register(*r);
195 }
196
197 f.render_widget(
199 Paragraph::new(format!(" Releases: {}", crate::update::releases_url()))
200 .style(Style::default().fg(gray).bg(bg)),
201 rows[5],
202 );
203
204 self.close_hint
206 .render(f, rows[6], Style::default().fg(gray).bg(bg), theme);
207 }
208}
209
210fn render_action(
213 f: &mut Frame,
214 area: Rect,
215 key: &str,
216 label: &str,
217 key_style: Style,
218 label_style: Style,
219) -> Rect {
220 let chunks = Layout::default()
221 .direction(Direction::Horizontal)
222 .constraints([Constraint::Length(key.len() as u16), Constraint::Min(1)])
223 .split(area);
224 f.render_widget(Paragraph::new(key.to_string()).style(key_style), chunks[0]);
225 f.render_widget(
226 Paragraph::new(label.to_string()).style(label_style),
227 chunks[1],
228 );
229 let indent = (key.len() - key.trim_start().len()) as u16;
230 let x = area.x.saturating_add(indent);
231 let text_w = (key.trim_start().width() + label.trim_end().width()) as u16;
232 Rect {
233 x,
234 y: area.y,
235 width: text_w.min(area.right().saturating_sub(x)),
236 height: 1,
237 }
238}
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}