Skip to main content

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