mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
feat(tui): map rendered cells to source text
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
//! Source-backed selection for the retained conversation viewport.
|
||||
//!
|
||||
//! Mouse coordinates are layout data, not clipboard data. A projection maps rendered terminal
|
||||
//! cells back to ranges in canonical semantic text; copied output is always sliced from that text.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::buffer::Cell;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::Wrap;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct SelectionSegment {
|
||||
pub(crate) columns: Range<u16>,
|
||||
pub(crate) bytes: Range<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) struct SelectionRow {
|
||||
pub(crate) segments: Vec<SelectionSegment>,
|
||||
}
|
||||
|
||||
/// Canonical copy text plus a per-rendered-row mapping back into that text.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct CellSelectionProjection {
|
||||
text: Arc<str>,
|
||||
rows: Vec<SelectionRow>,
|
||||
separator_before: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SemanticGrapheme<'a> {
|
||||
text: &'a str,
|
||||
bytes: Range<usize>,
|
||||
}
|
||||
|
||||
impl CellSelectionProjection {
|
||||
/// Build a projection by aligning the cell's rendered graphemes with semantic source text.
|
||||
///
|
||||
/// `first_row_prefix_columns` marks presentation-only chrome prepended to every logical line.
|
||||
/// It applies only to the first terminal row of a logical line because Ratatui may perform one
|
||||
/// final character wrap for an overlong token.
|
||||
pub(crate) fn from_display_lines(
|
||||
text: String,
|
||||
lines: Vec<Line<'static>>,
|
||||
width: u16,
|
||||
first_row_prefix_columns: u16,
|
||||
) -> Option<Self> {
|
||||
if text.is_empty() || width == 0 || lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let semantic = semantic_graphemes(&text);
|
||||
let mut semantic_cursor = 0;
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
let paragraph = Paragraph::new(line).wrap(Wrap { trim: false });
|
||||
let height = paragraph
|
||||
.line_count(width)
|
||||
.max(1)
|
||||
.try_into()
|
||||
.unwrap_or(u16::MAX);
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
|
||||
const UNTOUCHED: &str = "\0";
|
||||
let mut buffer = Buffer::filled(area, Cell::new(UNTOUCHED));
|
||||
paragraph.render(area, &mut buffer);
|
||||
|
||||
for row_index in 0..height {
|
||||
let mut row = SelectionRow::default();
|
||||
let mut column = 0;
|
||||
while column < width {
|
||||
let cell = &buffer[(column, row_index)];
|
||||
let symbol = cell.symbol();
|
||||
if cell.skip || symbol == UNTOUCHED {
|
||||
column = column.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if symbol.is_empty() {
|
||||
column = column.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
let symbol_width = symbol.width().max(1).min(usize::from(u16::MAX)) as u16;
|
||||
let columns = column..column.saturating_add(symbol_width).min(width);
|
||||
if row_index == 0 && columns.start < first_row_prefix_columns {
|
||||
column = column.saturating_add(symbol_width);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(source_index) =
|
||||
matching_semantic_index(&semantic, semantic_cursor, symbol)
|
||||
else {
|
||||
column = column.saturating_add(symbol_width);
|
||||
continue;
|
||||
};
|
||||
let source = &semantic[source_index];
|
||||
row.segments.push(SelectionSegment {
|
||||
columns,
|
||||
bytes: source.bytes.clone(),
|
||||
});
|
||||
semantic_cursor = source_index.saturating_add(1);
|
||||
column = column.saturating_add(symbol_width);
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
if rows.iter().all(|row| row.segments.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
text: Arc::from(text),
|
||||
rows,
|
||||
separator_before: "\n\n",
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn with_separator_before(mut self, separator_before: &'static str) -> Self {
|
||||
self.separator_before = separator_before;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
pub(crate) fn rows(&self) -> &[SelectionRow] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
pub(crate) fn hit(&self, row: usize, column: u16) -> Option<Range<usize>> {
|
||||
self.rows.get(row)?.segments.iter().find_map(|segment| {
|
||||
segment
|
||||
.columns
|
||||
.contains(&column)
|
||||
.then(|| segment.bytes.clone())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn closest_hit(&self, row: usize, column: u16) -> Option<Range<usize>> {
|
||||
if let Some(hit) = self.hit(row, column) {
|
||||
return Some(hit);
|
||||
}
|
||||
let segments = &self.rows.get(row)?.segments;
|
||||
let first = segments.first()?;
|
||||
if column < first.columns.start {
|
||||
return Some(first.bytes.start..first.bytes.start);
|
||||
}
|
||||
let last = segments.last()?;
|
||||
if column >= last.columns.end {
|
||||
return Some(last.bytes.end..last.bytes.end);
|
||||
}
|
||||
|
||||
segments.windows(2).find_map(|pair| {
|
||||
let [left, right] = pair else {
|
||||
return None;
|
||||
};
|
||||
if column < left.columns.end || column >= right.columns.start {
|
||||
return None;
|
||||
}
|
||||
let left_distance = column.saturating_sub(left.columns.end);
|
||||
let right_distance = right.columns.start.saturating_sub(column);
|
||||
if left_distance < right_distance {
|
||||
Some(left.bytes.end..left.bytes.end)
|
||||
} else {
|
||||
Some(right.bytes.start..right.bytes.start)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn closest_hit_in_any_row(&self, row: usize, column: u16) -> Option<Range<usize>> {
|
||||
self.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(candidate_row, selection_row)| {
|
||||
let first = selection_row.segments.first()?;
|
||||
let last = selection_row.segments.last()?;
|
||||
let column_distance = if column < first.columns.start {
|
||||
first.columns.start - column
|
||||
} else if column >= last.columns.end {
|
||||
column.saturating_sub(last.columns.end.saturating_sub(1))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some((
|
||||
(candidate_row.abs_diff(row), column_distance),
|
||||
candidate_row,
|
||||
))
|
||||
})
|
||||
.min_by_key(|(distance, _)| *distance)
|
||||
.and_then(|(_, candidate_row)| self.closest_hit(candidate_row, column))
|
||||
}
|
||||
}
|
||||
|
||||
fn semantic_graphemes(text: &str) -> Vec<SemanticGrapheme<'_>> {
|
||||
text.grapheme_indices(/*is_extended*/ true)
|
||||
.map(|(start, grapheme)| SemanticGrapheme {
|
||||
text: grapheme,
|
||||
bytes: start..start + grapheme.len(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn matching_semantic_index(
|
||||
semantic: &[SemanticGrapheme<'_>],
|
||||
cursor: usize,
|
||||
rendered: &str,
|
||||
) -> Option<usize> {
|
||||
let current = semantic.get(cursor)?;
|
||||
if current.text == rendered {
|
||||
return Some(cursor);
|
||||
}
|
||||
if rendered.chars().all(char::is_whitespace) {
|
||||
return current
|
||||
.text
|
||||
.chars()
|
||||
.all(|character| character.is_whitespace() && character != '\n' && character != '\r')
|
||||
.then_some(cursor);
|
||||
}
|
||||
|
||||
for (index, grapheme) in semantic.iter().enumerate().skip(cursor) {
|
||||
if grapheme.text == rendered {
|
||||
return Some(index);
|
||||
}
|
||||
if index != cursor && !grapheme.text.chars().all(char::is_whitespace) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct SelectionPoint {
|
||||
pub(crate) cell: usize,
|
||||
pub(crate) bytes: Range<usize>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ConversationSelection {
|
||||
anchor: Option<SelectionPoint>,
|
||||
focus: Option<SelectionPoint>,
|
||||
dragged: bool,
|
||||
}
|
||||
|
||||
impl ConversationSelection {
|
||||
pub(crate) fn start(&mut self, point: SelectionPoint) {
|
||||
self.anchor = Some(point.clone());
|
||||
self.focus = Some(point);
|
||||
self.dragged = false;
|
||||
}
|
||||
|
||||
pub(crate) fn update(&mut self, point: SelectionPoint) -> bool {
|
||||
if self.anchor.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.focus = Some(point);
|
||||
self.dragged = true;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn finish(
|
||||
&mut self,
|
||||
point: Option<SelectionPoint>,
|
||||
projections: &[Option<CellSelectionProjection>],
|
||||
) -> Option<String> {
|
||||
self.set_release_point(point);
|
||||
let text = self
|
||||
.dragged
|
||||
.then(|| self.selected_text(projections))
|
||||
.flatten();
|
||||
self.cancel();
|
||||
text.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn set_release_point(&mut self, point: Option<SelectionPoint>) {
|
||||
if let Some(point) = point {
|
||||
self.focus = Some(point);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancel(&mut self) {
|
||||
self.anchor = None;
|
||||
self.focus = None;
|
||||
self.dragged = false;
|
||||
}
|
||||
|
||||
pub(crate) fn is_active(&self) -> bool {
|
||||
self.anchor.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn selected_cell_span(&self) -> Option<Range<usize>> {
|
||||
let (start, end) = self.ordered_points()?;
|
||||
Some(start.cell..end.cell.saturating_add(1))
|
||||
}
|
||||
|
||||
pub(crate) fn selected_bytes_for_cell(
|
||||
&self,
|
||||
cell: usize,
|
||||
text_len: usize,
|
||||
) -> Option<Range<usize>> {
|
||||
let (start, end) = self.ordered_points()?;
|
||||
if cell < start.cell || cell > end.cell {
|
||||
return None;
|
||||
}
|
||||
let range = if start.cell == end.cell {
|
||||
start.bytes.start..end.bytes.end
|
||||
} else if cell == start.cell {
|
||||
start.bytes.start..text_len
|
||||
} else if cell == end.cell {
|
||||
0..end.bytes.end
|
||||
} else {
|
||||
0..text_len
|
||||
};
|
||||
(range.start < range.end).then_some(range)
|
||||
}
|
||||
|
||||
fn selected_text(&self, projections: &[Option<CellSelectionProjection>]) -> Option<String> {
|
||||
let (start, end) = self.ordered_points()?;
|
||||
let mut selected = String::new();
|
||||
for cell in start.cell..=end.cell {
|
||||
let projection = projections.get(cell)?.as_ref()?;
|
||||
let Some(range) = self.selected_bytes_for_cell(cell, projection.text().len()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(fragment) = projection.text().get(range) else {
|
||||
continue;
|
||||
};
|
||||
if !selected.is_empty() {
|
||||
selected.push_str(projection.separator_before);
|
||||
}
|
||||
selected.push_str(fragment);
|
||||
}
|
||||
Some(selected)
|
||||
}
|
||||
|
||||
fn ordered_points(&self) -> Option<(&SelectionPoint, &SelectionPoint)> {
|
||||
let anchor = self.anchor.as_ref()?;
|
||||
let focus = self.focus.as_ref()?;
|
||||
if (anchor.cell, anchor.bytes.start) <= (focus.cell, focus.bytes.start) {
|
||||
Some((anchor, focus))
|
||||
} else {
|
||||
Some((focus, anchor))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "conversation_selection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,359 @@
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn projection(
|
||||
text: &str,
|
||||
rendered: impl IntoIterator<Item = &'static str>,
|
||||
width: u16,
|
||||
prefix_columns: u16,
|
||||
) -> CellSelectionProjection {
|
||||
CellSelectionProjection::from_display_lines(
|
||||
text.to_string(),
|
||||
rendered.into_iter().map(Line::from).collect(),
|
||||
width,
|
||||
prefix_columns,
|
||||
)
|
||||
.expect("projection should contain selectable text")
|
||||
}
|
||||
|
||||
fn point(
|
||||
projection: &CellSelectionProjection,
|
||||
cell: usize,
|
||||
row: usize,
|
||||
column: u16,
|
||||
) -> SelectionPoint {
|
||||
SelectionPoint {
|
||||
cell,
|
||||
bytes: projection
|
||||
.hit(row, column)
|
||||
.expect("coordinate should map to source text"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_wrapped_selection_returns_original_space_without_newline() {
|
||||
let projection = projection(
|
||||
"alpha beta",
|
||||
["› alpha", " beta"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 2,
|
||||
);
|
||||
let mut selection = ConversationSelection::default();
|
||||
selection.start(point(
|
||||
&projection,
|
||||
/*cell*/ 0,
|
||||
/*row*/ 0,
|
||||
/*column*/ 2,
|
||||
));
|
||||
selection.update(point(
|
||||
&projection,
|
||||
/*cell*/ 0,
|
||||
/*row*/ 1,
|
||||
/*column*/ 5,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
selection.finish(/*point*/ None, &[Some(projection)]),
|
||||
Some("alpha beta".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_newline_is_preserved_across_rendered_rows() {
|
||||
let projection = projection(
|
||||
"alpha\nbeta",
|
||||
["› alpha", " beta"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 2,
|
||||
);
|
||||
let mut selection = ConversationSelection::default();
|
||||
selection.start(point(
|
||||
&projection,
|
||||
/*cell*/ 0,
|
||||
/*row*/ 0,
|
||||
/*column*/ 2,
|
||||
));
|
||||
selection.update(point(
|
||||
&projection,
|
||||
/*cell*/ 0,
|
||||
/*row*/ 1,
|
||||
/*column*/ 5,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
selection.finish(/*point*/ None, &[Some(projection)]),
|
||||
Some("alpha\nbeta".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_drag_normalizes_to_the_same_source_range() {
|
||||
let projection = projection(
|
||||
"alpha beta",
|
||||
["› alpha", " beta"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 2,
|
||||
);
|
||||
let mut selection = ConversationSelection::default();
|
||||
selection.start(point(
|
||||
&projection,
|
||||
/*cell*/ 0,
|
||||
/*row*/ 1,
|
||||
/*column*/ 5,
|
||||
));
|
||||
selection.update(point(
|
||||
&projection,
|
||||
/*cell*/ 0,
|
||||
/*row*/ 0,
|
||||
/*column*/ 2,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
selection.finish(/*point*/ None, &[Some(projection)]),
|
||||
Some("alpha beta".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_are_copied_from_source_instead_of_visual_spaces() {
|
||||
let projection = projection(
|
||||
"a\tb",
|
||||
["a b"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 0,
|
||||
);
|
||||
let mut selection = ConversationSelection::default();
|
||||
selection.start(point(
|
||||
&projection,
|
||||
/*cell*/ 0,
|
||||
/*row*/ 0,
|
||||
/*column*/ 0,
|
||||
));
|
||||
selection.update(point(
|
||||
&projection,
|
||||
/*cell*/ 0,
|
||||
/*row*/ 0,
|
||||
/*column*/ 5,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
selection.finish(/*point*/ None, &[Some(projection)]),
|
||||
Some("a\tb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_and_combining_graphemes_map_as_complete_utf8_ranges() {
|
||||
let projection = projection(
|
||||
"界e\u{301}🙂",
|
||||
["界é🙂"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 0,
|
||||
);
|
||||
|
||||
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 0), Some(0.."界".len()));
|
||||
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 1), Some(0.."界".len()));
|
||||
assert_eq!(
|
||||
projection.hit(/*row*/ 0, /*column*/ 2),
|
||||
Some("界".len().."界e\u{301}".len())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_prefix_and_padding_have_no_source_mapping() {
|
||||
let projection = projection(
|
||||
"hello",
|
||||
["› hello"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 2,
|
||||
);
|
||||
|
||||
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 0), None);
|
||||
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 1), None);
|
||||
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 8), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_wrap_padding_does_not_consume_an_invisible_source_space() {
|
||||
let projection = projection(
|
||||
"alpha beta",
|
||||
["alpha beta"],
|
||||
/*width*/ 8,
|
||||
/*prefix_columns*/ 0,
|
||||
);
|
||||
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 5), None);
|
||||
|
||||
let mut selection = ConversationSelection::default();
|
||||
selection.start(point(
|
||||
&projection,
|
||||
/*cell*/ 0,
|
||||
/*row*/ 0,
|
||||
/*column*/ 0,
|
||||
));
|
||||
selection.update(SelectionPoint {
|
||||
cell: 0,
|
||||
bytes: projection
|
||||
.closest_hit(/*row*/ 0, /*column*/ 7)
|
||||
.expect("padding should clamp to the last rendered grapheme"),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
selection.finish(/*point*/ None, &[Some(projection)]),
|
||||
Some("alpha".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamping_to_a_wrapped_prefix_stops_before_the_first_source_glyph() {
|
||||
let projection = projection(
|
||||
"alpha beta",
|
||||
["› alpha", " beta"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 2,
|
||||
);
|
||||
let mut selection = ConversationSelection::default();
|
||||
selection.start(point(
|
||||
&projection,
|
||||
/*cell*/ 0,
|
||||
/*row*/ 0,
|
||||
/*column*/ 2,
|
||||
));
|
||||
selection.update(SelectionPoint {
|
||||
cell: 0,
|
||||
bytes: projection
|
||||
.closest_hit(/*row*/ 1, /*column*/ 0)
|
||||
.expect("the prefix should clamp to the boundary before beta"),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
selection.finish(/*point*/ None, &[Some(projection)]),
|
||||
Some("alpha ".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_grapheme_continuation_does_not_consume_following_space() {
|
||||
let projection = projection(
|
||||
"界 x",
|
||||
["界 x"],
|
||||
/*width*/ 8,
|
||||
/*prefix_columns*/ 0,
|
||||
);
|
||||
|
||||
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 0), Some(0.."界".len()));
|
||||
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 1), Some(0.."界".len()));
|
||||
assert_eq!(
|
||||
projection.hit(/*row*/ 0, /*column*/ 2),
|
||||
Some("界".len().."界 ".len())
|
||||
);
|
||||
assert_eq!(
|
||||
projection.hit(/*row*/ 0, /*column*/ 3),
|
||||
Some("界 ".len().."界 x".len())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_cell_selection_uses_explicit_message_separator() {
|
||||
let first = projection(
|
||||
"first",
|
||||
["first"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 0,
|
||||
);
|
||||
let second = projection(
|
||||
"second",
|
||||
["second"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 0,
|
||||
);
|
||||
let mut selection = ConversationSelection::default();
|
||||
selection.start(point(
|
||||
&first, /*cell*/ 0, /*row*/ 0, /*column*/ 0,
|
||||
));
|
||||
selection.update(point(
|
||||
&second, /*cell*/ 1, /*row*/ 0, /*column*/ 5,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
selection.finish(/*point*/ None, &[Some(first), Some(second)]),
|
||||
Some("first\n\nsecond".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_continuation_uses_a_single_authored_line_break() {
|
||||
let first = projection(
|
||||
"first",
|
||||
["first"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 0,
|
||||
);
|
||||
let second = projection(
|
||||
"second",
|
||||
["second"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 0,
|
||||
)
|
||||
.with_separator_before("\n");
|
||||
let mut selection = ConversationSelection::default();
|
||||
selection.start(point(
|
||||
&first, /*cell*/ 0, /*row*/ 0, /*column*/ 0,
|
||||
));
|
||||
selection.update(point(
|
||||
&second, /*cell*/ 1, /*row*/ 0, /*column*/ 5,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
selection.finish(/*point*/ None, &[Some(first), Some(second)]),
|
||||
Some("first\nsecond".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_does_not_silently_skip_an_unsupported_cell() {
|
||||
let first = projection(
|
||||
"first",
|
||||
["first"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 0,
|
||||
);
|
||||
let third = projection(
|
||||
"third",
|
||||
["third"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 0,
|
||||
);
|
||||
let mut selection = ConversationSelection::default();
|
||||
selection.start(point(
|
||||
&first, /*cell*/ 0, /*row*/ 0, /*column*/ 0,
|
||||
));
|
||||
selection.update(point(
|
||||
&third, /*cell*/ 2, /*row*/ 0, /*column*/ 4,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
selection.finish(/*point*/ None, &[Some(first), None, Some(third)],),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_without_drag_does_not_copy() {
|
||||
let projection = projection(
|
||||
"hello",
|
||||
["hello"],
|
||||
/*width*/ 20,
|
||||
/*prefix_columns*/ 0,
|
||||
);
|
||||
let mut selection = ConversationSelection::default();
|
||||
selection.start(point(
|
||||
&projection,
|
||||
/*cell*/ 0,
|
||||
/*row*/ 0,
|
||||
/*column*/ 0,
|
||||
));
|
||||
|
||||
assert_eq!(selection.finish(/*point*/ None, &[Some(projection)]), None);
|
||||
}
|
||||
@@ -105,6 +105,7 @@ mod clipboard_paste;
|
||||
mod collaboration_modes;
|
||||
mod color;
|
||||
mod config_update;
|
||||
mod conversation_selection;
|
||||
mod conversation_viewport;
|
||||
pub(crate) mod custom_terminal;
|
||||
mod pets;
|
||||
|
||||
Reference in New Issue
Block a user