aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-29 00:48:10 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-29 00:48:10 +0300
commit324e625c60658ab0de46928be300fc1804af48df (patch)
tree4cafa5c8f6794c64863cd1755d93010875af68d3 /src
parent1bb2676388a3541360ca106354075d67376a6843 (diff)
fix: wrap update preview copy
Diffstat (limited to 'src')
-rw-r--r--src/app/input/overlays.rs32
-rw-r--r--src/ui.rs93
-rw-r--r--src/ui/release_notes.rs201
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
};
diff --git a/src/ui.rs b/src/ui.rs
index fbe25f9f..c9999a9a 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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");
+ }
+}