From 98e1bccbc0782af97b8ead26a55e3d06fabb11f8 Mon Sep 17 00:00:00 2001 From: Jonathan Jacobs Date: Thu, 24 Sep 2026 09:50:18 +0200 Subject: [PATCH 1/2] fix(ui): clip printed spans to the width the layout gave them A span the layout cut off at an edge was still printed in full, so a single word wider than the screen ran into the terminal's own wrapping. --- src/ui.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/ui.rs b/src/ui.rs index 1599853719..9af00b0866 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -23,6 +23,7 @@ mod menu; pub mod picker; const CARET: &str = "\u{2588}"; +const ELLIPSIS: &str = "…"; const DASHES: &str = "────────────────────────────────────────────────────────────────"; const BLANKS: &str = " "; @@ -293,7 +294,13 @@ fn print_spans( Payload::Leaf(span) => { blank_until(term, &mut at, pos, size.0, bg, bg_end)?; term.queue_move_cursor(pos[0], pos[1])?; - print_span(term, span, highlight.within(index), highlight.style)?; + print_span( + term, + span, + item_size[0], + highlight.within(index), + highlight.style, + )?; at[0] = pos[0].saturating_add(item_size[0]); } @@ -307,15 +314,27 @@ fn print_spans( Ok(()) } +/// Prints at most `width` columns of the span, which is less than its text +/// when the layout cut it off at an edge. A cut-off span ends in an ellipsis +/// in place of its last column. fn print_span( term: &mut TermBackend, Span(text, style): &Span, + width: u16, matches: impl Iterator>, match_style: Style, ) -> Result<(), Error> { + let is_cut_off = clip(text, width as usize).len() < text.len(); + let text = if is_cut_off { + clip(text, (width as usize).saturating_sub(1)) + } else { + text + }; let mut at = 0; for matched in matches { + let matched = matched.start.min(text.len())..matched.end.min(text.len()); + if at < matched.start { term.queue_print(&text[at..matched.start], style)?; } @@ -328,9 +347,28 @@ fn print_span( term.queue_print(&text[at..], style)?; } + if is_cut_off { + term.queue_print(ELLIPSIS, style)?; + } + Ok(()) } +/// The longest prefix of `text` that fits in `width` columns. +fn clip(text: &str, width: usize) -> &str { + let mut used = 0; + + for (i, grapheme) in text.grapheme_indices(true) { + used += UnicodeWidthStr::width(grapheme); + + if used > width { + return &text[..i]; + } + } + + text +} + fn blank_until( term: &mut TermBackend, at: &mut [u16; 2], @@ -404,6 +442,15 @@ mod tests { .collect() } + #[test] + fn clip_stops_before_a_grapheme_that_does_not_fit() { + assert_eq!("abc", clip("abcdef", 3)); + assert_eq!("abcdef", clip("abcdef", 10)); + // A double-width char that only half fits is left out entirely. + assert_eq!("a", clip("a漢", 2)); + assert_eq!("", clip("漢", 1)); + } + #[test] fn a_match_may_run_from_one_span_into_the_next() { assert_eq!( From 0950de2a50b83906b455fadce29990b8f3a8bcf8 Mon Sep 17 00:00:00 2001 From: Jonathan Jacobs Date: Thu, 24 Sep 2026 09:50:18 +0200 Subject: [PATCH 2/2] feat(config): `general.wrap_lines` to cut long lines off instead of wrapping --- src/config.rs | 1 + src/default_config.toml | 3 +++ src/screen/mod.rs | 2 +- src/tests/mod.rs | 18 +++++++++++++ ..._are_cut_off_with_wrap_lines_disabled.snap | 25 +++++++++++++++++++ ...tu__tests__long_lines_wrap_by_default.snap | 25 +++++++++++++++++++ src/ui/item.rs | 13 ++++++++-- src/ui/layout/node.rs | 3 ++- 8 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 src/tests/snapshots/gitu__tests__long_lines_are_cut_off_with_wrap_lines_disabled.snap create mode 100644 src/tests/snapshots/gitu__tests__long_lines_wrap_by_default.snap diff --git a/src/config.rs b/src/config.rs index ed4bd78566..01235d5006 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,6 +60,7 @@ pub struct GeneralConfig { pub log_author_width: usize, pub mouse_support: bool, pub mouse_scroll_lines: usize, + pub wrap_lines: bool, } #[derive(Default, Debug, Deserialize)] diff --git a/src/default_config.toml b/src/default_config.toml index 6088ab2bbc..1d5170b58a 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -14,6 +14,9 @@ recent_commits_limit = 10 log_author_width = 15 mouse_support = false mouse_scroll_lines = 3 +# Wrap lines that are wider than the screen. When disabled, they are cut off +# at the right edge instead. +wrap_lines = true # When to prompt for discard confirmation. Options: # "line" - always prompt even for individual lines (default), diff --git a/src/screen/mod.rs b/src/screen/mod.rs index b3186c6a2a..5e265a51fe 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -940,7 +940,7 @@ fn layout_item<'a>(layout: &mut UiTree<'a>, screen: &'a Screen, hide_cursor: boo let line_sel = line_selection_highlight(style, &line, is_line_sel); let bg = area_sel.patch(line_sel); - layout.row_with(bg, opts().fill_x(), |layout| { + layout.row_with(bg, ui::item::row_opts(&screen.config), |layout| { let gutter_char = if !hide_cursor && line.highlighted { gutter_char(style, is_line_sel, bg) } else { diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 9cef1630d6..c12b35bbe8 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -158,6 +158,24 @@ fn recent_commits_with_limit() { snapshot!(ctx, ""); } +const LONG_SUMMARY: &str = + "a commit summary that is far too wide to fit within eighty columns of terminal"; + +#[test] +fn long_lines_wrap_by_default() { + let ctx = setup_clone!(); + commit(&ctx.dir, LONG_SUMMARY, "testing\n"); + snapshot!(ctx, ""); +} + +#[test] +fn long_lines_are_cut_off_with_wrap_lines_disabled() { + let mut ctx = setup_clone!(); + ctx.config().general.wrap_lines = false; + commit(&ctx.dir, LONG_SUMMARY, "testing\n"); + snapshot!(ctx, ""); +} + #[test] fn log() { let ctx = setup_clone!(); diff --git a/src/tests/snapshots/gitu__tests__long_lines_are_cut_off_with_wrap_lines_disabled.snap b/src/tests/snapshots/gitu__tests__long_lines_are_cut_off_with_wrap_lines_disabled.snap new file mode 100644 index 0000000000..6cedad3814 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__long_lines_are_cut_off_with_wrap_lines_disabled.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/mod.rs +expression: ctx.redact_buffer() +--- +▌On branch main | +▌Your branch is ahead of 'origin/main' by 1 commit(s). | + | + Recent commits | + ac66c1a main add a commit summary that is far too wide to fit … Author Name __ | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: b2801eef61626397 diff --git a/src/tests/snapshots/gitu__tests__long_lines_wrap_by_default.snap b/src/tests/snapshots/gitu__tests__long_lines_wrap_by_default.snap new file mode 100644 index 0000000000..470445afd9 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__long_lines_wrap_by_default.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/mod.rs +expression: ctx.redact_buffer() +--- +▌On branch main | +▌Your branch is ahead of 'origin/main' by 1 commit(s). | + | + Recent commits | + ac66c1a main add a commit summary that is far too wide to fit Author Name __ | + within eighty columns of terminal | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: 70b38ef10efcdd9f diff --git a/src/ui/item.rs b/src/ui/item.rs index b97de88680..d469aff70d 100644 --- a/src/ui/item.rs +++ b/src/ui/item.rs @@ -7,11 +7,20 @@ use crate::gitu_diff::Status; use crate::highlight; use crate::item_data::{ItemData, Ref, SectionHeader}; use crate::items::Item; -use crate::ui::layout::opts; +use crate::ui::layout::{Opts, opts}; use crate::ui::{UiTree, layout_span}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +/// Options for a row holding an item, honouring `general.wrap_lines`. +pub(crate) fn row_opts(config: &Config) -> Opts { + if config.general.wrap_lines { + opts().fill_x() + } else { + opts().fill_x().no_wrap() + } +} + /// Lays out an [`Item`] as spans in the caller's container, which is expected to /// be a single row. /// @@ -78,7 +87,7 @@ pub(crate) fn layout_item<'a>( ), ); - layout.row(opts().fill_x(), |layout| { + layout.row(row_opts(config), |layout| { for reference in associated_references { layout_span(layout, (" ".into(), base)); layout_reference(layout, reference, config, base); diff --git a/src/ui/layout/node.rs b/src/ui/layout/node.rs index 16b9a99852..cdb57d1364 100644 --- a/src/ui/layout/node.rs +++ b/src/ui/layout/node.rs @@ -67,7 +67,8 @@ impl Opts { } } - #[allow(dead_code)] + /// Cuts a child that doesn't fit off at the edge, rather than starting a + /// new line. pub fn no_wrap(self) -> Self { Self { wrap: Some(false),