Skip to main content

kimun_notes/components/dialogs/
update_dialog.rs

1use 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
18/// Dialog shown when a newer release is available. On self-update-eligible
19/// channels it offers an in-place update; otherwise it shows the package
20/// manager's upgrade command. Either way the user can skip the version.
21///
22/// ```text
23/// ┌─ Update Available ───────────────────────────────────┐
24/// │                                                      │
25/// │  kimün 0.17.0  →  0.18.0                             │
26/// │                                                      │
27/// │  [U] Update now      [S] Skip this version           │
28/// │                                                      │
29/// │  Release notes: https://github.com/nico2sh/kimun/... │
30/// │  [Esc] Close                                          │
31/// └──────────────────────────────────────────────────────┘
32/// ```
33pub struct UpdateAvailableDialog {
34    current: String,
35    latest: String,
36    /// Whether this channel can self-update in place.
37    eligible: bool,
38    /// Upgrade command for package-manager channels (e.g. `brew upgrade kimun`).
39    upgrade_hint: Option<String>,
40    /// Dismiss-on-outside-press and hint-chip clicks (see [`ModalShell`]).
41    shell: ModalShell,
42    /// Each drawn `[U]`/`[S]` action with its key, from the last render.
43    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    /// Clicking an action runs its key; a press outside the popup closes it.
61    /// Modal: every mouse event is consumed.
62    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, // swallow other keys while open
94        }
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), // 0: spacer
119                Constraint::Length(1), // 1: version line
120                Constraint::Length(1), // 2: separator
121                Constraint::Length(1), // 3: action row
122                Constraint::Length(1), // 4: spacer
123                Constraint::Length(1), // 5: release notes / hint
124                Constraint::Length(1), // 6: Esc hint
125                Constraint::Min(0),    // 7: remainder
126            ])
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        // Row 1: version transition.
135        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        // Row 2: separator.
146        super::render_separator(f, rows[2], gray, bg);
147
148        // Row 3: actions.
149        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            // Package-manager channel: show the upgrade command instead.
172            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        // Row 5: release notes URL.
196        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        // Row 6: close hint.
203        self.close_hint
204            .render(f, rows[6], Style::default().fg(gray).bg(bg), theme);
205    }
206}
207
208/// Draw `key label` into `area` and return the drawn text's rect — the
209/// action's click target (leading indent excluded).
210fn 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        // Eligible: 'u' applies.
273        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        // Not eligible: 'u' is swallowed, no apply.
281        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}