diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-01 03:00:17 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-01 03:00:17 +0300 |
| commit | 389b61660f11ddd8fdf19b81e720b1a17890f100 (patch) | |
| tree | 53295166adae100a5e0c05eaf90aab7c29f58d10 /src | |
| parent | 0a949e0ebccc54a0c0212191a2c788bbad9539ce (diff) | |
fix: unify update-ready release notes scrolling
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/overlays.rs | 54 | ||||
| -rw-r--r-- | src/ui.rs | 4 | ||||
| -rw-r--r-- | src/ui/release_notes.rs | 244 |
3 files changed, 108 insertions, 194 deletions
diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs index 42a2cfef..423334b9 100644 --- a/src/app/input/overlays.rs +++ b/src/app/input/overlays.rs @@ -380,27 +380,18 @@ impl AppState { if inner.height < 8 || inner.width < 4 { return None; } - let body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content; - let preview = self - .release_notes - .as_ref() - .is_some_and(|notes| notes.preview); - Some( - crate::ui::release_notes_sections( - body, - preview, - &self.update_install_command, - &self.palette, - ) - .notes_body, - ) + Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) } 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 lines = crate::ui::release_notes_display_lines(notes, &self.palette); + let lines = crate::ui::release_notes_display_lines( + notes, + &self.update_install_command, + &self.palette, + ); let rows_for_width = |wrap_width: u16| { crate::ui::release_notes_wrapped_line_count(&lines, wrap_width.max(1)) @@ -753,4 +744,37 @@ mod tests { assert!(app.state.request_complete_onboarding); } + + #[test] + fn release_notes_preview_scrollbar_uses_full_content_body() { + let mut app = app_for_mouse_test(); + app.state.view.sidebar_rect = Rect::new(0, 0, 24, 16); + app.state.view.terminal_area = Rect::new(24, 0, 96, 16); + app.state.release_notes = Some(crate::app::state::ReleaseNotesState { + version: "9.9.9".into(), + body: "### Added\n- Custom command keybindings now accept an optional description field.\n\n### Fixed\n- Sidebar Git status refresh now deduplicates workspaces.\n- Large restored sessions no longer leave panes without shells after startup.\n- Pane shutdown no longer warns after the direct child has already exited.\n- Closing the last pane or tab in a parent worktree workspace now shows the existing confirmation before closing the whole worktree group.\n- Update prompts, toasts, and docs now distinguish installing a new binary from stopping or reattaching a running Herdr session to use it." + .into(), + scroll: 0, + preview: true, + }); + app.state.update_install_command = "brew update && brew upgrade herdr".into(); + + let inner = app.state.release_notes_modal_inner().unwrap(); + let expected_body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content; + let body = app.state.release_notes_body_rect().unwrap(); + + assert_eq!(body, expected_body); + + let metrics = app.state.release_notes_scroll_metrics().unwrap(); + assert_eq!(metrics.viewport_rows, body.height as usize); + assert!(metrics.max_offset_from_bottom > 0); + + let track = crate::ui::release_notes_scrollbar_rect(body, metrics).unwrap(); + assert_eq!(track.y, body.y); + assert!(matches!( + app.state + .release_notes_scrollbar_target_at(track.x, track.y), + Some(ScrollbarClickTarget::Thumb { .. } | ScrollbarClickTarget::Track { .. }) + )); + } } @@ -40,8 +40,8 @@ use self::onboarding::render_onboarding_overlay; use self::panes::{compute_pane_infos, render_panes, resize_tab_panes}; pub(crate) use self::release_notes::{ product_announcement_display_lines, release_notes_close_button_rect, - release_notes_display_lines, release_notes_sections, release_notes_wrapped_line_count, - PRODUCT_ANNOUNCEMENT_MODAL_SIZE, RELEASE_NOTES_MODAL_SIZE, + release_notes_display_lines, release_notes_wrapped_line_count, PRODUCT_ANNOUNCEMENT_MODAL_SIZE, + RELEASE_NOTES_MODAL_SIZE, }; use self::release_notes::{render_product_announcement_overlay, render_release_notes_overlay}; pub(crate) use self::scrollbar::{ diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs index 9941a216..5274739c 100644 --- a/src/ui/release_notes.rs +++ b/src/ui/release_notes.rs @@ -82,41 +82,28 @@ pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, ar .add_modifier(Modifier::BOLD), ); - let sections = release_notes_sections( - stack.content, - notes.preview, - &app.update_install_command, - &app.palette, - ); + let notes_body = stack.content; + let display_lines = + release_notes_display_lines(notes, &app.update_install_command, &app.palette); let metrics = crate::pane::ScrollMetrics { offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize, max_offset_from_bottom: app.release_notes_max_scroll() as usize, - viewport_rows: sections.notes_body.height.max(1) as usize, + viewport_rows: notes_body.height.max(1) as usize, }; - let track = release_notes_scrollbar_rect(sections.notes_body, metrics); + let track = release_notes_scrollbar_rect(notes_body, metrics); let notes_text_area = track .map(|_| { Rect::new( - sections.notes_body.x, - sections.notes_body.y, - sections.notes_body.width.saturating_sub(1), - sections.notes_body.height, + notes_body.x, + notes_body.y, + notes_body.width.saturating_sub(1), + notes_body.height, ) }) - .unwrap_or(sections.notes_body); - - if let Some(instructions_area) = sections.instructions { - render_release_notes_preview_panel( - frame, - instructions_area, - ¬es.version, - &app.update_install_command, - &app.palette, - ); - } + .unwrap_or(notes_body); let body = Paragraph::new( - release_notes_display_lines(notes, &app.palette) + display_lines .into_iter() .map(|(_, line)| line) .collect::<Vec<_>>(), @@ -389,108 +376,49 @@ pub(crate) fn release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<(usize, lines } -pub(crate) struct ReleaseNotesSections { - pub instructions: Option<Rect>, - pub notes_body: Rect, -} - -pub(crate) fn release_notes_sections( - area: Rect, - preview: bool, +fn release_notes_preview_line_entries<'a>( install_command: &str, p: &Palette, -) -> ReleaseNotesSections { - if preview && area.height >= 6 { - let preview_height = release_notes_preview_panel_height(area.width, install_command, p) - .min(area.height.saturating_sub(1)); - let rows = Layout::vertical([Constraint::Length(preview_height), Constraint::Min(0)]) - .areas::<2>(area); - ReleaseNotesSections { - instructions: Some(rows[0]), - notes_body: rows[1], - } - } else { - ReleaseNotesSections { - instructions: None, - notes_body: area, - } - } -} - -fn release_notes_preview_panel_height(area_width: u16, install_command: &str, p: &Palette) -> u16 { - let text_width = area_width.saturating_sub(2).max(1); - let text_height = Paragraph::new(release_notes_preview_lines("", install_command, p)) - .wrap(Wrap { trim: false }) - .line_count(text_width) as u16; - text_height.saturating_add(3) -} - -pub(super) fn release_notes_preview_lines( - _version: &str, - install_command: &str, - p: &Palette, -) -> Vec<Line<'static>> { +) -> Vec<(usize, Line<'a>)> { let title_style = Style::default().fg(p.text).add_modifier(Modifier::BOLD); let text_style = Style::default().fg(p.text); + let inline_code_style = Style::default() + .fg(p.accent) + .bg(p.surface0) + .add_modifier(Modifier::BOLD); let instruction = crate::update::update_install_instruction(install_command); + let (instruction_width, mut instruction_spans) = + release_notes_inline_spans(&instruction, text_style, inline_code_style); + instruction_spans.insert(0, Span::raw(" ")); vec![ - Line::from(vec![ - Span::styled( - "●", - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::styled(" update ready", title_style), - ]), - Line::from(vec![Span::styled(instruction, text_style)]), + ( + 15, + Line::from(vec![ + Span::raw(" "), + Span::styled( + "●", + Style::default().fg(p.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" update ready", title_style), + ]), + ), + (instruction_width + 1, Line::from(instruction_spans)), ] } -fn render_release_notes_preview_panel( - frame: &mut Frame, - area: Rect, - _version: &str, - install_command: &str, - p: &Palette, -) { - let preview_lines = release_notes_preview_lines(_version, install_command, p); - let text_height = Paragraph::new(preview_lines.clone()) - .wrap(Wrap { trim: false }) - .line_count(area.width.saturating_sub(2).max(1)) as u16; - let rows = Layout::vertical([ - Constraint::Length(text_height), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<4>(area); - - let text_area = Rect::new( - rows[0].x + 1, - rows[0].y, - rows[0].width.saturating_sub(2), - rows[0].height, - ); - frame.render_widget( - Paragraph::new(preview_lines).wrap(Wrap { trim: false }), - text_area, - ); - - let divider_area = Rect::new(rows[2].x + 1, rows[2].y, rows[2].width.saturating_sub(2), 1); - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "─".repeat(divider_area.width as usize), - Style::default().fg(p.surface1), - )])), - divider_area, - ); -} - pub(crate) fn release_notes_display_lines<'a>( notes: &'a ReleaseNotesState, + install_command: &str, p: &Palette, ) -> Vec<(usize, Line<'a>)> { - release_notes_lines(notes.body.as_str(), p) + let mut lines = Vec::new(); + if notes.preview { + lines.extend(release_notes_preview_line_entries(install_command, p)); + lines.push((0, Line::raw(""))); + } + lines.extend(release_notes_lines(notes.body.as_str(), p)); + lines } pub(crate) fn product_announcement_display_lines<'a>( @@ -520,7 +448,6 @@ pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect { mod tests { use super::*; use crate::app::state::Palette; - use ratatui::{backend::TestBackend, Terminal}; fn line_text(line: &Line<'_>) -> String { line.spans @@ -529,19 +456,6 @@ mod tests { .collect::<String>() } - fn buffer_text(buffer: &ratatui::buffer::Buffer, area: Rect) -> String { - (area.y..area.y + area.height) - .map(|row| { - (area.x..area.x + area.width) - .map(|x| buffer[(x, row)].symbol()) - .collect::<String>() - .trim_end() - .to_string() - }) - .collect::<Vec<_>>() - .join("\n") - } - #[test] fn release_notes_inline_code_spans_are_styled_without_backticks() { let palette = Palette::catppuccin(); @@ -573,68 +487,44 @@ mod tests { #[test] fn release_notes_preview_lines_show_update_steps() { let palette = Palette::catppuccin(); - let lines = release_notes_preview_lines("0.5.0", "herdr update", &palette); + let lines = release_notes_preview_line_entries("herdr update", &palette) + .into_iter() + .map(|(_, line)| line) + .collect::<Vec<_>>(); assert_eq!(lines.len(), 2); - assert_eq!(line_text(&lines[0]), "● update ready"); + assert_eq!(line_text(&lines[0]), " ● update ready"); assert_eq!( line_text(&lines[1]), - "detach, run `herdr update`, then follow its restart guidance" + " detach, run herdr update, then follow its restart guidance" ); - assert_eq!(lines[0].spans[0].style.fg, Some(palette.accent)); - assert_eq!(lines[0].spans[1].style.fg, Some(palette.text)); + assert_eq!(lines[0].spans[1].style.fg, Some(palette.accent)); + assert_eq!(lines[0].spans[2].style.fg, Some(palette.text)); + assert_eq!(lines[1].spans[2].content.as_ref(), "herdr update"); + assert_eq!(lines[1].spans[2].style.fg, Some(palette.accent)); + assert_eq!(lines[1].spans[2].style.bg, Some(palette.surface0)); } #[test] - fn release_notes_preview_section_expands_for_wrapped_install_command() { + fn release_notes_preview_display_is_part_of_the_scrollable_notes_body() { let palette = Palette::catppuccin(); - let area = Rect::new(0, 0, 40, 12); - let sections = - release_notes_sections(area, true, "brew update && brew upgrade herdr", &palette); - - let instructions = sections - .instructions - .expect("preview should reserve an instructions panel"); - assert_eq!(instructions.height, 7); - assert_eq!(sections.notes_body.y, 7); - assert_eq!(sections.notes_body.height, 5); - } + let notes = ReleaseNotesState { + version: "0.6.6".into(), + body: "### Added\n- One".into(), + scroll: 0, + preview: true, + }; - #[test] - fn release_notes_preview_section_keeps_short_install_command_compact() { - let palette = Palette::catppuccin(); - let area = Rect::new(0, 0, 80, 12); - let sections = release_notes_sections(area, true, "herdr update", &palette); - - let instructions = sections - .instructions - .expect("preview should reserve an instructions panel"); - assert_eq!(instructions.height, 5); - assert_eq!(sections.notes_body.y, 5); - assert_eq!(sections.notes_body.height, 7); - } + let lines = release_notes_display_lines(¬es, "herdr update", &palette); - #[test] - fn release_notes_preview_panel_renders_wrapped_install_command_suffix() { - let palette = Palette::catppuccin(); - let area = Rect::new(0, 0, 40, 7); - let backend = TestBackend::new(area.width, area.height); - let mut terminal = Terminal::new(backend).unwrap(); - - terminal - .draw(|frame| { - render_release_notes_preview_panel( - frame, - area, - "0.6.4", - "brew update && brew upgrade herdr", - &palette, - ); - }) - .unwrap(); - - let rendered = buffer_text(terminal.backend().buffer(), area); - assert!(rendered.contains("Herdr session"), "{rendered}"); + assert_eq!(line_text(&lines[0].1), " ● update ready"); + assert_eq!( + line_text(&lines[1].1), + " detach, run herdr update, then follow its restart guidance" + ); + assert_eq!(line_text(&lines[2].1), ""); + assert_eq!(line_text(&lines[3].1), " ADDED"); + assert_eq!(line_text(&lines[4].1), " • One"); } #[test] |
