aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-03 21:34:20 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-03 21:34:20 +0300
commitecf8b08595580e8a07fc4cf85e4800b1c1213cf3 (patch)
treeb915a50458dbfda9c60c28b32cca27f03abbd3b7 /src/app
parent05d6c6c2cd44c7db80c708c0e521ac4ac7e78e13 (diff)
feat: improve update-ready and release notes ux
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs1
-rw-r--r--src/app/input.rs122
-rw-r--r--src/app/mod.rs7
-rw-r--r--src/app/state.rs2
4 files changed, 112 insertions, 20 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index dc52b174..88366ce8 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -321,6 +321,7 @@ impl AppState {
}
AppEvent::UpdateReady { version } => {
self.update_available = Some(version.clone());
+ self.latest_release_notes_available = true;
self.update_dismissed = true;
self.toast = Some(ToastNotification {
kind: ToastKind::UpdateInstalled,
diff --git a/src/app/input.rs b/src/app/input.rs
index 73366576..e04c9245 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -484,12 +484,21 @@ fn modal_action_from_buttons<A: Copy>(col: u16, row: u16, buttons: &[(Rect, A)])
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GlobalMenuAction {
+ ApplyUpdate,
+ WhatsNew,
Keybinds,
Settings,
}
-impl GlobalMenuAction {
- const ALL: [Self; 2] = [Self::Keybinds, Self::Settings];
+fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> {
+ let mut actions = vec![GlobalMenuAction::Settings, GlobalMenuAction::Keybinds];
+ if state.latest_release_notes_available || state.update_available.is_some() {
+ actions.push(GlobalMenuAction::WhatsNew);
+ }
+ if state.update_available.is_some() {
+ actions.push(GlobalMenuAction::ApplyUpdate);
+ }
+ actions
}
fn normalize_theme_name(name: &str) -> String {
@@ -633,23 +642,39 @@ fn open_keybind_help(state: &mut AppState) {
state.mode = Mode::KeybindHelp;
}
+fn open_update_release_notes(state: &mut AppState) {
+ let Some(notes) = crate::release_notes::load_latest() else {
+ return;
+ };
+
+ state.release_notes = Some(crate::app::state::ReleaseNotesState {
+ version: notes.version,
+ body: notes.body,
+ scroll: 0,
+ preview: notes.preview,
+ });
+ state.mode = Mode::ReleaseNotes;
+}
+
fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) {
match action {
+ GlobalMenuAction::ApplyUpdate => state.should_quit = true,
+ GlobalMenuAction::WhatsNew => open_update_release_notes(state),
GlobalMenuAction::Keybinds => open_keybind_help(state),
GlobalMenuAction::Settings => open_settings(state),
}
}
fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) {
+ let actions = global_menu_actions(state);
match key.code {
KeyCode::Esc | KeyCode::Char('q') => leave_modal(state),
KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(),
- KeyCode::Down | KeyCode::Char('j') => {
- state.global_menu.move_next(GlobalMenuAction::ALL.len())
- }
+ KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()),
KeyCode::Enter => {
- let action = GlobalMenuAction::ALL[state.global_menu.highlighted];
- apply_global_menu_action(state, action);
+ if let Some(action) = actions.get(state.global_menu.highlighted).copied() {
+ apply_global_menu_action(state, action);
+ }
}
_ => {}
}
@@ -1411,16 +1436,39 @@ impl AppState {
pub(crate) fn global_launcher_rect(&self) -> Rect {
let footer = self.sidebar_footer_rect();
- let width = 6u16.min(footer.width.max(1));
+ let width = if self.update_available.is_some() {
+ 8
+ } else {
+ 6
+ }
+ .min(footer.width.max(1));
let x = footer.x + footer.width.saturating_sub(width);
Rect::new(x, footer.y, width, footer.height)
}
+ pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> {
+ let mut labels = vec!["settings", "keybinds"];
+ if self.latest_release_notes_available || self.update_available.is_some() {
+ labels.push("what's new");
+ }
+ if self.update_available.is_some() {
+ labels.push("quit to apply update");
+ }
+ labels
+ }
+
pub(crate) fn global_menu_rect(&self) -> Rect {
let screen = self.screen_rect();
let launcher = self.global_launcher_rect();
- let menu_w = 14u16.min(screen.width.max(1));
- let menu_h = (GlobalMenuAction::ALL.len() as u16 + 2).min(screen.height.max(1));
+ let labels = self.global_menu_labels();
+ let content_width = labels
+ .iter()
+ .map(|label| label.chars().count() as u16)
+ .max()
+ .unwrap_or(8)
+ .saturating_add(2);
+ let menu_w = content_width.saturating_add(2).min(screen.width.max(1));
+ let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1));
let max_x = screen.x + screen.width.saturating_sub(menu_w);
let desired_x = launcher.x + launcher.width.saturating_sub(menu_w);
let x = desired_x.min(max_x);
@@ -1438,7 +1486,7 @@ impl AppState {
return None;
}
let idx = (row - rect.y - 1) as usize;
- GlobalMenuAction::ALL.get(idx).copied()
+ global_menu_actions(self).get(idx).copied()
}
pub(crate) fn keybind_help_popup_rect(&self) -> Rect {
@@ -1689,13 +1737,10 @@ impl AppState {
&& mouse.row < launcher.y + launcher.height;
if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu {
+ let actions = global_menu_actions(self);
let hovered = self
.global_menu_item_at(mouse.column, mouse.row)
- .and_then(|action| {
- GlobalMenuAction::ALL
- .iter()
- .position(|item| *item == action)
- });
+ .and_then(|action| actions.iter().position(|item| *item == action));
self.global_menu.hover(hovered);
return None;
}
@@ -2496,6 +2541,8 @@ mod tests {
crate::api::EventHub::default(),
);
app.state.mode = Mode::Terminal;
+ app.state.update_available = None;
+ app.state.latest_release_notes_available = false;
app.state.view.sidebar_rect = Rect::new(0, 0, 26, 20);
app.state.view.terminal_area = Rect::new(26, 0, 80, 20);
app
@@ -2867,7 +2914,7 @@ mod tests {
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
menu.x + 2,
- menu.y + 1,
+ menu.y + 2,
));
assert_eq!(app.state.mode, Mode::KeybindHelp);
@@ -2887,13 +2934,52 @@ mod tests {
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
menu.x + 2,
- menu.y + 2,
+ menu.y + 1,
));
assert_eq!(app.state.mode, Mode::Settings);
}
#[test]
+ fn update_pending_menu_surfaces_apply_update_action() {
+ let mut app = app_for_mouse_test();
+ app.state.update_available = Some("0.3.2".into());
+ app.state.latest_release_notes_available = true;
+
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ assert_eq!(
+ app.state.global_menu_labels(),
+ vec!["settings", "keybinds", "what's new", "quit to apply update"]
+ );
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ menu.x + 2,
+ menu.y + 4,
+ ));
+
+ assert!(app.state.should_quit);
+ }
+
+ #[test]
+ fn whats_new_remains_in_menu_for_latest_installed_release_notes() {
+ let mut app = app_for_mouse_test();
+ app.state.latest_release_notes_available = true;
+
+ assert_eq!(
+ app.state.global_menu_labels(),
+ vec!["settings", "keybinds", "what's new"]
+ );
+ }
+
+ #[test]
fn clicking_keybind_help_close_button_closes_overlay() {
let mut app = app_for_mouse_test();
app.state.mode = Mode::KeybindHelp;
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 17fc1b5d..1ef52e1f 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -173,6 +173,8 @@ impl App {
(Vec::new(), None, 0)
};
+ let latest_release_notes_available = crate::release_notes::load_latest().is_some();
+
let mode = if config.should_show_onboarding() {
state::Mode::Onboarding
} else if startup_release_notes.is_some() {
@@ -216,6 +218,7 @@ impl App {
selection: None,
context_menu: None,
update_available: None,
+ latest_release_notes_available,
update_dismissed: false,
config_diagnostic,
toast: None,
@@ -1692,9 +1695,9 @@ impl App {
self.state.release_notes = None;
if !preview {
- if let Err(err) = crate::release_notes::clear_pending() {
+ if let Err(err) = crate::release_notes::mark_current_version_seen() {
self.state.config_diagnostic =
- Some(format!("failed to clear release notes: {err}"));
+ Some(format!("failed to update release notes status: {err}"));
self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5));
}
}
diff --git a/src/app/state.rs b/src/app/state.rs
index 43174081..bb1f5bfe 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -551,6 +551,7 @@ pub struct AppState {
pub context_menu: Option<ContextMenuState>,
// Notifications
pub update_available: Option<String>,
+ pub latest_release_notes_available: bool,
pub update_dismissed: bool,
pub config_diagnostic: Option<String>,
pub toast: Option<ToastNotification>,
@@ -651,6 +652,7 @@ impl AppState {
selection: None,
context_menu: None,
update_available: None,
+ latest_release_notes_available: false,
update_dismissed: false,
config_diagnostic: None,
toast: None,