返回 CodeWhale
transcript.rs
根目录 / vendor / codewhale-ratatui / src / components / transcript.rs
1 //! A caller-authored transcript: headings, prose with semantic spans,
2 //! quotes, lists, tables and inset code.
3 //!
4 //! The host owns the parser, the clipboard and the scroll position. This kit
5 //! owns wrapping, clipping and honesty: a link target is metadata that never
6 //! reaches the screen, `copy_text` is the exact original source, and a
7 //! caller's semantic role is painted as the caller asked. No syntax parser,
8 //! no OSC sequence, no file access.
9
10 use std::borrow::Cow;
11
12 use ratatui::{
13 buffer::Buffer,
14 layout::Rect,
15 style::{Modifier, Style},
16 text::{Line, Span},
17 widgets::{
18 Block, BorderType, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
19 StatefulWidget, Widget, Wrap,
20 },
21 };
22 use unicode_segmentation::UnicodeSegmentation;
23
24 use crate::{Paint, Role, Theme, text};
25
26 use super::workbench::row;
27
28 /// One atom of painted ink: a grapheme and the style it was asked to carry.
29 type Atom = (String, Style);
30
31 fn push_atom(row: &mut Vec<Atom>, grapheme: &str, style: Style) {
32 row.push((grapheme.to_string(), style));
33 }
34
35 fn row_width(row: &[Atom]) -> usize {
36 row.iter().map(|(text, _)| text::width(text)).sum()
37 }
38
39 /// Split a row at its last whitespace atom: everything before stays, the
40 /// remainder carries to the next row. Without whitespace the row is kept
41 /// whole and the caller breaks it hard.
42 fn split_at_space(row: &[Atom]) -> (Vec<Atom>, Vec<Atom>) {
43 let Some(index) = row
44 .iter()
45 .rposition(|(text, _)| !text.is_empty() && text.chars().all(char::is_whitespace))
46 else {
47 return (row.to_vec(), Vec::new());
48 };
49 (row[..index].to_vec(), row[index + 1..].to_vec())
50 }
51
52 /// Word-aware wrapping over grapheme atoms. `first` budgets the first row and
53 /// `rest` every continuation; a grapheme wider than the budget still lands.
54 fn wrap_atoms(atoms: &[Atom], first: usize, rest: usize) -> Vec<Vec<Atom>> {
55 let mut rows: Vec<Vec<Atom>> = Vec::new();
56 let mut row: Vec<Atom> = Vec::new();
57 let mut width = 0usize;
58 let mut budget = first.max(1);
59 for (grapheme, style) in atoms {
60 let glyph = text::width(grapheme);
61 if width > 0 && width + glyph > budget {
62 let (keep, carry) = split_at_space(&row);
63 if !keep.is_empty() && !carry.is_empty() {
64 rows.push(keep);
65 row = carry;
66 width = row_width(&row);
67 } else {
68 rows.push(std::mem::take(&mut row));
69 width = 0;
70 }
71 budget = rest.max(1);
72 }
73 push_atom(&mut row, grapheme, *style);
74 width += glyph;
75 }
76 if !row.is_empty() {
77 rows.push(row);
78 }
79 if rows.is_empty() {
80 rows.push(Vec::new());
81 }
82 rows
83 }
84
85 fn row_line(row: &[Atom]) -> Line<'static> {
86 let mut spans: Vec<Span<'static>> = Vec::new();
87 for (text, style) in row {
88 if let Some(last) = spans.last_mut().filter(|last| last.style == *style) {
89 last.content.to_mut().push_str(text);
90 } else {
91 spans.push(Span::styled(text.clone(), *style));
92 }
93 }
94 Line::from(spans)
95 }
96
97 fn prefixed(prefix: &str, style: Style, line: Line<'static>) -> Line<'static> {
98 let mut spans = Vec::with_capacity(line.spans.len() + 1);
99 spans.push(Span::styled(prefix.to_string(), style));
100 spans.extend(line.spans);
101 Line::from(spans)
102 }
103
104 /// Wrap logical lines behind a prefix. The first visual row carries `prefix`;
105 /// every later row carries `indent`, which is the same rail for a quote and
106 /// blank gutter or list padding elsewhere.
107 fn flow(
108 logical: Vec<Vec<Atom>>,
109 first: usize,
110 rest: usize,
111 prefix: &str,
112 prefix_style: Style,
113 indent: &str,
114 ) -> Vec<Line<'static>> {
115 let mut out = Vec::new();
116 let mut leading = true;
117 for (index, atoms) in logical.into_iter().enumerate() {
118 let budget = if index == 0 { first } else { rest };
119 for (row_index, atoms) in wrap_atoms(&atoms, budget, rest).into_iter().enumerate() {
120 let line = row_line(&atoms);
121 if leading && row_index == 0 {
122 out.push(prefixed(prefix, prefix_style, line));
123 leading = false;
124 } else if indent.is_empty() {
125 out.push(line);
126 } else {
127 out.push(prefixed(indent, Style::default(), line));
128 }
129 }
130 }
131 out
132 }
133
134 fn wrapped(logical: Vec<Vec<Atom>>, first: usize, rest: usize) -> Vec<Line<'static>> {
135 flow(logical, first, rest, "", Style::default(), "")
136 }
137
138 /// Sanitized single-style text, split into logical lines on `\n`.
139 fn ink_atoms(value: &str, style: Style) -> Vec<Vec<Atom>> {
140 // Split structural newlines before filtering hidden controls. The generic
141 // single-line sanitizer intentionally removes every control, including LF.
142 value
143 .split('\n')
144 .map(|line| {
145 let expanded = line.replace('\t', " ");
146 let safe = text::display_safe(&expanded);
147 let mut atoms = Vec::new();
148 for grapheme in safe.graphemes(true) {
149 push_atom(&mut atoms, grapheme, style);
150 }
151 atoms
152 })
153 .collect()
154 }
155
156 /// Sanitized caller spans, split into logical lines on `\n`.
157 fn span_atoms<'a>(spans: &[TranscriptSpan<'a>], theme: &Theme) -> Vec<Vec<Atom>> {
158 let mut lines: Vec<Vec<Atom>> = vec![Vec::new()];
159 for span in spans {
160 let style = span.style(theme);
161 for (index, piece) in span.text.split('\n').enumerate() {
162 if index > 0 {
163 lines.push(Vec::new());
164 }
165 let expanded = piece.replace('\t', " ");
166 let safe = text::display_safe(&expanded);
167 for grapheme in safe.graphemes(true) {
168 push_atom(
169 lines.last_mut().expect("one line is always present"),
170 grapheme,
171 style,
172 );
173 }
174 }
175 }
176 lines
177 }
178
179 fn paint_lines(area: Rect, buf: &mut Buffer, lines: &[Line<'static>], offset: usize) {
180 for (index, line) in lines
181 .iter()
182 .skip(offset)
183 .take(usize::from(area.height))
184 .enumerate()
185 {
186 let y = area
187 .y
188 .saturating_add(u16::try_from(index).unwrap_or(u16::MAX));
189 if line.style.bg.is_some() {
190 buf.set_style(Rect::new(area.x, y, area.width, 1), line.style);
191 }
192 row(Rect::new(area.x, y, area.width, 1), buf, line);
193 }
194 }
195
196 fn table_row_line(
197 cells: &[String],
198 widths: &[usize],
199 separator: &str,
200 separator_style: Style,
201 cell_style: Style,
202 ascii: bool,
203 ) -> Line<'static> {
204 let mut spans = Vec::new();
205 for (index, cell) in cells.iter().enumerate() {
206 if index > 0 {
207 spans.push(Span::styled(separator.to_string(), separator_style));
208 }
209 let width = widths.get(index).copied().unwrap_or(0);
210 spans.push(Span::styled(text::pad(cell, width, ascii), cell_style));
211 }
212 Line::from(spans)
213 }
214
215 /// A narrow table reads as one `column: value` entry per row rather than
216 /// columns squeezed past legibility.
217 fn stacked_lines(
218 header: &[String],
219 body: &[Vec<String>],
220 width: usize,
221 theme: &Theme,
222 ) -> Vec<Line<'static>> {
223 let mut out = Vec::new();
224 for cells in body {
225 for (index, cell) in cells.iter().enumerate() {
226 let name = header.get(index).map(String::as_str).unwrap_or("");
227 let entry = if name.is_empty() {
228 cell.clone()
229 } else {
230 format!("{name}: {cell}")
231 };
232 out.extend(wrapped(
233 ink_atoms(&entry, theme.fg(Role::Foreground)),
234 width,
235 width,
236 ));
237 }
238 }
239 out
240 }
241
242 fn table_lines<'a>(
243 columns: &[Cow<'a, str>],
244 rows: &[Vec<Cow<'a, str>>],
245 width: usize,
246 theme: &Theme,
247 ) -> Vec<Line<'static>> {
248 let ascii = theme.ascii();
249 let col_count = columns
250 .len()
251 .max(rows.iter().map(Vec::len).max().unwrap_or(0));
252 if col_count == 0 {
253 return Vec::new();
254 }
255 let header: Vec<String> = (0..col_count)
256 .map(|index| {
257 columns
258 .get(index)
259 .map(|cell| text::display_safe(&cell.replace(['\n', '\r', '\t'], " ")).into_owned())
260 .unwrap_or_default()
261 })
262 .collect();
263 let body: Vec<Vec<String>> = rows
264 .iter()
265 .map(|row| {
266 (0..col_count)
267 .map(|index| {
268 row.get(index)
269 .map(|cell| {
270 text::display_safe(&cell.replace(['\n', '\r', '\t'], " ")).into_owned()
271 })
272 .unwrap_or_default()
273 })
274 .collect()
275 })
276 .collect();
277 let separator = if ascii { " | " } else { " │ " };
278 let separator_width = text::width(separator);
279 let fixed = separator_width * col_count.saturating_sub(1);
280 let mut widths: Vec<usize> = (0..col_count)
281 .map(|index| {
282 let header_width = text::width(&header[index]);
283 let body_width = body
284 .iter()
285 .map(|row| text::width(&row[index]))
286 .max()
287 .unwrap_or(0);
288 header_width.max(body_width).min(32)
289 })
290 .collect();
291 while widths.iter().sum::<usize>() + fixed > width && widths.iter().any(|w| *w > 3) {
292 let widest = widths
293 .iter()
294 .enumerate()
295 .max_by_key(|(_, width)| **width)
296 .map(|(index, _)| index)
297 .unwrap_or(0);
298 widths[widest] = widths[widest].saturating_sub(1);
299 }
300 if widths.iter().sum::<usize>() + fixed > width {
301 return stacked_lines(&header, &body, width, theme);
302 }
303 let mut out = vec![table_row_line(
304 &header,
305 &widths,
306 separator,
307 theme.fg(Role::Border),
308 theme.fg(Role::Muted).add_modifier(Modifier::BOLD),
309 ascii,
310 )];
311 for row in &body {
312 out.push(table_row_line(
313 row,
314 &widths,
315 separator,
316 theme.fg(Role::Border),
317 theme.fg(Role::Foreground),
318 ascii,
319 ));
320 }
321 out
322 }
323
324 /// The semantic role a caller gave one span. The kit paints this role; it
325 /// never inspects the text to guess a different one.
326 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
327 pub enum TranscriptSpanRole {
328 Text,
329 Strong,
330 Emphasis,
331 Code,
332 Link,
333 Muted,
334 Success,
335 Warning,
336 }
337
338 impl TranscriptSpanRole {
339 pub const ALL: [Self; 8] = [
340 Self::Text,
341 Self::Strong,
342 Self::Emphasis,
343 Self::Code,
344 Self::Link,
345 Self::Muted,
346 Self::Success,
347 Self::Warning,
348 ];
349
350 #[must_use]
351 pub const fn role(self) -> Role {
352 match self {
353 Self::Text | Self::Strong | Self::Emphasis => Role::Foreground,
354 Self::Code | Self::Success => Role::Live,
355 Self::Link => Role::Primary,
356 Self::Muted => Role::Muted,
357 Self::Warning => Role::Attention,
358 }
359 }
360
361 #[must_use]
362 pub fn style(self, theme: &Theme) -> Style {
363 let base = theme.fg(self.role());
364 match self {
365 Self::Strong => base.add_modifier(Modifier::BOLD),
366 Self::Emphasis => base.add_modifier(Modifier::ITALIC),
367 // Underline is the only link mark: the target is never an escape
368 // sequence, an OSC 8 hyperlink or a file destination.
369 Self::Link => base.add_modifier(Modifier::UNDERLINED),
370 _ => base,
371 }
372 }
373 }
374
375 /// One run of text with the semantic role its author gave it. `target` is
376 /// link metadata for the host; it is never painted.
377 #[derive(Clone, Debug, PartialEq, Eq)]
378 pub struct TranscriptSpan<'a> {
379 pub text: Cow<'a, str>,
380 pub role: TranscriptSpanRole,
381 pub target: Option<Cow<'a, str>>,
382 }
383
384 impl<'a> TranscriptSpan<'a> {
385 #[must_use]
386 pub fn new(text: impl Into<Cow<'a, str>>, role: TranscriptSpanRole) -> Self {
387 Self {
388 text: text.into(),
389 role,
390 target: None,
391 }
392 }
393
394 #[must_use]
395 pub fn plain(text: impl Into<Cow<'a, str>>) -> Self {
396 Self::new(text, TranscriptSpanRole::Text)
397 }
398
399 #[must_use]
400 pub fn strong(text: impl Into<Cow<'a, str>>) -> Self {
401 Self::new(text, TranscriptSpanRole::Strong)
402 }
403
404 #[must_use]
405 pub fn emphasis(text: impl Into<Cow<'a, str>>) -> Self {
406 Self::new(text, TranscriptSpanRole::Emphasis)
407 }
408
409 #[must_use]
410 pub fn code(text: impl Into<Cow<'a, str>>) -> Self {
411 Self::new(text, TranscriptSpanRole::Code)
412 }
413
414 #[must_use]
415 pub fn muted(text: impl Into<Cow<'a, str>>) -> Self {
416 Self::new(text, TranscriptSpanRole::Muted)
417 }
418
419 #[must_use]
420 pub fn success(text: impl Into<Cow<'a, str>>) -> Self {
421 Self::new(text, TranscriptSpanRole::Success)
422 }
423
424 #[must_use]
425 pub fn warning(text: impl Into<Cow<'a, str>>) -> Self {
426 Self::new(text, TranscriptSpanRole::Warning)
427 }
428
429 /// A link whose visible text is painted and whose target stays out of
430 /// band for the host's own dispatch.
431 #[must_use]
432 pub fn link(text: impl Into<Cow<'a, str>>, target: impl Into<Cow<'a, str>>) -> Self {
433 let mut span = Self::new(text, TranscriptSpanRole::Link);
434 span.target = Some(target.into());
435 span
436 }
437
438 #[must_use]
439 pub fn target(mut self, target: impl Into<Cow<'a, str>>) -> Self {
440 self.target = Some(target.into());
441 self
442 }
443
444 #[must_use]
445 pub fn style(&self, theme: &Theme) -> Style {
446 self.role.style(theme)
447 }
448 }
449
450 /// One action a code block reports to the host. The kit never copies.
451 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
452 pub enum TranscriptAction {
453 Copy,
454 }
455
456 impl TranscriptAction {
457 #[must_use]
458 pub const fn word(self) -> &'static str {
459 match self {
460 Self::Copy => "Copy",
461 }
462 }
463 }
464
465 const COPY_ACTIONS: [TranscriptAction; 1] = [TranscriptAction::Copy];
466 const NO_ACTIONS: [TranscriptAction; 0] = [];
467
468 /// Inset code with a language, an optional gutter and exact copy text.
469 ///
470 /// `source` is the original, verbatim: `source()` and `copy_text()` return it
471 /// unchanged, newlines included. Painting uses `display`, when given, or the
472 /// sanitized source, and never mutates what a host would copy.
473 #[derive(Clone, Debug)]
474 pub struct CodeBlock<'a> {
475 pub language: Cow<'a, str>,
476 pub source: Cow<'a, str>,
477 pub display: Option<Cow<'a, str>>,
478 pub gutter: bool,
479 pub inset: u16,
480 pub copy: bool,
481 pub copy_label: Cow<'a, str>,
482 /// Optional caller semantic spans over the display text.
483 pub spans: Vec<TranscriptSpan<'a>>,
484 }
485
486 impl<'a> CodeBlock<'a> {
487 #[must_use]
488 pub fn new(language: impl Into<Cow<'a, str>>, source: impl Into<Cow<'a, str>>) -> Self {
489 Self {
490 language: language.into(),
491 source: source.into(),
492 display: None,
493 gutter: true,
494 inset: 2,
495 copy: true,
496 copy_label: Cow::Borrowed("Copy"),
497 spans: Vec::new(),
498 }
499 }
500
501 /// A separate display copy. The original `source` is still what is copied.
502 #[must_use]
503 pub fn display(mut self, display: impl Into<Cow<'a, str>>) -> Self {
504 self.display = Some(display.into());
505 self
506 }
507
508 #[must_use]
509 pub fn gutter(mut self, gutter: bool) -> Self {
510 self.gutter = gutter;
511 self
512 }
513
514 #[must_use]
515 pub fn inset(mut self, inset: u16) -> Self {
516 self.inset = inset;
517 self
518 }
519
520 #[must_use]
521 pub fn copyable(mut self, copy: bool) -> Self {
522 self.copy = copy;
523 self
524 }
525
526 #[must_use]
527 pub fn copy_label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
528 self.copy_label = label.into();
529 self
530 }
531
532 /// Caller-owned semantic spans for the display text. The kit paints these
533 /// roles and never classifies the code itself.
534 #[must_use]
535 pub fn spans(mut self, spans: Vec<TranscriptSpan<'a>>) -> Self {
536 self.spans = spans;
537 self
538 }
539
540 /// The exact original text, newlines included.
541 #[must_use]
542 pub fn source(&self) -> &str {
543 &self.source
544 }
545
546 /// The exact original text a host would put on the clipboard.
547 #[must_use]
548 pub fn copy_text(&self) -> &str {
549 &self.source
550 }
551
552 /// What this block paints, sanitized at paint time.
553 #[must_use]
554 pub fn display_text(&self) -> &str {
555 self.display.as_deref().unwrap_or(&self.source)
556 }
557
558 #[must_use]
559 pub fn copy_action(&self) -> Option<TranscriptAction> {
560 self.copy.then_some(TranscriptAction::Copy)
561 }
562
563 #[must_use]
564 pub fn actions(&self) -> &'static [TranscriptAction] {
565 if self.copy {
566 &COPY_ACTIONS
567 } else {
568 &NO_ACTIONS
569 }
570 }
571
572 /// The block's painted rows at `width`: an optional language and Copy
573 /// header, then the wrapped, gutter-numbered body.
574 #[must_use]
575 pub fn lines(&self, width: u16, theme: &Theme) -> Vec<Line<'static>> {
576 let cells = usize::from(width);
577 if cells == 0 {
578 return Vec::new();
579 }
580 let ascii = theme.ascii();
581 let mut out = Vec::new();
582 let language = text::display_safe(&self.language);
583 if !language.is_empty() || self.copy {
584 let mut spans: Vec<Span<'static>> = Vec::new();
585 if !language.is_empty() {
586 spans.push(Span::styled(
587 language.into_owned(),
588 theme.fg(Role::Muted).add_modifier(Modifier::BOLD),
589 ));
590 }
591 if self.copy {
592 if !spans.is_empty() {
593 spans.push(Span::raw(" "));
594 }
595 spans.push(Span::styled(
596 text::display_safe(&self.copy_label).into_owned(),
597 theme.fg(Role::Primary),
598 ));
599 }
600 out.push(Line::from(spans));
601 }
602 let logical = if self.spans.is_empty() {
603 ink_atoms(self.display_text(), theme.fg(Role::Foreground))
604 } else {
605 span_atoms(&self.spans, theme)
606 };
607 let count = logical.len().max(1);
608 let digits = count.to_string().len().max(2);
609 let rail = if ascii { "| " } else { "│ " };
610 let gutter = self.gutter && cells >= digits + 1 + text::width(rail) + 2;
611 let gutter_width = if gutter {
612 digits + 1 + text::width(rail)
613 } else {
614 0
615 };
616 let inset = usize::from(self.inset).min(cells.saturating_sub(gutter_width + 2));
617 let lead = " ".repeat(inset);
618 let indent = " ".repeat(gutter_width + inset);
619 let prefix_width = gutter_width + inset;
620 let budget = cells.saturating_sub(prefix_width).max(1);
621 for (index, atoms) in logical.into_iter().enumerate() {
622 let prefix = if gutter {
623 format!("{:>width$} {}{}", index + 1, rail, lead, width = digits)
624 } else {
625 lead.clone()
626 };
627 out.extend(flow(
628 vec![atoms],
629 budget,
630 budget,
631 &prefix,
632 theme.fg(Role::Border),
633 &indent,
634 ));
635 }
636 for line in &mut out {
637 line.style = line.style.patch(theme.bg(Role::Surface));
638 }
639 out
640 }
641
642 #[must_use]
643 pub fn height(&self, width: u16, theme: &Theme) -> u16 {
644 u16::try_from(self.lines(width, theme).len()).unwrap_or(u16::MAX)
645 }
646 }
647
648 impl Paint for CodeBlock<'_> {
649 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
650 let area = area.intersection(buf.area);
651 if area.is_empty() {
652 return;
653 }
654 let lines = self.lines(area.width, theme);
655 paint_lines(area, buf, &lines, 0);
656 }
657
658 fn height(&self, width: u16, theme: &Theme) -> u16 {
659 u16::try_from(self.lines(width, theme).len()).unwrap_or(u16::MAX)
660 }
661 }
662
663 /// One structured block a caller authored. The host parses; the kit paints.
664 #[derive(Clone, Debug)]
665 pub enum TranscriptBlock<'a> {
666 /// A heading, `1` through `6`.
667 Heading { level: u8, text: Cow<'a, str> },
668 /// Prose with caller semantic spans.
669 Paragraph { spans: Vec<TranscriptSpan<'a>> },
670 /// A quote with an optional attribution line.
671 Quote {
672 text: Cow<'a, str>,
673 attribution: Option<Cow<'a, str>>,
674 },
675 /// A list; `ordered` numbers the items.
676 List {
677 items: Vec<TranscriptSpan<'a>>,
678 ordered: bool,
679 },
680 /// A table whose first row is `columns`.
681 Table {
682 columns: Vec<Cow<'a, str>>,
683 rows: Vec<Vec<Cow<'a, str>>>,
684 },
685 /// Inset code with a language, a gutter and copy metadata.
686 Code(CodeBlock<'a>),
687 }
688
689 impl<'a> TranscriptBlock<'a> {
690 #[must_use]
691 pub fn heading(level: u8, text: impl Into<Cow<'a, str>>) -> Self {
692 Self::Heading {
693 level,
694 text: text.into(),
695 }
696 }
697
698 #[must_use]
699 pub fn paragraph(spans: Vec<TranscriptSpan<'a>>) -> Self {
700 Self::Paragraph { spans }
701 }
702
703 /// A paragraph of plain prose.
704 #[must_use]
705 pub fn prose(text: impl Into<Cow<'a, str>>) -> Self {
706 Self::Paragraph {
707 spans: vec![TranscriptSpan::plain(text)],
708 }
709 }
710
711 #[must_use]
712 pub fn quote(text: impl Into<Cow<'a, str>>) -> Self {
713 Self::Quote {
714 text: text.into(),
715 attribution: None,
716 }
717 }
718
719 #[must_use]
720 pub fn quote_by(text: impl Into<Cow<'a, str>>, attribution: impl Into<Cow<'a, str>>) -> Self {
721 Self::Quote {
722 text: text.into(),
723 attribution: Some(attribution.into()),
724 }
725 }
726
727 #[must_use]
728 pub fn list(items: Vec<TranscriptSpan<'a>>) -> Self {
729 Self::List {
730 items,
731 ordered: false,
732 }
733 }
734
735 #[must_use]
736 pub fn ordered(items: Vec<TranscriptSpan<'a>>) -> Self {
737 Self::List {
738 items,
739 ordered: true,
740 }
741 }
742
743 #[must_use]
744 pub fn table(columns: Vec<Cow<'a, str>>, rows: Vec<Vec<Cow<'a, str>>>) -> Self {
745 Self::Table { columns, rows }
746 }
747
748 #[must_use]
749 pub fn code(block: CodeBlock<'a>) -> Self {
750 Self::Code(block)
751 }
752
753 /// The block's painted rows at `width`.
754 #[must_use]
755 pub fn lines(&self, width: u16, theme: &Theme) -> Vec<Line<'static>> {
756 let cells = usize::from(width).max(1);
757 let ascii = theme.ascii();
758 match self {
759 Self::Heading { level, text } => {
760 let style = match level {
761 1 => theme.fg(Role::Primary).add_modifier(Modifier::BOLD),
762 2 => theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
763 _ => theme.fg(Role::Muted).add_modifier(Modifier::BOLD),
764 };
765 wrapped(ink_atoms(text, style), cells, cells)
766 }
767 Self::Paragraph { spans } => wrapped(span_atoms(spans, theme), cells, cells),
768 Self::Quote { text, attribution } => {
769 let rail = if ascii { "| " } else { "▏ " };
770 let ink = theme.fg(Role::Foreground).add_modifier(Modifier::ITALIC);
771 let inner = cells.saturating_sub(2).max(1);
772 let mut out = flow(
773 ink_atoms(text, ink),
774 inner,
775 inner,
776 rail,
777 theme.fg(Role::Border),
778 rail,
779 );
780 if let Some(attribution) = attribution {
781 let dash = if ascii { "- " } else { "— " };
782 let line = format!("{dash}{}", text::display_safe(attribution));
783 out.extend(flow(
784 ink_atoms(&line, theme.fg(Role::Muted)),
785 inner,
786 inner,
787 rail,
788 theme.fg(Role::Border),
789 rail,
790 ));
791 }
792 out
793 }
794 Self::List { items, ordered } => {
795 let mut out = Vec::new();
796 for (index, item) in items.iter().enumerate() {
797 let prefix = if *ordered {
798 format!("{}. ", index + 1)
799 } else if ascii {
800 "- ".to_string()
801 } else {
802 "• ".to_string()
803 };
804 let prefix_width = text::width(&prefix);
805 let inner = cells.saturating_sub(prefix_width).max(1);
806 let indent = " ".repeat(prefix_width);
807 out.extend(flow(
808 span_atoms(std::slice::from_ref(item), theme),
809 inner,
810 inner,
811 &prefix,
812 theme.fg(Role::Muted),
813 &indent,
814 ));
815 }
816 out
817 }
818 Self::Table { columns, rows } => table_lines(columns, rows, cells, theme),
819 Self::Code(block) => block.lines(width, theme),
820 }
821 }
822
823 #[must_use]
824 pub fn height(&self, width: u16, theme: &Theme) -> u16 {
825 u16::try_from(self.lines(width, theme).len()).unwrap_or(u16::MAX)
826 }
827 }
828
829 impl Paint for TranscriptBlock<'_> {
830 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
831 let area = area.intersection(buf.area);
832 if area.is_empty() {
833 return;
834 }
835 let lines = self.lines(area.width, theme);
836 paint_lines(area, buf, &lines, 0);
837 }
838
839 fn height(&self, width: u16, theme: &Theme) -> u16 {
840 u16::try_from(self.lines(width, theme).len()).unwrap_or(u16::MAX)
841 }
842 }
843
844 /// A link the host may dispatch: the visible text and the out-of-band target.
845 /// The target is never painted, opened or written to a file by the kit.
846 #[derive(Clone, Debug, PartialEq, Eq)]
847 pub struct TranscriptLink {
848 pub text: String,
849 pub target: String,
850 }
851
852 fn collect_links(spans: &[TranscriptSpan<'_>], links: &mut Vec<TranscriptLink>) {
853 for span in spans {
854 if let Some(target) = &span.target {
855 links.push(TranscriptLink {
856 text: text::display_safe(&span.text).into_owned(),
857 target: target.to_string(),
858 });
859 }
860 }
861 }
862
863 /// A caller-authored transcript. `offset` is the host's scroll position in
864 /// painted rows: paint clips to it and to the given height.
865 #[derive(Clone, Debug, Default)]
866 pub struct Transcript<'a> {
867 pub blocks: Vec<TranscriptBlock<'a>>,
868 pub offset: usize,
869 }
870
871 impl<'a> Transcript<'a> {
872 #[must_use]
873 pub fn new(blocks: Vec<TranscriptBlock<'a>>) -> Self {
874 Self { blocks, offset: 0 }
875 }
876
877 #[must_use]
878 pub fn offset(mut self, offset: usize) -> Self {
879 self.offset = offset;
880 self
881 }
882
883 #[must_use]
884 pub fn block(mut self, block: TranscriptBlock<'a>) -> Self {
885 self.blocks.push(block);
886 self
887 }
888
889 /// Every painted row at `width`, before the host's offset clips it.
890 #[must_use]
891 pub fn lines(&self, width: u16, theme: &Theme) -> Vec<Line<'static>> {
892 let mut out = Vec::new();
893 for block in &self.blocks {
894 out.extend(block.lines(width, theme));
895 }
896 out
897 }
898
899 #[must_use]
900 pub fn height(&self, width: u16, theme: &Theme) -> u16 {
901 u16::try_from(self.lines(width, theme).len()).unwrap_or(u16::MAX)
902 }
903
904 /// Every link in the transcript, in reading order. Out-of-band metadata
905 /// for the host; nothing here paints.
906 #[must_use]
907 pub fn links(&self) -> Vec<TranscriptLink> {
908 let mut links = Vec::new();
909 for block in &self.blocks {
910 match block {
911 TranscriptBlock::Paragraph { spans } => collect_links(spans, &mut links),
912 TranscriptBlock::List { items, .. } => collect_links(items, &mut links),
913 TranscriptBlock::Code(block) => collect_links(&block.spans, &mut links),
914 _ => {}
915 }
916 }
917 links
918 }
919 }
920
921 impl Paint for Transcript<'_> {
922 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
923 let area = area.intersection(buf.area);
924 if area.is_empty() {
925 return;
926 }
927 let lines = self.lines(area.width, theme);
928 paint_lines(area, buf, &lines, self.offset);
929 }
930
931 fn height(&self, width: u16, theme: &Theme) -> u16 {
932 u16::try_from(self.lines(width, theme).len()).unwrap_or(u16::MAX)
933 }
934 }
935
936 // Native viewport and selection adapted from Codewhale TUI presentation.
937 // Adapted from Codewhale, licensed under the MIT License:
938 // Copyright (c) 2024-2025 DeepSeek-TUI Contributors
939 //
940 // Permission is hereby granted, free of charge, to any person obtaining a
941 // copy of this software and associated documentation files (the "Software"),
942 // to deal in the Software without restriction, including without limitation
943 // the rights to use, copy, modify, merge, publish, distribute, sublicense,
944 // and/or sell copies of the Software, and to permit persons to whom the
945 // Software is furnished to do so, subject to the following conditions:
946 //
947 // The above copyright notice and this permission notice shall be included
948 // in all copies or substantial portions of the Software.
949 //
950 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
951 // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
952 // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
953 // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
954 // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
955 // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
956 // DEALINGS IN THE SOFTWARE.
957
958 /// Caller-owned scroll facts. This never changes a session's scroll state.
959 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
960 pub struct TranscriptScrollFacts {
961 pub top: usize,
962 pub visible: usize,
963 pub total: usize,
964 }
965
966 /// Exact caller styles for the native transcript's independent chrome slots.
967 #[derive(Clone, Copy, Debug, Default)]
968 pub struct TranscriptViewportStyles {
969 pub background: Style,
970 pub track: Style,
971 pub thumb: Style,
972 pub jump_border: Style,
973 pub jump_arrow: Style,
974 }
975
976 /// One pure projection of already parsed/wrapped host rows. The parser, raw
977 /// copy source, streaming/cache receipts, selection endpoints and scroll intent
978 /// remain with the host. Targets never enter this type or its painted cells.
979 /// Metadata must describe guarded, prewrapped display rows; `offset` counts
980 /// logical rows after the pinned prefix. Optional wrapping is Ratatui-owned.
981 #[derive(Clone, Debug)]
982 pub struct TranscriptViewport<'a> {
983 pub rows: &'a [Line<'static>],
984 pub offset: usize,
985 pub pinned_rows: u16,
986 pub wrap: bool,
987 pub fill: bool,
988 pub ascii: bool,
989 pub style: Style,
990 pub styles: TranscriptViewportStyles,
991 pub scrollbar: Option<TranscriptScrollFacts>,
992 pub jump_to_latest: bool,
993 }
994
995 /// Final clipped content/chrome rectangles and guarded styled rows.
996 #[derive(Clone, Debug)]
997 pub struct TranscriptViewportPlan {
998 pub area: Rect,
999 pub body_area: Rect,
1000 pub link_area: Rect,
1001 pub jump: Option<Rect>,
1002 rows: Vec<Line<'static>>,
1003 wrap: bool,
1004 fill: bool,
1005 ascii: bool,
1006 style: Style,
1007 styles: TranscriptViewportStyles,
1008 scrollbar: Option<TranscriptScrollFacts>,
1009 }
1010
1011 fn safe_transcript_line(line: &Line<'static>) -> Line<'static> {
1012 let mut safe = line.clone();
1013 safe.spans = line
1014 .spans
1015 .iter()
1016 .map(|span| Span::styled(text::display_safe(&span.content).into_owned(), span.style))
1017 .collect();
1018 safe
1019 }
1020
1021 impl<'a> TranscriptViewport<'a> {
1022 pub fn new(rows: &'a [Line<'static>]) -> Self {
1023 Self {
1024 rows,
1025 offset: 0,
1026 pinned_rows: 0,
1027 wrap: false,
1028 fill: false,
1029 ascii: false,
1030 style: Style::default(),
1031 styles: TranscriptViewportStyles::default(),
1032 scrollbar: None,
1033 jump_to_latest: false,
1034 }
1035 }
1036 pub fn plan(&self, area: Rect) -> TranscriptViewportPlan {
1037 let pinned = self.pinned_rows.min(area.height);
1038 let body_area = Rect::new(
1039 area.x,
1040 area.y.saturating_add(pinned),
1041 area.width,
1042 area.height.saturating_sub(pinned),
1043 );
1044 let scrollbar = self.scrollbar.filter(|facts| {
1045 facts.total > facts.visible && body_area.width > 1 && body_area.height > 0
1046 });
1047 let link_area = Rect::new(
1048 area.x,
1049 area.y,
1050 area.width.saturating_sub(u16::from(scrollbar.is_some())),
1051 area.height,
1052 );
1053 let prefix = usize::from(pinned).min(self.rows.len());
1054 let mut rows: Vec<_> = self.rows[..prefix]
1055 .iter()
1056 .map(safe_transcript_line)
1057 .collect();
1058 rows.extend(
1059 self.rows
1060 .iter()
1061 .skip(prefix.saturating_add(self.offset))
1062 .take(usize::from(body_area.height))
1063 .map(safe_transcript_line),
1064 );
1065 TranscriptViewportPlan {
1066 area,
1067 body_area,
1068 link_area,
1069 jump: self
1070 .jump_to_latest
1071 .then(|| transcript_jump_rect(body_area, scrollbar.is_some()))
1072 .flatten(),
1073 rows,
1074 wrap: self.wrap,
1075 fill: self.fill,
1076 ascii: self.ascii,
1077 style: self.style,
1078 styles: self.styles,
1079 scrollbar,
1080 }
1081 }
1082 /// Paint content first. A host's existing semantic Ocean finishing may run
1083 /// before `plan.paint_chrome`; it does not need another text compositor.
1084 pub fn render_content(&self, area: Rect, buf: &mut Buffer) -> TranscriptViewportPlan {
1085 let plan = self.plan(area.intersection(buf.area));
1086 plan.paint_content(buf);
1087 plan
1088 }
1089 pub fn render(&self, area: Rect, buf: &mut Buffer) -> TranscriptViewportPlan {
1090 let plan = self.render_content(area, buf);
1091 plan.paint_chrome(buf);
1092 plan
1093 }
1094 }
1095
1096 impl TranscriptViewportPlan {
1097 /// The exact display-safe, pinned/offset rows used by this plan's content
1098 /// painter. Hosts may derive frame-only semantic masks from these facts
1099 /// without repeating the viewport projection or touching raw copy source.
1100 #[must_use]
1101 pub fn display_rows(&self) -> &[Line<'static>] {
1102 &self.rows
1103 }
1104
1105 /// Visible hyperlink cells, excluding scroll chrome and the opaque jump button.
1106 /// Hosts retain the corresponding target outside these rectangles.
1107 pub fn link_rects(&self, row: usize, start: usize, end: usize) -> Vec<Rect> {
1108 let Some(rect) = transcript_link_rect(self.link_area, row, start, end) else {
1109 return Vec::new();
1110 };
1111 let Some(button) = self
1112 .jump
1113 .filter(|button| !rect.intersection(*button).is_empty())
1114 else {
1115 return vec![rect];
1116 };
1117 let mut visible = Vec::with_capacity(2);
1118 if rect.x < button.x {
1119 visible.push(Rect::new(rect.x, rect.y, button.x - rect.x, 1));
1120 }
1121 if rect.right() > button.right() {
1122 visible.push(Rect::new(
1123 button.right(),
1124 rect.y,
1125 rect.right() - button.right(),
1126 1,
1127 ));
1128 }
1129 visible
1130 }
1131 fn paint_content(&self, buf: &mut Buffer) {
1132 if self.area.is_empty() {
1133 return;
1134 }
1135 if self.fill {
1136 Block::default()
1137 .style(self.styles.background)
1138 .render(self.area, buf);
1139 }
1140 let mut paragraph = Paragraph::new(self.rows.clone()).style(self.style);
1141 if self.wrap {
1142 paragraph = paragraph.wrap(Wrap { trim: false });
1143 }
1144 paragraph.render(self.area, buf);
1145 }
1146 pub fn paint_chrome(&self, buf: &mut Buffer) {
1147 if self.area.intersection(buf.area).is_empty() {
1148 return;
1149 }
1150 if let Some(facts) = self.scrollbar {
1151 let range = facts.total.saturating_sub(facts.visible);
1152 let mut state = ScrollbarState::new(range)
1153 .position(facts.top.min(range))
1154 .viewport_content_length(facts.visible);
1155 Scrollbar::new(ScrollbarOrientation::VerticalRight)
1156 .begin_symbol(None)
1157 .end_symbol(None)
1158 .track_symbol(Some(if self.ascii { "|" } else { "│" }))
1159 .track_style(self.styles.track)
1160 .thumb_symbol(if self.ascii { "|" } else { "┃" })
1161 .thumb_style(self.styles.thumb)
1162 .render(self.body_area.intersection(buf.area), buf, &mut state);
1163 }
1164 if let Some(area) = self.jump {
1165 let mut block = Block::default()
1166 .borders(Borders::ALL)
1167 .border_type(BorderType::Rounded)
1168 .border_style(self.styles.jump_border)
1169 .style(self.styles.background);
1170 if self.ascii {
1171 block = block.border_set(ratatui::symbols::border::Set {
1172 top_left: "+",
1173 top_right: "+",
1174 bottom_left: "+",
1175 bottom_right: "+",
1176 vertical_left: "|",
1177 vertical_right: "|",
1178 horizontal_top: "-",
1179 horizontal_bottom: "-",
1180 });
1181 }
1182 block.render(area.intersection(buf.area), buf);
1183 let arrow = (area.x.saturating_add(1), area.y.saturating_add(1));
1184 if buf.area.contains(arrow.into()) {
1185 buf[arrow]
1186 .set_symbol(if self.ascii { "v" } else { "↓" })
1187 .set_style(self.styles.jump_arrow.add_modifier(Modifier::BOLD));
1188 }
1189 }
1190 }
1191 }
1192 impl Paint for TranscriptViewport<'_> {
1193 fn paint(&self, area: Rect, buf: &mut Buffer, _theme: &Theme) {
1194 self.render(area, buf);
1195 }
1196 fn height(&self, width: u16, _theme: &Theme) -> u16 {
1197 if width == 0 {
1198 return 0;
1199 }
1200 let pinned = usize::from(self.pinned_rows).min(self.rows.len());
1201 if self.wrap {
1202 let rows: Vec<_> = self.rows[..pinned]
1203 .iter()
1204 .chain(self.rows.iter().skip(pinned.saturating_add(self.offset)))
1205 .map(safe_transcript_line)
1206 .collect();
1207 return u16::try_from(
1208 Paragraph::new(rows)
1209 .wrap(Wrap { trim: false })
1210 .line_count(width),
1211 )
1212 .unwrap_or(u16::MAX);
1213 }
1214 u16::try_from(
1215 pinned.saturating_add(
1216 self.rows
1217 .len()
1218 .saturating_sub(pinned.saturating_add(self.offset)),
1219 ),
1220 )
1221 .unwrap_or(u16::MAX)
1222 }
1223 }
1224
1225 /// Native three-cell jump affordance, never overlapping an owned scroll rail.
1226 /// The host decides whether it is appropriate and dispatches the click.
1227 pub fn transcript_jump_rect(area: Rect, scrollbar: bool) -> Option<Rect> {
1228 if area.width < 3 + u16::from(scrollbar) || area.height < 3 {
1229 return None;
1230 }
1231 Some(Rect::new(
1232 area.right()
1233 .saturating_sub(u16::from(scrollbar))
1234 .saturating_sub(3),
1235 area.bottom().saturating_sub(3),
1236 3,
1237 3,
1238 ))
1239 }
1240
1241 /// Project inclusive display-column metadata to a clipped terminal row. The
1242 /// opaque target and terminal/browser opening policy stay entirely with hosts.
1243 pub fn transcript_link_rect(area: Rect, row: usize, start: usize, end: usize) -> Option<Rect> {
1244 if area.is_empty()
1245 || row >= usize::from(area.height)
1246 || end < start
1247 || start >= usize::from(area.width)
1248 {
1249 return None;
1250 }
1251 let end = end.min(usize::from(area.width).saturating_sub(1));
1252 let x = area
1253 .x
1254 .saturating_add(u16::try_from(start).unwrap_or(u16::MAX));
1255 let y = area
1256 .y
1257 .saturating_add(u16::try_from(row).unwrap_or(u16::MAX));
1258 let rect = Rect::new(x, y, u16::try_from(end - start + 1).unwrap_or(u16::MAX), 1);
1259 let clipped = rect.intersection(area);
1260 (!clipped.is_empty()).then_some(clipped)
1261 }
1262
1263 /// Whole-grapheme selection projection, preserving every source span's style
1264 /// facts and applying the supplied selection style only to covered cells.
1265 pub fn transcript_selected_spans(
1266 line: &Line<'static>,
1267 start: usize,
1268 end: usize,
1269 selection: Style,
1270 ) -> Vec<Span<'static>> {
1271 transcript_selected_spans_measured(line, start, end, selection, text::width)
1272 }
1273
1274 /// The host may supply its existing terminal-column grammar (for example CJK
1275 /// ambiguous characters and non-VS keycaps) without another selection painter.
1276 pub fn transcript_selected_spans_measured(
1277 line: &Line<'static>,
1278 start: usize,
1279 end: usize,
1280 selection: Style,
1281 measure_grapheme: impl Fn(&str) -> usize,
1282 ) -> Vec<Span<'static>> {
1283 let mut result = Vec::with_capacity(line.spans.len().saturating_add(2));
1284 let mut column = 0usize;
1285 for span in &line.spans {
1286 let value = text::display_safe(&span.content);
1287 let span_end =
1288 column.saturating_add(value.graphemes(true).fold(0usize, |width, grapheme| {
1289 width.saturating_add(measure_grapheme(grapheme))
1290 }));
1291 if span_end <= start || column >= end {
1292 result.push(Span::styled(value.into_owned(), span.style));
1293 } else if column >= start && span_end <= end {
1294 result.push(Span::styled(
1295 value.into_owned(),
1296 span.style.patch(selection),
1297 ));
1298 } else {
1299 let (mut before, mut selected, mut after) =
1300 (String::new(), String::new(), String::new());
1301 let mut position = column;
1302 for grapheme in value.graphemes(true) {
1303 let next = position.saturating_add(measure_grapheme(grapheme));
1304 if next <= start {
1305 before.push_str(grapheme);
1306 } else if position >= end {
1307 after.push_str(grapheme);
1308 } else {
1309 selected.push_str(grapheme);
1310 }
1311 position = next;
1312 }
1313 for (text, style) in [
1314 (before, span.style),
1315 (selected, span.style.patch(selection)),
1316 (after, span.style),
1317 ] {
1318 if !text.is_empty() {
1319 result.push(Span::styled(text, style));
1320 }
1321 }
1322 }
1323 column = span_end;
1324 }
1325 result
1326 }
1327
1327 lines RUST