mirror of
https://github.com/openai/codex.git
synced 2026-09-28 08:43:01 +08:00
Preserve table cell source metadata through TUI rendering (#48548)
## What changed - Retain table identity, column alignments, cell coordinates, source byte ranges, and inline formatting in copy metadata. - Carry cell fragments through grid padding, wrapping, key/value layouts, and pipe output, including escaped pipes and empty cells. - Preserve logical source metadata when remapping wrapped lines, even without hyperlinks. ## Testing Add coverage for cell fragments across narrow and wide layouts, blank table continuations, and Markdown selection copy structure and visible transformations. GitOrigin-RevId: 8422f224cffe2e74813394f0623b9167bdd4ac59
This commit is contained in:
@@ -14,6 +14,8 @@ use pulldown_cmark::Tag;
|
||||
use crate::clipboard_copy::CopyFormat;
|
||||
use crate::terminal_hyperlinks::LogicalLineSource;
|
||||
|
||||
pub(crate) mod table;
|
||||
|
||||
// Bound retained inline stacks and container prefixes independently of parser nesting.
|
||||
pub(crate) const MAX_COPY_DEPTH: usize = 64;
|
||||
|
||||
@@ -56,6 +58,7 @@ pub(crate) struct CopyLine {
|
||||
/// Restore the containing item when a multiline selection starts in its later paragraph.
|
||||
pub(crate) item_prefix: String,
|
||||
pub(crate) code: bool,
|
||||
pub(crate) table: Option<table::TableLine>,
|
||||
pub(crate) rule: bool,
|
||||
pub(crate) heading: usize,
|
||||
pub(crate) hard_break: bool,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Retain original table-cell ranges through grid padding, wrapping, and record labels.
|
||||
//!
|
||||
//! Each displayed fragment keeps its source line and cell coordinates so selection
|
||||
//! serialization can recover only visible, selected content.
|
||||
|
||||
use super::CopyLine;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::LogicalLineSource;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "table_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct TableLine {
|
||||
pub(crate) table: Arc<[&'static str]>,
|
||||
fragments: Vec<Fragment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Fragment {
|
||||
output: Range<usize>,
|
||||
row: usize,
|
||||
column: usize,
|
||||
source: LogicalLineSource,
|
||||
}
|
||||
|
||||
impl TableLine {
|
||||
pub(crate) fn empty(table: &Arc<[&'static str]>) -> Self {
|
||||
Self {
|
||||
table: Arc::clone(table),
|
||||
fragments: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn annotate_cell(
|
||||
lines: &mut [HyperlinkLine],
|
||||
copies: Vec<CopyLine>,
|
||||
table: &Arc<[&'static str]>,
|
||||
row: usize,
|
||||
column: usize,
|
||||
) {
|
||||
for (line, copy) in lines.iter_mut().zip(copies) {
|
||||
let mut source = LogicalLineSource::from_line(&line.line);
|
||||
source.copy = Some(Arc::new(copy));
|
||||
attach(
|
||||
line,
|
||||
Some(TableLine {
|
||||
table: Arc::clone(table),
|
||||
fragments: vec![Fragment {
|
||||
output: source.range.clone(),
|
||||
row,
|
||||
column,
|
||||
source,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attach(line: &mut HyperlinkLine, table: Option<TableLine>) {
|
||||
if let Some(table) = table {
|
||||
let mut source = LogicalLineSource::from_line(&line.line);
|
||||
source.copy = Some(Arc::new(CopyLine {
|
||||
table: Some(table),
|
||||
..Default::default()
|
||||
}));
|
||||
line.source = Some(source);
|
||||
}
|
||||
}
|
||||
|
||||
/// Project a displayed cell fragment into the byte coordinates of its composed row.
|
||||
pub(crate) fn append(output: &mut Option<TableLine>, source: &LogicalLineSource, offset: usize) {
|
||||
let Some(table) = source.copy.as_ref().and_then(|copy| copy.table.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
let output = output.get_or_insert_with(|| TableLine::empty(&table.table));
|
||||
for fragment in &table.fragments {
|
||||
let start = source.range.start.max(fragment.output.start);
|
||||
let end = source.range.end.min(fragment.output.end);
|
||||
if start > end || start == end && !fragment.source.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut cell = fragment.source.clone();
|
||||
cell.range = cell.range.start + start - fragment.output.start
|
||||
..cell.range.start + end - fragment.output.start;
|
||||
output.fragments.push(Fragment {
|
||||
output: offset + source.prefix_bytes + start - source.range.start
|
||||
..offset + source.prefix_bytes + end - source.range.start,
|
||||
row: fragment.row,
|
||||
column: fragment.column,
|
||||
source: cell,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Exercise retained cell coordinates, inline styles, and byte ranges through each layout.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn table_layouts_preserve_cell_fragments() {
|
||||
for (markdown, expected) in [
|
||||
(
|
||||
"| **alpha head** | B |\n|---|---|\n| **one two three four** | `value` |\n| | last |",
|
||||
BTreeMap::from([
|
||||
((0, 0), "**alpha head**"),
|
||||
((0, 1), "B"),
|
||||
((1, 0), "**one two three four**"),
|
||||
((1, 1), "`value`"),
|
||||
((2, 0), ""),
|
||||
((2, 1), "last"),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"| **alpha head** | `a\\|b` |\n|---|---|",
|
||||
BTreeMap::from([((0, 0), "**alpha head**"), ((0, 1), "`a|b`")]),
|
||||
),
|
||||
] {
|
||||
for width in [4, 12, 80] {
|
||||
let lines = crate::markdown_render::render_markdown_lines_with_width_and_cwd(
|
||||
markdown,
|
||||
Some(width),
|
||||
/*cwd*/ None,
|
||||
);
|
||||
let mut seen = BTreeMap::new();
|
||||
let mut identity = None;
|
||||
for source in lines.into_iter().filter_map(|line| line.source) {
|
||||
let Some(table) = source.copy.as_ref().and_then(|copy| copy.table.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
let identity = identity.get_or_insert_with(|| Arc::clone(&table.table));
|
||||
assert!(Arc::ptr_eq(identity, &table.table));
|
||||
for fragment in &table.fragments {
|
||||
let cell = &fragment.source;
|
||||
assert_eq!(
|
||||
&source.text[fragment.output.clone()],
|
||||
&cell.text[cell.range.clone()],
|
||||
"width {width}, cell ({}, {})",
|
||||
fragment.row,
|
||||
fragment.column,
|
||||
);
|
||||
let markdown = cell.copy.as_ref().unwrap().render(
|
||||
&cell.text,
|
||||
0..cell.text.len(),
|
||||
/*depth*/ 0,
|
||||
);
|
||||
let key = (fragment.row, fragment.column);
|
||||
assert_eq!(markdown, expected[&key]);
|
||||
seen.insert(key, markdown);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
seen,
|
||||
expected
|
||||
.iter()
|
||||
.map(|(key, value)| (*key, value.to_string()))
|
||||
.collect()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,7 @@ impl IndentContext {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct TableCell {
|
||||
lines: Vec<HyperlinkLine>,
|
||||
copies: Vec<crate::markdown_copy::CopyLine>,
|
||||
}
|
||||
|
||||
// TableCell mutators inlined — called per-span during table event parsing.
|
||||
@@ -201,11 +202,27 @@ impl TableCell {
|
||||
fn ensure_line(&mut self) {
|
||||
if self.lines.is_empty() {
|
||||
self.lines.push(HyperlinkLine::new(Line::default()));
|
||||
self.copies.push(Default::default());
|
||||
}
|
||||
}
|
||||
|
||||
fn push_annotated(&mut self, mut appended: HyperlinkLine) {
|
||||
fn push_annotated(
|
||||
&mut self,
|
||||
mut appended: HyperlinkLine,
|
||||
inline: &[crate::markdown_copy::Inline],
|
||||
) {
|
||||
self.ensure_line();
|
||||
if let Some(copy) = self.copies.last_mut() {
|
||||
copy.push(
|
||||
appended
|
||||
.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.len())
|
||||
.sum(),
|
||||
inline,
|
||||
);
|
||||
}
|
||||
if let Some(line) = self.lines.last_mut() {
|
||||
let shift = line.width();
|
||||
line.line.spans.append(&mut appended.line.spans);
|
||||
@@ -220,6 +237,7 @@ impl TableCell {
|
||||
#[inline]
|
||||
fn hard_break(&mut self) {
|
||||
self.lines.push(HyperlinkLine::new(Line::default()));
|
||||
self.copies.push(Default::default());
|
||||
}
|
||||
|
||||
fn plain_text(&self) -> String {
|
||||
@@ -1111,17 +1129,44 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
return;
|
||||
};
|
||||
|
||||
let table: std::sync::Arc<[&str]> = table_state
|
||||
.alignments
|
||||
.iter()
|
||||
.map(|alignment| match alignment {
|
||||
Alignment::None => "---",
|
||||
Alignment::Left => ":---",
|
||||
Alignment::Center => ":---:",
|
||||
Alignment::Right => "---:",
|
||||
})
|
||||
.collect();
|
||||
let RenderedTableLines {
|
||||
table_lines,
|
||||
table_lines_prewrapped,
|
||||
spillover_lines,
|
||||
} = self.render_table_lines(table_state);
|
||||
} = self.render_table_lines(table_state, &table);
|
||||
let mut pending_marker_line = self.pending_marker_line;
|
||||
for line in table_lines {
|
||||
for mut line in table_lines {
|
||||
if line
|
||||
.source
|
||||
.as_ref()
|
||||
.and_then(|source| source.copy.as_ref())
|
||||
.is_none()
|
||||
{
|
||||
crate::markdown_copy::table::attach(
|
||||
&mut line,
|
||||
Some(crate::markdown_copy::table::TableLine::empty(&table)),
|
||||
);
|
||||
}
|
||||
if table_lines_prewrapped {
|
||||
self.push_prewrapped_line(line, pending_marker_line);
|
||||
} else {
|
||||
let table = line
|
||||
.source
|
||||
.as_ref()
|
||||
.and_then(|source| source.copy.as_ref())
|
||||
.and_then(|copy| copy.table.clone());
|
||||
self.push_hyperlink_line(line);
|
||||
self.copy_line.table = table;
|
||||
self.copy_line.code = true;
|
||||
self.flush_current_line();
|
||||
}
|
||||
@@ -1237,7 +1282,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
if let Some(table_state) = self.table_state.as_mut()
|
||||
&& let Some(cell) = table_state.current_cell.as_mut()
|
||||
{
|
||||
cell.push_annotated(annotated);
|
||||
cell.push_annotated(annotated, &self.copy_inline);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,7 +1322,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
if let Some(table_state) = self.table_state.as_mut()
|
||||
&& let Some(cell) = table_state.current_cell.as_mut()
|
||||
{
|
||||
cell.push_annotated(std::mem::take(&mut annotated));
|
||||
cell.push_annotated(std::mem::take(&mut annotated), &self.copy_inline);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,7 +1337,11 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
/// Falls back to key/value records when body rows cannot fit in the aligned
|
||||
/// grid; header-only tables retain raw pipe output because they contain no
|
||||
/// records to transpose.
|
||||
fn render_table_lines(&self, mut table_state: TableState) -> RenderedTableLines {
|
||||
fn render_table_lines(
|
||||
&self,
|
||||
mut table_state: TableState,
|
||||
table: &std::sync::Arc<[&'static str]>,
|
||||
) -> RenderedTableLines {
|
||||
let column_count = table_state.alignments.len();
|
||||
if column_count == 0 {
|
||||
return RenderedTableLines {
|
||||
@@ -1326,6 +1375,21 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
for row in &mut rows {
|
||||
Self::normalize_row(row, column_count);
|
||||
}
|
||||
for (row_index, row) in std::iter::once(&mut header)
|
||||
.chain(rows.iter_mut())
|
||||
.enumerate()
|
||||
{
|
||||
for (column, cell) in row.iter_mut().enumerate() {
|
||||
cell.ensure_line();
|
||||
crate::markdown_copy::table::annotate_cell(
|
||||
&mut cell.lines,
|
||||
std::mem::take(&mut cell.copies),
|
||||
table,
|
||||
row_index,
|
||||
column,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let metrics = Self::collect_table_column_metrics(&header, &rows, column_count);
|
||||
let available_width = self.available_table_width(column_count);
|
||||
@@ -1691,16 +1755,25 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
.get(row_line)
|
||||
.is_some_and(|line| Self::line_display_width(&line.line) > 0)
|
||||
}) else {
|
||||
out.push(HyperlinkLine::new(Line::default().style(row_style)));
|
||||
let mut line = wrapped_cells
|
||||
.iter()
|
||||
.find_map(|cell| cell.get(row_line))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
line.line.style = row_style;
|
||||
out.push(line);
|
||||
continue;
|
||||
};
|
||||
let mut spans = Vec::new();
|
||||
let mut copy = None;
|
||||
let mut byte_offset = 0;
|
||||
for (column, width) in column_widths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(last_visible_column + 1)
|
||||
{
|
||||
spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING)));
|
||||
byte_offset += TABLE_CELL_PADDING;
|
||||
let mut line = wrapped_cells[column]
|
||||
.get(row_line)
|
||||
.cloned()
|
||||
@@ -1715,19 +1788,33 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
if left_padding > 0 {
|
||||
spans.push(Span::raw(" ".repeat(left_padding)));
|
||||
}
|
||||
byte_offset += left_padding;
|
||||
if let Some(source) = &line.source {
|
||||
crate::markdown_copy::table::append(&mut copy, source, byte_offset);
|
||||
}
|
||||
byte_offset += line
|
||||
.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.len())
|
||||
.sum::<usize>();
|
||||
spans.append(&mut line.line.spans);
|
||||
let is_last_column = column == last_visible_column;
|
||||
if right_padding > 0 && !is_last_column {
|
||||
spans.push(Span::raw(" ".repeat(right_padding)));
|
||||
byte_offset += right_padding;
|
||||
}
|
||||
if !is_last_column {
|
||||
spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING)));
|
||||
byte_offset += TABLE_CELL_PADDING;
|
||||
}
|
||||
if !is_last_column {
|
||||
spans.push(Span::raw(" ".repeat(TABLE_COLUMN_GAP)));
|
||||
byte_offset += TABLE_COLUMN_GAP;
|
||||
}
|
||||
}
|
||||
let mut out_line = HyperlinkLine::new(Line::from(spans).style(row_style));
|
||||
crate::markdown_copy::table::attach(&mut out_line, copy);
|
||||
let mut column_start = 0usize;
|
||||
for (column, width) in column_widths
|
||||
.iter()
|
||||
@@ -1779,12 +1866,16 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
|
||||
fn row_to_pipe_line(row: &[TableCell]) -> HyperlinkLine {
|
||||
let mut out = HyperlinkLine::new(Line::default());
|
||||
let mut copy = None;
|
||||
let mut byte_offset = 1;
|
||||
out.push_span("|".into(), /*destination*/ None);
|
||||
for cell in row {
|
||||
out.push_span(" ".into(), /*destination*/ None);
|
||||
byte_offset += 1;
|
||||
for (index, line) in cell.lines.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push_span(" ".into(), /*destination*/ None);
|
||||
byte_offset += 1;
|
||||
}
|
||||
let text = line
|
||||
.line
|
||||
@@ -1802,7 +1893,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
out.push_span(Span::raw(std::mem::take(current_text)), destination);
|
||||
}
|
||||
};
|
||||
for ch in text.chars() {
|
||||
for (byte, ch) in text.char_indices() {
|
||||
let destination = line
|
||||
.hyperlinks
|
||||
.iter()
|
||||
@@ -1814,15 +1905,23 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
}
|
||||
if ch == '|' {
|
||||
current_text.push_str("\\|");
|
||||
byte_offset += 1;
|
||||
} else {
|
||||
current_text.push(ch);
|
||||
}
|
||||
if let Some(mut source) = line.source.clone() {
|
||||
source.range = byte..byte + ch.len_utf8();
|
||||
crate::markdown_copy::table::append(&mut copy, &source, byte_offset);
|
||||
}
|
||||
byte_offset += ch.len_utf8();
|
||||
column += char_width(ch);
|
||||
}
|
||||
flush(&mut out, &mut current_text, current_destination);
|
||||
}
|
||||
out.push_span(" |".into(), /*destination*/ None);
|
||||
byte_offset += 2;
|
||||
}
|
||||
crate::markdown_copy::table::attach(&mut out, copy);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1860,7 +1959,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
.map(|line| line_to_static(&line))
|
||||
.collect::<Vec<_>>();
|
||||
if rendered.is_empty() {
|
||||
wrapped.push(HyperlinkLine::new(Line::default()));
|
||||
wrapped.push(source_line.clone());
|
||||
} else {
|
||||
wrapped.extend(remap_wrapped_line(source_line, rendered));
|
||||
};
|
||||
@@ -2057,7 +2156,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
if let Some(table_state) = self.table_state.as_mut()
|
||||
&& let Some(cell) = table_state.current_cell.as_mut()
|
||||
{
|
||||
cell.push_annotated(destination);
|
||||
cell.push_annotated(destination, &self.copy_inline);
|
||||
}
|
||||
self.push_span_to_table_cell(")".into());
|
||||
} else {
|
||||
@@ -2087,8 +2186,14 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
let span = Span::styled(local_target_display, style);
|
||||
if self.in_table_cell() {
|
||||
if show_label {
|
||||
for label_span in link.local_label_spans {
|
||||
for (label_span, inline) in link
|
||||
.local_label_spans
|
||||
.into_iter()
|
||||
.zip(link.local_label_inline)
|
||||
{
|
||||
let previous = std::mem::replace(&mut self.copy_inline, inline);
|
||||
self.push_span_to_table_cell(label_span);
|
||||
self.copy_inline = previous;
|
||||
}
|
||||
self.push_span_to_table_cell(" (".into());
|
||||
}
|
||||
@@ -2218,7 +2323,12 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
|
||||
let mut spans = self.prefix_spans(pending_marker_line);
|
||||
let mut source = crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line);
|
||||
let mut copy = crate::markdown_copy::CopyLine::default();
|
||||
let mut copy = line
|
||||
.source
|
||||
.as_ref()
|
||||
.and_then(|source| source.copy.as_deref())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
copy.prefix = self.copy_prefix(pending_marker_line);
|
||||
copy.continuation = self.copy_prefix(/*pending_marker_line*/ false);
|
||||
copy.item_prefix = self.copy_prefix(/*pending_marker_line*/ true);
|
||||
@@ -2612,9 +2722,9 @@ mod tests {
|
||||
#[test]
|
||||
fn wrap_cell_preserves_hard_break_lines() {
|
||||
let mut cell = TableCell::default();
|
||||
cell.push_annotated(Line::from("first line").into());
|
||||
cell.push_annotated(Line::from("first line").into(), &[]);
|
||||
cell.hard_break();
|
||||
cell.push_annotated(Line::from("second line").into());
|
||||
cell.push_annotated(Line::from("second line").into(), &[]);
|
||||
|
||||
let writer = W::new(
|
||||
"",
|
||||
@@ -2640,6 +2750,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_table_continuations_do_not_repeat_the_first_cell() {
|
||||
let mut cell = make_cell("first");
|
||||
cell.hard_break();
|
||||
cell.hard_break();
|
||||
cell.push_annotated(Line::from("last").into(), &[]);
|
||||
cell.hard_break();
|
||||
let writer = W::new(
|
||||
"",
|
||||
/*wrap_width*/ Some(80),
|
||||
/*cwd*/ None,
|
||||
&never_hide_link_destination,
|
||||
);
|
||||
let lines = writer.render_table_row(
|
||||
&[make_cell("short"), cell],
|
||||
&[5, 5],
|
||||
&[Alignment::None, Alignment::None],
|
||||
Style::default(),
|
||||
);
|
||||
let rendered: Vec<_> = lines.iter().map(|line| line.line.to_string()).collect();
|
||||
insta::assert_debug_snapshot!(rendered);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Type alias for calling private associated functions on Writer.
|
||||
// ---------------------------------------------------------------
|
||||
@@ -2648,7 +2781,7 @@ mod tests {
|
||||
/// Build a single-line `TableCell` from plain text.
|
||||
fn make_cell(text: &str) -> TableCell {
|
||||
let mut cell = TableCell::default();
|
||||
cell.push_annotated(Line::from(text.to_string()).into());
|
||||
cell.push_annotated(Line::from(text.to_string()).into(), &[]);
|
||||
cell
|
||||
}
|
||||
|
||||
|
||||
@@ -163,8 +163,13 @@ fn render_aligned_field(
|
||||
let wrapped_value = wrap_cell(value, value_width);
|
||||
for (line_index, value_line) in wrapped_value.into_iter().enumerate() {
|
||||
let mut spans = Vec::new();
|
||||
let mut copy = None;
|
||||
if line_index == 0 {
|
||||
let label = header.plain_text();
|
||||
let label_source = label_line(header, label_style);
|
||||
if let Some(source) = &label_source.source {
|
||||
crate::markdown_copy::table::append(&mut copy, source, FIELD_LEADING_PADDING);
|
||||
}
|
||||
spans.push(Span::raw(" ".repeat(FIELD_LEADING_PADDING)));
|
||||
spans.push(Span::styled(label.clone(), label_style));
|
||||
spans.push(Span::raw(" ".repeat(
|
||||
@@ -173,7 +178,7 @@ fn render_aligned_field(
|
||||
} else {
|
||||
spans.push(Span::raw(" ".repeat(value_indent)));
|
||||
}
|
||||
push_prefixed_value_line(out, spans, value_line);
|
||||
push_prefixed_value_line(out, spans, value_line, copy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,16 +192,22 @@ fn render_stacked_field(
|
||||
let label_width = available_width
|
||||
.map(|width| width.saturating_sub(FIELD_LEADING_PADDING).max(1))
|
||||
.unwrap_or_else(|| display_width(&header.plain_text()).max(1));
|
||||
let label = Line::from(Span::styled(header.plain_text(), label_style));
|
||||
let label = label_line(header, label_style);
|
||||
let mut wrapped_labels = Vec::new();
|
||||
push_owned_lines(
|
||||
&word_wrap_line(&label, RtOptions::new(label_width)),
|
||||
&word_wrap_line(&label.line, RtOptions::new(label_width)),
|
||||
&mut wrapped_labels,
|
||||
);
|
||||
for label_line in wrapped_labels {
|
||||
for label_line in remap_wrapped_line(&label, wrapped_labels) {
|
||||
let mut spans = vec![Span::raw(" ".repeat(FIELD_LEADING_PADDING))];
|
||||
spans.extend(label_line.spans);
|
||||
out.push(HyperlinkLine::new(Line::from(spans)));
|
||||
let mut copy = None;
|
||||
if let Some(source) = &label_line.source {
|
||||
crate::markdown_copy::table::append(&mut copy, source, FIELD_LEADING_PADDING);
|
||||
}
|
||||
spans.extend(label_line.line.spans);
|
||||
let mut line = HyperlinkLine::new(Line::from(spans));
|
||||
crate::markdown_copy::table::attach(&mut line, copy);
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
let value_width = available_width
|
||||
@@ -207,6 +218,7 @@ fn render_stacked_field(
|
||||
out,
|
||||
vec![Span::raw(" ".repeat(STACKED_VALUE_INDENT))],
|
||||
value_line,
|
||||
/*copy*/ None,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -215,13 +227,19 @@ fn push_prefixed_value_line(
|
||||
out: &mut Vec<HyperlinkLine>,
|
||||
mut prefix: Vec<Span<'static>>,
|
||||
mut value_line: HyperlinkLine,
|
||||
mut copy: Option<crate::markdown_copy::table::TableLine>,
|
||||
) {
|
||||
let bytes = prefix.iter().map(|span| span.content.len()).sum();
|
||||
if let Some(source) = &value_line.source {
|
||||
crate::markdown_copy::table::append(&mut copy, source, bytes);
|
||||
}
|
||||
let shift = prefix
|
||||
.iter()
|
||||
.map(|span| display_width(span.content.as_ref()))
|
||||
.sum::<usize>();
|
||||
prefix.append(&mut value_line.line.spans);
|
||||
let mut output_line = HyperlinkLine::new(Line::from(prefix));
|
||||
crate::markdown_copy::table::attach(&mut output_line, copy);
|
||||
output_line
|
||||
.hyperlinks
|
||||
.extend(value_line.hyperlinks.into_iter().map(|mut link| {
|
||||
@@ -231,6 +249,26 @@ fn push_prefixed_value_line(
|
||||
out.push(output_line);
|
||||
}
|
||||
|
||||
fn label_line(header: &TableCell, style: Style) -> HyperlinkLine {
|
||||
let mut line = HyperlinkLine::new(Line::from(Span::styled(header.plain_text(), style)));
|
||||
let mut copy = None;
|
||||
let mut offset = 0;
|
||||
for header_line in &header.lines {
|
||||
if let Some(source) = &header_line.source {
|
||||
crate::markdown_copy::table::append(&mut copy, source, offset);
|
||||
}
|
||||
offset += header_line
|
||||
.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.len())
|
||||
.sum::<usize>()
|
||||
+ 1;
|
||||
}
|
||||
crate::markdown_copy::table::attach(&mut line, copy);
|
||||
line
|
||||
}
|
||||
|
||||
fn wrap_cell(cell: &TableCell, width: usize) -> Vec<HyperlinkLine> {
|
||||
if cell.lines.is_empty() {
|
||||
return vec![HyperlinkLine::new(Line::default())];
|
||||
@@ -243,7 +281,7 @@ fn wrap_cell(cell: &TableCell, width: usize) -> Vec<HyperlinkLine> {
|
||||
.map(|line| line_to_static(&line))
|
||||
.collect::<Vec<_>>();
|
||||
if rendered.is_empty() {
|
||||
wrapped.push(HyperlinkLine::new(Line::default()));
|
||||
wrapped.push(source_line.clone());
|
||||
} else {
|
||||
wrapped.extend(remap_wrapped_line(source_line, rendered));
|
||||
}
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/markdown_render.rs
|
||||
assertion_line: 2773
|
||||
expression: rendered
|
||||
---
|
||||
[
|
||||
" short first",
|
||||
"",
|
||||
" last",
|
||||
"",
|
||||
]
|
||||
@@ -426,10 +426,16 @@ pub(crate) fn remap_wrapped_line(
|
||||
wrapped: Vec<Line<'static>>,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let mut out = plain_hyperlink_lines(wrapped);
|
||||
if source.hyperlinks.is_empty() {
|
||||
if source.hyperlinks.is_empty() && source.source.is_none() {
|
||||
return out;
|
||||
}
|
||||
let source_text = line_text(&source.line);
|
||||
if source_text.trim().is_empty() {
|
||||
for line in &mut out {
|
||||
line.source = source.source.clone();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let mut source_byte = 0usize;
|
||||
let mut source_column = 0usize;
|
||||
let mut link_index = 0usize;
|
||||
@@ -447,6 +453,10 @@ pub(crate) fn remap_wrapped_line(
|
||||
continue;
|
||||
};
|
||||
let mapped = &rendered[rendered_start..];
|
||||
line.source = source
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|source| source.wrapped(source_byte..source_byte + mapped.len(), rendered_start));
|
||||
let mut output_column = display_width(&rendered[..rendered_start]);
|
||||
for grapheme in mapped.graphemes(/*is_extended*/ true) {
|
||||
let width = display_width(grapheme);
|
||||
|
||||
@@ -7,6 +7,9 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use std::path::Path;
|
||||
|
||||
#[path = "markdown_element_copy_tests.rs"]
|
||||
mod elements;
|
||||
|
||||
#[test]
|
||||
fn selected_markdown_preserves_lists_and_inline_code() {
|
||||
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(AgentMarkdownCell::new(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Coverage of the Markdown grammar and visible transformations used by selection copy.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn supported_elements_preserve_structure_at_narrow_and_wide_widths() {
|
||||
let samples = [
|
||||
"# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six",
|
||||
"Setext heading\n===\n\nSubheading\n---",
|
||||
"**strong** _emphasis_ ~~deleted~~ and `inline_code` with ``a`b``.",
|
||||
"Escaped \\*stars\\*, & entity, café 界, and 👩💻.",
|
||||
"- first\n- second\n - nested\n\n3. third\n4. fourth",
|
||||
"- [ ] pending\n- [x] complete",
|
||||
"> quoted **text**\n>\n> - item\n> - nested",
|
||||
"First paragraph.\n\nHard break: \nnext line.\n\n---\n\nLast paragraph.",
|
||||
"Prose\n\n```\nlet x = \"*literal* <tag>\";\n```\n\nAfter",
|
||||
"Image .",
|
||||
"Literal <b>HTML</b> and unsupported [^footnote].",
|
||||
];
|
||||
for source in samples {
|
||||
for width in [18, 100] {
|
||||
let layout = markdown_layout(source, width);
|
||||
let copied = payload(&layout, 0..layout.text().len()).0;
|
||||
assert_eq!(
|
||||
crate::clipboard_html::render_markdown(&copied),
|
||||
crate::clipboard_html::render_markdown(source),
|
||||
"width {width}: {source}\n{copied}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transformed_content_copies_visible_text_without_active_html_or_images() {
|
||||
for (source, expected) in [
|
||||
("Soft break\nsame paragraph.", "Soft break\nsame paragraph."),
|
||||
("[**local**](/repo/file.rs)", "**local** (repo/file.rs)"),
|
||||
(
|
||||
"[label][reference]\n\n[reference]: https://example.com",
|
||||
"[label (https://example.com)](https://example.com)",
|
||||
),
|
||||
(
|
||||
"<https://example.com>",
|
||||
"[https://example.com (https://example.com)](https://example.com)",
|
||||
),
|
||||
("", "image alt"),
|
||||
] {
|
||||
let layout = markdown_layout(source, /*width*/ 40);
|
||||
let copied = payload(&layout, 0..layout.text().len()).0;
|
||||
assert_eq!(
|
||||
crate::clipboard_html::render_markdown(&copied),
|
||||
crate::clipboard_html::render_markdown(expected),
|
||||
"{source}\n{copied}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_and_mermaid_copy_the_visible_rendering() {
|
||||
for source in [
|
||||
"Inline $\\alpha_1$ and \\(x^{2}\\).",
|
||||
"\\[\n\\frac{a}{b}\n\\]",
|
||||
"```mermaid\nflowchart LR\nA[Start] --> B[Finish]\n```",
|
||||
] {
|
||||
let layout = markdown_layout(source, /*width*/ 80);
|
||||
let (copied, format) = payload(&layout, 0..layout.text().len());
|
||||
if format == CopyFormat::PlainText {
|
||||
assert_eq!(copied, layout.text());
|
||||
} else {
|
||||
let rendered = markdown_layout(&copied, /*width*/ 80);
|
||||
assert_eq!(rendered.text(), layout.text());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user