diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-29 00:48:10 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-29 00:48:10 +0300 |
| commit | 324e625c60658ab0de46928be300fc1804af48df (patch) | |
| tree | 4cafa5c8f6794c64863cd1755d93010875af68d3 /src | |
| parent | 1bb2676388a3541360ca106354075d67376a6843 (diff) | |
fix: wrap update preview copy
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/overlays.rs | 32 | ||||
| -rw-r--r-- | src/ui.rs | 93 | ||||
| -rw-r--r-- | src/ui/release_notes.rs | 201 |
3 files changed, 215 insertions, 111 deletions
diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs index 7d2a8ff3..42a2cfef 100644 --- a/src/app/input/overlays.rs +++ b/src/app/input/overlays.rs @@ -385,7 +385,15 @@ impl AppState { .release_notes .as_ref() .is_some_and(|notes| notes.preview); - Some(crate::ui::release_notes_sections(body, preview).notes_body) + Some( + crate::ui::release_notes_sections( + body, + preview, + &self.update_install_command, + &self.palette, + ) + .notes_body, + ) } fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { @@ -394,17 +402,14 @@ impl AppState { let viewport_rows = body.height.max(1) as usize; let lines = crate::ui::release_notes_display_lines(notes, &self.palette); - let rows_for_width = |wrap_width: usize| { - lines - .iter() - .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1))) - .sum::<usize>() + let rows_for_width = |wrap_width: u16| { + crate::ui::release_notes_wrapped_line_count(&lines, wrap_width.max(1)) }; - let full_width = body.width.max(1) as usize; + let full_width = body.width.max(1); let mut total_rows = rows_for_width(full_width); let wrap_width = if total_rows > viewport_rows && full_width > 1 { - body.width.saturating_sub(1).max(1) as usize + body.width.saturating_sub(1).max(1) } else { full_width }; @@ -496,17 +501,14 @@ impl AppState { let viewport_rows = body.height.max(1) as usize; let lines = crate::ui::product_announcement_display_lines(announcement, &self.palette); - let rows_for_width = |wrap_width: usize| { - lines - .iter() - .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1))) - .sum::<usize>() + let rows_for_width = |wrap_width: u16| { + crate::ui::release_notes_wrapped_line_count(&lines, wrap_width.max(1)) }; - let full_width = body.width.max(1) as usize; + let full_width = body.width.max(1); let mut total_rows = rows_for_width(full_width); let wrap_width = if total_rows > viewport_rows && full_width > 1 { - body.width.saturating_sub(1).max(1) as usize + body.width.saturating_sub(1).max(1) } else { full_width }; @@ -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, PRODUCT_ANNOUNCEMENT_MODAL_SIZE, - RELEASE_NOTES_MODAL_SIZE, + release_notes_display_lines, release_notes_sections, 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::{ @@ -472,16 +472,11 @@ fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> V #[cfg(test)] mod tests { use super::keybind_help::keybind_help_groups; - use super::release_notes::{release_notes_lines, release_notes_preview_lines}; use super::scrollbar::scrollbar_thumb; use super::*; - use crate::{ - app::state::{Palette, ViewLayout}, - layout::PaneInfo, - workspace::Workspace, - }; + use crate::{app::state::ViewLayout, layout::PaneInfo, workspace::Workspace}; + use ratatui::style::Color; use ratatui::{backend::TestBackend, Terminal}; - use ratatui::{style::Color, text::Line}; #[tokio::test] async fn focused_pane_cursor_wins_during_terminal_render() { @@ -961,13 +956,6 @@ mod tests { assert_eq!(scrollbar_offset_from_drag_row(metrics, track, row, grab), 7); } - fn line_text(line: &Line<'_>) -> String { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::<String>() - } - fn buffer_row_text(buffer: &ratatui::buffer::Buffer, area: Rect, row: u16) -> String { (area.x..area.x + area.width) .map(|x| buffer[(x, row)].symbol()) @@ -992,79 +980,6 @@ mod tests { } #[test] - fn release_notes_inline_code_spans_are_styled_without_backticks() { - let palette = Palette::catppuccin(); - let lines = release_notes_lines("- `herdr pane run ...` now works", &palette); - - assert_eq!(lines.len(), 1); - assert_eq!(line_text(&lines[0].1), " • herdr pane run ... now works"); - assert_eq!(lines[0].1.spans[1].content.as_ref(), "herdr pane run ..."); - assert_eq!(lines[0].1.spans[1].style.fg, Some(palette.accent)); - assert_eq!(lines[0].1.spans[1].style.bg, Some(palette.surface0)); - } - - #[test] - fn release_notes_config_inline_code_uses_nonbreaking_spaces() { - let palette = Palette::catppuccin(); - let lines = release_notes_lines("- After: `new_tab = \"prefix+c\"`", &palette); - - assert_eq!(lines.len(), 1); - assert_eq!( - lines[0].1.spans[2].content.as_ref(), - "new_tab\u{00a0}=\u{00a0}\"prefix+c\"" - ); - assert_eq!( - line_text(&lines[0].1).replace('\u{00a0}', " "), - " • After: new_tab = \"prefix+c\"" - ); - } - - #[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); - - assert_eq!(lines.len(), 2); - assert_eq!(line_text(&lines[0]), "● update ready"); - assert_eq!( - line_text(&lines[1]), - "detach from this session, then run herdr update in your shell" - ); - assert_eq!(lines[0].spans[0].style.fg, Some(palette.accent)); - assert_eq!(lines[0].spans[1].style.fg, Some(palette.text)); - } - - #[test] - fn release_notes_fenced_code_blocks_render_as_preformatted_lines() { - let palette = Palette::catppuccin(); - let lines = release_notes_lines( - "### Fixed\n```bash\njust check\n- not a bullet\n```\n- after", - &palette, - ); - - assert_eq!(lines.len(), 4); - assert_eq!(line_text(&lines[0].1), " FIXED"); - assert_eq!(line_text(&lines[1].1), "▏ just check"); - assert_eq!(line_text(&lines[2].1), "▏ - not a bullet"); - assert_eq!(line_text(&lines[3].1), " • after"); - assert_eq!(lines[1].1.spans[0].style.fg, Some(palette.accent)); - assert_eq!(lines[1].1.spans[0].style.bg, Some(palette.surface1)); - assert_eq!(lines[1].1.spans[1].style.bg, Some(palette.surface1)); - assert_eq!(lines[1].1.spans[2].style.bg, Some(palette.surface1)); - } - - #[test] - fn release_notes_fenced_code_blocks_preserve_blank_lines() { - let palette = Palette::catppuccin(); - let lines = release_notes_lines("```\nfirst\n\nsecond\n```", &palette); - - assert_eq!(lines.len(), 3); - assert_eq!(line_text(&lines[0].1), "▏ first"); - assert_eq!(line_text(&lines[1].1), "▏ "); - assert_eq!(line_text(&lines[2].1), "▏ second"); - } - - #[test] fn prefix_mode_renders_prefix_indicator() { let mut app = crate::app::state::AppState::test_new(); app.mode = Mode::Prefix; diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs index e6d20885..1e0769e7 100644 --- a/src/ui/release_notes.rs +++ b/src/ui/release_notes.rs @@ -82,7 +82,12 @@ 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); + let sections = release_notes_sections( + stack.content, + notes.preview, + &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, @@ -389,9 +394,17 @@ pub(crate) struct ReleaseNotesSections { pub notes_body: Rect, } -pub(crate) fn release_notes_sections(area: Rect, preview: bool) -> ReleaseNotesSections { +pub(crate) fn release_notes_sections( + area: Rect, + preview: bool, + install_command: &str, + p: &Palette, +) -> ReleaseNotesSections { if preview && area.height >= 6 { - let rows = Layout::vertical([Constraint::Length(5), Constraint::Min(0)]).areas::<2>(area); + 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], @@ -404,6 +417,14 @@ pub(crate) fn release_notes_sections(area: Rect, preview: bool) -> ReleaseNotesS } } +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<'a>( _version: &str, install_command: &'a str, @@ -439,11 +460,15 @@ fn render_release_notes_preview_panel( 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(2), - Constraint::Length(1), + Constraint::Length(text_height), Constraint::Length(1), Constraint::Length(1), + Constraint::Min(0), ]) .areas::<4>(area); @@ -454,8 +479,7 @@ fn render_release_notes_preview_panel( rows[0].height, ); frame.render_widget( - Paragraph::new(release_notes_preview_lines(_version, install_command, p)) - .wrap(Wrap { trim: false }), + Paragraph::new(preview_lines).wrap(Wrap { trim: false }), text_area, ); @@ -483,7 +507,170 @@ pub(crate) fn product_announcement_display_lines<'a>( release_notes_lines(announcement.body.as_str(), p) } +pub(crate) fn release_notes_wrapped_line_count(lines: &[(usize, Line<'_>)], width: u16) -> usize { + Paragraph::new( + lines + .iter() + .map(|(_, line)| line.clone()) + .collect::<Vec<_>>(), + ) + .wrap(Wrap { trim: false }) + .line_count(width.max(1)) +} + pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect { let width = action_button_width(Some("esc"), "close"); Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::state::Palette; + use ratatui::{backend::TestBackend, Terminal}; + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .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(); + let lines = release_notes_lines("- `herdr pane run ...` now works", &palette); + + assert_eq!(lines.len(), 1); + assert_eq!(line_text(&lines[0].1), " • herdr pane run ... now works"); + assert_eq!(lines[0].1.spans[1].content.as_ref(), "herdr pane run ..."); + assert_eq!(lines[0].1.spans[1].style.fg, Some(palette.accent)); + assert_eq!(lines[0].1.spans[1].style.bg, Some(palette.surface0)); + } + + #[test] + fn release_notes_config_inline_code_uses_nonbreaking_spaces() { + let palette = Palette::catppuccin(); + let lines = release_notes_lines("- After: `new_tab = \"prefix+c\"`", &palette); + + assert_eq!(lines.len(), 1); + assert_eq!( + lines[0].1.spans[2].content.as_ref(), + "new_tab\u{00a0}=\u{00a0}\"prefix+c\"" + ); + assert_eq!( + line_text(&lines[0].1).replace('\u{00a0}', " "), + " • After: new_tab = \"prefix+c\"" + ); + } + + #[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); + + assert_eq!(lines.len(), 2); + assert_eq!(line_text(&lines[0]), "● update ready"); + assert_eq!( + line_text(&lines[1]), + "detach from this session, then run herdr update in your shell" + ); + assert_eq!(lines[0].spans[0].style.fg, Some(palette.accent)); + assert_eq!(lines[0].spans[1].style.fg, Some(palette.text)); + } + + #[test] + fn release_notes_preview_section_expands_for_wrapped_install_command() { + 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); + } + + #[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); + } + + #[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("your shell"), "{rendered}"); + } + + #[test] + fn release_notes_fenced_code_blocks_render_as_preformatted_lines() { + let palette = Palette::catppuccin(); + let lines = release_notes_lines( + "### Fixed\n```bash\njust check\n- not a bullet\n```\n- after", + &palette, + ); + + assert_eq!(lines.len(), 4); + assert_eq!(line_text(&lines[0].1), " FIXED"); + assert_eq!(line_text(&lines[1].1), "▏ just check"); + assert_eq!(line_text(&lines[2].1), "▏ - not a bullet"); + assert_eq!(line_text(&lines[3].1), " • after"); + assert_eq!(lines[1].1.spans[0].style.fg, Some(palette.accent)); + assert_eq!(lines[1].1.spans[0].style.bg, Some(palette.surface1)); + assert_eq!(lines[1].1.spans[1].style.bg, Some(palette.surface1)); + assert_eq!(lines[1].1.spans[2].style.bg, Some(palette.surface1)); + } + + #[test] + fn release_notes_fenced_code_blocks_preserve_blank_lines() { + let palette = Palette::catppuccin(); + let lines = release_notes_lines("```\nfirst\n\nsecond\n```", &palette); + + assert_eq!(lines.len(), 3); + assert_eq!(line_text(&lines[0].1), "▏ first"); + assert_eq!(line_text(&lines[1].1), "▏ "); + assert_eq!(line_text(&lines[2].1), "▏ second"); + } +} |
