mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Preserve Markdown formatting when copying transcript selections (#47896)
## Why Transcript selections were copied as plain text, losing the formatting of rendered Markdown when pasted. ## What changed - Retain semantic Markdown annotations through rendering, wrapping, and streaming, then serialize selected text with balanced markup. - Preserve inline formatting, web links, headings, lists, blockquotes, and hard breaks in Markdown clipboard payloads. - Keep code-only and other literal-only selections as plain text. In mixed selections, escape user prose and fence code, tables, and tool output to preserve their literal content. ## Testing Add unit tests and Markdown/HTML snapshots covering partial selections, nested containers, escaping, links, resize stability, and streaming boundaries. GitOrigin-RevId: f0476334a6bf8ad0fb57626627bd258443540852
This commit is contained in:
@@ -868,7 +868,9 @@ impl App {
|
||||
) -> Result<AppRunControl> {
|
||||
self.finish_clipboard(tui);
|
||||
if matches!(&event, TuiEvent::Key(_))
|
||||
&& self.handle_composer_copy_event(tui, &event, tui::Tui::copy_transcript_selection)
|
||||
&& self.handle_composer_copy_event(tui, &event, |tui, text| {
|
||||
tui.copy_transcript_selection(text, crate::clipboard_copy::CopyFormat::PlainText)
|
||||
})
|
||||
{
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ impl App {
|
||||
}
|
||||
AppEvent::OpenWarnings => self.chat_widget.open_warnings(&self.transcript_cells),
|
||||
AppEvent::CopyWarning(text) => {
|
||||
let result = tui.copy_transcript_selection(&text);
|
||||
let result = tui.copy_transcript_selection(&text, crate::clipboard_copy::CopyFormat::PlainText);
|
||||
self.chat_widget.show_selection_copy_result(result);
|
||||
}
|
||||
AppEvent::OpenTranscriptExportFilePrompt => {
|
||||
|
||||
@@ -292,7 +292,12 @@ impl App {
|
||||
self.render_owned_transcript(tui, size)?;
|
||||
}
|
||||
if composer_ready
|
||||
&& self.handle_composer_copy_event(tui, event, tui::Tui::copy_transcript_selection)
|
||||
&& self.handle_composer_copy_event(tui, event, |tui, text| {
|
||||
tui.copy_transcript_selection(
|
||||
text,
|
||||
crate::clipboard_copy::CopyFormat::PlainText,
|
||||
)
|
||||
})
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -465,7 +470,7 @@ impl App {
|
||||
&self.transcript_cells,
|
||||
&text,
|
||||
!copy_on_select,
|
||||
|text| tui.copy_transcript_selection(text),
|
||||
|text, format| tui.copy_transcript_selection(text, format),
|
||||
);
|
||||
self.transcript_view
|
||||
.show_copy_feedback(&result, text.chars().count());
|
||||
|
||||
@@ -55,7 +55,10 @@ impl App {
|
||||
let markdown = render_markdown_transcript(&cells)?;
|
||||
match destination {
|
||||
TranscriptExportDestination::Clipboard => {
|
||||
let result = tui.copy_transcript_selection(&markdown);
|
||||
let result = tui.copy_transcript_selection(
|
||||
&markdown,
|
||||
crate::clipboard_copy::CopyFormat::PlainText,
|
||||
);
|
||||
self.chat_widget.show_copy_result("conversation", result);
|
||||
}
|
||||
TranscriptExportDestination::File(path) => {
|
||||
|
||||
@@ -70,7 +70,10 @@ async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion()
|
||||
("café\nsecond line".into(), CopyFormat::Markdown)
|
||||
);
|
||||
for _ in 0..100 {
|
||||
assert_eq!(tui.copy_transcript_selection("newer"), Ok(CopyStatus::Busy));
|
||||
assert_eq!(
|
||||
tui.copy_transcript_selection("newer", CopyFormat::PlainText),
|
||||
Ok(CopyStatus::Busy)
|
||||
);
|
||||
}
|
||||
assert!(tui.clipboard.poll().is_none());
|
||||
let mut overlay = Overlay::new_transcript(
|
||||
|
||||
@@ -243,6 +243,7 @@ impl HistoryCell for UserHistoryCell {
|
||||
lines.push(HyperlinkLine::new(Line::from("").style(style)));
|
||||
for source in lines.iter_mut().filter_map(|line| line.source.as_mut()) {
|
||||
source.right_reserve = 1;
|
||||
source.copy_as_prose = true;
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const DIRECTIVE_PREFIX: &str = "::codex-inline-vis{";
|
||||
const CONTENT_REFERENCE_PREFIX: &str = "\u{e200}visualize\u{e202}";
|
||||
const CONTENT_REFERENCE_SUFFIX: char = '\u{e201}';
|
||||
const MAX_FRAGMENT_BYTES: u64 = 2 * 1024 * 1024;
|
||||
pub(crate) const LINK_PLACEHOLDER_PREFIX: &str = "https://codex.invalid/inline-visualization/";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct InlineVisualizationContext {
|
||||
@@ -288,7 +289,7 @@ fn link_placeholder() -> String {
|
||||
let mut bytes = [0_u8; 24];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
let token = URL_SAFE_NO_PAD.encode(bytes);
|
||||
format!("https://codex.invalid/inline-visualization/{token}")
|
||||
format!("{LINK_PLACEHOLDER_PREFIX}{token}")
|
||||
}
|
||||
|
||||
fn parse_directive_file(directive: &str) -> Option<Cow<'_, str>> {
|
||||
|
||||
@@ -359,6 +359,28 @@ fn finalized_agent_cell_replays_visualization_link() {
|
||||
.iter()
|
||||
.all(|destination| destination.starts_with("file://"))
|
||||
);
|
||||
let source = lines
|
||||
.iter()
|
||||
.find(|line| !line.hyperlinks.is_empty())
|
||||
.and_then(|line| line.source.clone())
|
||||
.expect("linked line copy source");
|
||||
let start = source.text.find("file://").expect("visible file URL");
|
||||
let mut selected = Vec::new();
|
||||
crate::markdown_copy::SelectedLine::append(
|
||||
&mut selected,
|
||||
source,
|
||||
start..start + "file://".len(),
|
||||
"",
|
||||
);
|
||||
let (copied, format) = crate::markdown_copy::selection(&selected, "file://");
|
||||
assert_eq!(
|
||||
(copied.as_str(), format),
|
||||
("file://", crate::clipboard_copy::CopyFormat::Markdown)
|
||||
);
|
||||
assert_eq!(
|
||||
crate::clipboard_html::render_markdown(&copied),
|
||||
"<p>file://</p>\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -128,6 +128,7 @@ mod config_update;
|
||||
pub(crate) mod custom_terminal;
|
||||
mod daybreak;
|
||||
mod experimental_features;
|
||||
mod markdown_copy;
|
||||
mod permission_discovery;
|
||||
mod pets;
|
||||
mod worktree_browser;
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
//! Semantic copy annotations in rendered-byte coordinates, independent of terminal styling.
|
||||
//!
|
||||
//! Wrapping and selection snapshots share each logical line's annotations. Only copying
|
||||
//! serializes Markdown; literal-only selections keep their original text and clipboard format.
|
||||
//! Rich selections preserve selected words and semantic formatting with balanced markup,
|
||||
//! rather than reproducing the complete response's source spelling (available through `/copy`).
|
||||
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pulldown_cmark::Event;
|
||||
use pulldown_cmark::Tag;
|
||||
|
||||
use crate::clipboard_copy::CopyFormat;
|
||||
use crate::terminal_hyperlinks::LogicalLineSource;
|
||||
|
||||
// Bound retained inline stacks and container prefixes independently of parser nesting.
|
||||
pub(crate) const MAX_COPY_DEPTH: usize = 64;
|
||||
|
||||
// A fixed, deduplicated style order gives each nested wrapper a distinct delimiter family.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum Inline {
|
||||
// Keep one link around its formatted label instead of repeating its destination per style.
|
||||
Link(Arc<str>),
|
||||
Delimiter(&'static str),
|
||||
Code,
|
||||
/// Balance parser events for links whose visible text has no copied wrapper.
|
||||
Ignored,
|
||||
}
|
||||
|
||||
impl Inline {
|
||||
pub(crate) fn from_event(event: &Event<'_>) -> Option<Self> {
|
||||
match event {
|
||||
Event::Start(Tag::Emphasis) => Some(Self::Delimiter("_")),
|
||||
Event::Start(Tag::Strong) => Some(Self::Delimiter("**")),
|
||||
Event::Start(Tag::Strikethrough) => Some(Self::Delimiter("~~")),
|
||||
Event::Start(Tag::Link { dest_url, .. }) => Some(
|
||||
if crate::terminal_hyperlinks::web_destination(dest_url).is_some()
|
||||
&& !dest_url.starts_with(crate::inline_visualization::LINK_PLACEHOLDER_PREFIX)
|
||||
{
|
||||
Self::Link(dest_url.as_ref().into())
|
||||
} else {
|
||||
Self::Ignored
|
||||
},
|
||||
),
|
||||
Event::Code(_) => Some(Self::Code),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct CopyLine {
|
||||
pub(crate) prefix: String,
|
||||
pub(crate) continuation: String,
|
||||
/// Restore the containing item when a multiline selection starts in its later paragraph.
|
||||
pub(crate) item_prefix: String,
|
||||
pub(crate) code: bool,
|
||||
pub(crate) rule: bool,
|
||||
pub(crate) heading: usize,
|
||||
pub(crate) hard_break: bool,
|
||||
/// A visual separator inserted between sibling list items, never source content.
|
||||
pub(crate) omit: bool,
|
||||
runs: Vec<(Range<usize>, Vec<Inline>)>,
|
||||
}
|
||||
|
||||
impl CopyLine {
|
||||
pub(crate) fn push(&mut self, length: usize, inline: &[Inline]) {
|
||||
if length == 0 {
|
||||
return;
|
||||
}
|
||||
let mut inline = inline[..inline.len().min(MAX_COPY_DEPTH)]
|
||||
.iter()
|
||||
.filter(|mark| **mark != Inline::Ignored)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
inline.sort();
|
||||
inline.dedup();
|
||||
let start = self
|
||||
.runs
|
||||
.last()
|
||||
.map_or(/*default*/ 0, |(range, _)| range.end);
|
||||
if let Some((range, previous)) = self.runs.last_mut()
|
||||
&& previous == &inline
|
||||
{
|
||||
range.end += length;
|
||||
} else {
|
||||
self.runs.push((start..start + length, inline));
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, text: &str, range: Range<usize>, depth: usize) -> String {
|
||||
if self.rule && range == (0..text.len()) {
|
||||
return "***".to_owned();
|
||||
}
|
||||
let mut out = String::new();
|
||||
let first = self.runs.partition_point(|(run, _)| run.end <= range.start);
|
||||
let mut runs = self.runs[first..]
|
||||
.iter()
|
||||
.take_while(|(run, _)| run.start < range.end)
|
||||
.peekable();
|
||||
while let Some((run, inline)) = runs.next() {
|
||||
let start = run.start.max(range.start);
|
||||
let mut end = run.end.min(range.end);
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
let mark = inline.get(depth);
|
||||
while let Some((next, inline)) = runs.peek()
|
||||
&& next.start == end
|
||||
&& inline.get(depth) == mark
|
||||
&& end < range.end
|
||||
{
|
||||
end = next.end.min(range.end);
|
||||
runs.next();
|
||||
}
|
||||
match mark {
|
||||
Some(Inline::Code) => {
|
||||
let content = &text[start..end];
|
||||
let fence = fence(content, /*minimum*/ 1);
|
||||
let padding =
|
||||
if content.starts_with(['`', ' ']) || content.ends_with(['`', ' ']) {
|
||||
if content.chars().all(|ch| ch == ' ') {
|
||||
""
|
||||
} else {
|
||||
" "
|
||||
}
|
||||
} else {
|
||||
""
|
||||
};
|
||||
append_inline(
|
||||
&mut out,
|
||||
&format!("{fence}{padding}{content}{padding}{fence}"),
|
||||
);
|
||||
}
|
||||
Some(mark) => {
|
||||
let body = self.render(text, start..end, depth + 1);
|
||||
let trimmed = body.trim();
|
||||
if trimmed.is_empty() {
|
||||
out.push_str(&body);
|
||||
continue;
|
||||
}
|
||||
out.push_str(&body[..body.len() - body.trim_start().len()]);
|
||||
match mark {
|
||||
Inline::Delimiter(delimiter) => {
|
||||
append_inline(&mut out, &format!("{delimiter}{trimmed}{delimiter}"))
|
||||
}
|
||||
Inline::Link(destination) => {
|
||||
let destination = destination
|
||||
.replace('\\', "\\\\")
|
||||
.replace('&', "&")
|
||||
.replace('<', "%3C")
|
||||
.replace('>', "%3E")
|
||||
.replace('\n', "%0A")
|
||||
.replace('\r', "%0D");
|
||||
append_inline(&mut out, &format!("[{trimmed}](<{destination}>)"));
|
||||
}
|
||||
Inline::Code | Inline::Ignored => unreachable!(),
|
||||
}
|
||||
out.push_str(&body[body.trim_end().len()..]);
|
||||
}
|
||||
None => {
|
||||
let heading_end = self.heading.min(end).max(start);
|
||||
out.push_str(&text[start..heading_end]);
|
||||
append_inline(&mut out, &escape(&text[heading_end..end]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// Entity decoding and omitted link wrappers can turn a delimiter's punctuation neighbor into
|
||||
// a word character. Protect that boundary without changing the selected text or adding spacing.
|
||||
fn append_inline(out: &mut String, text: &str) {
|
||||
let word = |ch: char| !ch.is_whitespace() && !ch.is_ascii_punctuation();
|
||||
let left = text.starts_with('_')
|
||||
|| text.starts_with(['*', '~'])
|
||||
&& text
|
||||
.chars()
|
||||
.next()
|
||||
.and_then(|delimiter| text.trim_start_matches(delimiter).chars().next())
|
||||
.is_some_and(|ch| !ch.is_alphanumeric());
|
||||
if left && let Some(ch) = out.chars().last().filter(|ch| word(*ch)) {
|
||||
out.pop();
|
||||
out.push_str(&format!("&#{};", u32::from(ch)));
|
||||
}
|
||||
let right = out.ends_with('_')
|
||||
|| out.ends_with(['*', '~'])
|
||||
&& out
|
||||
.chars()
|
||||
.next_back()
|
||||
.and_then(|delimiter| out.trim_end_matches(delimiter).chars().next_back())
|
||||
.is_some_and(|ch| !ch.is_alphanumeric());
|
||||
if right && let Some(ch) = text.chars().next().filter(|ch| word(*ch)) {
|
||||
out.push_str(&format!("&#{};", u32::from(ch)));
|
||||
out.push_str(&text[ch.len_utf8()..]);
|
||||
} else {
|
||||
out.push_str(text);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SelectedLine {
|
||||
source: LogicalLineSource,
|
||||
range: Range<usize>,
|
||||
separator: String,
|
||||
}
|
||||
|
||||
impl SelectedLine {
|
||||
pub(crate) fn append(
|
||||
lines: &mut Vec<Self>,
|
||||
source: LogicalLineSource,
|
||||
range: Range<usize>,
|
||||
separator: &str,
|
||||
) {
|
||||
// Streaming can split the same logical line across history cells.
|
||||
if let Some(previous) = lines.last_mut()
|
||||
&& Arc::ptr_eq(&previous.source.text, &source.text)
|
||||
&& previous.range.end <= range.start
|
||||
{
|
||||
previous.range.end = range.end;
|
||||
} else {
|
||||
let separator = if separator.starts_with('\n')
|
||||
&& lines.last().is_some_and(|previous| {
|
||||
previous.range.end == previous.source.text.len()
|
||||
&& (previous.source.copy_as_prose
|
||||
|| previous
|
||||
.source
|
||||
.copy
|
||||
.as_ref()
|
||||
.is_some_and(|copy| copy.hard_break))
|
||||
}) {
|
||||
format!(" {separator}")
|
||||
} else {
|
||||
separator.to_string()
|
||||
};
|
||||
lines.push(Self {
|
||||
source,
|
||||
range,
|
||||
separator,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn selection(lines: &[SelectedLine], plain: &str) -> (String, CopyFormat) {
|
||||
let rich = lines.iter().any(|line| {
|
||||
!line.range.is_empty()
|
||||
&& line
|
||||
.source
|
||||
.copy
|
||||
.as_ref()
|
||||
.is_some_and(|copy| !copy.code && !copy.omit)
|
||||
});
|
||||
if !rich {
|
||||
return (plain.to_owned(), CopyFormat::PlainText);
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut lines = lines
|
||||
.iter()
|
||||
.filter(|line| !line.source.copy.as_ref().is_some_and(|copy| copy.omit))
|
||||
.peekable();
|
||||
let mut first = true;
|
||||
let mut indentation: Option<usize> = None;
|
||||
while let Some(line) = lines.next() {
|
||||
if !first {
|
||||
out.push_str(&line.separator);
|
||||
}
|
||||
let prefix = line.source.copy.as_ref().map_or("", |copy| {
|
||||
if indentation.is_none() && !line.range.is_empty() && lines.peek().is_some() {
|
||||
©.item_prefix
|
||||
} else {
|
||||
©.prefix
|
||||
}
|
||||
});
|
||||
if !line.range.is_empty() {
|
||||
let spaces = prefix
|
||||
.split("> ")
|
||||
.map(|part| part.len() - part.trim_start_matches(' ').len())
|
||||
.sum();
|
||||
indentation = Some(indentation.map_or(spaces, |previous| previous.min(spaces)));
|
||||
}
|
||||
let prefix = dedent(prefix, indentation.unwrap_or(/*default*/ 0));
|
||||
if line.range.is_empty() {
|
||||
if line.source.text.is_empty() {
|
||||
out.push_str(prefix.trim_end());
|
||||
}
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
if line.source.copy_as_prose {
|
||||
let text = &line.source.text[line.range.clone()];
|
||||
push_prose_fragment(&mut out, &escape(text));
|
||||
} else if let Some(copy) = &line.source.copy
|
||||
&& !copy.code
|
||||
{
|
||||
if line.range.start == 0 || first && lines.peek().is_some() {
|
||||
out.push_str(&prefix);
|
||||
}
|
||||
let body = copy.render(&line.source.text, line.range.clone(), /*depth*/ 0);
|
||||
push_prose_fragment(&mut out, &body);
|
||||
} else {
|
||||
// Preserve whitespace and Markdown-looking tool output within mixed selections.
|
||||
let mut code = line.source.text[line.range.clone()].to_owned();
|
||||
let continuation = line
|
||||
.source
|
||||
.copy
|
||||
.as_ref()
|
||||
.map_or("", |copy| copy.continuation.as_str());
|
||||
while let Some(next) = lines.peek()
|
||||
&& !next.source.copy_as_prose
|
||||
&& next.source.copy.is_some() == line.source.copy.is_some()
|
||||
&& next.source.copy.as_ref().is_none_or(|copy| copy.code)
|
||||
&& next
|
||||
.source
|
||||
.copy
|
||||
.as_ref()
|
||||
.map_or("", |copy| copy.prefix.as_str())
|
||||
== continuation
|
||||
&& next
|
||||
.source
|
||||
.copy
|
||||
.as_ref()
|
||||
.map_or("", |copy| copy.continuation.as_str())
|
||||
== continuation
|
||||
{
|
||||
code.push_str(&next.separator);
|
||||
code.push_str(&next.source.text[next.range.clone()]);
|
||||
lines.next();
|
||||
}
|
||||
let fence = fence(&code, /*minimum*/ 3);
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
let continuation = dedent(continuation, indentation.unwrap_or(/*default*/ 0));
|
||||
// A checkbox label must precede the fence on its own line.
|
||||
if prefix.ends_with("[ ] ") || prefix.ends_with("[x] ") {
|
||||
out.push_str(prefix.trim_end());
|
||||
out.push('\n');
|
||||
out.push_str(&continuation);
|
||||
} else {
|
||||
out.push_str(&prefix);
|
||||
}
|
||||
out.push_str(&format!("{fence}\n"));
|
||||
for row in code.split_inclusive('\n') {
|
||||
out.push_str(&continuation);
|
||||
out.push_str(row);
|
||||
}
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&continuation);
|
||||
out.push_str(&fence);
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
out.retain(|ch| !ch.is_control() || matches!(ch, '\n' | '\t'));
|
||||
(out, CopyFormat::Markdown)
|
||||
}
|
||||
|
||||
// Remove unselected ancestor list padding without discarding blockquote markers.
|
||||
fn dedent(prefix: &str, mut spaces: usize) -> String {
|
||||
prefix
|
||||
.split_inclusive("> ")
|
||||
.map(|part| {
|
||||
let remove = spaces.min(part.len() - part.trim_start_matches(' ').len());
|
||||
spaces -= remove;
|
||||
&part[remove..]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fence(text: &str, minimum: usize) -> String {
|
||||
"`".repeat(
|
||||
text.split(|ch| ch != '`')
|
||||
.map(str::len)
|
||||
.max()
|
||||
.unwrap_or(/*default*/ 0)
|
||||
.saturating_add(/*rhs*/ 1)
|
||||
.max(minimum),
|
||||
)
|
||||
}
|
||||
|
||||
// Append escaped prose without allowing its leading whitespace to become code indentation.
|
||||
fn push_prose_fragment(out: &mut String, text: &str) {
|
||||
let body = text.trim_start_matches([' ', '\t']);
|
||||
for ch in text[..text.len() - body.len()].chars() {
|
||||
out.push_str(if ch == '\t' { "	" } else { " " });
|
||||
}
|
||||
out.push_str(body);
|
||||
}
|
||||
|
||||
fn escape(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut whitespace_prefix = true;
|
||||
let mut numeric_prefix = false;
|
||||
let mut number_has_trailing_space = false;
|
||||
for ch in text.chars() {
|
||||
let block_marker =
|
||||
"=-+".contains(ch) && whitespace_prefix || matches!(ch, '.' | ')') && numeric_prefix;
|
||||
if "\\`*_[]<>~#|&!".contains(ch) || block_marker {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(ch);
|
||||
if ch.is_whitespace() {
|
||||
number_has_trailing_space |= numeric_prefix;
|
||||
} else {
|
||||
numeric_prefix = ch.is_ascii_digit()
|
||||
&& (whitespace_prefix || numeric_prefix && !number_has_trailing_space);
|
||||
whitespace_prefix = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "markdown_copy_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Verify escaping and bounded copy annotations through the real renderer.
|
||||
|
||||
use super::*;
|
||||
use crate::markdown_render::render_markdown_lines_with_width_and_cwd;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn escaping_tracks_block_prefixes_without_rescanning_them() {
|
||||
for (text, expected) in [
|
||||
(" - item", " \\- item"),
|
||||
(" 123.)", " 123\\.)"),
|
||||
("12\u{2003}.", "12\u{2003}\\."),
|
||||
("12 3.", "12 3."),
|
||||
("\u{2003}12\t)", "\u{2003}12\t\\)"),
|
||||
("12.34)", "12\\.34)"),
|
||||
("word - text", "word - text"),
|
||||
("\u{2003}+", "\u{2003}\\+"),
|
||||
] {
|
||||
assert_eq!(escape(text), expected);
|
||||
}
|
||||
let digits = "1".repeat(/*n*/ 20_000);
|
||||
let punctuation = ".".repeat(/*n*/ 20_000);
|
||||
assert_eq!(
|
||||
escape(&format!("{digits}{punctuation}")),
|
||||
format!("{digits}\\{punctuation}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_lazy_continuations_keep_bounded_relative_copy_prefixes() {
|
||||
let depth = 256;
|
||||
let continuation_count = 256;
|
||||
let markdown = format!(
|
||||
"{}text\n{}",
|
||||
"- ".repeat(depth),
|
||||
"a\n".repeat(continuation_count)
|
||||
);
|
||||
let lines =
|
||||
render_markdown_lines_with_width_and_cwd(&markdown, /*width*/ None, /*cwd*/ None);
|
||||
let copies = lines
|
||||
.iter()
|
||||
.filter_map(|line| line.source.as_ref())
|
||||
.filter_map(|source| source.copy.as_ref())
|
||||
.collect::<Vec<_>>();
|
||||
let continuation = " ".repeat(MAX_COPY_DEPTH);
|
||||
let item_prefix = format!("{}- ", " ".repeat(MAX_COPY_DEPTH - 1));
|
||||
assert_eq!(copies.len(), continuation_count + 1);
|
||||
assert!(
|
||||
copies
|
||||
.iter()
|
||||
.all(|copy| copy.continuation == continuation && copy.item_prefix == item_prefix)
|
||||
);
|
||||
assert_eq!(copies[0].prefix, item_prefix);
|
||||
assert!(copies[1..].iter().all(|copy| copy.prefix == continuation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_display_padding_does_not_displace_a_retained_container() {
|
||||
let depth = MAX_COPY_DEPTH + 1;
|
||||
let indent = " ".repeat(depth);
|
||||
let markdown = format!(
|
||||
"{}before\n\n{indent}```\n{indent}code()\n{indent}```\n",
|
||||
"- ".repeat(depth)
|
||||
);
|
||||
let lines =
|
||||
render_markdown_lines_with_width_and_cwd(&markdown, /*width*/ None, /*cwd*/ None);
|
||||
let prefixes = lines
|
||||
.iter()
|
||||
.filter_map(|line| line.source.as_ref())
|
||||
.filter_map(|source| source.copy.as_ref())
|
||||
.filter(|copy| copy.code)
|
||||
.map(|copy| copy.prefix.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(prefixes, vec![" ".repeat(MAX_COPY_DEPTH)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_runs_share_destinations_and_bound_nested_formatting() {
|
||||
for destination in ["https://example.com/", "/repo/"] {
|
||||
let destination = format!("{destination}{}", "a".repeat(/*n*/ 4096));
|
||||
let label = format!(
|
||||
"**prefix** {}middle{}",
|
||||
"*a ".repeat(/*n*/ 128),
|
||||
" b*".repeat(/*n*/ 128)
|
||||
);
|
||||
let markdown = format!("[{label}](<{destination}>)");
|
||||
let lines = render_markdown_lines_with_width_and_cwd(
|
||||
&markdown,
|
||||
/*width*/ None,
|
||||
Some(Path::new("/")),
|
||||
);
|
||||
let runs = lines
|
||||
.iter()
|
||||
.filter_map(|line| line.source.as_ref())
|
||||
.filter_map(|source| source.copy.as_ref())
|
||||
.flat_map(|copy| ©.runs)
|
||||
.map(|(_, inline)| inline)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(runs.len() > 1);
|
||||
assert!(runs.iter().all(|inline| inline.len() <= 5));
|
||||
let links = runs
|
||||
.iter()
|
||||
.flat_map(|inline| inline.iter())
|
||||
.filter_map(|inline| {
|
||||
if let Inline::Link(destination) = inline {
|
||||
Some(destination)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if destination.starts_with('/') {
|
||||
assert!(links.is_empty());
|
||||
continue;
|
||||
}
|
||||
assert!(links.len() > 1);
|
||||
assert!(links.iter().all(|link| Arc::ptr_eq(links[0], link)));
|
||||
}
|
||||
}
|
||||
@@ -162,14 +162,24 @@ impl MarkdownStyles {
|
||||
struct IndentContext {
|
||||
prefix: Vec<Span<'static>>,
|
||||
marker: Option<Vec<Span<'static>>>,
|
||||
copy_marker: String,
|
||||
is_list: bool,
|
||||
}
|
||||
|
||||
impl IndentContext {
|
||||
fn new(prefix: Vec<Span<'static>>, marker: Option<Vec<Span<'static>>>, is_list: bool) -> Self {
|
||||
// Keep the canonical marker separate from depth-dependent display padding.
|
||||
let copy_marker = marker
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
.trim_start()
|
||||
.replace('•', "-");
|
||||
Self {
|
||||
prefix,
|
||||
marker,
|
||||
copy_marker,
|
||||
is_list,
|
||||
}
|
||||
}
|
||||
@@ -396,6 +406,7 @@ struct LinkState {
|
||||
/// can collapse to this canonical target without losing descriptive labels.
|
||||
local_target_display: Option<String>,
|
||||
local_label_spans: Vec<Span<'static>>,
|
||||
local_label_inline: Vec<Vec<crate::markdown_copy::Inline>>,
|
||||
}
|
||||
|
||||
fn should_render_link_destination(dest_url: &str) -> bool {
|
||||
@@ -415,6 +426,8 @@ struct Writer<'a, 'policy> {
|
||||
text: Vec<HyperlinkLine>,
|
||||
styles: MarkdownStyles,
|
||||
inline_styles: Vec<Style>,
|
||||
copy_inline: Vec<crate::markdown_copy::Inline>,
|
||||
copy_line: crate::markdown_copy::CopyLine,
|
||||
indent_stack: Vec<IndentContext>,
|
||||
list_indices: Vec<Option<u64>>,
|
||||
list_needs_blank_before_next_item: Vec<bool>,
|
||||
@@ -455,6 +468,8 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
text: Vec::new(),
|
||||
styles: MarkdownStyles::default(),
|
||||
inline_styles: Vec::new(),
|
||||
copy_inline: Vec::new(),
|
||||
copy_line: Default::default(),
|
||||
indent_stack: Vec::new(),
|
||||
list_indices: Vec::new(),
|
||||
list_needs_blank_before_next_item: Vec::new(),
|
||||
@@ -499,6 +514,17 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
I: Iterator<Item = (Event<'a>, Range<usize>)>,
|
||||
{
|
||||
self.prepare_for_event(&event);
|
||||
let copy_inline = crate::markdown_copy::Inline::from_event(&event);
|
||||
if let Some(inline) = ©_inline {
|
||||
self.copy_inline.push(inline.clone());
|
||||
}
|
||||
let close_inline = matches!(
|
||||
event,
|
||||
Event::Code(_)
|
||||
| Event::End(
|
||||
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link
|
||||
)
|
||||
);
|
||||
match event {
|
||||
Event::Start(tag) => self.start_tag(tag, range, iter),
|
||||
Event::End(tag) => self.end_tag(tag, range),
|
||||
@@ -517,6 +543,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
self.push_blank_line();
|
||||
}
|
||||
self.push_line(Line::from("———"));
|
||||
self.copy_line.rule = true;
|
||||
self.needs_newline = true;
|
||||
}
|
||||
Event::Html(html) => self.html(html, /*inline*/ false),
|
||||
@@ -533,6 +560,9 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if close_inline {
|
||||
self.copy_inline.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_for_event(&mut self, event: &Event<'a>) {
|
||||
@@ -623,7 +653,15 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
|
||||
fn end_tag(&mut self, tag: TagEnd, range: Range<usize>) {
|
||||
match tag {
|
||||
TagEnd::Paragraph => self.end_paragraph(),
|
||||
TagEnd::Paragraph => {
|
||||
// At a streaming EOF, the parser omits a trailing hard-break event. Retain it
|
||||
// before this line is committed and a later delta supplies the next line.
|
||||
self.copy_line.hard_break |= self
|
||||
.input
|
||||
.get(..range.end)
|
||||
.is_some_and(|prefix| prefix.ends_with(" \n") || prefix.ends_with(" \r\n"));
|
||||
self.end_paragraph();
|
||||
}
|
||||
TagEnd::Heading(_) => self.end_heading(),
|
||||
TagEnd::BlockQuote => self.end_blockquote(),
|
||||
TagEnd::CodeBlock => self.end_codeblock(range),
|
||||
@@ -696,7 +734,9 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
HeadingLevel::H6 => self.styles.h6,
|
||||
};
|
||||
let content = format!("{} ", "#".repeat(level as usize));
|
||||
let heading = content.len();
|
||||
self.push_line(Line::from(vec![Span::styled(content, heading_style)]));
|
||||
self.copy_line.heading = heading;
|
||||
self.push_inline_style(heading_style);
|
||||
self.needs_newline = false;
|
||||
}
|
||||
@@ -865,6 +905,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
self.push_table_cell_hard_break();
|
||||
return;
|
||||
}
|
||||
self.copy_line.hard_break = true;
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
|
||||
@@ -923,6 +964,14 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
if separate {
|
||||
let index = self.text.len();
|
||||
self.push_blank_line();
|
||||
if let Some(line) = self.text.get_mut(index) {
|
||||
let mut source =
|
||||
crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line);
|
||||
let mut copy = crate::markdown_copy::CopyLine::default();
|
||||
copy.omit = true;
|
||||
source.copy = Some(std::sync::Arc::new(copy));
|
||||
line.source = Some(source);
|
||||
}
|
||||
if self.text.len() > index
|
||||
&& let Some(list) = self.uniform_lists.last_mut()
|
||||
{
|
||||
@@ -1073,6 +1122,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
self.push_prewrapped_line(line, pending_marker_line);
|
||||
} else {
|
||||
self.push_hyperlink_line(line);
|
||||
self.copy_line.code = true;
|
||||
self.flush_current_line();
|
||||
}
|
||||
pending_marker_line = false;
|
||||
@@ -1981,6 +2031,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
None
|
||||
},
|
||||
local_label_spans: Vec::new(),
|
||||
local_label_inline: Vec::new(),
|
||||
destination: dest_url,
|
||||
});
|
||||
}
|
||||
@@ -2050,8 +2101,14 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
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(label_span);
|
||||
self.copy_inline = previous;
|
||||
}
|
||||
self.push_span(" (".into());
|
||||
}
|
||||
@@ -2075,6 +2132,13 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
fn push_local_link_label_span(&mut self, span: Span<'static>) {
|
||||
if let Some(link) = self.link.as_mut() {
|
||||
link.local_label_spans.push(span);
|
||||
link.local_label_inline.push(
|
||||
self.copy_inline[..self
|
||||
.copy_inline
|
||||
.len()
|
||||
.min(crate::markdown_copy::MAX_COPY_DEPTH)]
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2093,10 +2157,13 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
fn flush_current_line(&mut self) {
|
||||
if let Some(mut line) = self.current_line_content.take() {
|
||||
let style = self.current_line_style;
|
||||
let mut source = crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line);
|
||||
source.copy = Some(std::sync::Arc::new(std::mem::take(&mut self.copy_line)));
|
||||
// NB we don't wrap code in code blocks, in order to preserve whitespace for copy/paste.
|
||||
if !self.current_line_in_code_block
|
||||
&& let Some(width) = self.wrap_width
|
||||
{
|
||||
line.source = Some(source);
|
||||
let opts = RtOptions::new(width)
|
||||
.initial_indent(self.current_initial_indent.clone().into())
|
||||
.subsequent_indent(self.current_subsequent_indent.clone().into());
|
||||
@@ -2108,8 +2175,6 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
}
|
||||
} else {
|
||||
let mut spans = self.current_initial_indent.clone();
|
||||
let mut source =
|
||||
crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line);
|
||||
source.prefix_bytes = spans.iter().map(|span| span.content.len()).sum();
|
||||
source.continuation_indent = self.current_subsequent_indent.clone().into();
|
||||
line.source = Some(source);
|
||||
@@ -2152,6 +2217,16 @@ 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();
|
||||
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);
|
||||
copy.code = true;
|
||||
source.copy = Some(std::sync::Arc::new(copy));
|
||||
source.prefix_bytes = spans.iter().map(|span| span.content.len()).sum();
|
||||
source.continuation_indent = self.prefix_spans(/*pending_marker_line*/ false).into();
|
||||
line.source = Some(source);
|
||||
let shift = Self::spans_display_width(&spans);
|
||||
spans.append(&mut line.line.spans);
|
||||
for hyperlink in &mut line.hyperlinks {
|
||||
@@ -2174,6 +2249,14 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
self.current_initial_indent = self.prefix_spans(was_pending);
|
||||
self.current_subsequent_indent = self.prefix_spans(/*pending_marker_line*/ false);
|
||||
self.current_line_style = style;
|
||||
self.copy_line = crate::markdown_copy::CopyLine::default();
|
||||
self.copy_line.prefix = self.copy_prefix(was_pending);
|
||||
self.copy_line.continuation = self.copy_prefix(/*pending_marker_line*/ false);
|
||||
self.copy_line.item_prefix = self.copy_prefix(/*pending_marker_line*/ true);
|
||||
self.copy_line.code = self.in_code_block;
|
||||
// Heading markers are already visible; retain them as Markdown syntax.
|
||||
self.copy_line
|
||||
.push(line.spans.iter().map(|span| span.content.len()).sum(), &[]);
|
||||
self.current_line_content = Some(HyperlinkLine::new(line));
|
||||
self.current_line_in_code_block = self.in_code_block;
|
||||
self.line_ends_with_local_link_target = false;
|
||||
@@ -2195,6 +2278,7 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
if let Some(line) = self.current_line_content.as_mut() {
|
||||
self.copy_line.push(span.content.len(), &self.copy_inline);
|
||||
line.push_span(
|
||||
span,
|
||||
self.link.as_ref().map(|link| link.destination.as_str()),
|
||||
@@ -2207,6 +2291,15 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
if let Some(line) = self.current_line_content.as_mut() {
|
||||
self.copy_line.push(
|
||||
appended
|
||||
.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.len())
|
||||
.sum(),
|
||||
&self.copy_inline,
|
||||
);
|
||||
let shift = line.width();
|
||||
line.line.spans.append(&mut appended.line.spans);
|
||||
line.hyperlinks
|
||||
@@ -2249,6 +2342,33 @@ impl<'a, 'policy> Writer<'a, 'policy> {
|
||||
self.text.push(line);
|
||||
}
|
||||
|
||||
// Markdown needs relative container indentation, not the display's fixed-depth padding.
|
||||
fn copy_prefix(&self, pending_marker_line: bool) -> String {
|
||||
// Code's display padding is not a Markdown container. Keep only the innermost semantic
|
||||
// containers so lazy continuation lines cannot each retain an arbitrarily deep prefix.
|
||||
let containers =
|
||||
&self.indent_stack[..self.indent_stack.len() - usize::from(self.in_code_block)];
|
||||
let containers = &containers[containers
|
||||
.len()
|
||||
.saturating_sub(crate::markdown_copy::MAX_COPY_DEPTH)..];
|
||||
let last_list = containers.iter().rposition(|context| context.is_list);
|
||||
let mut prefix = String::new();
|
||||
for (index, context) in containers.iter().enumerate() {
|
||||
if context.is_list {
|
||||
let marker = &context.copy_marker;
|
||||
let width = marker.find('[').unwrap_or(marker.len());
|
||||
if pending_marker_line && Some(index) == last_list {
|
||||
prefix.push_str(marker);
|
||||
} else {
|
||||
prefix.extend(std::iter::repeat_n(' ', width));
|
||||
}
|
||||
} else {
|
||||
prefix.extend(context.prefix.iter().map(|span| span.content.as_ref()));
|
||||
}
|
||||
}
|
||||
prefix
|
||||
}
|
||||
|
||||
fn prefix_spans(&self, pending_marker_line: bool) -> Vec<Span<'static>> {
|
||||
let mut prefix: Vec<Span<'static>> = Vec::new();
|
||||
let last_marker_index = if pending_marker_line {
|
||||
|
||||
@@ -21,11 +21,19 @@ impl Writer<'_, '_> {
|
||||
let checkbox = if checked { "☑" } else { "☐" };
|
||||
span.content = format!("{prefix}{checkbox} ").into();
|
||||
context.prefix = vec![Span::from(" ".repeat(Self::spans_display_width(marker)))];
|
||||
context
|
||||
.copy_marker
|
||||
.push_str(if checked { "[x] " } else { "[ ] " });
|
||||
}
|
||||
if self.current_line_content.is_some() {
|
||||
// Loose lists start their paragraph before the task marker arrives.
|
||||
self.current_initial_indent = self.prefix_spans(/*pending_marker_line*/ true);
|
||||
self.current_subsequent_indent = self.prefix_spans(/*pending_marker_line*/ false);
|
||||
self.copy_line.prefix = self.copy_prefix(/*pending_marker_line*/ true);
|
||||
self.copy_line.continuation = self.copy_prefix(/*pending_marker_line*/ false);
|
||||
self.copy_line
|
||||
.item_prefix
|
||||
.clone_from(&self.copy_line.prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,7 +450,7 @@ impl TranscriptOverlay {
|
||||
&self.cells,
|
||||
&text,
|
||||
!copy_on_select,
|
||||
|text| tui.copy_transcript_selection(text),
|
||||
|text, format| tui.copy_transcript_selection(text, format),
|
||||
);
|
||||
if resume_following
|
||||
&& matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_)))
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
//! A conservative fast path for one open, top-level, language-tagged code fence.
|
||||
|
||||
use crate::markdown_copy::CopyLine;
|
||||
use crate::render::highlight::MAX_HIGHLIGHT_LINE_BYTES;
|
||||
use crate::render::highlight::StreamingCodeHighlighter;
|
||||
use crate::render::highlight::syntax_theme_revision;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::LogicalLineSource;
|
||||
use ratatui::text::Span;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An append-only fence whose original source is identical to pulldown-cmark's code text.
|
||||
pub(super) struct OpenCodeFence {
|
||||
@@ -100,12 +103,19 @@ impl OpenCodeFence {
|
||||
let (highlighter, lines) = highlighter.append(committed_source)?;
|
||||
self.highlighter = Some(highlighter);
|
||||
self.source_len = raw_source.len();
|
||||
let mut copy = CopyLine::default();
|
||||
copy.code = true;
|
||||
let copy = Arc::new(copy);
|
||||
let lines = lines
|
||||
.into_iter()
|
||||
.map(|mut line| {
|
||||
let mut source = LogicalLineSource::from_line(&line);
|
||||
source.copy = Some(Arc::clone(©));
|
||||
// The canonical writer installs an empty indent span for top-level fences.
|
||||
line.spans.insert(/*index*/ 0, Span::default());
|
||||
HyperlinkLine::new(line)
|
||||
let mut line = HyperlinkLine::new(line);
|
||||
line.source = Some(source);
|
||||
line
|
||||
})
|
||||
.collect();
|
||||
Some((self, lines))
|
||||
|
||||
@@ -23,6 +23,9 @@ pub(crate) enum LineWrapPolicy {
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct LogicalLineSource {
|
||||
pub(crate) text: Arc<str>,
|
||||
pub(crate) copy: Option<Arc<crate::markdown_copy::CopyLine>>,
|
||||
/// Literal user text remains prose when copied alongside rendered Markdown.
|
||||
pub(crate) copy_as_prose: bool,
|
||||
/// Span styles share the text's byte coordinates, including whitespace omitted by wrapping.
|
||||
pub(crate) styles: Arc<[(Range<usize>, Style)]>,
|
||||
/// Row style, applied beneath explicit span styles just like ratatui's `Line`.
|
||||
@@ -42,6 +45,8 @@ impl LogicalLineSource {
|
||||
let end = text.len();
|
||||
Self {
|
||||
text: text.into(),
|
||||
copy: None,
|
||||
copy_as_prose: false,
|
||||
styles: vec![(0..end, Style::default())].into(),
|
||||
line_style: Style::default(),
|
||||
span_style: Style::default(),
|
||||
@@ -100,6 +105,8 @@ impl LogicalLineSource {
|
||||
let end = displayed.end.clamp(source_start, source_end);
|
||||
Self {
|
||||
text: Arc::clone(&self.text),
|
||||
copy: self.copy.clone(),
|
||||
copy_as_prose: self.copy_as_prose,
|
||||
styles: Arc::clone(&self.styles),
|
||||
line_style: self.line_style,
|
||||
span_style: self.span_style,
|
||||
|
||||
@@ -592,6 +592,10 @@ impl TranscriptView {
|
||||
#[path = "transcript_view_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "transcript_view/markdown_copy_tests.rs"]
|
||||
mod markdown_copy_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "transcript_view/copy_on_select_tests.rs"]
|
||||
mod copy_on_select_tests;
|
||||
|
||||
@@ -21,7 +21,7 @@ fn pending_copy_feedback_survives_expiry_and_selection_replacement() {
|
||||
&cells,
|
||||
&selected,
|
||||
/*clear_selection*/ true,
|
||||
|_| Ok(CopyStatus::Pending(1)),
|
||||
|_, _format| Ok(CopyStatus::Pending(1)),
|
||||
);
|
||||
view.show_copy_feedback(&pending, selected.chars().count());
|
||||
view.show_copy_feedback(&Ok(CopyStatus::Busy), /*characters*/ 0);
|
||||
|
||||
@@ -80,7 +80,7 @@ fn copy_on_select_waits_for_release_and_retains_selection() {
|
||||
&cells,
|
||||
&copied,
|
||||
/*clear_selection*/ false,
|
||||
|_| Ok(CopyStatus::Pending(1)),
|
||||
|_, _format| Ok(CopyStatus::Pending(1)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -130,10 +130,15 @@ fn copying_selection_at_bottom_does_not_show_back_to_bottom() {
|
||||
view.begin_selection(&cells, /*column*/ 0, /*row*/ 2, /*clicks*/ 2);
|
||||
view.end_drag();
|
||||
let selected = view.selected_text(&cells).expect("selected word");
|
||||
view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |text| {
|
||||
assert_eq!(text, "needle");
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
|
||||
})
|
||||
view.copy_selected_text_with(
|
||||
&cells,
|
||||
&selected,
|
||||
/*clear_selection*/ true,
|
||||
|text, _format| {
|
||||
assert_eq!(text, "needle");
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
|
||||
},
|
||||
)
|
||||
.expect("successful copy");
|
||||
|
||||
let after = paint(&mut view, &cells, /*width*/ 40);
|
||||
@@ -221,9 +226,12 @@ fn copying_live_selection_retains_the_displayed_revision() {
|
||||
view.sync_live_tail(/*width*/ 40, /*key*/ None, |_| {
|
||||
Some(vec![HyperlinkLine::from("changed live text")])
|
||||
});
|
||||
view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |_| {
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
|
||||
})
|
||||
view.copy_selected_text_with(
|
||||
&cells,
|
||||
&selected,
|
||||
/*clear_selection*/ true,
|
||||
|_, _format| Ok(crate::clipboard_copy::CopyStatus::Confirmed),
|
||||
)
|
||||
.unwrap();
|
||||
let buffer = paint(&mut view, &cells, /*width*/ 40);
|
||||
assert!(!view.is_following());
|
||||
|
||||
@@ -0,0 +1,688 @@
|
||||
use super::*;
|
||||
use crate::clipboard_copy::CopyFormat;
|
||||
use crate::history_cell::AgentMarkdownCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn selected_markdown_preserves_lists_and_inline_code() {
|
||||
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(AgentMarkdownCell::new(
|
||||
"- setting `closeRequested_`,\n- removing the tailer from `tailers_`,\n- clearing its live/discovery state.".into(),
|
||||
Path::new("/"),
|
||||
))];
|
||||
let mut view = TranscriptView::default();
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 24,
|
||||
);
|
||||
view.render(area, &mut Buffer::empty(area), &cells);
|
||||
// Exercise the public gesture path rather than manufacturing metadata.
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
view.handle_mouse(
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 2,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
&cells,
|
||||
);
|
||||
view.handle_mouse(
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: 31,
|
||||
row: 23,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
&cells,
|
||||
);
|
||||
let plain = view.selected_text(&cells).unwrap();
|
||||
view.copy_selected_text_with(
|
||||
&cells,
|
||||
&plain,
|
||||
/*clear_selection*/ true,
|
||||
|text, format| {
|
||||
assert_eq!(format, CopyFormat::Markdown);
|
||||
insta::assert_snapshot!(text);
|
||||
insta::assert_snapshot!(
|
||||
"selected_list_html",
|
||||
crate::clipboard_html::render_markdown(text)
|
||||
);
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn markdown_layout(markdown: &str, width: u16) -> TextLayout {
|
||||
let cell = AgentMarkdownCell::new(markdown.into(), Path::new("/"));
|
||||
TextLayout::new(
|
||||
cell.retained_hyperlink_lines(width, /*detailed*/ false),
|
||||
width,
|
||||
)
|
||||
}
|
||||
|
||||
fn payload(layout: &TextLayout, range: std::ops::Range<usize>) -> (String, CopyFormat) {
|
||||
let mut lines = Vec::new();
|
||||
layout.copy_lines(range.clone(), "", &mut lines);
|
||||
crate::markdown_copy::selection(&lines, &layout.text()[range])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_markup_is_balanced_and_preserves_selected_characters() {
|
||||
for (source, selected, expected) in [
|
||||
("before `closeRequested_` after", "Requested", "`Requested`"),
|
||||
("before ``a`b`` after", "a`", "`` a` ``"),
|
||||
("**hello café界**", "café界", "**café界**"),
|
||||
("before * first *", "first", "first"),
|
||||
("before **hello world** after", " world", " **world**"),
|
||||
(
|
||||
"**strong *nested* text**",
|
||||
"strong nested text",
|
||||
"**strong _nested_ text**",
|
||||
),
|
||||
(
|
||||
r"escaped \",
|
||||
"escaped !label",
|
||||
r"escaped \",
|
||||
),
|
||||
(
|
||||
"[documentation](https://example.com)",
|
||||
"ment", // codespell:ignore ment
|
||||
"[ment](<https://example.com>)", // codespell:ignore ment
|
||||
),
|
||||
("[**label**](/repo/file.rs)", "label", "**label**"),
|
||||
(
|
||||
"escaped \\*literal\\* & `code`",
|
||||
"*literal* & code",
|
||||
"\\*literal\\* \\& `code`",
|
||||
),
|
||||
] {
|
||||
let layout = markdown_layout(source, /*width*/ 80);
|
||||
let start = layout.text().find(selected).expect(source);
|
||||
assert_eq!(
|
||||
payload(&layout, start..start + selected.len()),
|
||||
(expected.into(), CopyFormat::Markdown),
|
||||
"{source}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_boundaries_preserve_selected_text_and_formatting() {
|
||||
let mut copies = Vec::new();
|
||||
for (source, selected, expected_html) in [
|
||||
(
|
||||
"_a **b**_**c**",
|
||||
"a bc",
|
||||
"<p><em>a</em> <strong><em>b</em>c</strong></p>\n",
|
||||
),
|
||||
(
|
||||
"_a **b**_**c**",
|
||||
"bc",
|
||||
"<p><strong><em>b</em>c</strong></p>\n",
|
||||
),
|
||||
(
|
||||
"*a **b***[**c**](/repo)",
|
||||
"a bc",
|
||||
"<p><em>a</em> <strong><em>b</em>c</strong></p>\n",
|
||||
),
|
||||
("_*a*_[_b_](/repo)", "ab", "<p><em>ab</em></p>\n"),
|
||||
(
|
||||
"*[a **b**](https://example.com) c*",
|
||||
"a b",
|
||||
"<p><a href=\"https://example.com\"><em>a</em> <strong><em>b</em></strong></a></p>\n",
|
||||
),
|
||||
(
|
||||
"a*b*c a**b**c",
|
||||
"abc abc",
|
||||
"<p>a<em>b</em>c a<strong>b</strong>c</p>\n",
|
||||
),
|
||||
("a_b_c", "abc", "<p>a<em>b</em>c</p>\n"),
|
||||
("a__b__c", "abc", "<p>a<strong>b</strong>c</p>\n"),
|
||||
("́_b_", "\u{301}b", "<p>\u{301}<em>b</em></p>\n"),
|
||||
("a*!*b", "a!b", "<p>a<em>!</em>b</p>\n"),
|
||||
("a*。*b", "a。b", "<p>a<em>。</em>b</p>\n"),
|
||||
(
|
||||
"a**_b_**c",
|
||||
"abc",
|
||||
"<p>a<strong><em>b</em></strong>c</p>\n",
|
||||
),
|
||||
(
|
||||
"a**`b`**c",
|
||||
"abc",
|
||||
"<p>a<strong><code>b</code></strong>c</p>\n",
|
||||
),
|
||||
("_a_[b](/repo)", "ab", "<p><em>a</em>b</p>\n"),
|
||||
(
|
||||
"    **hello**",
|
||||
" hello",
|
||||
"<p> <strong>hello</strong></p>\n",
|
||||
),
|
||||
] {
|
||||
let layout = markdown_layout(source, /*width*/ 80);
|
||||
let start = layout.text().find(selected).expect(source);
|
||||
let (copied, format) = payload(&layout, start..start + selected.len());
|
||||
assert_eq!(format, CopyFormat::Markdown);
|
||||
let html = crate::clipboard_html::render_markdown(&copied);
|
||||
assert_eq!(html, expected_html, "{source:?} => {copied:?}");
|
||||
copies.push(format!(
|
||||
"{source}\nSelected: {selected:?}\nMarkdown: {copied}\n{html}"
|
||||
));
|
||||
}
|
||||
insta::assert_snapshot!(copies.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_keep_nesting_and_paragraphs_without_sibling_spacers() {
|
||||
let source = "3. first item long enough to wrap\n4. second item\n - nested `code`\n\n another paragraph\n - next nested\n5. final item\n\nOutside.";
|
||||
let mut copies = Vec::new();
|
||||
for width in [24, 80] {
|
||||
let layout = markdown_layout(source, width);
|
||||
let copied = payload(&layout, 0..layout.text().len()).0;
|
||||
assert_eq!(
|
||||
payload(&layout.rewrap(/*width*/ 14), 0..layout.text().len()).0,
|
||||
copied
|
||||
);
|
||||
copies.push(copied);
|
||||
}
|
||||
assert_eq!(copies[0], copies[1]);
|
||||
insta::assert_snapshot!(copies[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_only_is_literal_and_mixed_code_has_safe_fences() {
|
||||
let layout = markdown_layout(
|
||||
"Prose\n\n````text\n *literal* <tag>\n```\n````\n\nAfter",
|
||||
/*width*/ 80,
|
||||
);
|
||||
let selected = " *literal* <tag>\n```";
|
||||
let start = layout.text().find(selected).unwrap();
|
||||
assert_eq!(
|
||||
payload(&layout, start..start + selected.len()),
|
||||
(selected.into(), CopyFormat::PlainText)
|
||||
);
|
||||
let (copy, format) = payload(&layout, 0..layout.text().len());
|
||||
assert_eq!(format, CopyFormat::Markdown);
|
||||
assert!(copy.contains("````\n *literal* <tag>\n```\n````"));
|
||||
insta::assert_snapshot!(crate::clipboard_html::render_markdown(©));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_plain_output_remains_literal_in_html() {
|
||||
let layout = markdown_layout("**Answer**", /*width*/ 40);
|
||||
let literal = "*log* <tag>\n- not a list";
|
||||
let output = TextLayout::new(
|
||||
literal.lines().map(HyperlinkLine::from).collect(),
|
||||
/*width*/ 40,
|
||||
);
|
||||
let mut lines = Vec::new();
|
||||
layout.copy_lines(0..layout.text().len(), "", &mut lines);
|
||||
output.copy_lines(0..output.text().len(), "\n", &mut lines);
|
||||
let (copy, format) = crate::markdown_copy::selection(&lines, "unused");
|
||||
assert_eq!(format, CopyFormat::Markdown);
|
||||
assert_eq!(
|
||||
crate::clipboard_html::render_markdown(©),
|
||||
"<p><strong>Answer</strong></p>\n<pre><code>*log* <tag>\n- not a list\n</code></pre>\n"
|
||||
);
|
||||
assert_eq!(
|
||||
payload(&output, 0..output.text().len()),
|
||||
(literal.into(), CopyFormat::PlainText)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_user_prose_preserves_literal_text_without_code_fences() {
|
||||
let cell = crate::history_cell::new_user_prompt(
|
||||
" Explain *literal*\nnext <tag>".into(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec!["https://example.com/image.png".into()],
|
||||
);
|
||||
let user = TextLayout::new(
|
||||
cell.retained_hyperlink_lines(/*width*/ 24, /*detailed*/ false),
|
||||
/*width*/ 24,
|
||||
);
|
||||
assert_eq!(
|
||||
payload(&user, 0..user.text().len()),
|
||||
(user.text().into(), CopyFormat::PlainText)
|
||||
);
|
||||
let user = user.rewrap(/*width*/ 12);
|
||||
let answer = markdown_layout("**Answer**", /*width*/ 24);
|
||||
let tool = TextLayout::new(vec![HyperlinkLine::from("*log* <tag>")], /*width*/ 24);
|
||||
let mut copies = Vec::new();
|
||||
for layouts in [[&user, &answer, &tool], [&answer, &tool, &user]] {
|
||||
let mut lines = Vec::new();
|
||||
for layout in layouts {
|
||||
layout.copy_lines(0..layout.text().len(), "\n\n", &mut lines);
|
||||
}
|
||||
let (text, format) = crate::markdown_copy::selection(&lines, "unused");
|
||||
assert_eq!(format, CopyFormat::Markdown);
|
||||
copies.push(crate::clipboard_html::render_markdown(&text));
|
||||
}
|
||||
insta::assert_snapshot!(copies.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_selection_keeps_its_revision_and_format_across_resize() {
|
||||
use crate::clipboard_copy::CopyStatus;
|
||||
let cell: Arc<dyn HistoryCell> = Arc::new(AgentMarkdownCell::new(
|
||||
"before `selected` after".into(),
|
||||
Path::new("/"),
|
||||
));
|
||||
let mut cells = vec![cell];
|
||||
let mut view = TranscriptView::default();
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 4,
|
||||
);
|
||||
view.render(area, &mut Buffer::empty(area), &cells);
|
||||
let layout = view.layout(&cells, /*index*/ 0).unwrap();
|
||||
let start = layout.text().find("selected").unwrap();
|
||||
view.begin_selection(
|
||||
&cells,
|
||||
layout.column_for_offset(start + 8),
|
||||
/*row*/ 0,
|
||||
/*clicks*/ 1,
|
||||
);
|
||||
view.extend_selection(layout.column_for_offset(start), /*row*/ 0);
|
||||
view.end_drag();
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("selected"));
|
||||
cells[0] = Arc::new(AgentMarkdownCell::new("replacement".into(), Path::new("/")));
|
||||
let narrow = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 4,
|
||||
);
|
||||
view.render(narrow, &mut Buffer::empty(narrow), &cells);
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("selected"));
|
||||
for clear_selection in [false, true] {
|
||||
view.copy_selected_text_with(&cells, "selected", clear_selection, |text, format| {
|
||||
assert_eq!((text, format), ("`selected`", CopyFormat::Markdown));
|
||||
Ok(CopyStatus::Pending(1))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("selected"));
|
||||
assert_eq!(
|
||||
view.finish_copy(
|
||||
&cells,
|
||||
&(1, Ok(CopyStatus::Confirmed)),
|
||||
/*current*/ true
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(view.has_selection_range(), !clear_selection);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_punctuation_is_not_reinterpreted_and_selected_breaks_survive() {
|
||||
for (source, expected_html) in [
|
||||
("&amp;", "<p>&amp;</p>\n"),
|
||||
(r"1\) literal", "<p>1) literal</p>\n"),
|
||||
("foo\n\\=\\=\\=", "<p>foo\n===</p>\n"),
|
||||
] {
|
||||
let layout = markdown_layout(source, /*width*/ 80);
|
||||
let copy = payload(&layout, 0..layout.text().len()).0;
|
||||
assert_eq!(crate::clipboard_html::render_markdown(©), expected_html);
|
||||
}
|
||||
let layout = markdown_layout("first\n\nsecond", /*width*/ 80);
|
||||
assert_eq!(payload(&layout, 5..layout.text().len()).0, "\n\nsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_hard_breaks_survive_only_when_selected() {
|
||||
let layout = markdown_layout("first \nsecond", /*width*/ 80);
|
||||
assert_eq!(payload(&layout, 0..5).0, "first");
|
||||
assert_eq!(payload(&layout, 0..6).0, "first \n");
|
||||
assert_eq!(
|
||||
crate::clipboard_html::render_markdown(&payload(&layout, 0..layout.text().len()).0),
|
||||
"<p>first<br />\nsecond</p>\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_lists_and_transformed_tables_keep_their_meaning() {
|
||||
for (source, expected) in [
|
||||
("- [x] done\n- [ ] next", "- [x] done\n- [ ] next"),
|
||||
("1. [x] done\n2. [ ] next", "1. [x] done\n2. [ ] next"),
|
||||
] {
|
||||
let layout = markdown_layout(source, /*width*/ 80);
|
||||
assert_eq!(payload(&layout, 0..layout.text().len()).0, expected);
|
||||
}
|
||||
let layout = markdown_layout(
|
||||
"Prose\n\n| A | B |\n| - | - |\n| x | y |",
|
||||
/*width*/ 80,
|
||||
);
|
||||
let copied = payload(&layout, 0..layout.text().len()).0;
|
||||
let html = crate::clipboard_html::render_markdown(&copied);
|
||||
assert!(html.contains("<pre><code>"), "{copied}\n{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_entities_are_not_decoded_twice() {
|
||||
let layout = markdown_layout(
|
||||
"[label](https://example.com/?x=&amp;)",
|
||||
/*width*/ 80,
|
||||
);
|
||||
let copy = payload(&layout, 0..5).0;
|
||||
assert_eq!(
|
||||
crate::clipboard_html::render_markdown(©),
|
||||
"<p><a href=\"https://example.com/?x=&amp;\">label</a></p>\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_local_link_keeps_one_visible_destination() {
|
||||
let layout = markdown_layout("Open [**label**](/repo/file.rs).", /*width*/ 80);
|
||||
let copied = payload(&layout, 0..layout.text().len()).0;
|
||||
assert_eq!(copied, "Open **label** (repo/file.rs).");
|
||||
assert_eq!(
|
||||
crate::clipboard_html::render_markdown(&copied),
|
||||
"<p>Open <strong>label</strong> (repo/file.rs).</p>\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_rules_keep_structure_and_partial_rules_keep_selected_text() {
|
||||
let mut copies = Vec::new();
|
||||
for (source, partial) in [
|
||||
("Before\n\n---\n\nAfter", "—"),
|
||||
("Before\n\n> ---\n\nAfter", "> —"),
|
||||
] {
|
||||
let layout = markdown_layout(source, /*width*/ 80);
|
||||
let copied = payload(&layout, 0..layout.text().len()).0;
|
||||
copies.push(format!(
|
||||
"{copied}\n{}",
|
||||
crate::clipboard_html::render_markdown(&copied)
|
||||
));
|
||||
let start = layout.text().find('—').unwrap();
|
||||
assert_eq!(payload(&layout, start..start + "—".len()).0, partial);
|
||||
}
|
||||
insta::assert_snapshot!(copies.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_spillover_keeps_prose_outside_the_literal_table() {
|
||||
let layout = markdown_layout(
|
||||
"**Before**\n\n| A | B |\n| --- | --- |\n| one | two |\nHTML block:\n<div>Prose after the table.</div>\n\nAfter",
|
||||
/*width*/ 80,
|
||||
);
|
||||
let copied = payload(&layout, 0..layout.text().len()).0;
|
||||
insta::assert_snapshot!(format!(
|
||||
"{copied}\n{}",
|
||||
crate::clipboard_html::render_markdown(&copied)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_prose_keeps_leading_whitespace_outside_code_blocks() {
|
||||
let mut copies = Vec::new();
|
||||
for source in [
|
||||
"prefix suffix",
|
||||
"prefix\tsuffix",
|
||||
"**prefix suffix**",
|
||||
"> prefix suffix",
|
||||
] {
|
||||
let layout = markdown_layout(source, /*width*/ 80);
|
||||
let start = layout.text().find("prefix").unwrap() + "prefix".len();
|
||||
let copied = payload(&layout, start..layout.text().len()).0;
|
||||
copies.push(format!(
|
||||
"{copied}\n{}",
|
||||
crate::clipboard_html::render_markdown(&copied)
|
||||
));
|
||||
}
|
||||
insta::assert_snapshot!(copies.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_tables_keep_their_copy_containers() {
|
||||
let mut copies = Vec::new();
|
||||
for source in [
|
||||
"Before\n\n> | A | B |\n> | --- | --- |\n> | one | two |",
|
||||
"Before\n\n- intro\n\n | A | B |\n | --- | --- |\n | one | two |",
|
||||
] {
|
||||
let layout = markdown_layout(source, /*width*/ 80);
|
||||
let copied = payload(&layout, 0..layout.text().len()).0;
|
||||
copies.push(format!(
|
||||
"{copied}\n{}",
|
||||
crate::clipboard_html::render_markdown(&copied)
|
||||
));
|
||||
}
|
||||
insta::assert_snapshot!(copies.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_hard_breaks_survive_surrounding_blocks() {
|
||||
let source = "First paragraph.\n\nSecond paragraph with a hard break: \nnext line.\n\n> quoted **words** \n> next quote line\n\nFinal paragraph.";
|
||||
let mut stream = crate::streaming::controller::StreamController::new(
|
||||
Some(78),
|
||||
Path::new("/"),
|
||||
crate::history_cell::HistoryRenderMode::Rich,
|
||||
);
|
||||
let mut cells: Vec<Arc<dyn HistoryCell>> = Vec::new();
|
||||
for chunk in source.split_inclusive('\n') {
|
||||
stream.push(chunk);
|
||||
while let Some(cell) = stream.on_commit_tick_batch(/*max_lines*/ 1).0 {
|
||||
cells.push(cell.into());
|
||||
}
|
||||
}
|
||||
if let Some(cell) = stream.finalize().0 {
|
||||
cells.push(cell.into());
|
||||
}
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 40,
|
||||
);
|
||||
let mut view = TranscriptView::default();
|
||||
view.render(area, &mut Buffer::empty(area), &cells);
|
||||
view.begin_selection(&cells, /*column*/ 2, /*row*/ 0, /*clicks*/ 1);
|
||||
view.extend_selection(/*column*/ 79, /*row*/ 39);
|
||||
let plain = view.selected_text(&cells).unwrap();
|
||||
view.copy_selected_text_with(&cells, &plain, /*clear_selection*/ true, |text, format| {
|
||||
assert_eq!(format, CopyFormat::Markdown);
|
||||
assert_eq!(
|
||||
crate::clipboard_html::render_markdown(text),
|
||||
"<p>First paragraph.</p>\n<p>Second paragraph with a hard break:<br />\nnext line.</p>\n<blockquote>\n<p>quoted <strong>words</strong><br />\nnext quote line</p>\n</blockquote>\n<p>Final paragraph.</p>\n"
|
||||
);
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_open_code_fence_keeps_streamed_rows_in_one_block() {
|
||||
let mut stream = crate::streaming::controller::StreamController::new(
|
||||
/*width*/ Some(78),
|
||||
Path::new("/"),
|
||||
crate::history_cell::HistoryRenderMode::Rich,
|
||||
);
|
||||
let mut cells: Vec<Arc<dyn HistoryCell>> = Vec::new();
|
||||
for chunk in ["Prose\n\n```rust\nlet a = 1;\n", "let b = 2;\n"] {
|
||||
stream.push(chunk);
|
||||
while let Some(cell) = stream.on_commit_tick_batch(/*max_lines*/ 1).0 {
|
||||
cells.push(cell.into());
|
||||
}
|
||||
}
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10,
|
||||
);
|
||||
let mut view = TranscriptView::default();
|
||||
view.render(area, &mut Buffer::empty(area), &cells);
|
||||
view.begin_selection(&cells, /*column*/ 2, /*row*/ 0, /*clicks*/ 1);
|
||||
view.extend_selection(/*column*/ 79, /*row*/ 9);
|
||||
let plain = view.selected_text(&cells).unwrap();
|
||||
view.copy_selected_text_with(
|
||||
&cells,
|
||||
&plain,
|
||||
/*clear_selection*/ true,
|
||||
|text, format| {
|
||||
assert_eq!(format, CopyFormat::Markdown);
|
||||
insta::assert_snapshot!(crate::clipboard_html::render_markdown(text), @r#"
|
||||
<p>Prose</p>
|
||||
<pre><code>let a = 1;
|
||||
let b = 2;
|
||||
</code></pre>
|
||||
"#);
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_cell_selection_keeps_boundary_breaks() {
|
||||
let first = markdown_layout("first", /*width*/ 40);
|
||||
let second = markdown_layout("**second**", /*width*/ 40);
|
||||
for (left, right, expected) in [(5..5, 0..6, "\n**second**"), (0..5, 0..0, "first\n")] {
|
||||
let mut lines = Vec::new();
|
||||
first.copy_lines(left, "", &mut lines);
|
||||
second.copy_lines(right, "\n", &mut lines);
|
||||
assert_eq!(
|
||||
crate::markdown_copy::selection(&lines, "unused").0,
|
||||
expected
|
||||
);
|
||||
}
|
||||
let rule = markdown_layout("---", /*width*/ 40);
|
||||
let mut lines = Vec::new();
|
||||
first.copy_lines(0..first.text().len(), "", &mut lines);
|
||||
rule.copy_lines(0..rule.text().len(), "\n", &mut lines);
|
||||
let copied = crate::markdown_copy::selection(&lines, "unused").0;
|
||||
assert_eq!(
|
||||
crate::clipboard_html::render_markdown(&copied),
|
||||
"<p>first</p>\n<hr />\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_top_level_code_blocks_keep_blank_lines_and_boundaries() {
|
||||
let layout = markdown_layout(
|
||||
"Prose\n\n```\none\n\ninside\n```\n\n```\ntwo\n```",
|
||||
/*width*/ 80,
|
||||
);
|
||||
let copied = payload(&layout, 0..layout.text().len()).0;
|
||||
insta::assert_snapshot!(format!(
|
||||
"Markdown:\n{copied}\n\nHTML:\n{}",
|
||||
crate::clipboard_html::render_markdown(&copied)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_code_containers_remain_separate() {
|
||||
for (source, expected_html) in [
|
||||
(
|
||||
"Prose\n\n> ```\n> quoted\n> ```\n\n```\nunquoted\n```",
|
||||
"<p>Prose</p>\n<blockquote>\n<pre><code>quoted\n</code></pre>\n</blockquote>\n<pre><code>unquoted\n</code></pre>\n",
|
||||
),
|
||||
(
|
||||
"Prose\n\n- ```\n one\n ```\n- ```\n two\n ```",
|
||||
"<p>Prose</p>\n<ul>\n<li>\n<pre><code>one\n</code></pre>\n</li>\n<li>\n<pre><code>two\n</code></pre>\n</li>\n</ul>\n",
|
||||
),
|
||||
(
|
||||
"- before\n\n ```text\n let value = 1;\n ```\n\n after\n\n> quoted \n> next",
|
||||
"<ul>\n<li>\n<p>before</p>\n<pre><code>let value = 1;\n</code></pre>\n<p>after</p>\n</li>\n</ul>\n<blockquote>\n<p>quoted<br />\nnext</p>\n</blockquote>\n",
|
||||
),
|
||||
] {
|
||||
let layout = markdown_layout(source, /*width*/ 80);
|
||||
let copied = payload(&layout, 0..layout.text().len()).0;
|
||||
assert_eq!(
|
||||
crate::clipboard_html::render_markdown(&copied),
|
||||
expected_html,
|
||||
"{copied}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_list_and_code_containers_keep_their_structure() {
|
||||
for (source, start, end, expected) in [
|
||||
(
|
||||
"- parent\n - nested `code`",
|
||||
"nested",
|
||||
"code",
|
||||
"- nested `code`",
|
||||
),
|
||||
(
|
||||
"1. first\n2. **second**",
|
||||
"irst",
|
||||
"second",
|
||||
"1. irst\n2. **second**",
|
||||
),
|
||||
(
|
||||
"10. first\n\n second paragraph\n11. next",
|
||||
"first",
|
||||
"next",
|
||||
"10. first\n\n second paragraph\n11. next",
|
||||
),
|
||||
(
|
||||
"1. intro\n\n more words\n2. next",
|
||||
"words",
|
||||
"next",
|
||||
"1. words\n2. next",
|
||||
),
|
||||
(
|
||||
"- outer A\n - inner A\n- outer B\n - inner B",
|
||||
"inner A",
|
||||
"inner B",
|
||||
"- inner A\n- outer B\n - inner B",
|
||||
),
|
||||
(
|
||||
"> - outer\n> > - nested `code`",
|
||||
"nested",
|
||||
"code",
|
||||
"> > - nested `code`",
|
||||
),
|
||||
(
|
||||
"10. [x] first\n\n paragraph\n11. [ ] next",
|
||||
"first",
|
||||
"next",
|
||||
"10. [x] first\n\n paragraph\n11. [ ] next",
|
||||
),
|
||||
(
|
||||
"10. before\n\n ```\n code\n ```\n\n after",
|
||||
"before",
|
||||
"after",
|
||||
"10. before\n\n ```\n code\n ```\n\n after",
|
||||
),
|
||||
(
|
||||
"- outer\n - before\n\n ```\n code\n ```\n\n after",
|
||||
"before",
|
||||
"after",
|
||||
"- before\n\n ```\n code\n ```\n\n after",
|
||||
),
|
||||
(
|
||||
"Before\n\n indented_code()\n\nAfter",
|
||||
"Before",
|
||||
"After",
|
||||
"Before\n\n```\nindented_code()\n```\n\nAfter",
|
||||
),
|
||||
(
|
||||
"10. intro\n\n ```\n code\n ```\n\n after",
|
||||
"code",
|
||||
"after",
|
||||
"10. ```\n code\n ```\n\n after",
|
||||
),
|
||||
(
|
||||
"- [x] intro\n\n code\n\n after",
|
||||
"code",
|
||||
"after",
|
||||
"- [x]\n ```\n code\n ```\n\n after",
|
||||
),
|
||||
(
|
||||
"> - intro\n>\n> ```\n> code\n> ```\n>\n> after",
|
||||
"code",
|
||||
"after",
|
||||
"> - ```\n> code\n> ```\n>\n> after",
|
||||
),
|
||||
] {
|
||||
let layout = markdown_layout(source, /*width*/ 80);
|
||||
let start = layout.text().find(start).expect(source);
|
||||
let end = layout.text().rfind(end).expect(source) + end.len();
|
||||
let copied = payload(&layout, start..end).0;
|
||||
assert_eq!(
|
||||
crate::clipboard_html::render_markdown(&copied),
|
||||
crate::clipboard_html::render_markdown(expected),
|
||||
"{copied:?} from {source:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -186,9 +186,12 @@ fn paused_header_reservation_survives_copy_until_scroll_or_resize() {
|
||||
);
|
||||
let selected = view.selected_text(&cells).expect("selected word");
|
||||
assert_eq!(selected, "done");
|
||||
view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |_| {
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
|
||||
})
|
||||
view.copy_selected_text_with(
|
||||
&cells,
|
||||
&selected,
|
||||
/*clear_selection*/ true,
|
||||
|_, _format| Ok(crate::clipboard_copy::CopyStatus::Confirmed),
|
||||
)
|
||||
.expect("confirmed copy");
|
||||
let bookmark = view.bookmark(&cells);
|
||||
view.set_presentation(/*detailed*/ true, HistoryRenderMode::Rich);
|
||||
|
||||
@@ -38,7 +38,7 @@ fn right_click_retains_selection_until_confirmed_and_preserves_reading_position(
|
||||
&cells,
|
||||
&selected,
|
||||
/*clear_selection*/ true,
|
||||
|copied| {
|
||||
|copied, _format| {
|
||||
assert_eq!(copied, selected);
|
||||
result.clone()
|
||||
},
|
||||
|
||||
@@ -159,7 +159,10 @@ impl TranscriptView {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn selected_text(&mut self, cells: &[Arc<dyn HistoryCell>]) -> Option<String> {
|
||||
fn selected_ranges(
|
||||
&mut self,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
) -> Option<Vec<(Arc<TextLayout>, std::ops::Range<usize>)>> {
|
||||
if !self.has_selection_range() {
|
||||
return None;
|
||||
}
|
||||
@@ -176,16 +179,12 @@ impl TranscriptView {
|
||||
if start == end {
|
||||
return None;
|
||||
}
|
||||
let mut text = String::new();
|
||||
let mut previous: Option<Arc<TextLayout>> = None;
|
||||
let mut ranges = Vec::new();
|
||||
for index in start.index..=end.index {
|
||||
let layout = self.layout(cells, index)?;
|
||||
if layout.row_count() == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(previous) = &previous {
|
||||
text.push_str(previous.separator_after(&layout));
|
||||
}
|
||||
let begin = if index == start.index {
|
||||
start.offset
|
||||
} else {
|
||||
@@ -196,7 +195,19 @@ impl TranscriptView {
|
||||
} else {
|
||||
layout.text().len()
|
||||
};
|
||||
text.push_str(layout.text().get(begin..finish)?);
|
||||
ranges.push((layout, begin..finish));
|
||||
}
|
||||
Some(ranges)
|
||||
}
|
||||
|
||||
pub(crate) fn selected_text(&mut self, cells: &[Arc<dyn HistoryCell>]) -> Option<String> {
|
||||
let mut text = String::new();
|
||||
let mut previous: Option<Arc<TextLayout>> = None;
|
||||
for (layout, range) in self.selected_ranges(cells)? {
|
||||
if let Some(previous) = &previous {
|
||||
text.push_str(previous.separator_after(&layout));
|
||||
}
|
||||
text.push_str(layout.text().get(range)?);
|
||||
previous = Some(layout);
|
||||
}
|
||||
text.retain(|ch| !ch.is_control() || matches!(ch, '\n' | '\t'));
|
||||
@@ -210,9 +221,22 @@ impl TranscriptView {
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
text: &str,
|
||||
clear_selection: bool,
|
||||
copy: impl FnOnce(&str) -> Result<crate::clipboard_copy::CopyStatus, String>,
|
||||
copy: impl FnOnce(
|
||||
&str,
|
||||
crate::clipboard_copy::CopyFormat,
|
||||
) -> Result<crate::clipboard_copy::CopyStatus, String>,
|
||||
) -> Result<crate::clipboard_copy::CopyStatus, String> {
|
||||
let result = copy(text);
|
||||
let mut lines = Vec::new();
|
||||
let mut previous: Option<Arc<TextLayout>> = None;
|
||||
for (layout, range) in self.selected_ranges(cells).unwrap_or_default() {
|
||||
let separator = previous
|
||||
.as_ref()
|
||||
.map_or("", |previous| previous.separator_after(&layout));
|
||||
layout.copy_lines(range, separator, &mut lines);
|
||||
previous = Some(layout);
|
||||
}
|
||||
let (text, format) = crate::markdown_copy::selection(&lines, text);
|
||||
let result = copy(&text, format);
|
||||
match result {
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed) => {
|
||||
if clear_selection {
|
||||
|
||||
@@ -98,7 +98,7 @@ fn copy_shortcuts_clear_selection_only_after_confirmed_delivery() {
|
||||
&cells,
|
||||
&text,
|
||||
/*clear_selection*/ true,
|
||||
|text| {
|
||||
|text, _format| {
|
||||
assert_eq!(text, "selected\tcafé");
|
||||
Err("clipboard unavailable".to_string())
|
||||
},
|
||||
@@ -118,7 +118,7 @@ fn copy_shortcuts_clear_selection_only_after_confirmed_delivery() {
|
||||
&cells,
|
||||
&text,
|
||||
/*clear_selection*/ true,
|
||||
|text| {
|
||||
|text, _format| {
|
||||
assert_eq!(text, "selected\tcafé");
|
||||
Ok(status)
|
||||
},
|
||||
@@ -169,7 +169,7 @@ fn command_c_ignores_release_and_copies_only_an_active_selection() {
|
||||
panic!("Cmd+C must copy the transcript selection");
|
||||
};
|
||||
assert_eq!(text, "selected café");
|
||||
view.copy_selected_text_with(&cells, &text, /*clear_selection*/ true, |_| {
|
||||
view.copy_selected_text_with(&cells, &text, /*clear_selection*/ true, |_, _format| {
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -648,7 +648,7 @@ fn pending_copy_only_finishes_the_original_selection() {
|
||||
&cells,
|
||||
&selected,
|
||||
/*clear_selection*/ true,
|
||||
|_| Ok(CopyStatus::Pending(1)),
|
||||
|_, _format| Ok(CopyStatus::Pending(1)),
|
||||
)
|
||||
.unwrap();
|
||||
view.follow_pending_copy();
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
source: tui/src/transcript_view/markdown_copy_tests.rs
|
||||
expression: "format!(\"Markdown:\\n{copied}\\n\\nHTML:\\n{}\",\ncrate::clipboard_html::render_markdown(&copied))"
|
||||
---
|
||||
Markdown:
|
||||
Prose
|
||||
|
||||
```
|
||||
one
|
||||
|
||||
inside
|
||||
```
|
||||
|
||||
```
|
||||
two
|
||||
```
|
||||
|
||||
HTML:
|
||||
<p>Prose</p>
|
||||
<pre><code>one
|
||||
|
||||
inside
|
||||
</code></pre>
|
||||
<pre><code>two
|
||||
</code></pre>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/transcript_view/markdown_copy_tests.rs
|
||||
assertion_line: 88
|
||||
expression: "crate::clipboard_html::render_markdown(©)"
|
||||
---
|
||||
<p>Prose</p>
|
||||
<pre><code> *literal* <tag>
|
||||
```
|
||||
</code></pre>
|
||||
<p>After</p>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
---
|
||||
source: tui/src/transcript_view/markdown_copy_tests.rs
|
||||
assertion_line: 348
|
||||
expression: "copies.join(\"\\n---\\n\")"
|
||||
---
|
||||
Before
|
||||
|
||||
***
|
||||
|
||||
After
|
||||
<p>Before</p>
|
||||
<hr />
|
||||
<p>After</p>
|
||||
|
||||
---
|
||||
Before
|
||||
|
||||
>
|
||||
> ***
|
||||
|
||||
After
|
||||
<p>Before</p>
|
||||
<blockquote>
|
||||
<hr />
|
||||
</blockquote>
|
||||
<p>After</p>
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
---
|
||||
source: tui/src/transcript_view/markdown_copy_tests.rs
|
||||
assertion_line: 139
|
||||
expression: "copies.join(\"\\n\")"
|
||||
---
|
||||
_a **b**_**c**
|
||||
Selected: "a bc"
|
||||
Markdown: _a_ **_b_c**
|
||||
<p><em>a</em> <strong><em>b</em>c</strong></p>
|
||||
|
||||
_a **b**_**c**
|
||||
Selected: "bc"
|
||||
Markdown: **_b_c**
|
||||
<p><strong><em>b</em>c</strong></p>
|
||||
|
||||
*a **b***[**c**](/repo)
|
||||
Selected: "a bc"
|
||||
Markdown: _a_ **_b_c**
|
||||
<p><em>a</em> <strong><em>b</em>c</strong></p>
|
||||
|
||||
_*a*_[_b_](/repo)
|
||||
Selected: "ab"
|
||||
Markdown: _ab_
|
||||
<p><em>ab</em></p>
|
||||
|
||||
*[a **b**](https://example.com) c*
|
||||
Selected: "a b"
|
||||
Markdown: [_a_ **_b_**](<https://example.com>)
|
||||
<p><a href="https://example.com"><em>a</em> <strong><em>b</em></strong></a></p>
|
||||
|
||||
a*b*c a**b**c
|
||||
Selected: "abc abc"
|
||||
Markdown: a_b_c a**b**c
|
||||
<p>a<em>b</em>c a<strong>b</strong>c</p>
|
||||
|
||||
a_b_c
|
||||
Selected: "abc"
|
||||
Markdown: a_b_c
|
||||
<p>a<em>b</em>c</p>
|
||||
|
||||
a__b__c
|
||||
Selected: "abc"
|
||||
Markdown: a**b**c
|
||||
<p>a<strong>b</strong>c</p>
|
||||
|
||||
́_b_
|
||||
Selected: "\u{301}b"
|
||||
Markdown: ́_b_
|
||||
<p>́<em>b</em></p>
|
||||
|
||||
a*!*b
|
||||
Selected: "a!b"
|
||||
Markdown: a_\!_b
|
||||
<p>a<em>!</em>b</p>
|
||||
|
||||
a*。*b
|
||||
Selected: "a。b"
|
||||
Markdown: a_。_b
|
||||
<p>a<em>。</em>b</p>
|
||||
|
||||
a**_b_**c
|
||||
Selected: "abc"
|
||||
Markdown: a**_b_**c
|
||||
<p>a<strong><em>b</em></strong>c</p>
|
||||
|
||||
a**`b`**c
|
||||
Selected: "abc"
|
||||
Markdown: a**`b`**c
|
||||
<p>a<strong><code>b</code></strong>c</p>
|
||||
|
||||
_a_[b](/repo)
|
||||
Selected: "ab"
|
||||
Markdown: _a_b
|
||||
<p><em>a</em>b</p>
|
||||
|
||||
    **hello**
|
||||
Selected: " hello"
|
||||
Markdown:     **hello**
|
||||
<p> <strong>hello</strong></p>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/transcript_view/markdown_copy_tests.rs
|
||||
assertion_line: 76
|
||||
expression: "copies[0]"
|
||||
---
|
||||
3. first item long enough to wrap
|
||||
4. second item
|
||||
- nested `code`
|
||||
|
||||
another paragraph
|
||||
- next nested
|
||||
5. final item
|
||||
|
||||
Outside.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tui/src/transcript_view/markdown_copy_tests.rs
|
||||
expression: "copies.join(\"\\n---\\n\")"
|
||||
---
|
||||
<p>[Image #1]</p>
|
||||
<p> Explain *literal*<br />
|
||||
next <tag></p>
|
||||
<p><strong>Answer</strong></p>
|
||||
<pre><code>*log* <tag>
|
||||
</code></pre>
|
||||
|
||||
---
|
||||
<p><strong>Answer</strong></p>
|
||||
<pre><code>*log* <tag>
|
||||
|
||||
</code></pre>
|
||||
<p>[Image #1]</p>
|
||||
<p> Explain *literal*<br />
|
||||
next <tag></p>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
---
|
||||
source: tui/src/transcript_view/markdown_copy_tests.rs
|
||||
assertion_line: 393
|
||||
expression: "copies.join(\"\\n---\\n\")"
|
||||
---
|
||||
Before
|
||||
|
||||
> ```
|
||||
> A B
|
||||
> ━━━━━ ━━━━━
|
||||
> one two
|
||||
> ```
|
||||
<p>Before</p>
|
||||
<blockquote>
|
||||
<pre><code> A B
|
||||
━━━━━ ━━━━━
|
||||
one two
|
||||
</code></pre>
|
||||
</blockquote>
|
||||
|
||||
---
|
||||
Before
|
||||
|
||||
- intro
|
||||
|
||||
```
|
||||
A B
|
||||
━━━━━ ━━━━━
|
||||
one two
|
||||
```
|
||||
<p>Before</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>intro</p>
|
||||
<pre><code> A B
|
||||
━━━━━ ━━━━━
|
||||
one two
|
||||
</code></pre>
|
||||
</li>
|
||||
</ul>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tui/src/transcript_view/markdown_copy_tests.rs
|
||||
assertion_line: 379
|
||||
expression: "copies.join(\"\\n---\\n\")"
|
||||
---
|
||||
    suffix
|
||||
<p> suffix</p>
|
||||
|
||||
---
|
||||
	suffix
|
||||
<p> suffix</p>
|
||||
|
||||
---
|
||||
    **suffix**
|
||||
<p> <strong>suffix</strong></p>
|
||||
|
||||
---
|
||||
    suffix
|
||||
<p> suffix</p>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/transcript_view/markdown_copy_tests.rs
|
||||
assertion_line: 27
|
||||
expression: "crate::clipboard_html::render_markdown(text)"
|
||||
---
|
||||
<ul>
|
||||
<li>setting <code>closeRequested_</code>,</li>
|
||||
<li>removing the tailer from <code>tailers_</code>,</li>
|
||||
<li>clearing its live/discovery state.</li>
|
||||
</ul>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/transcript_view/markdown_copy_tests.rs
|
||||
assertion_line: 26
|
||||
expression: text
|
||||
---
|
||||
- setting `closeRequested_`,
|
||||
- removing the tailer from `tailers_`,
|
||||
- clearing its live/discovery state.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
---
|
||||
source: tui/src/transcript_view/markdown_copy_tests.rs
|
||||
assertion_line: 358
|
||||
expression: "format!(\"{copied}\\n{}\", crate::clipboard_html::render_markdown(&copied))"
|
||||
---
|
||||
**Before**
|
||||
|
||||
```
|
||||
A B
|
||||
━━━━━ ━━━━━
|
||||
one two
|
||||
```
|
||||
HTML block:
|
||||
\<div\>Prose after the table.\</div\>
|
||||
|
||||
After
|
||||
<p><strong>Before</strong></p>
|
||||
<pre><code> A B
|
||||
━━━━━ ━━━━━
|
||||
one two
|
||||
</code></pre>
|
||||
<p>HTML block:
|
||||
<div>Prose after the table.</div></p>
|
||||
<p>After</p>
|
||||
@@ -179,6 +179,29 @@ impl TextLayout {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn copy_lines(
|
||||
&self,
|
||||
range: Range<usize>,
|
||||
separator: &str,
|
||||
output: &mut Vec<crate::markdown_copy::SelectedLine>,
|
||||
) {
|
||||
let mut offset = 0;
|
||||
for (index, line) in self.logical.iter().enumerate() {
|
||||
let end = offset + line.origin.range.len();
|
||||
if range.start <= end && range.end >= offset {
|
||||
let selected = line.origin.range.start + range.start.saturating_sub(offset)
|
||||
..line.origin.range.start + range.end.min(end) - offset;
|
||||
crate::markdown_copy::SelectedLine::append(
|
||||
output,
|
||||
line.origin.clone(),
|
||||
selected,
|
||||
if index == 0 { separator } else { "\n" },
|
||||
);
|
||||
}
|
||||
offset = end + 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ use crate::clipboard_copy::CopyFormat;
|
||||
use crate::clipboard_copy::CopyStatus;
|
||||
|
||||
impl Tui {
|
||||
pub(crate) fn copy_transcript_selection(&mut self, text: &str) -> Result<CopyStatus, String> {
|
||||
pub(crate) fn copy_transcript_selection(
|
||||
&mut self,
|
||||
text: &str,
|
||||
format: CopyFormat,
|
||||
) -> Result<CopyStatus, String> {
|
||||
self.clipboard
|
||||
.copy(text.into(), CopyFormat::PlainText, self.frame_requester())
|
||||
.copy(text.into(), format, self.frame_requester())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user