1use std::sync::Arc;
9
10pub use crossterm::style::Color;
11pub use tau_proto::MAX_HYPERLINK_TARGET_BYTES;
13use unicode_segmentation::UnicodeSegmentation;
14use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
15
16use crate::TwoLineElision;
17
18pub fn display_width(text: &str) -> usize {
24 UnicodeSegmentation::graphemes(text, true)
25 .map(screen_grapheme_width)
26 .sum()
27}
28
29pub fn truncate_to_width(text: &str, max_width: usize) -> String {
32 if max_width == 0 {
33 return String::new();
34 }
35 if display_width(text) <= max_width {
36 return text.to_owned();
37 }
38 if max_width == 1 {
39 return "…".to_owned();
40 }
41
42 let mut out = String::new();
43 let mut width = 0;
44 let prefix_width = max_width - 1;
45 for grapheme in UnicodeSegmentation::graphemes(text, true) {
46 let grapheme_width = screen_grapheme_width(grapheme);
47 if prefix_width < width + grapheme_width {
48 break;
49 }
50 width += grapheme_width;
51 out.push_str(grapheme);
52 }
53 out.push('…');
54 out
55}
56
57pub fn previous_grapheme_boundary(text: &str, pos: usize) -> usize {
59 let pos = pos.min(text.len());
60 UnicodeSegmentation::grapheme_indices(text, true)
61 .map(|(idx, _)| idx)
62 .take_while(|idx| *idx < pos)
63 .last()
64 .unwrap_or(0)
65}
66
67pub fn next_grapheme_boundary(text: &str, pos: usize) -> usize {
69 if text.len() <= pos {
70 return text.len();
71 }
72 for (idx, grapheme) in UnicodeSegmentation::grapheme_indices(text, true) {
73 let end = idx + grapheme.len();
74 if pos < end {
75 return end;
76 }
77 }
78 text.len()
79}
80
81pub(crate) fn is_line_break_grapheme(grapheme: &str) -> bool {
82 matches!(grapheme, "\n" | "\r\n" | "\r")
83}
84
85pub(crate) fn screen_grapheme_width(grapheme: &str) -> usize {
86 if is_line_break_grapheme(grapheme) {
87 0
88 } else if grapheme == "\t" || grapheme.chars().any(char::is_control) {
89 1
90 } else {
91 UnicodeWidthStr::width(grapheme)
92 }
93}
94
95pub(crate) fn push_grapheme_cells(
96 cells: &mut Vec<Cell>,
97 grapheme: &str,
98 style: Style,
99 hyperlink: Option<&Arc<str>>,
100) {
101 if grapheme == "\t" {
102 cells.push(Cell::new(' ', style).with_hyperlink(hyperlink.cloned()));
103 return;
104 }
105 if grapheme.chars().any(char::is_control) {
106 cells.push(Cell::new('�', style).with_hyperlink(hyperlink.cloned()));
107 return;
108 }
109 let grapheme_width = screen_grapheme_width(grapheme);
110 for (idx, ch) in grapheme.chars().enumerate() {
111 let width = if idx == 0 { grapheme_width } else { 0 };
112 cells.push(
113 Cell::new(ch, style)
114 .with_width(width)
115 .with_hyperlink(hyperlink.cloned()),
116 );
117 }
118}
119
120pub(crate) fn visit_styled_graphemes(
121 spans: &[Span],
122 mut f: impl FnMut(&str, Style, Option<&Arc<str>>),
123) {
124 let mut text = String::new();
125 let mut char_styles = Vec::new();
126 for span in spans {
127 for ch in span.text.chars() {
128 char_styles.push((text.len(), span.style, span.hyperlink.as_ref()));
129 text.push(ch);
130 }
131 }
132
133 let mut style_idx = 0;
134 for (byte, grapheme) in UnicodeSegmentation::grapheme_indices(text.as_str(), true) {
135 while style_idx + 1 < char_styles.len() && char_styles[style_idx + 1].0 <= byte {
136 style_idx += 1;
137 }
138 let style = char_styles
139 .get(style_idx)
140 .map(|(_, style, _)| *style)
141 .unwrap_or_default();
142 let hyperlink = char_styles.get(style_idx).and_then(|(_, _, link)| *link);
143 f(grapheme, style, hyperlink);
144 }
145}
146
147#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
149pub struct Style {
150 pub fg: Option<Color>,
152 pub bg: Option<Color>,
154 pub bold: bool,
156 pub underline: bool,
158 pub italic: bool,
160 pub strikethrough: bool,
162}
163
164impl Style {
165 pub fn fg(mut self, color: Color) -> Self {
167 self.fg = Some(color);
168 self
169 }
170
171 pub fn bg(mut self, color: Color) -> Self {
173 self.bg = Some(color);
174 self
175 }
176
177 pub fn bold(mut self) -> Self {
179 self.bold = true;
180 self
181 }
182
183 pub fn underline(mut self) -> Self {
185 self.underline = true;
186 self
187 }
188
189 pub fn italic(mut self) -> Self {
191 self.italic = true;
192 self
193 }
194
195 pub fn strikethrough(mut self) -> Self {
197 self.strikethrough = true;
198 self
199 }
200}
201
202#[derive(Clone, PartialEq, Eq, Debug)]
204pub struct Cell {
205 pub ch: char,
207 pub style: Style,
209 pub width: usize,
211 pub hyperlink: Option<Arc<str>>,
213}
214
215impl Cell {
216 pub(crate) fn sanitized_char(ch: char) -> char {
217 if ch == '\t' {
218 ' '
219 } else if ch.is_control() {
220 '�'
221 } else {
222 ch
223 }
224 }
225
226 pub fn new(ch: char, style: Style) -> Self {
228 let ch = Self::sanitized_char(ch);
229 Self {
230 ch,
231 style,
232 width: ch.width().unwrap_or(0),
233 hyperlink: None,
234 }
235 }
236
237 pub fn plain(ch: char) -> Self {
239 let ch = Self::sanitized_char(ch);
240 Self {
241 ch,
242 style: Style::default(),
243 width: ch.width().unwrap_or(0),
244 hyperlink: None,
245 }
246 }
247
248 pub(crate) fn normalized(&self) -> Self {
249 let ch = Self::sanitized_char(self.ch);
250 if ch == self.ch {
251 self.clone()
252 } else {
253 Self {
254 ch,
255 style: self.style,
256 width: ch.width().unwrap_or(0),
257 hyperlink: self.hyperlink.clone(),
258 }
259 }
260 }
261
262 pub fn with_width(mut self, width: usize) -> Self {
264 self.width = width;
265 self
266 }
267
268 pub fn with_hyperlink(mut self, hyperlink: Option<Arc<str>>) -> Self {
270 self.hyperlink = hyperlink.filter(|target| sanitize_hyperlink_target(target).is_some());
271 self
272 }
273
274 pub fn col_width(&self) -> usize {
277 self.width
278 }
279}
280
281#[derive(Clone, Debug, PartialEq, Eq)]
287pub struct Span {
288 pub text: String,
290 pub style: Style,
295 pub hyperlink: Option<Arc<str>>,
297}
298
299impl Span {
300 pub fn new(text: impl Into<String>, style: Style) -> Self {
302 Self {
303 text: text.into(),
304 style,
305 hyperlink: None,
306 }
307 }
308
309 pub fn plain(text: impl Into<String>) -> Self {
311 Self {
312 text: text.into(),
313 style: Style::default(),
314 hyperlink: None,
315 }
316 }
317
318 pub fn hyperlink(mut self, target: impl AsRef<str>) -> Self {
320 self.hyperlink = sanitize_hyperlink_target(target.as_ref()).map(Arc::from);
321 self
322 }
323}
324
325#[derive(Clone, Debug, Default, PartialEq, Eq)]
334pub struct StyledText {
335 spans: Vec<Span>,
336}
337
338impl StyledText {
339 pub fn new() -> Self {
341 Self::default()
342 }
343
344 pub fn push(&mut self, span: Span) {
346 self.spans.push(span);
347 }
348
349 pub fn spans(&self) -> &[Span] {
351 &self.spans
352 }
353
354 pub fn spans_mut(&mut self) -> &mut [Span] {
356 &mut self.spans
357 }
358
359 pub fn char_count(&self) -> usize {
364 let mut text = String::new();
365 for span in &self.spans {
366 text.push_str(&span.text);
367 }
368 display_width(&text)
369 }
370
371 pub fn is_empty(&self) -> bool {
373 self.spans.iter().all(|s| s.text.is_empty())
374 }
375
376 pub fn to_cells(&self) -> Vec<Cell> {
378 let mut cells = Vec::new();
379 visit_styled_graphemes(&self.spans, |grapheme, style, hyperlink| {
380 if !is_line_break_grapheme(grapheme) {
381 push_grapheme_cells(&mut cells, grapheme, style, hyperlink);
382 }
383 });
384 cells
385 }
386}
387
388pub fn sanitize_hyperlink_target(target: &str) -> Option<&str> {
390 (!target.is_empty()
391 && target.len() <= MAX_HYPERLINK_TARGET_BYTES
392 && !target.chars().any(char::is_control))
393 .then_some(target)
394}
395
396impl From<&str> for StyledText {
397 fn from(s: &str) -> Self {
398 Self {
399 spans: vec![Span::plain(s)],
400 }
401 }
402}
403
404impl From<String> for StyledText {
405 fn from(s: String) -> Self {
406 Self {
407 spans: vec![Span::plain(s)],
408 }
409 }
410}
411
412impl From<Span> for StyledText {
413 fn from(span: Span) -> Self {
414 Self { spans: vec![span] }
415 }
416}
417
418impl From<Vec<Span>> for StyledText {
419 fn from(spans: Vec<Span>) -> Self {
420 Self { spans }
421 }
422}
423
424#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
426pub struct BlockId(
427 pub u64,
429);
430
431#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
433pub enum Align {
434 #[default]
436 Left,
437 Center,
439}
440
441#[derive(Clone, Debug, PartialEq, Eq)]
443pub(crate) enum BlockLayout {
444 Ordinary,
446 Priority {
448 line: crate::PriorityLine,
450 body: StyledText,
452 },
453 TwoLineElision(TwoLineElision),
455}
456
457#[derive(Clone, Debug, PartialEq, Eq)]
463pub struct StyledBlock {
464 pub content: StyledText,
466 pub right_content: StyledText,
472 pub(crate) layout: BlockLayout,
474 pub bg: Option<Color>,
476 pub align: Align,
478 pub margin_left: u16,
483 pub margin_right: u16,
488}
489
490impl StyledBlock {
491 pub fn new(content: impl Into<StyledText>) -> Self {
493 Self {
494 content: content.into(),
495 right_content: StyledText::new(),
496 layout: BlockLayout::Ordinary,
497 bg: None,
498 align: Align::Left,
499 margin_left: 0,
500 margin_right: 0,
501 }
502 }
503
504 #[must_use]
511 pub fn is_empty(&self) -> bool {
512 match &self.layout {
513 BlockLayout::Ordinary => self.content.is_empty(),
514 BlockLayout::Priority { line, .. } => line.is_empty(),
515 BlockLayout::TwoLineElision(elision) => elision.is_empty(),
516 }
517 }
518
519 pub fn bg(mut self, color: Color) -> Self {
521 self.bg = Some(color);
522 self
523 }
524
525 pub fn align(mut self, align: Align) -> Self {
527 self.align = align;
528 self
529 }
530
531 pub fn right_content(mut self, content: impl Into<StyledText>) -> Self {
536 self.right_content = content.into();
537 self
538 }
539
540 pub fn two_line_elision(mut self, elision: TwoLineElision) -> Self {
542 self.layout = BlockLayout::TwoLineElision(elision);
543 self
544 }
545
546 pub fn priority_line(mut self, line: crate::PriorityLine) -> Self {
548 self.layout = BlockLayout::Priority {
549 line,
550 body: StyledText::new(),
551 };
552 self
553 }
554
555 pub fn priority_line_body(mut self, body: impl Into<StyledText>) -> Self {
560 if let BlockLayout::Priority {
561 body: current_body, ..
562 } = &mut self.layout
563 {
564 *current_body = body.into();
565 }
566 self
567 }
568
569 pub fn priority_line_content(&self) -> Option<&crate::PriorityLine> {
571 match &self.layout {
572 BlockLayout::Priority { line, .. } => Some(line),
573 BlockLayout::Ordinary | BlockLayout::TwoLineElision(_) => None,
574 }
575 }
576
577 pub fn priority_line_body_content(&self) -> Option<&StyledText> {
579 match &self.layout {
580 BlockLayout::Priority { body, .. } => Some(body),
581 BlockLayout::Ordinary | BlockLayout::TwoLineElision(_) => None,
582 }
583 }
584
585 pub fn margin_left(mut self, n: u16) -> Self {
589 self.margin_left = n;
590 self
591 }
592
593 pub fn margin_right(mut self, n: u16) -> Self {
597 self.margin_right = n;
598 self
599 }
600
601 pub fn margins(mut self, left: u16, right: u16) -> Self {
605 self.margin_left = left;
606 self.margin_right = right;
607 self
608 }
609}
610
611impl From<&str> for StyledBlock {
612 fn from(s: &str) -> Self {
613 Self::new(s)
614 }
615}
616
617impl From<String> for StyledBlock {
618 fn from(s: String) -> Self {
619 Self::new(s)
620 }
621}
622
623impl From<StyledText> for StyledBlock {
624 fn from(text: StyledText) -> Self {
625 Self::new(text)
626 }
627}