diff --git a/Cargo.lock b/Cargo.lock index 0a23ede..cf3f112 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2517,7 +2517,7 @@ dependencies = [ [[package]] name = "kit" -version = "0.1.96" +version = "0.1.97" dependencies = [ "a2a-protocol-client", "a2a-protocol-server", diff --git a/Cargo.toml b/Cargo.toml index 4a5659e..191ff98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kit" -version = "0.1.96" +version = "0.1.97" edition = "2024" rust-version = "1.94.0" publish = false diff --git a/src/tui/app.rs b/src/tui/app.rs index 76b5934..c20438d 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -148,12 +148,36 @@ pub struct CodeHit { pub range: Range, } +/// A display row's tags: owning tool call, fenced-code content, the logical +/// source line it was wrapped from, and source whitespace removed before this +/// row. The latter lets copy distinguish word wraps from hard token wraps. pub(super) type CachedTranscriptRow = ( Line<'static>, - (Option, Option), + (Option, Option, Option), Vec, + String, ); +/// A drag selection over the transcript, in absolute display-line coordinates +/// so it stays anchored to content while the transcript scrolls. Both cells +/// are inclusive; `anchor` is where the drag began. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Selection { + pub anchor: (usize, usize), + pub head: (usize, usize), +} + +impl Selection { + /// The selection's cells in reading order, both ends inclusive. + pub fn ordered(&self) -> ((usize, usize), (usize, usize)) { + if self.anchor <= self.head { + (self.anchor, self.head) + } else { + (self.head, self.anchor) + } + } +} + /// What the event loop should do after a key press. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ModelChoice { @@ -512,6 +536,10 @@ pub struct App { pub transcript_top: usize, pub transcript_left: usize, pub transcript_width: usize, + pub selection: Option, + /// Pending left press; the flag suppresses a release-click when this press + /// dismissed an older selection, while still allowing it to start a drag. + press: Option<(usize, usize, bool)>, pub toast: Option<(String, Instant)>, /// When the last key arrived, for telling a paste from typing. pub last_key: Option, @@ -766,6 +794,8 @@ impl App { transcript_top: 0, transcript_left: 0, transcript_width: 0, + selection: None, + press: None, toast: None, last_key: None, } @@ -1627,6 +1657,8 @@ impl App { self.row_calls.clear(); self.row_links.clear(); self.row_code.clear(); + self.selection = None; + self.press = None; } #[cfg(test)] @@ -1668,13 +1700,21 @@ impl App { } pub fn scroll_by(&mut self, lines: isize) { + self.press = None; let top = self.total_lines.saturating_sub(self.viewport); let current = self.scroll.min(top); self.scroll = current.saturating_add_signed(lines).min(top); self.follow = self.scroll >= top; } + fn scroll_to_top(&mut self) { + self.press = None; + self.follow = false; + self.scroll = 0; + } + pub fn scroll_to_bottom(&mut self) { + self.press = None; self.follow = true; self.scroll = usize::MAX; } @@ -1949,6 +1989,10 @@ impl App { } KeyCode::Char('d') if control && self.editor.is_empty() => return Action::Quit, KeyCode::Char('y') if control => { + if let Some(text) = self.selection_text() { + self.toast("copied selection"); + return Action::Copy(text); + } let Some(text) = self.latest_agent_text() else { self.toast("no agent response to copy"); return Action::None; @@ -2099,7 +2143,7 @@ impl App { self.editor.history_next(); } } - KeyCode::Home if control => self.scroll = 0, + KeyCode::Home if control => self.scroll_to_top(), KeyCode::End if control => self.scroll_to_bottom(), KeyCode::Home => self.editor.move_line_start(), KeyCode::End => self.editor.move_line_end(), @@ -2141,13 +2185,70 @@ impl App { return Action::Redraw; } MouseEventKind::Down(MouseButton::Left) => { - return self.click(mouse.column as usize, mouse.row as usize); + let dismissed = self.selection.take().is_some(); + self.press = Some((mouse.column as usize, mouse.row as usize, dismissed)); + if dismissed { + return Action::Redraw; + } + } + MouseEventKind::Drag(MouseButton::Left) => { + let Some((column, row, _)) = self.press else { + return Action::None; + }; + let Some(anchor) = self.transcript_position(column, row) else { + return Action::None; + }; + let head = + self.transcript_position_clamped(mouse.column as usize, mouse.row as usize); + self.selection = Some(Selection { anchor, head }); + return Action::Redraw; + } + MouseEventKind::Up(MouseButton::Left) => { + let press = self.press.take(); + // A drag that produced a selection is not a click. + if self.selection.is_some() { + return Action::None; + } + if let Some((column, row, false)) = press { + return self.click(column, row); + } } _ => {} } Action::None } + pub(super) fn clear_transcript_interaction(&mut self) { + self.selection = None; + self.press = None; + } + + /// Maps a screen cell to (absolute transcript line, column), if it is + /// inside the transcript area. + fn transcript_position(&self, column: usize, row: usize) -> Option<(usize, usize)> { + if self.scroll == usize::MAX || self.viewport == 0 { + return None; + } + let offset = row.checked_sub(self.transcript_top)?; + let inside = offset < self.viewport + && column >= self.transcript_left + && column < self.transcript_left + self.transcript_width; + inside.then(|| (self.scroll + offset, column - self.transcript_left)) + } + + /// Like [`Self::transcript_position`], but clamps a cell outside the + /// transcript to its nearest edge so a drag can leave the area. + fn transcript_position_clamped(&self, column: usize, row: usize) -> (usize, usize) { + let last_row = self.transcript_top + self.viewport.saturating_sub(1); + let row = row.clamp(self.transcript_top, last_row); + let last_column = self.transcript_left + self.transcript_width.saturating_sub(1); + let column = column.clamp(self.transcript_left, last_column); + ( + self.scroll + (row - self.transcript_top), + column - self.transcript_left, + ) + } + /// Copies code, opens links, or folds tool output at the clicked row. fn click(&mut self, column: usize, row: usize) -> Action { if self.scroll == usize::MAX { @@ -2199,6 +2300,95 @@ impl App { .find(|link| column >= link.start && column < link.end) .map(|link| link.url.clone()) } + + /// The cached row behind an absolute transcript line. `None` covers the + /// separator rows between blocks and lines outside the cache. + fn transcript_row(&self, line: usize) -> Option<(usize, &CachedTranscriptRow)> { + let total = self.transcript_prefixes.last().copied()?; + if line >= total || self.blocks.is_empty() { + return None; + } + let block = self + .transcript_prefixes + .partition_point(|prefix| *prefix <= line) + .saturating_sub(1) + .min(self.blocks.len() - 1); + let span_start = self.transcript_prefixes[block]; + let content_start = span_start + usize::from(span_start > 0); + let row = line.checked_sub(content_start)?; + self.transcript_cache + .get(block)? + .as_ref()? + .rows + .get(row) + .map(|cached| (block, cached)) + } + + /// The selected text, reconstructed from the rendered rows: rows wrapped + /// from one logical line rejoin, trailing padding is dropped, and fenced + /// code loses the two-column display indent it is drawn with. + pub fn selection_text(&self) -> Option { + let selection = self.selection?; + let (start, end) = selection.ordered(); + let mut lines: Vec = Vec::new(); + let mut last_logical: Option<(usize, usize)> = None; + for line in start.0..=end.0 { + let Some((block, row)) = self.transcript_row(line) else { + lines.push(String::new()); + last_logical = None; + continue; + }; + let text: String = row + .0 + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + let from = if line == start.0 { start.1 } else { 0 }; + let to = if line == end.0 { end.1 + 1 } else { usize::MAX }; + let mut fragment = column_slice(&text, from, to).trim_end().to_string(); + if row.1.1.is_some() && from == 0 && fragment.starts_with(" ") { + fragment.drain(..2); + } + let logical = row.1.2.map(|index| (block, index)); + match (logical, last_logical) { + (Some(current), Some(previous)) if current == previous => { + let joined = lines.last_mut().expect("a wrapped row follows its first"); + let fragment = fragment.trim_start(); + if !fragment.is_empty() { + joined.push_str(&row.3); + joined.push_str(fragment); + } + } + _ => lines.push(fragment), + } + last_logical = logical; + } + let text = lines.join("\n"); + let text = text.trim_matches('\n'); + (!text.trim().is_empty()).then(|| text.to_string()) + } +} + +/// The substring of `text` covering display columns `[from, to)`. +fn column_slice(text: &str, from: usize, to: usize) -> &str { + use unicode_width::UnicodeWidthChar; + + let mut column = 0; + let mut start = None; + let mut end = text.len(); + for (index, character) in text.char_indices() { + let width = character.width().unwrap_or(0); + if width > 0 && column >= to { + end = index; + break; + } + if start.is_none() && column + width > from { + start = Some(index); + } + column += width; + } + start.and_then(|start| text.get(start..end)).unwrap_or("") } #[cfg(target_os = "macos")] @@ -2246,7 +2436,9 @@ mod tests { use agent_client_protocol::schema::v2::{StopReason, ToolCallStatus, ToolKind}; use agentkit_core::{DataRef, Item, ItemKind, MediaPart, MetadataMap, Modality, Part}; - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + }; use super::{ Action, App, AttachmentKind, Block, MAX_IMAGE_BASE64_BYTES, MAX_IMAGE_SOURCE_BYTES, @@ -2267,6 +2459,67 @@ mod tests { } } + #[test] + fn column_slice_includes_a_selected_wide_character_and_its_combining_marks() { + assert_eq!(super::column_slice("界a", 1, 2), "界"); + assert_eq!(super::column_slice("e\u{301}x", 0, 1), "e\u{301}"); + } + + #[test] + fn scrolling_or_dismissing_a_selection_cancels_a_pending_mouse_press() { + let mut app = app(); + app.total_lines = 20; + app.viewport = 5; + app.follow = false; + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 2, + row: 2, + modifiers: KeyModifiers::NONE, + }; + + app.handle_mouse(down); + assert!(app.press.is_some()); + app.scroll_by(1); + assert!(app.press.is_none()); + + app.handle_mouse(down); + app.scroll_to_bottom(); + assert!(app.press.is_none()); + + app.handle_mouse(down); + app.scroll_to_top(); + assert!(app.press.is_none()); + + app.transcript_width = 10; + app.selection = Some(super::Selection { + anchor: (0, 0), + head: (0, 1), + }); + assert!(matches!(app.handle_mouse(down), Action::Redraw)); + assert!(app.selection.is_none()); + assert!(app.press.is_some()); + + assert!(matches!( + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 3, + ..down + }), + Action::Redraw + )); + assert!(app.selection.is_some()); + assert!(matches!( + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 3, + ..down + }), + Action::None + )); + assert!(app.press.is_none()); + } + fn app() -> App { App::new( PathBuf::from("/tmp"), diff --git a/src/tui/theme.rs b/src/tui/theme.rs index c86a88e..413437b 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -34,6 +34,7 @@ struct Palette { code_fg: Color, code_bg: Color, bar_bg: Color, + selection_bg: Color, } const DARK: Palette = Palette { @@ -49,6 +50,7 @@ const DARK: Palette = Palette { code_fg: Color::Rgb(232, 200, 140), code_bg: Color::Rgb(30, 33, 41), bar_bg: Color::Rgb(24, 27, 34), + selection_bg: Color::Rgb(62, 76, 110), }; /// The same roles at the same hues, darkened to carry on a light terminal. @@ -65,6 +67,7 @@ const LIGHT: Palette = Palette { code_fg: Color::Rgb(124, 72, 16), code_bg: Color::Rgb(234, 236, 240), bar_bg: Color::Rgb(216, 220, 228), + selection_bg: Color::Rgb(184, 202, 235), }; /// The palette in force, as an [`Appearance`] discriminant. @@ -420,6 +423,10 @@ pub fn bar() -> Style { Style::default().fg(palette().dim).bg(palette().bar_bg) } +pub fn selection() -> Style { + Style::default().bg(palette().selection_bg) +} + /// The frame this indicator shows on an animation tick. pub fn pulse(kind: Pulse, tick: usize) -> &'static str { let frames = kind.frames(); diff --git a/src/tui/ui.rs b/src/tui/ui.rs index c464f70..cbf15ae 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -43,7 +43,7 @@ const MAX_PENDING_STEER_ROWS: usize = 3; /// Rows of raw tool output rendered when a card is opened. const MAX_OUTPUT_ROWS: usize = 400; -type TranscriptTag = (Option, Option); +type TranscriptTag = (Option, Option, Option); type TaggedTranscriptLine = (LinkedLine, TranscriptTag); pub fn draw(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime) { @@ -355,7 +355,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti refresh_transcript_cache_with_images(app, images, width); let working_rows = if app.working() { wrap_linked_tagged( - &[(LinkedLine::plain(working_line(app)), (None, None))], + &[(LinkedLine::plain(working_line(app)), (None, None, None))], width, ) } else { @@ -385,7 +385,12 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti app.row_links.reserve(height); let mut visible = Vec::with_capacity(height); let end = offset.saturating_add(height); - let separator = (Line::default(), (None, None), Vec::new()); + let separator = ( + Line::default(), + (None, None, None), + Vec::new(), + String::new(), + ); let mut visible_images: Vec<(usize, usize, i16)> = Vec::new(); let mut materialize = |row: &crate::tui::app::CachedTranscriptRow| { #[cfg(test)] @@ -452,7 +457,9 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti materialize(row); } } + let row_widths: Vec = visible.iter().map(ratatui::text::Line::width).collect(); frame.render_widget(Paragraph::new(visible), inner); + draw_selection(frame, app, inner, offset, &row_widths); for (block_index, source_index, y) in visible_images { let Some(Block::User(message)) = app.blocks.get(block_index) else { continue; @@ -478,6 +485,46 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti } } +/// Restyles the cells a drag selected. The highlight hugs each row's text +/// instead of running to the margin, so it shows exactly what a copy takes. +fn draw_selection( + frame: &mut Frame<'_>, + app: &App, + inner: Rect, + offset: usize, + row_widths: &[usize], +) { + let Some(selection) = app.selection else { + return; + }; + let (start, end) = selection.ordered(); + for (row_index, row_width) in row_widths.iter().copied().enumerate() { + let line = offset + row_index; + if line < start.0 || line > end.0 { + continue; + } + let from = if line == start.0 { start.1 } else { 0 }; + let to = if line == end.0 { + (end.1 + 1).min(row_width) + } else { + row_width + }; + let to = to.min(inner.width as usize); + if from >= to { + continue; + } + frame.buffer_mut().set_style( + Rect { + x: inner.x + from as u16, + y: inner.y + row_index as u16, + width: (to - from) as u16, + height: 1, + }, + theme::selection(), + ); + } +} + fn welcome() -> Paragraph<'static> { let lines = vec![ Line::default(), @@ -512,13 +559,14 @@ fn hint(left_key: &str, left: &str, right_key: &str, right: &str) -> Line<'stati /// Renders the transcript, tagging each line with the tool call it belongs to /// so a click on a card can be traced back to it. fn refresh_transcript_cache_with_images(app: &mut App, images: &mut ImageRuntime, width: usize) { - if app.transcript_revisions.len() != app.blocks.len() + let structure_changed = app.transcript_revisions.len() != app.blocks.len() || app.transcript_cache.len() != app.blocks.len() - || app.transcript_prefixes.len() != app.blocks.len() + 1 - { + || app.transcript_prefixes.len() != app.blocks.len() + 1; + if structure_changed { app.sync_transcript_cache(); } let width_changed = app.transcript_cache_width != width; + let mut layout_changed = structure_changed || width_changed; if width_changed { app.transcript_cache_width = width; app.transcript_dirty.extend(0..app.blocks.len()); @@ -559,6 +607,7 @@ fn refresh_transcript_cache_with_images(app: &mut App, images: &mut ImageRuntime }; if missing || rows.len() != old_count { first_changed_count = first_changed_count.min(block_index); + layout_changed |= !missing; } app.transcript_cache[block_index] = Some(CachedTranscriptBlock { revision, @@ -578,6 +627,9 @@ fn refresh_transcript_cache_with_images(app: &mut App, images: &mut ImageRuntime app.transcript_prefixes[index + 1] = app.transcript_prefixes[index] + rows + usize::from(app.transcript_prefixes[index] > 0); } + if layout_changed { + app.clear_transcript_interaction(); + } } #[cfg(test)] @@ -595,7 +647,10 @@ fn user_block_rows( let mut placements = Vec::new(); for (line_index, text) in message.text.split('\n').enumerate() { rows.extend(wrap_linked_tagged( - &[(user_line(text, line_index == 0), (None, None))], + &[( + user_line(text, line_index == 0), + (None, None, Some(line_index)), + )], width, )); if reserve_images { @@ -606,9 +661,14 @@ fn user_block_rows( .filter(|(_, image)| image.line == line_index) { let row = rows.len(); - rows.extend( - (0..RESERVED_ROWS).map(|_| (Line::default(), (None, None), Vec::new())), - ); + rows.extend((0..RESERVED_ROWS).map(|_| { + ( + Line::default(), + (None, None, None), + Vec::new(), + String::new(), + ) + })); placements.push(CachedTranscriptImage { source, row }); } } @@ -671,12 +731,13 @@ fn transcript_block_lines( }; block_lines .into_iter() - .map(|(line, code)| { + .enumerate() + .map(|(line_index, (line, code))| { let code = code.map(|range| CodeHit { block: block_index, range, }); - (line, (call.clone(), code)) + (line, (call.clone(), code, Some(line_index))) }) .collect() } @@ -1753,12 +1814,18 @@ mod tests { .position(|line| line.contains(" cargo check")) .expect("code row is on screen"); - let action = app.handle_mouse(MouseEvent { + app.handle_mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: 5, row: u16::try_from(row).unwrap(), modifiers: KeyModifiers::NONE, }); + let action = app.handle_mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 5, + row: u16::try_from(row).unwrap(), + modifiers: KeyModifiers::NONE, + }); let Action::Copy(text) = action else { panic!("expected code copy action"); @@ -1789,17 +1856,158 @@ mod tests { .lines() .position(|line| line.contains("lines of output")) .expect("fold row is on screen"); - app.handle_mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 6, - row: u16::try_from(row).unwrap(), - modifiers: KeyModifiers::NONE, - }); + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + app.handle_mouse(MouseEvent { + kind, + column: 6, + row: u16::try_from(row).unwrap(), + modifiers: KeyModifiers::NONE, + }); + } let frame = render(&mut app, 100, 24); println!("{frame}"); assert!(frame.contains("output line 39")); } + #[test] + fn selection_copy_rejoins_wrapped_lines_and_keeps_paragraphs() { + use crate::tui::app::Selection; + + let mut app = App::new( + PathBuf::from("/tmp/kit"), + "openai-subscription".into(), + "gpt-5.4".into(), + "0:0".into(), + ); + let source = "alpha beta gamma delta epsilon zeta\n\nsecond paragraph"; + app.blocks.push(Block::Agent(source.into())); + let frame = render(&mut app, 30, 24); + assert!( + frame.lines().any(|line| line.contains("alpha beta")), + "text should be on screen: {frame}" + ); + assert!( + !frame.lines().any(|line| line.contains("delta epsilon")), + "paragraph should have wrapped: {frame}" + ); + + app.selection = Some(Selection { + anchor: (0, 0), + head: ( + app.total_lines.saturating_sub(1), + app.transcript_width.saturating_sub(1), + ), + }); + assert_eq!(app.selection_text().as_deref(), Some(source)); + } + + #[test] + fn selection_copy_does_not_add_spaces_to_hard_wrapped_tokens() { + use crate::tui::app::Selection; + + let mut app = App::new( + PathBuf::from("/tmp/kit"), + "openai-subscription".into(), + "gpt-5.4".into(), + "0:0".into(), + ); + let source = "abcdefghijklmnopqrstuvwxyz0123456789"; + app.blocks.push(Block::Agent(source.into())); + let _ = render(&mut app, 20, 24); + assert!(app.total_lines > 1); + + app.selection = Some(Selection { + anchor: (0, 0), + head: ( + app.total_lines.saturating_sub(1), + app.transcript_width.saturating_sub(1), + ), + }); + assert_eq!(app.selection_text().as_deref(), Some(source)); + } + + #[test] + fn transcript_reflow_clears_display_coordinate_selection() { + use crate::tui::app::Selection; + + let mut app = sample(); + let _ = render(&mut app, 40, 24); + app.selection = Some(Selection { + anchor: (0, 0), + head: (0, 2), + }); + + let _ = render(&mut app, 100, 24); + assert!(app.selection.is_none()); + } + + #[test] + fn selection_copy_strips_the_code_display_indent() { + use crate::tui::app::Selection; + + let mut app = sample(); + let frame = render(&mut app, 100, 24); + let row = frame + .lines() + .position(|line| line.contains(" cargo check")) + .expect("code row is on screen"); + let line = app.scroll + (row - app.transcript_top); + app.selection = Some(Selection { + anchor: (line, 0), + head: (line, app.transcript_width.saturating_sub(1)), + }); + assert_eq!(app.selection_text().as_deref(), Some("cargo check")); + } + + #[test] + fn dragging_selects_and_ctrl_y_copies_instead_of_clicking() { + use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + }; + + let mut app = sample(); + let frame = render(&mut app, 100, 24); + let row = frame + .lines() + .position(|line| line.contains("• one")) + .expect("bullet line is on screen"); + let row = u16::try_from(row).unwrap(); + let left = u16::try_from(app.transcript_left).unwrap(); + let mouse = |kind, column| MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), left + 2)); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), left + 4)); + let action = app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), left + 4)); + assert!( + matches!(action, Action::None), + "releasing a drag must not click" + ); + assert!(app.selection.is_some()); + + let action = app.handle_key(KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + }); + let Action::Copy(text) = action else { + panic!("expected selection copy"); + }; + assert_eq!(text, "one"); + + // The next press clears the selection. + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), left)); + assert!(app.selection.is_none()); + } + #[test] fn keeps_duplicate_and_wrapped_link_targets_exact() { let mut app = App::new( diff --git a/src/tui/wrap.rs b/src/tui/wrap.rs index 43bb648..bc47484 100644 --- a/src/tui/wrap.rs +++ b/src/tui/wrap.rs @@ -92,19 +92,21 @@ pub fn wrap_tagged( } /// Wraps lines whose spans carry exact link identity, retaining that identity -/// in the hit ranges for every display row produced. +/// in the hit ranges for every display row produced. The final tuple field is +/// source whitespace removed before that row, so copy can restore word wraps +/// without inserting spaces into hard-wrapped tokens. pub fn wrap_linked_tagged( lines: &[(LinkedLine, T)], width: usize, -) -> Vec<(Line<'static>, T, Vec)> { +) -> Vec<(Line<'static>, T, Vec, String)> { if width == 0 { return Vec::new(); } let mut wrapped = Vec::with_capacity(lines.len()); for (line, tag) in lines { - for row in wrap_linked_line(line, width) { + for (row, separator) in wrap_linked_line(line, width) { let (line, hits) = linked_flush(row); - wrapped.push((line, tag.clone(), hits)); + wrapped.push((line, tag.clone(), hits, separator)); } } wrapped @@ -167,25 +169,30 @@ fn wrap_line(line: &Line<'static>, width: usize) -> Vec> { /// Takes the pending spans as a line, without the trailing space that the /// wrap point left behind. -fn wrap_linked_line(line: &LinkedLine, width: usize) -> Vec> { +fn wrap_linked_line(line: &LinkedLine, width: usize) -> Vec<(Vec, String)> { if line.width() <= width { - return vec![line.spans.clone()]; + return vec![(line.spans.clone(), String::new())]; } let indent = hanging_indent(&line.line()).min(width / 2); let mut lines = Vec::new(); let mut spans = Vec::new(); + let mut separator = String::new(); let mut used = 0; for (chunk, style, url) in linked_chunks(line) { let chunk_width = chunk.width(); let blank = chunk.trim().is_empty(); if blank && spans.is_empty() && !lines.is_empty() { + separator.push_str(&chunk); continue; } if used + chunk_width > width && !spans.is_empty() { - trim_linked_and_push(&mut lines, &mut spans); + let before = std::mem::take(&mut separator); + let trailing = trim_linked_and_push(&mut lines, &mut spans, before); + separator.push_str(&trailing); used = 0; if blank { + separator.push_str(&chunk); continue; } if indent > 0 { @@ -215,7 +222,9 @@ fn wrap_linked_line(line: &LinkedLine, width: usize) -> Vec> { let character = character.to_string(); let character_width = character.width(); if used + character_width > width { - trim_linked_and_push(&mut lines, &mut spans); + let before = std::mem::take(&mut separator); + let trailing = trim_linked_and_push(&mut lines, &mut spans, before); + separator.push_str(&trailing); used = indent; if indent > 0 { spans.push(LinkedSpan { @@ -232,19 +241,26 @@ fn wrap_linked_line(line: &LinkedLine, width: usize) -> Vec> { } } if !spans.is_empty() { - trim_linked_and_push(&mut lines, &mut spans); + trim_linked_and_push(&mut lines, &mut spans, separator); } lines } -fn trim_linked_and_push(lines: &mut Vec>, spans: &mut Vec) { +fn trim_linked_and_push( + lines: &mut Vec<(Vec, String)>, + spans: &mut Vec, + separator: String, +) -> String { + let mut trailing = Vec::new(); while spans .last() .is_some_and(|span| span.span.content.trim().is_empty()) { - spans.pop(); + trailing.push(spans.pop().expect("the final span exists").span.content); } - lines.push(std::mem::take(spans)); + trailing.reverse(); + lines.push((std::mem::take(spans), separator)); + trailing.concat() } fn linked_flush(spans: Vec) -> (Line<'static>, Vec) { @@ -373,7 +389,7 @@ mod tests { let linked = wrap_linked_tagged(&[(LinkedLine::plain(line), ())], 10); let linked_lines = linked .into_iter() - .map(|(line, (), _)| line) + .map(|(line, (), _, _)| line) .collect::>(); assert_eq!(text(&linked_lines), [" alpha", " beta", " gamma"]); }