diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/input.rs | 178 | ||||
| -rw-r--r-- | src/app/mod.rs | 44 | ||||
| -rw-r--r-- | src/app/state.rs | 13 |
3 files changed, 235 insertions, 0 deletions
diff --git a/src/app/input.rs b/src/app/input.rs index 4822a2e0..e0f012e9 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -40,6 +40,7 @@ impl App { let key = key.as_key_event(); match self.state.mode { Mode::Onboarding => self.handle_onboarding_key(key), + Mode::ReleaseNotes => self.handle_release_notes_key(key), Mode::Navigate => handle_navigate_key(&mut self.state, key), Mode::RenameSession => handle_rename_key(&mut self.state, key), Mode::Resize => handle_resize_key(&mut self.state, key), @@ -107,6 +108,28 @@ impl App { } } + fn handle_release_notes_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => self.dismiss_release_notes(), + KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1), + KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1), + KeyCode::PageUp => self.scroll_release_notes(-8), + KeyCode::PageDown => self.scroll_release_notes(8), + KeyCode::Home => { + if let Some(notes) = &mut self.state.release_notes { + notes.scroll = 0; + } + } + KeyCode::End => { + let max_scroll = self.state.release_notes_max_scroll(); + if let Some(notes) = &mut self.state.release_notes { + notes.scroll = max_scroll; + } + } + _ => {} + } + } + fn handle_settings_key(&mut self, key: KeyEvent) { if let Some(action) = update_settings_state(&mut self.state, key) { match action { @@ -118,6 +141,57 @@ impl App { } pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { + if self.state.mode == Mode::ReleaseNotes { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) + if self + .state + .release_notes_close_button_at(mouse.column, mouse.row) => + { + self.dismiss_release_notes(); + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some(target) = self + .state + .release_notes_scrollbar_target_at(mouse.column, mouse.row) + { + match target { + ScrollbarClickTarget::Thumb { grab_row_offset } => { + self.state.drag = Some(DragState { + target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, + }); + } + ScrollbarClickTarget::Track { offset_from_bottom } => { + self.state + .set_release_notes_offset_from_bottom(offset_from_bottom); + } + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(DragState { + target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, + }) = &self.state.drag + { + if let Some(offset_from_bottom) = self + .state + .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset) + { + self.state + .set_release_notes_offset_from_bottom(offset_from_bottom); + } + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.state.drag = None; + } + MouseEventKind::ScrollUp => self.scroll_release_notes(-3), + MouseEventKind::ScrollDown => self.scroll_release_notes(3), + _ => {} + } + return; + } + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && self.state.on_sidebar_divider(mouse.column, mouse.row) { @@ -667,6 +741,108 @@ impl AppState { Some(block.inner(popup)) } + fn release_notes_modal_inner(&self) -> Option<ratatui::layout::Rect> { + self.onboarding_modal_inner(76, 20) + } + + fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool { + let Some(inner) = self.release_notes_modal_inner() else { + return false; + }; + if inner.height < 4 || inner.width < 12 { + return false; + } + let button = + ratatui::layout::Rect::new(inner.x + inner.width.saturating_sub(11), inner.y, 9, 1); + col >= button.x + && col < button.x + button.width + && row >= button.y + && row < button.y + button.height + } + + fn release_notes_body_rect(&self) -> Option<Rect> { + let inner = self.release_notes_modal_inner()?; + if inner.height < 8 || inner.width < 4 { + return None; + } + let rows = ratatui::layout::Layout::vertical([ + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Min(1), + ratatui::layout::Constraint::Length(1), + ]) + .areas::<5>(inner); + Some(rows[3]) + } + + fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { + let notes = self.release_notes.as_ref()?; + let body = self.release_notes_body_rect()?; + let viewport_rows = body.height.max(1) as usize; + let wrap_width = body.width.max(1) as usize; + let total_rows = notes + .body + .lines() + .map(|line| line.chars().count().max(1).div_ceil(wrap_width)) + .sum::<usize>(); + let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); + Some(crate::pane::ScrollMetrics { + offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize), + max_offset_from_bottom, + viewport_rows, + }) + } + + pub(crate) fn release_notes_max_scroll(&self) -> u16 { + self.release_notes_scroll_metrics() + .map(|metrics| metrics.max_offset_from_bottom as u16) + .unwrap_or(0) + } + + fn release_notes_scrollbar_target_at( + &self, + col: u16, + row: u16, + ) -> Option<ScrollbarClickTarget> { + let body = self.release_notes_body_rect()?; + let metrics = self.release_notes_scroll_metrics()?; + let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; + if !(col >= track.x + && col < track.x + track.width + && row >= track.y + && row < track.y + track.height) + { + return None; + } + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { + Some(ScrollbarClickTarget::Thumb { grab_row_offset }) + } else { + Some(ScrollbarClickTarget::Track { + offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), + }) + } + } + + fn release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> { + let body = self.release_notes_body_rect()?; + let metrics = self.release_notes_scroll_metrics()?; + let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; + Some(crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + row, + grab_row_offset, + )) + } + + fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let max_scroll = self.release_notes_max_scroll() as usize; + if let Some(notes) = &mut self.release_notes { + notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; + } + } + fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) { if !matches!( mouse.kind, @@ -1060,6 +1236,7 @@ impl AppState { self.sidebar_width_auto = false; self.set_manual_sidebar_width(mouse.column); } + DragTarget::ReleaseNotesScrollbar { .. } => {} } } else if let Some(sel) = &mut self.selection { sel.drag(mouse.column, mouse.row); @@ -1537,6 +1714,7 @@ mod tests { &Config::default(), true, None, + None, api_rx, crate::api::EventHub::default(), ); diff --git a/src/app/mod.rs b/src/app/mod.rs index 928ed2aa..7c670586 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -132,6 +132,7 @@ impl App { config: &Config, no_session: bool, config_diagnostic: Option<String>, + startup_release_notes: Option<crate::release_notes::ReleaseNotes>, api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, event_hub: crate::api::EventHub, ) -> Self { @@ -167,6 +168,8 @@ impl App { let mode = if config.should_show_onboarding() { state::Mode::Onboarding + } else if startup_release_notes.is_some() { + state::Mode::ReleaseNotes } else if active.is_some() { state::Mode::Terminal } else { @@ -184,6 +187,12 @@ impl App { name_input: String::new(), onboarding_step: 0, onboarding_selected: 1, + release_notes: startup_release_notes.map(|notes| state::ReleaseNotesState { + version: notes.version, + body: notes.body, + scroll: 0, + preview: notes.preview, + }), view: state::ViewState { sidebar_rect: Rect::default(), terminal_area: Rect::default(), @@ -1214,6 +1223,41 @@ impl App { serde_json::to_string(&response).unwrap() } + pub(crate) fn dismiss_release_notes(&mut self) { + let preview = self + .state + .release_notes + .as_ref() + .is_some_and(|notes| notes.preview); + + self.state.release_notes = None; + if !preview { + if let Err(err) = crate::release_notes::clear_pending() { + self.state.config_diagnostic = + Some(format!("failed to clear release notes: {err}")); + self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5)); + } + } + + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + + pub(crate) fn scroll_release_notes(&mut self, delta: i16) { + let max_scroll = self.state.release_notes_max_scroll(); + if let Some(notes) = &mut self.state.release_notes { + notes.scroll = if delta.is_negative() { + notes.scroll.saturating_sub(delta.unsigned_abs()) + } else { + notes.scroll.saturating_add(delta as u16) + } + .min(max_scroll); + } + } + pub(crate) fn complete_onboarding(&mut self) { let (sound_enabled, toast_enabled) = match self.state.onboarding_selected { 0 => (false, false), diff --git a/src/app/state.rs b/src/app/state.rs index cd45bbb6..49c740ae 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -332,6 +332,7 @@ pub struct ViewState { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Onboarding, + ReleaseNotes, Navigate, Terminal, RenameSession, @@ -399,6 +400,9 @@ pub(crate) enum DragTarget { pane_id: crate::layout::PaneId, grab_row_offset: u16, }, + ReleaseNotesScrollbar { + grab_row_offset: u16, + }, SidebarDivider, } @@ -449,6 +453,13 @@ pub struct ToastNotification { pub context: String, } +pub struct ReleaseNotesState { + pub version: String, + pub body: String, + pub scroll: u16, + pub preview: bool, +} + /// All application state — pure data, no channels or async runtime. /// Testable without PTYs or a tokio runtime. pub struct AppState { @@ -462,6 +473,7 @@ pub struct AppState { pub name_input: String, pub onboarding_step: usize, pub onboarding_selected: usize, + pub release_notes: Option<ReleaseNotesState>, // View geometry (computed before render, consumed by render + mouse) pub view: ViewState, pub(crate) drag: Option<DragState>, @@ -551,6 +563,7 @@ impl AppState { name_input: String::new(), onboarding_step: 0, onboarding_selected: 1, + release_notes: None, view: ViewState { sidebar_rect: Rect::default(), terminal_area: Rect::default(), |
