返回 DeepSeek-TUI-2026
ui_text.rs
根目录 / crates / tui / src / tui / ui_text.rs
1 //! Shared text helpers for TUI selection and clipboard workflows.
2
3 use ratatui::text::Line;
4 use unicode_width::UnicodeWidthChar;
5
6 use crate::tui::history::HistoryCell;
7 use crate::tui::osc8;
8
9 pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String {
10 cell.transcript_lines(width)
11 .into_iter()
12 .map(line_to_string)
13 .collect::<Vec<_>>()
14 .join("\n")
15 }
16
17 fn line_to_string(line: Line<'static>) -> String {
18 let mut out = String::new();
19 for span in line.spans {
20 if span.content.contains('\x1b') {
21 osc8::strip_into(&span.content, &mut out);
22 } else {
23 out.push_str(&span.content);
24 }
25 }
26 out
27 }
28
29 pub(super) fn line_to_plain(line: &Line<'static>) -> String {
30 let mut out = String::new();
31 for span in &line.spans {
32 if span.content.contains('\x1b') {
33 osc8::strip_into(&span.content, &mut out);
34 } else {
35 out.push_str(span.content.as_ref());
36 }
37 }
38 out
39 }
40
41 pub(super) fn text_display_width(text: &str) -> usize {
42 text.chars().map(char_display_width).sum()
43 }
44
45 pub(super) fn slice_text(text: &str, start: usize, end: usize) -> String {
46 if end <= start {
47 return String::new();
48 }
49
50 let mut out = String::new();
51 let mut col = 0usize;
52 for ch in text.chars() {
53 let ch_width = char_display_width(ch);
54 let ch_start = col;
55 let ch_end = col.saturating_add(ch_width);
56 if ch_end > start && ch_start < end {
57 out.push(ch);
58 }
59 col = ch_end;
60 if col >= end {
61 break;
62 }
63 }
64 out
65 }
66
67 fn char_display_width(ch: char) -> usize {
68 if ch == '\t' {
69 4
70 } else {
71 UnicodeWidthChar::width(ch).unwrap_or(0).max(1)
72 }
73 }
74
75 #[cfg(test)]
76 mod tests {
77 use super::*;
78 use ratatui::text::Span;
79
80 #[test]
81 fn line_to_plain_strips_osc_8_wrapper() {
82 // A span carrying an OSC 8-wrapped URL must not leak the escape into
83 // selection / clipboard output. The visible label survives.
84 let wrapped = format!(
85 "\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
86 "https://example.com", "https://example.com"
87 );
88 let line = Line::from(vec![
89 Span::raw("see "),
90 Span::raw(wrapped),
91 Span::raw(" for details"),
92 ]);
93 assert_eq!(line_to_plain(&line), "see https://example.com for details");
94 }
95
96 #[test]
97 fn line_to_plain_passes_through_plain_spans() {
98 let line = Line::from(vec![Span::raw("plain "), Span::raw("text")]);
99 assert_eq!(line_to_plain(&line), "plain text");
100 }
101 }
102
102 lines RUST