返回 CodeWhale
workspace.rs
1 //! Transcript, composer and fleet surfaces over caller-owned data.
2 //!
3 //! These are renderers, not another session or input runtime. Hosts own the
4 //! author, task, route, state, elapsed words and text editing. The visual
5 //! grammar comes from the Engine's `history/message.rs`, `tool_card.rs`,
6 //! `agent_card.rs` and `composer_chrome.rs`: a speaker anchor, continuation
7 //! rails, semantic tool summaries, agent identity and a quiet input ledge.
8
9 use std::borrow::Cow;
10
11 use ratatui::{
12 buffer::Buffer,
13 layout::Rect,
14 style::Modifier,
15 text::{Line, Span},
16 widgets::Widget,
17 };
18 use unicode_segmentation::UnicodeSegmentation;
19
20 use crate::{
21 Depth, HorizonRule, KeyHints, Paint, Panel, Role, State, StatusMark, Theme, glyphs, text,
22 };
23
24 /// A plain-text transcript entry. The host supplies its author and marker;
25 /// this component neither parses Markdown nor guesses who sent the message.
26 /// [`Message::native`] uses the current TUI's inline speaker mark and dim
27 /// continuation rail. A nonempty author adds the optional heading variation.
28 #[derive(Clone, Debug)]
29 pub struct Message<'a> {
30 pub author: Cow<'a, str>,
31 pub body: Cow<'a, str>,
32 pub author_role: Role,
33 pub marker: &'static str,
34 }
35
36 impl<'a> Message<'a> {
37 #[must_use]
38 pub fn new(author: impl Into<Cow<'a, str>>, body: impl Into<Cow<'a, str>>) -> Self {
39 Self {
40 author: author.into(),
41 body: body.into(),
42 author_role: Role::Foreground,
43 marker: glyphs::CURRENT,
44 }
45 }
46
47 /// The current Codewhale transcript's plain-text message decoration:
48 /// `● body` first, then `▏ ` before each continuation. The host can choose
49 /// another speaker with [`Self::marker`] and its ink with [`Self::role`].
50 /// Whitespace-only assistant entries render nothing. No Markdown parser,
51 /// streaming clock, or session state is introduced by this constructor.
52 #[must_use]
53 pub fn native(body: impl Into<Cow<'a, str>>) -> Self {
54 Self::new("", body).role(Role::Primary)
55 }
56
57 #[must_use]
58 pub fn role(mut self, role: Role) -> Self {
59 self.author_role = role;
60 self
61 }
62
63 /// Use a charter mark such as [`glyphs::USER`] for a user-authored entry.
64 #[must_use]
65 pub fn marker(mut self, marker: &'static str) -> Self {
66 self.marker = marker;
67 self
68 }
69
70 fn lines(&self, width: u16, theme: &Theme) -> Vec<Line<'static>> {
71 if width == 0 {
72 return Vec::new();
73 }
74 if self.author.is_empty() {
75 return self.native_lines(width, theme);
76 }
77 let mut lines = vec![Line::from(vec![
78 Span::styled(
79 safe(glyphs::pick(self.marker, theme.ascii())),
80 theme.fg(self.author_role),
81 ),
82 Span::raw(" "),
83 Span::styled(
84 safe(&self.author),
85 theme.fg(self.author_role).add_modifier(Modifier::BOLD),
86 ),
87 ])];
88 let rail = width >= 3;
89 let body = wrapped(
90 &self.body,
91 width.saturating_sub(if rail { 2 } else { 0 }),
92 theme.ascii(),
93 );
94 lines.extend(body_lines(&body, rail, theme, Role::Foreground));
95 lines
96 }
97
98 fn native_lines(&self, width: u16, theme: &Theme) -> Vec<Line<'static>> {
99 // The native assistant skips stray blank streaming cells, including
100 // caller text whose only visible content disappears during sanitizing.
101 if self.marker == glyphs::CURRENT
102 && self
103 .body
104 .split('\n')
105 .all(|line| text::display_safe(line).trim().is_empty())
106 {
107 return Vec::new();
108 }
109 let marker = safe(glyphs::pick(self.marker, theme.ascii()));
110 let marker_width = text::width(&marker);
111 // Native message.rs reserves prefix + two cells before wrapping,
112 // while the painted prefix itself occupies prefix + one cell.
113 let reserved = u16::try_from(marker_width.saturating_add(2)).unwrap_or(u16::MAX);
114 let mut body = wrapped(
115 &self.body,
116 width.saturating_sub(reserved).max(1),
117 theme.ascii(),
118 );
119 if body.lines.is_empty() {
120 body.lines.push(String::new());
121 }
122 body.lines
123 .into_iter()
124 .enumerate()
125 .map(|(index, body)| {
126 let mut spans = Vec::new();
127 if !marker.is_empty() {
128 if index == 0 {
129 spans.push(Span::styled(
130 marker.clone(),
131 theme.fg(self.author_role).add_modifier(Modifier::BOLD),
132 ));
133 spans.push(Span::raw(" "));
134 } else {
135 let rail = glyphs::pick(glyphs::TRANSCRIPT_RAIL, theme.ascii());
136 spans.push(Span::styled(
137 format!("{}{}", rail.trim_end(), " ".repeat(marker_width)),
138 theme.fg(Role::Dim),
139 ));
140 }
141 }
142 spans.push(Span::styled(body, theme.fg(Role::Foreground)));
143 Line::from(spans)
144 })
145 .collect()
146 }
147 }
148
149 impl Paint for Message<'_> {
150 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
151 let area = area.intersection(buf.area);
152 for (index, line) in self
153 .lines(area.width, theme)
154 .iter()
155 .take(usize::from(area.height))
156 .enumerate()
157 {
158 super::workbench::row(
159 Rect {
160 y: area.y.saturating_add(rows(index)),
161 height: 1,
162 ..area
163 },
164 buf,
165 line,
166 );
167 }
168 }
169
170 fn height(&self, width: u16, theme: &Theme) -> u16 {
171 rows(self.lines(width, theme).len())
172 }
173 }
174
175 /// A tool preview with a semantic title and subject supplied by the host.
176 ///
177 /// `omitted_lines` counts logical output lines the host removed before
178 /// supplying `output`. If the viewport clips output, the footer also counts
179 /// logical lines whose full content is not visible (including a partially
180 /// visible final line). A wrapped line is never counted twice.
181 #[derive(Clone, Debug)]
182 pub struct ToolCard<'a> {
183 pub title: Cow<'a, str>,
184 pub subject: Cow<'a, str>,
185 pub status: StatusMark,
186 pub elapsed: Option<Cow<'a, str>>,
187 pub output: Cow<'a, str>,
188 pub omitted_lines: usize,
189 /// Words after the count. Hosts can supply translated copy.
190 pub omission_label: Cow<'a, str>,
191 }
192
193 impl<'a> ToolCard<'a> {
194 #[must_use]
195 pub fn new(
196 title: impl Into<Cow<'a, str>>,
197 subject: impl Into<Cow<'a, str>>,
198 state: State,
199 ) -> Self {
200 Self {
201 title: title.into(),
202 subject: subject.into(),
203 status: StatusMark::new(state),
204 elapsed: None,
205 output: Cow::Borrowed(""),
206 omitted_lines: 0,
207 omission_label: Cow::Borrowed("output lines not fully shown"),
208 }
209 }
210
211 #[must_use]
212 pub fn output(mut self, output: impl Into<Cow<'a, str>>) -> Self {
213 self.output = output.into();
214 self
215 }
216
217 #[must_use]
218 pub fn elapsed(mut self, elapsed: impl Into<Cow<'a, str>>) -> Self {
219 self.elapsed = Some(elapsed.into());
220 self
221 }
222
223 #[must_use]
224 pub fn omitted_lines(mut self, count: usize) -> Self {
225 self.omitted_lines = count;
226 self
227 }
228
229 #[must_use]
230 pub fn omission_label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
231 self.omission_label = label.into();
232 self
233 }
234
235 #[must_use]
236 pub fn status_word(mut self, word: impl Into<Cow<'static, str>>) -> Self {
237 self.status = self.status.word(word);
238 self
239 }
240
241 fn heading(&self, width: u16, theme: &Theme) -> Vec<Line<'static>> {
242 let mut lines = vec![activity_header(
243 &self.title,
244 &self.status,
245 self.elapsed.as_deref(),
246 theme,
247 )];
248 let subject = wrapped(&self.subject, width, theme.ascii());
249 lines.extend(
250 subject
251 .lines
252 .into_iter()
253 .map(|line| Line::from(Span::styled(line, theme.fg(Role::Primary)))),
254 );
255 lines
256 }
257 }
258
259 impl Paint for ToolCard<'_> {
260 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
261 let area = area.intersection(buf.area);
262 if area.is_empty() {
263 return;
264 }
265 let inner = Panel::new(Depth::Raised).draw(area, buf, theme);
266 if inner.is_empty() {
267 return;
268 }
269 let heading = self.heading(inner.width, theme);
270 paint_lines(&heading, inner, buf);
271 let output_area = below(inner, rows(heading.len()));
272 if output_area.is_empty() {
273 return;
274 }
275 let rail = output_area.width >= 3;
276 let output = wrapped(
277 &self.output,
278 output_area.width.saturating_sub(if rail { 2 } else { 0 }),
279 theme.ascii(),
280 );
281 let clipped = output.lines.len() > usize::from(output_area.height);
282 let needs_footer =
283 self.omitted_lines > 0 || clipped || output.hidden_lines(output.lines.len()) > 0;
284 let visible = usize::from(output_area.height.saturating_sub(u16::from(needs_footer)));
285 let shown = visible.min(output.lines.len());
286 let omitted = self
287 .omitted_lines
288 .saturating_add(output.hidden_lines(shown));
289 let output_lines = body_lines(&output, rail, theme, Role::Muted);
290 paint_lines(
291 &output_lines,
292 Rect {
293 height: rows(shown),
294 ..output_area
295 },
296 buf,
297 );
298 if needs_footer && omitted > 0 {
299 let footer = Line::from(Span::styled(
300 format!("{omitted} {}", safe(&self.omission_label)),
301 theme.fg(Role::Hint),
302 ));
303 footer.render(
304 Rect {
305 y: output_area.y.saturating_add(rows(shown)),
306 height: 1,
307 ..output_area
308 },
309 buf,
310 );
311 }
312 }
313
314 fn height(&self, width: u16, theme: &Theme) -> u16 {
315 let inner = panel_width(width, theme);
316 if inner == 0 {
317 return panel_height(0, width, theme);
318 }
319 let output = wrapped(
320 &self.output,
321 inner.saturating_sub(if inner >= 3 { 2 } else { 0 }),
322 theme.ascii(),
323 );
324 let footer =
325 usize::from(self.omitted_lines > 0 || output.hidden_lines(output.lines.len()) > 0);
326 panel_height(
327 rows(
328 self.heading(inner, theme)
329 .len()
330 .saturating_add(output.lines.len())
331 .saturating_add(footer),
332 ),
333 width,
334 theme,
335 )
336 }
337 }
338
339 /// Display-only input chrome. Text, placeholder, context and hint words are
340 /// caller-owned. The host continues to own editing, cursor placement, history,
341 /// completion, submission predicates and input handling.
342 ///
343 /// ```no_run
344 /// use codewhale_ratatui::{Composer, Paint, Theme};
345 /// # fn draw(frame: &mut ratatui::Frame<'_>) {
346 /// let theme = Theme::detect();
347 /// let composer = Composer::new("Review the changes")
348 /// .context("codewhale-ratatui / main");
349 /// frame.render_widget(composer.themed(&theme), frame.area());
350 /// # }
351 /// ```
352 #[derive(Clone, Debug)]
353 pub struct Composer<'a> {
354 pub text: Cow<'a, str>,
355 pub placeholder: Cow<'a, str>,
356 pub context: Option<Cow<'a, str>>,
357 pub hints: Option<&'a KeyHints>,
358 pub focused: bool,
359 pub prompt: &'static str,
360 }
361
362 impl<'a> Composer<'a> {
363 #[must_use]
364 pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
365 Self {
366 text: text.into(),
367 placeholder: Cow::Borrowed("Ask Codewhale to do something"),
368 context: None,
369 hints: None,
370 focused: false,
371 prompt: "›",
372 }
373 }
374
375 #[must_use]
376 pub fn placeholder(mut self, placeholder: impl Into<Cow<'a, str>>) -> Self {
377 self.placeholder = placeholder.into();
378 self
379 }
380
381 /// A host-provided repository, branch, mode or route summary. Nothing is
382 /// discovered or inferred by the component.
383 #[must_use]
384 pub fn context(mut self, context: impl Into<Cow<'a, str>>) -> Self {
385 self.context = Some(context.into());
386 self
387 }
388
389 #[must_use]
390 pub fn hints(mut self, hints: &'a KeyHints) -> Self {
391 self.hints = Some(hints);
392 self
393 }
394
395 #[must_use]
396 pub fn focused(mut self, focused: bool) -> Self {
397 self.focused = focused;
398 self
399 }
400
401 #[must_use]
402 pub fn prompt(mut self, prompt: &'static str) -> Self {
403 self.prompt = prompt;
404 self
405 }
406
407 fn input_lines(&self, width: u16, theme: &Theme) -> Vec<Line<'static>> {
408 if width == 0 {
409 return Vec::new();
410 }
411 let prompt = safe(glyphs::pick(self.prompt, theme.ascii()));
412 let lead = rows(text::width(&prompt).saturating_add(1)).min(width.saturating_sub(1));
413 let placeholder = self.text.is_empty();
414 let content = if placeholder {
415 &self.placeholder
416 } else {
417 &self.text
418 };
419 let input = wrapped(content, width.saturating_sub(lead), theme.ascii());
420 let body_role = if placeholder {
421 Role::Hint
422 } else {
423 Role::Foreground
424 };
425 let prompt_role = if self.focused {
426 Role::Primary
427 } else {
428 Role::Muted
429 };
430 let mut lines: Vec<Line<'static>> = input
431 .lines
432 .into_iter()
433 .enumerate()
434 .map(|(index, line)| {
435 let prefix = if index == 0 {
436 text::pad(&prompt, usize::from(lead), theme.ascii())
437 } else {
438 " ".repeat(usize::from(lead))
439 };
440 Line::from(vec![
441 Span::styled(prefix, theme.fg(prompt_role)),
442 Span::styled(line, theme.fg(body_role)),
443 ])
444 })
445 .collect();
446 if lines.is_empty() {
447 lines.push(Line::from(""));
448 }
449 lines
450 }
451 }
452
453 impl Paint for Composer<'_> {
454 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
455 let area = area.intersection(buf.area);
456 if area.is_empty() {
457 return;
458 }
459 let mut input_area = area;
460 // Compact viewports give their sole row to text before chrome.
461 if area.height >= 2 {
462 let mut horizon = HorizonRule::new();
463 if let Some(context) = &self.context {
464 horizon = horizon.label(context.clone());
465 }
466 horizon.paint(Rect { height: 1, ..area }, buf, theme);
467 input_area = below(area, 1);
468 }
469 let hints = self
470 .hints
471 .map(|hints| hints.lines(area.width, theme))
472 .unwrap_or_default();
473 let input = self.input_lines(input_area.width, theme);
474 // Reserve a hints rail only when a row of caller text still fits.
475 let hints_height = rows(hints.len()).min(input_area.height.saturating_sub(1));
476 let text_area = Rect {
477 height: input_area.height.saturating_sub(hints_height),
478 ..input_area
479 };
480 paint_lines(&input, text_area, buf);
481 if hints_height > 0 {
482 paint_lines(
483 &hints,
484 Rect {
485 y: text_area.bottom(),
486 height: hints_height,
487 ..input_area
488 },
489 buf,
490 );
491 }
492 }
493
494 fn height(&self, width: u16, theme: &Theme) -> u16 {
495 if width == 0 {
496 return 0;
497 }
498 let hints = self.hints.map_or(0, |hints| hints.height(width, theme));
499 1_u16
500 .saturating_add(rows(self.input_lines(width, theme).len()))
501 .saturating_add(hints)
502 }
503 }
504
505 /// One agent's own identity and reported work. The component has no mailbox,
506 /// timers, provider routing, state transitions or inherited parent task.
507 #[derive(Clone, Debug)]
508 pub struct AgentCard<'a> {
509 pub title: Cow<'a, str>,
510 pub status: StatusMark,
511 pub role: Option<Cow<'a, str>>,
512 pub route: Option<Cow<'a, str>>,
513 pub task: Cow<'a, str>,
514 pub elapsed: Option<Cow<'a, str>>,
515 }
516
517 impl<'a> AgentCard<'a> {
518 #[must_use]
519 pub fn new(title: impl Into<Cow<'a, str>>, state: State) -> Self {
520 Self {
521 title: title.into(),
522 status: StatusMark::new(state),
523 role: None,
524 route: None,
525 task: Cow::Borrowed(""),
526 elapsed: None,
527 }
528 }
529
530 #[must_use]
531 pub fn role(mut self, role: impl Into<Cow<'a, str>>) -> Self {
532 self.role = Some(role.into());
533 self
534 }
535
536 #[must_use]
537 pub fn route(mut self, route: impl Into<Cow<'a, str>>) -> Self {
538 self.route = Some(route.into());
539 self
540 }
541
542 #[must_use]
543 pub fn task(mut self, task: impl Into<Cow<'a, str>>) -> Self {
544 self.task = task.into();
545 self
546 }
547
548 #[must_use]
549 pub fn elapsed(mut self, elapsed: impl Into<Cow<'a, str>>) -> Self {
550 self.elapsed = Some(elapsed.into());
551 self
552 }
553
554 #[must_use]
555 pub fn status_word(mut self, word: impl Into<Cow<'static, str>>) -> Self {
556 self.status = self.status.word(word);
557 self
558 }
559
560 fn lines(&self, width: u16, theme: &Theme) -> Vec<Line<'static>> {
561 if width == 0 {
562 return Vec::new();
563 }
564 let mut lines = vec![activity_header(
565 &self.title,
566 &self.status,
567 self.elapsed.as_deref(),
568 theme,
569 )];
570 let identity: Vec<_> = [self.role.as_deref(), self.route.as_deref()]
571 .into_iter()
572 .flatten()
573 .map(safe)
574 .collect();
575 if !identity.is_empty() {
576 let identity = wrapped(&identity.join(" / "), width, theme.ascii());
577 lines.extend(
578 identity
579 .lines
580 .into_iter()
581 .map(|line| Line::from(Span::styled(line, theme.fg(Role::Muted)))),
582 );
583 }
584 let rail = width >= 3;
585 let task = wrapped(
586 &self.task,
587 width.saturating_sub(if rail { 2 } else { 0 }),
588 theme.ascii(),
589 );
590 lines.extend(body_lines(&task, rail, theme, Role::Foreground));
591 lines
592 }
593 }
594
595 impl Paint for AgentCard<'_> {
596 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
597 let area = area.intersection(buf.area);
598 if area.is_empty() {
599 return;
600 }
601 let inner = Panel::new(Depth::Raised).draw(area, buf, theme);
602 paint_lines(&self.lines(inner.width, theme), inner, buf);
603 }
604
605 fn height(&self, width: u16, theme: &Theme) -> u16 {
606 panel_height(
607 rows(self.lines(panel_width(width, theme), theme).len()),
608 width,
609 theme,
610 )
611 }
612 }
613
614 /// A compact fleet rail. Each entry retains its own reported status, role,
615 /// route, task and elapsed text; no fleet-wide success is inferred.
616 #[derive(Clone, Debug)]
617 pub struct Fleet<'a> {
618 pub title: Cow<'a, str>,
619 pub agents: Vec<AgentCard<'a>>,
620 pub empty_label: Cow<'a, str>,
621 pub omission_label: Cow<'a, str>,
622 }
623
624 impl<'a> Fleet<'a> {
625 #[must_use]
626 pub fn new(agents: Vec<AgentCard<'a>>) -> Self {
627 Self {
628 title: Cow::Borrowed("Fleet"),
629 agents,
630 empty_label: Cow::Borrowed("No agents reported"),
631 omission_label: Cow::Borrowed("agents not fully shown"),
632 }
633 }
634
635 #[must_use]
636 pub fn title(mut self, title: impl Into<Cow<'a, str>>) -> Self {
637 self.title = title.into();
638 self
639 }
640
641 #[must_use]
642 pub fn empty_label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
643 self.empty_label = label.into();
644 self
645 }
646
647 #[must_use]
648 pub fn omission_label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
649 self.omission_label = label.into();
650 self
651 }
652 }
653
654 impl Paint for Fleet<'_> {
655 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
656 let area = area.intersection(buf.area);
657 if area.is_empty() {
658 return;
659 }
660 let inner = Panel::new(Depth::Deep).draw(area, buf, theme);
661 if inner.is_empty() {
662 return;
663 }
664 Line::from(Span::styled(
665 safe(&self.title),
666 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
667 ))
668 .render(Rect { height: 1, ..inner }, buf);
669 let body = below(inner, 1);
670 if body.is_empty() {
671 return;
672 }
673 if self.agents.is_empty() {
674 Line::from(Span::styled(safe(&self.empty_label), theme.fg(Role::Hint)))
675 .render(body, buf);
676 return;
677 }
678 let mut lines = Vec::new();
679 let mut ends = Vec::new();
680 for agent in &self.agents {
681 if !lines.is_empty() {
682 lines.push(Line::from(""));
683 }
684 lines.extend(agent.lines(body.width, theme));
685 ends.push(lines.len());
686 }
687 let clipped = lines.len() > usize::from(body.height);
688 let visible = usize::from(body.height.saturating_sub(u16::from(clipped)));
689 paint_lines(
690 &lines,
691 Rect {
692 height: rows(visible),
693 ..body
694 },
695 buf,
696 );
697 if clipped {
698 let hidden = ends
699 .len()
700 .saturating_sub(ends.partition_point(|end| *end <= visible));
701 Line::from(Span::styled(
702 format!("{hidden} {}", safe(&self.omission_label)),
703 theme.fg(Role::Hint),
704 ))
705 .render(
706 Rect {
707 y: body.y.saturating_add(rows(visible)),
708 height: 1,
709 ..body
710 },
711 buf,
712 );
713 }
714 }
715
716 fn height(&self, width: u16, theme: &Theme) -> u16 {
717 if width == 0 {
718 return 0;
719 }
720 let inner_width = width.saturating_sub(if width >= 8 { 2 } else { 0 });
721 let content = if self.agents.is_empty() {
722 1
723 } else {
724 self.agents
725 .iter()
726 .map(|agent| agent.lines(inner_width, theme).len())
727 .sum::<usize>()
728 .saturating_add(self.agents.len().saturating_sub(1))
729 };
730 rows(content.saturating_add(1))
731 }
732 }
733
734 // Splitting logical lines before display_safe preserves authored line breaks
735 // while rejecting terminal controls and bidi overrides within each line.
736 fn safe(value: &str) -> String {
737 text::display_safe(value).into_owned()
738 }
739
740 #[derive(Default)]
741 struct Wrapped {
742 lines: Vec<String>,
743 /// End-exclusive row index and whether every grapheme fitted.
744 ends: Vec<(usize, bool)>,
745 }
746
747 impl Wrapped {
748 fn hidden_lines(&self, visible: usize) -> usize {
749 self.ends
750 .iter()
751 .filter(|(end, complete)| *end > visible || !complete)
752 .count()
753 }
754 }
755
756 fn wrapped(value: &str, width: u16, ascii: bool) -> Wrapped {
757 let mut result = Wrapped::default();
758 if value.is_empty() || width == 0 {
759 return result;
760 }
761 let width = usize::from(width);
762 for source in value.split('\n') {
763 let source = safe(source);
764 let mut current = String::new();
765 let mut cells = 0usize;
766 let mut complete = true;
767 for grapheme in source.graphemes(true) {
768 let size = text::width(grapheme);
769 if cells.saturating_add(size) > width && !current.is_empty() {
770 // Prefer an authored whitespace boundary. Long words still
771 // wrap between whole graphemes; indentation stays intact.
772 let boundary = current
773 .grapheme_indices(true)
774 .filter(|(_, cluster)| cluster.chars().all(char::is_whitespace))
775 .map(|(index, cluster)| index + cluster.len())
776 .next_back()
777 .filter(|end| {
778 current[..*end]
779 .chars()
780 .any(|character| !character.is_whitespace())
781 });
782 if let Some(end) = boundary {
783 let tail = current.split_off(end);
784 result.lines.push(std::mem::replace(&mut current, tail));
785 cells = text::width(&current);
786 } else {
787 result.lines.push(std::mem::take(&mut current));
788 cells = 0;
789 }
790 if cells.saturating_add(size) > width && !current.is_empty() {
791 result.lines.push(std::mem::take(&mut current));
792 cells = 0;
793 }
794 }
795 if size > width {
796 // A wide grapheme cannot fit in a one-cell viewport. Show an
797 // explicit cut mark, never split it or overwrite the neighbor.
798 result
799 .lines
800 .push(text::truncate(grapheme, width, ascii).into_owned());
801 complete = false;
802 } else {
803 current.push_str(grapheme);
804 cells = cells.saturating_add(size);
805 }
806 }
807 if !current.is_empty() || source.is_empty() {
808 result.lines.push(current);
809 }
810 result.ends.push((result.lines.len(), complete));
811 }
812 result
813 }
814
815 fn body_lines(body: &Wrapped, rail: bool, theme: &Theme, role: Role) -> Vec<Line<'static>> {
816 body.lines
817 .iter()
818 .map(|line| {
819 let mut spans = Vec::new();
820 if rail {
821 spans.push(Span::styled(
822 glyphs::pick(glyphs::TRANSCRIPT_RAIL, theme.ascii()),
823 theme.fg(Role::Border),
824 ));
825 }
826 spans.push(Span::styled(line.clone(), theme.fg(role)));
827 Line::from(spans)
828 })
829 .collect()
830 }
831
832 fn activity_header(
833 title: &str,
834 status: &StatusMark,
835 elapsed: Option<&str>,
836 theme: &Theme,
837 ) -> Line<'static> {
838 let mut spans = vec![
839 Span::styled(status.glyph(theme), theme.fg(status.state.role())),
840 Span::raw(" "),
841 Span::styled(
842 safe(title),
843 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
844 ),
845 Span::raw(" "),
846 Span::styled(safe(&status.word), theme.fg(status.state.role())),
847 ];
848 if let Some(elapsed) = elapsed {
849 spans.push(Span::raw(" "));
850 spans.push(Span::styled(safe(elapsed), theme.fg(Role::Muted)));
851 }
852 Line::from(spans)
853 }
854
855 fn paint_lines(lines: &[Line<'_>], area: Rect, buf: &mut Buffer) {
856 if area.is_empty() {
857 return;
858 }
859 for (index, line) in lines.iter().take(usize::from(area.height)).enumerate() {
860 line.render(
861 Rect {
862 y: area.y.saturating_add(rows(index)),
863 height: 1,
864 ..area
865 },
866 buf,
867 );
868 }
869 }
870
871 fn below(area: Rect, used: u16) -> Rect {
872 let used = used.min(area.height);
873 Rect {
874 y: area.y.saturating_add(used),
875 height: area.height.saturating_sub(used),
876 ..area
877 }
878 }
879
880 fn rows(count: usize) -> u16 {
881 u16::try_from(count).unwrap_or(u16::MAX)
882 }
883
884 fn panel_width(width: u16, theme: &Theme) -> u16 {
885 let inner = width.saturating_sub(if raised_edged(theme) { 2 } else { 0 });
886 inner.saturating_sub(if inner >= 8 { 2 } else { 0 })
887 }
888
889 fn panel_height(content: u16, width: u16, theme: &Theme) -> u16 {
890 if width == 0 {
891 return 0;
892 }
893 if !raised_edged(theme) {
894 return content;
895 }
896 let bordered = content.saturating_add(2);
897 bordered.saturating_add(if bordered >= 6 { 2 } else { 0 })
898 }
899
900 fn raised_edged(theme: &Theme) -> bool {
901 !theme.grounds_differ(Role::Surface, Role::Background)
902 }
903
903 lines RUST