| 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 | ©_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 |