aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/input.rs178
-rw-r--r--src/app/mod.rs44
-rw-r--r--src/app/state.rs13
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(),