use ratatui::Frame;
use ratatui::crossterm::event::KeyCode;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::widgets::Paragraph;
use crate::components::Component;
use crate::components::event_state::EventState;
use crate::components::events::{AppEvent, AppTx};
use crate::components::panel::{ModalSpec, modal_chrome};
use crate::settings::themes::Theme;
use crate::update::UpdateStatus;
pub struct UpdateAvailableDialog {
current: String,
latest: String,
eligible: bool,
upgrade_hint: Option<String>,
}
impl UpdateAvailableDialog {
pub fn new(status: &UpdateStatus) -> Self {
Self {
current: status.current.clone(),
latest: status.latest.clone(),
eligible: status.channel.self_update_eligible(),
upgrade_hint: status.channel.upgrade_hint().map(str::to_string),
}
}
pub fn handle_key(
&mut self,
key: ratatui::crossterm::event::KeyEvent,
tx: &AppTx,
) -> EventState {
match key.code {
KeyCode::Char('u') | KeyCode::Char('U') if self.eligible => {
tx.send(AppEvent::ApplyUpdate).ok();
tx.send(AppEvent::CloseOverlay).ok();
EventState::Consumed
}
KeyCode::Char('s') | KeyCode::Char('S') => {
tx.send(AppEvent::DismissUpdate(self.latest.clone())).ok();
tx.send(AppEvent::CloseOverlay).ok();
EventState::Consumed
}
KeyCode::Esc => {
tx.send(AppEvent::CloseOverlay).ok();
EventState::Consumed
}
_ => EventState::Consumed, }
}
}
impl Component for UpdateAvailableDialog {
fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
let popup_area = super::fixed_centered_rect(58, 11, rect);
let inner = modal_chrome(
f,
popup_area,
theme,
ModalSpec {
title: Some(" Update Available "),
border: Some(Style::default().fg(theme.accent.to_ratatui())),
..Default::default()
},
);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(inner);
let bg = theme.bg_panel.to_ratatui();
let fg = theme.fg.to_ratatui();
let gray = theme.gray.to_ratatui();
let key_fg = theme.selection_fg.to_ratatui();
let accent = theme.accent.to_ratatui();
f.render_widget(
Paragraph::new(format!(" kimün {} → {}", self.current, self.latest)).style(
Style::default()
.fg(accent)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
rows[1],
);
super::render_separator(f, rows[2], gray, bg);
let key_style = Style::default()
.fg(key_fg)
.bg(bg)
.add_modifier(Modifier::BOLD);
let label_style = Style::default().fg(fg).bg(bg);
if self.eligible {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(24), Constraint::Min(1)])
.split(rows[3]);
render_action(f, cols[0], " [U]", " Update now", key_style, label_style);
render_action(
f,
cols[1],
"[S]",
" Skip this version",
key_style,
label_style,
);
} else {
let hint = self
.upgrade_hint
.clone()
.unwrap_or_else(|| "Download the latest release manually.".to_string());
f.render_widget(
Paragraph::new(format!(" Run: {hint}")).style(label_style),
rows[3],
);
render_action(
f,
rows[4],
" [S]",
" Skip this version",
key_style,
label_style,
);
}
f.render_widget(
Paragraph::new(format!(" Releases: {}", crate::update::releases_url()))
.style(Style::default().fg(gray).bg(bg)),
rows[5],
);
f.render_widget(
Paragraph::new(" [Esc] Close").style(Style::default().fg(gray).bg(bg)),
rows[6],
);
}
}
fn render_action(
f: &mut Frame,
area: Rect,
key: &str,
label: &str,
key_style: Style,
label_style: Style,
) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(key.len() as u16), Constraint::Min(1)])
.split(area);
f.render_widget(Paragraph::new(key.to_string()).style(key_style), chunks[0]);
f.render_widget(
Paragraph::new(label.to_string()).style(label_style),
chunks[1],
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::update::InstallChannel;
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
use tokio::sync::mpsc;
fn status(channel: InstallChannel) -> UpdateStatus {
UpdateStatus {
current: "0.17.0".into(),
latest: "0.18.0".into(),
channel,
update_available: true,
dismissed: false,
}
}
#[test]
fn skip_sends_dismiss_and_close() {
let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
let mut d = UpdateAvailableDialog::new(&status(InstallChannel::Direct));
let state = d.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), &tx);
assert_eq!(state, EventState::Consumed);
assert!(matches!(rx.try_recv(), Ok(AppEvent::DismissUpdate(v)) if v == "0.18.0"));
assert!(matches!(rx.try_recv(), Ok(AppEvent::CloseOverlay)));
}
#[test]
fn update_now_only_on_eligible_channel() {
let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
let mut d = UpdateAvailableDialog::new(&status(InstallChannel::Script));
d.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE), &tx);
assert!(matches!(rx.try_recv(), Ok(AppEvent::ApplyUpdate)));
let (tx2, mut rx2) = mpsc::unbounded_channel::<AppEvent>();
let mut d2 = UpdateAvailableDialog::new(&status(InstallChannel::Brew));
let state = d2.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE), &tx2);
assert_eq!(state, EventState::Consumed);
assert!(rx2.try_recv().is_err());
}
}