mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 10:01:30 -10:00
fix(tui): fragment selections copy exact text, not whole cells (#6228)
Since #6156 every drag selection projected to whole-cell Markdown source, so a pinpoint fragment copied the entire model message. selection_to_markdown now only fires when the selection covers every touched cell end to end (edge columns reach content edges, line range aligns to cell spans); anything else falls through to the exact rendered-text fragment. Whole-cell selection keeps Markdown source unchanged. Gate: cargo fmt clean; partial_selection fragment 1 passed 0 failed (failed before the fix); full_cell_selection 1 passed 0 failed; selection_to_text suite 8 passed 0 failed.
This commit is contained in:
1 parent
b89349286f
commit
ba7e7fa996
2 files changed
+185
-4
No files matched your search
@@ -1881,17 +1881,101 @@ pub(crate) fn copy_active_selection(app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a drag selection covers every cell it touches end to end (#6228).
|
||||
///
|
||||
/// Two checks: the edge columns must reach the content edges on the boundary
|
||||
/// lines, and the line range must not cut a cell in half at either end.
|
||||
/// Middle lines are fully covered by construction, and cells render as
|
||||
/// contiguous spans, so the two edge cells decide for the whole range.
|
||||
fn selection_covers_cells_fully(
|
||||
app: &App,
|
||||
start: &TranscriptSelectionPoint,
|
||||
end: &TranscriptSelectionPoint,
|
||||
start_index: usize,
|
||||
end_index: usize,
|
||||
) -> bool {
|
||||
let (first_head, _) = match content_column_span(app, start_index) {
|
||||
Some(span) => span,
|
||||
None => return false,
|
||||
};
|
||||
if start.column > first_head {
|
||||
return false;
|
||||
}
|
||||
let (_, last_tail) = match content_column_span(app, end_index) {
|
||||
Some(span) => span,
|
||||
None => return false,
|
||||
};
|
||||
if end.column < last_tail {
|
||||
return false;
|
||||
}
|
||||
let line_meta = app.viewport.transcript_cache.line_meta();
|
||||
let mut edge_cells = (start_index..=end_index).filter_map(|line_index| {
|
||||
line_meta
|
||||
.get(line_index)
|
||||
.and_then(|meta| meta.cell_line())
|
||||
.map(|(cell_index, _)| cell_index)
|
||||
});
|
||||
let Some(first_cell) = edge_cells.next() else {
|
||||
return false;
|
||||
};
|
||||
let last_cell = edge_cells.last().unwrap_or(first_cell);
|
||||
[first_cell, last_cell].into_iter().all(|cell| {
|
||||
let mut span = line_meta
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(line_index, meta)| {
|
||||
meta.cell_line()
|
||||
.filter(|(cell_index, _)| *cell_index == cell)
|
||||
.map(|_| line_index)
|
||||
});
|
||||
match (span.next(), span.last()) {
|
||||
(Some(cell_first), Some(cell_last)) => {
|
||||
cell_first >= start_index && cell_last <= end_index
|
||||
}
|
||||
(Some(only), None) => start_index <= only && only <= end_index,
|
||||
(None, _) => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Rendered-column span of selectable content on one transcript cache line.
|
||||
///
|
||||
/// Mirrors the prefix math in [`selection_to_text`]: rail decorations plus
|
||||
/// copy-only prefixes are visual, so content runs from their combined width
|
||||
/// to that width plus the content's display width.
|
||||
fn content_column_span(app: &App, line_index: usize) -> Option<(usize, usize)> {
|
||||
let cache = &app.viewport.transcript_cache;
|
||||
let full_width = text_display_width(&line_to_plain(cache.lines().get(line_index)?));
|
||||
let rail_width = cache.rail_prefix_width(line_index).min(full_width);
|
||||
let copy_prefix = cache
|
||||
.line_meta()
|
||||
.get(line_index)
|
||||
.map(|meta| meta.copy_prefix_width())
|
||||
.unwrap_or(0)
|
||||
.min(full_width.saturating_sub(rail_width));
|
||||
let head = rail_width.saturating_add(copy_prefix);
|
||||
let tail = head.saturating_add(
|
||||
full_width
|
||||
.saturating_sub(rail_width)
|
||||
.saturating_sub(copy_prefix),
|
||||
);
|
||||
Some((head, tail))
|
||||
}
|
||||
|
||||
/// Project a transcript drag selection to Markdown source (#6156).
|
||||
///
|
||||
/// Collects every history cell intersecting the selection's rendered line
|
||||
/// range, in order, and serializes each through
|
||||
/// `history_cell_to_clipboard_text` — the same canonical projection Ctrl-Y
|
||||
/// and `/copy` use — joined with a blank line. Returns the payload plus the
|
||||
/// projected cell count for the toast. A selection that cuts a cell in half
|
||||
/// rounds out to the whole cell; the caller says so in the toast.
|
||||
/// projected cell count for the toast.
|
||||
///
|
||||
/// Returns `None` when no cell metadata intersects the range or every
|
||||
/// projection is blank; the caller falls back to [`selection_to_text`].
|
||||
/// Markdown source is only truthful for whole cells, so a selection that
|
||||
/// cuts a cell in half is not projected here at all — it keeps its exact
|
||||
/// rendered text through the caller's [`selection_to_text`] fallback (#6228).
|
||||
///
|
||||
/// Returns `None` when the selection is a fragment, when no cell metadata
|
||||
/// intersects the range, or when every projection is blank.
|
||||
pub(crate) fn selection_to_markdown(app: &App) -> Option<(String, usize)> {
|
||||
let (start, end) = app.viewport.transcript_selection.ordered_endpoints()?;
|
||||
let lines = app.viewport.transcript_cache.lines();
|
||||
@@ -1900,6 +1984,9 @@ pub(crate) fn selection_to_markdown(app: &App) -> Option<(String, usize)> {
|
||||
}
|
||||
let end_index = end.line_index.min(lines.len().saturating_sub(1));
|
||||
let start_index = start.line_index.min(end_index);
|
||||
if !selection_covers_cells_fully(app, &start, &end, start_index, end_index) {
|
||||
return None;
|
||||
}
|
||||
let line_meta = app.viewport.transcript_cache.line_meta();
|
||||
let width = app
|
||||
.viewport
|
||||
|
||||
@@ -3689,6 +3689,100 @@ fn selection_to_text_handles_multiline_and_reversed_endpoints() {
|
||||
assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\ngam"));
|
||||
}
|
||||
|
||||
/// #6228: with `selection_copy_markdown` on (the default), a fragment
|
||||
/// selection copies exactly the fragment — the Markdown path only fires for
|
||||
/// whole cells and never rounds a fragment out to the cells it touches.
|
||||
#[test]
|
||||
fn partial_selection_copies_exact_fragment_not_whole_cells() {
|
||||
let mut app = create_test_app();
|
||||
assert!(
|
||||
app.viewport.selection_copy_markdown,
|
||||
"markdown copy is the default under test"
|
||||
);
|
||||
app.history = vec![HistoryCell::Assistant {
|
||||
content: "alpha beta\ngamma delta".to_string(),
|
||||
streaming: false,
|
||||
}];
|
||||
app.resync_history_revisions();
|
||||
app.viewport.transcript_cache.ensure(
|
||||
&app.history,
|
||||
&app.history_revisions,
|
||||
80,
|
||||
app.transcript_render_options(),
|
||||
);
|
||||
|
||||
app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
|
||||
line_index: 0,
|
||||
column: 6,
|
||||
});
|
||||
app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
|
||||
line_index: 1,
|
||||
column: 5,
|
||||
});
|
||||
|
||||
assert_eq!(selection_to_markdown(&app), None);
|
||||
copy_active_selection(&mut app);
|
||||
assert_eq!(app.clipboard.last_written_text(), Some("a beta\ngam"));
|
||||
|
||||
// A fully-covered middle line is still partial: the line range cuts the
|
||||
// cell in half, so no whole cell exists to project.
|
||||
app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
|
||||
line_index: 1,
|
||||
column: 0,
|
||||
});
|
||||
app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
|
||||
line_index: 1,
|
||||
column: 80,
|
||||
});
|
||||
assert_eq!(
|
||||
selection_to_markdown(&app),
|
||||
None,
|
||||
"mid-cell line range must not round out"
|
||||
);
|
||||
}
|
||||
|
||||
/// #6156 retained: a selection covering whole cells end to end still copies
|
||||
/// Markdown source rather than rendered text.
|
||||
#[test]
|
||||
fn full_cell_selection_keeps_markdown_source() {
|
||||
let mut app = create_test_app();
|
||||
app.history = vec![HistoryCell::Assistant {
|
||||
content: "alpha beta\ngamma delta".to_string(),
|
||||
streaming: false,
|
||||
}];
|
||||
app.resync_history_revisions();
|
||||
app.viewport.transcript_cache.ensure(
|
||||
&app.history,
|
||||
&app.history_revisions,
|
||||
80,
|
||||
app.transcript_render_options(),
|
||||
);
|
||||
|
||||
let last = app
|
||||
.viewport
|
||||
.transcript_cache
|
||||
.lines()
|
||||
.len()
|
||||
.saturating_sub(1);
|
||||
app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
|
||||
line_index: 0,
|
||||
column: 0,
|
||||
});
|
||||
app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
|
||||
line_index: last,
|
||||
column: 80,
|
||||
});
|
||||
|
||||
let (text, cells) = selection_to_markdown(&app).expect("whole-cell selection keeps markdown");
|
||||
assert_eq!(cells, 1);
|
||||
assert_eq!(text, "alpha beta\ngamma delta");
|
||||
copy_active_selection(&mut app);
|
||||
assert_eq!(
|
||||
app.clipboard.last_written_text(),
|
||||
Some("alpha beta\ngamma delta")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_to_text_removes_visual_wrap_breaks_from_paragraphs() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
Reference in new issue
Block a user