1use std::time::Duration;
61
62use crate::GlyphInk;
63use frust::Theme;
64use frust::authoring::Role;
65use frust::authoring::text::{
66 FontFamily, FontWeight, GenericSlot, LineHeight, TextContext, TextLayout, TextStyle,
67};
68use frust::authoring::{
69 BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View,
70 Widget,
71};
72use frust::{AnimationController, Curve};
73use kurbo::{Point, Rect, RoundedRect, Shape, Size};
74use peniko::{Brush, Color};
75
76use crate::motion::GlyphStagger;
77
78const HEAD_PAD_X: f64 = 12.0;
82const HEAD_PAD_Y: f64 = 9.0;
84const DOT_D: f64 = 8.0;
86const DOT_GAP: f64 = 6.0;
88const HEADER_HEIGHT: f64 = HEAD_PAD_Y * 2.0 + DOT_D;
90
91const BODY_PAD_X: f64 = 16.0;
93const BODY_PAD_Y: f64 = 14.0;
94const FONT_SIZE: f32 = 11.5;
96const LINE_HEIGHT: f32 = 1.8;
98const BORDER_WIDTH: f64 = 1.0;
100const PATH_TOLERANCE: f64 = 0.1;
103
104const PROMPT_MARKER: &str = "$ ";
109
110const FALLBACK_RADIUS: f64 = 10.0;
112
113const TERM_DOT_ERROR: Color = Color::from_rgb8(0xff, 0x6b, 0x6b);
119const TERM_DOT_WARNING: Color = Color::from_rgb8(0xf5, 0xc8, 0x60);
120const TERM_DOT_SUCCESS: Color = Color::from_rgb8(0x5f, 0xd8, 0x8f);
121
122const TERM_BORDER: Color = Color::from_rgb8(0x43, 0x3f, 0x36);
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum TermLineKind {
129 Prompt,
132 Output,
134 Comment,
137}
138
139#[derive(Clone, Debug, PartialEq)]
141pub struct TermLine {
142 kind: TermLineKind,
143 text: String,
144}
145
146impl TermLine {
147 pub fn prompt(text: impl Into<String>) -> Self {
149 Self {
150 kind: TermLineKind::Prompt,
151 text: text.into(),
152 }
153 }
154
155 pub fn output(text: impl Into<String>) -> Self {
157 Self {
158 kind: TermLineKind::Output,
159 text: text.into(),
160 }
161 }
162
163 pub fn comment(text: impl Into<String>) -> Self {
165 Self {
166 kind: TermLineKind::Comment,
167 text: text.into(),
168 }
169 }
170}
171
172pub struct TermBlockView {
174 lines: Vec<TermLine>,
175 staggered: bool,
176}
177
178pub fn term_block(lines: impl Into<Vec<TermLine>>) -> TermBlockView {
181 TermBlockView {
182 lines: lines.into(),
183 staggered: false,
184 }
185}
186
187#[allow(non_snake_case)]
190pub fn TermBlock(lines: impl Into<Vec<TermLine>>) -> TermBlockView {
191 term_block(lines)
192}
193
194impl TermBlockView {
195 pub fn staggered(mut self, staggered: bool) -> Self {
197 self.staggered = staggered;
198 self
199 }
200}
201
202struct GlyphLabel {
206 content: String,
207 layout: Option<TextLayout>,
208 laid_out_style: Option<TextStyle>,
209 laid_out_max_width: Option<f32>,
210}
211
212impl GlyphLabel {
213 fn new(content: impl Into<String>) -> Self {
214 Self {
215 content: content.into(),
216 layout: None,
217 laid_out_style: None,
218 laid_out_max_width: None,
219 }
220 }
221
222 fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: Option<f32>) -> Size {
223 if let Some(cached) = &self.layout
224 && self.laid_out_max_width == max_width
225 && self.laid_out_style.as_ref() == Some(style)
226 {
227 return cached.size();
228 }
229 let text_ctx = ctx.text_context::<TextContext>();
230 let laid = text_ctx.layout(&self.content, style, max_width);
231 let size = laid.size();
232 self.layout = Some(laid);
233 self.laid_out_style = Some(style.clone());
234 self.laid_out_max_width = max_width;
235 size
236 }
237
238 fn paint(&self, origin: Point, scene: &mut dyn PaintScene) {
239 if let Some(layout) = &self.layout {
240 for run in layout.to_scene_runs(origin) {
241 scene.draw_glyph_run(run);
242 }
243 }
244 }
245}
246
247struct TermLineRuns {
250 kind: TermLineKind,
251 text: String,
252 marker: Option<GlyphLabel>,
253 content: GlyphLabel,
254 marker_size: Size,
255 content_size: Size,
256}
257
258impl TermLineRuns {
259 fn new(line: &TermLine) -> Self {
260 Self {
261 kind: line.kind,
262 text: line.text.clone(),
263 marker: matches!(line.kind, TermLineKind::Prompt)
264 .then(|| GlyphLabel::new(PROMPT_MARKER)),
265 content: GlyphLabel::new(line.text.clone()),
266 marker_size: Size::ZERO,
267 content_size: Size::ZERO,
268 }
269 }
270
271 fn line_height(&self) -> f64 {
272 self.content_size.height.max(self.marker_size.height)
273 }
274}
275
276pub struct TermBlockWidget {
278 lines: Vec<TermLineRuns>,
279 staggered: bool,
280 stagger: Option<AnimationController>,
285}
286
287fn mono_style(color: Color) -> TextStyle {
288 TextStyle {
289 family: FontFamily::stack_with_generic(["IBM Plex Mono"], GenericSlot::Monospace),
291 weight: FontWeight::REGULAR,
292 line_height: LineHeight::FontSizeRelative(LINE_HEIGHT),
293 ..TextStyle::new(FONT_SIZE, color)
294 }
295}
296
297fn resolve_ink(theme: Option<&Theme>) -> GlyphInk {
300 theme
301 .and_then(|t| t.extension::<GlyphInk>().copied())
302 .unwrap_or_else(GlyphInk::default_ink)
303}
304
305fn resolve_radius(theme: Option<&Theme>) -> f64 {
307 theme.map(|t| t.shape.medium).unwrap_or(FALLBACK_RADIUS)
308}
309
310fn content_color(kind: TermLineKind, ink: &GlyphInk) -> Color {
312 match kind {
313 TermLineKind::Prompt => ink.terminal_fg,
314 TermLineKind::Output => ink.terminal_out,
315 TermLineKind::Comment => ink.terminal_comment,
316 }
317}
318
319fn build_controller(n: usize) -> AnimationController {
320 let total_ms = GlyphStagger::glyph().total_duration(n);
321 let mut c = AnimationController::new(Duration::from_millis(total_ms.round().max(0.0) as u64))
322 .with_curve(Curve::Linear);
323 c.forward();
324 c
325}
326
327impl<State: 'static> View<State> for TermBlockView {
328 type Element = TermBlockWidget;
329
330 fn build(&self, _ctx: &mut BuildCtx<'_>) -> TermBlockWidget {
331 TermBlockWidget {
332 lines: self.lines.iter().map(TermLineRuns::new).collect(),
333 staggered: self.staggered,
334 stagger: self.staggered.then(|| build_controller(self.lines.len())),
335 }
336 }
337
338 fn rebuild(
339 &self,
340 prev: &Self,
341 element: &mut TermBlockWidget,
342 _ctx: &mut BuildCtx<'_>,
343 ) -> ChangeFlags {
344 let mut flags = ChangeFlags::NONE;
345 if prev.lines != self.lines {
346 element.lines = self.lines.iter().map(TermLineRuns::new).collect();
347 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
348 if self.staggered {
349 element.stagger = Some(build_controller(self.lines.len()));
350 }
351 }
352 if prev.staggered != self.staggered {
353 element.staggered = self.staggered;
354 element.stagger = self.staggered.then(|| build_controller(self.lines.len()));
355 flags |= ChangeFlags::PAINT;
356 }
357 flags
358 }
359}
360
361impl Widget for TermBlockWidget {
362 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
363 let theme = Theme::from_layout_ctx(ctx);
364 let ink = resolve_ink(theme);
365
366 let max_width = bc.max().width;
367 let text_max_width = if max_width.is_finite() {
368 Some((max_width - BODY_PAD_X * 2.0).max(0.0) as f32)
369 } else {
370 None
371 };
372
373 let mut max_line_w = 0.0f64;
374 let mut body_height = 0.0f64;
375 for line in &mut self.lines {
376 let marker_size = match &mut line.marker {
377 Some(m) => m.layout(ctx, &mono_style(ink.terminal_prompt), None),
378 None => Size::ZERO,
379 };
380 let content_max = text_max_width.map(|w| (w - marker_size.width as f32).max(0.0));
381 let content_size = line.content.layout(
382 ctx,
383 &mono_style(content_color(line.kind, &ink)),
384 content_max,
385 );
386 line.marker_size = marker_size;
387 line.content_size = content_size;
388 max_line_w = max_line_w.max(marker_size.width + content_size.width);
389 body_height += content_size.height.max(marker_size.height);
390 }
391
392 let header_natural_width = HEAD_PAD_X * 2.0 + DOT_D * 3.0 + DOT_GAP * 2.0;
393 let width = if max_width.is_finite() {
394 max_width
395 } else {
396 (max_line_w + BODY_PAD_X * 2.0).max(header_natural_width)
397 };
398 let height = HEADER_HEIGHT + body_height + BODY_PAD_Y * 2.0;
399 bc.constrain(Size::new(width, height))
400 }
401
402 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
403 let theme = Theme::from_paint_ctx(ctx);
404 let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
405 let ink = resolve_ink(theme);
406 let radius = resolve_radius(theme);
407 let origin = ctx.origin();
408 let size = ctx.size();
409
410 scene.fill_rounded_rect(origin, size, radius, ink.terminal_bg);
411 let rr = RoundedRect::from_rect(Rect::from_origin_size(Point::ORIGIN, size), radius);
412 let path = rr.to_path(PATH_TOLERANCE);
413 scene.stroke_path(origin, &path, BORDER_WIDTH, &Brush::Solid(TERM_BORDER));
414
415 scene.fill_rect(
416 origin,
417 Size::new(size.width, HEADER_HEIGHT),
418 ink.terminal_head,
419 );
420 let dot_y = origin.y + (HEADER_HEIGHT - DOT_D) / 2.0;
421 for (i, color) in [TERM_DOT_ERROR, TERM_DOT_WARNING, TERM_DOT_SUCCESS]
422 .into_iter()
423 .enumerate()
424 {
425 let dot_x = origin.x + HEAD_PAD_X + i as f64 * (DOT_D + DOT_GAP);
426 scene.fill_rounded_rect(
427 Point::new(dot_x, dot_y),
428 Size::new(DOT_D, DOT_D),
429 DOT_D / 2.0,
430 color,
431 );
432 }
433
434 let n = self.lines.len();
435 let animating = self.staggered
436 && self
437 .stagger
438 .as_mut()
439 .map(|c| c.advance(ctx.frame_time()))
440 .unwrap_or(false);
441 let overall = self
442 .stagger
443 .as_ref()
444 .map(|c| c.value_clamped())
445 .unwrap_or(1.0);
446 let stagger_spec = GlyphStagger::glyph();
447
448 let mut y = origin.y + HEADER_HEIGHT + BODY_PAD_Y;
449 for (i, line) in self.lines.iter().enumerate() {
450 let line_h = line.line_height();
451 let alpha = if self.staggered {
452 stagger_spec.item_progress(overall, i, n, reduce_motion) as f32
453 } else {
454 1.0
455 };
456 if alpha > 0.0 {
457 scene.push_layer(
458 Point::new(origin.x, y),
459 Size::new(size.width, line_h),
460 alpha,
461 );
462 let mut x = origin.x + BODY_PAD_X;
463 if let Some(marker) = &line.marker {
464 marker.paint(Point::new(x, y), scene);
465 x += line.marker_size.width;
466 }
467 line.content.paint(Point::new(x, y), scene);
468 scene.pop_layer();
469 }
470 y += line_h;
471 }
472
473 if animating {
474 ctx.request_frame();
475 }
476 }
477
478 fn semantics(&self, ctx: &mut SemanticsCtx) {
479 let text = self
481 .lines
482 .iter()
483 .map(|l| l.text.as_str())
484 .collect::<Vec<_>>()
485 .join("\n");
486 ctx.push_node(Role::Label, |node| {
487 node.set_label(text.as_str());
488 });
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use frust::FrameTime;
496 use frust::authoring::text::TextContext;
497 use frust::authoring::{BuildCtx, LayoutCtx};
498 use std::any::Any;
499
500 fn ft_ms(ms: f64) -> FrameTime {
501 FrameTime::from_nanos((ms * 1_000_000.0) as u64)
502 }
503
504 #[derive(Default)]
505 struct Recorder {
506 rrects: Vec<(Point, Size, f64, Color)>,
507 strokes: Vec<Color>,
508 glyph_colors: Vec<Color>,
509 layers: Vec<(Point, Size, f32)>,
510 }
511
512 impl PaintScene for Recorder {
513 fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
514 self.rrects.push((o, s, 0.0, c));
515 }
516 fn draw_text(&mut self, _o: Point, _t: &str) {}
517 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
518 self.rrects.push((o, s, radius, color));
519 }
520 fn stroke_path(&mut self, _o: Point, _path: &kurbo::BezPath, _width: f64, brush: &Brush) {
521 if let Brush::Solid(c) = brush {
522 self.strokes.push(*c);
523 }
524 }
525 fn draw_glyph_run(&mut self, run: frust::authoring::scene::GlyphRun) {
526 if let Brush::Solid(c) = run.brush {
527 self.glyph_colors.push(c);
528 }
529 }
530 fn push_layer(&mut self, origin: Point, size: Size, alpha: f32) {
531 self.layers.push((origin, size, alpha));
532 }
533 fn pop_layer(&mut self) {}
534 }
535
536 fn build(lines: Vec<TermLine>, staggered: bool) -> TermBlockWidget {
537 let view: TermBlockView = term_block(lines).staggered(staggered);
538 let mut counter = 0u64;
539 View::<()>::build(&view, &mut BuildCtx::new(&mut counter))
540 }
541
542 fn layout_and_paint(widget: &mut TermBlockWidget, theme: Option<&Theme>) -> Recorder {
543 let mut tcx = TextContext::new();
544 let mut lctx = match theme {
545 Some(t) => {
546 LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(t as &dyn Any))
547 }
548 None => LayoutCtx::with_text_context(&mut tcx as &mut dyn Any),
549 };
550 let size = widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
551 let mut rec = Recorder::default();
552 let mut pctx = match theme {
553 Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
554 None => PaintCtx::new(Point::ZERO, size),
555 };
556 widget.paint(&mut pctx, &mut rec);
557 rec
558 }
559
560 fn demo_lines() -> Vec<TermLine> {
561 vec![
562 TermLine::prompt("zellij attach dev-main"),
563 TermLine::output("attached to session \"dev-main\" \u{b7} 3 panes"),
564 TermLine::comment("# build finished in 8.2s"),
565 ]
566 }
567
568 #[test]
569 fn unthemed_line_colors_match_glyph_ink_default() {
570 let mut w = build(demo_lines(), false);
571 let rec = layout_and_paint(&mut w, None);
572 let ink = GlyphInk::default_ink();
573 assert_eq!(rec.glyph_colors[0], ink.terminal_prompt); assert_eq!(rec.glyph_colors[1], ink.terminal_fg); assert_eq!(rec.glyph_colors[2], ink.terminal_out); assert_eq!(rec.glyph_colors[3], ink.terminal_comment); }
579
580 #[test]
581 fn unthemed_dot_and_bg_colors_use_fallback_constants() {
582 let mut w = build(demo_lines(), false);
583 let rec = layout_and_paint(&mut w, None);
584 let ink = GlyphInk::default_ink();
585 assert_eq!(rec.rrects[0].3, ink.terminal_bg); assert_eq!(rec.strokes[0], TERM_BORDER);
587 assert_eq!(rec.rrects[1].3, ink.terminal_head);
590 assert_eq!(rec.rrects[2].3, TERM_DOT_ERROR);
591 assert_eq!(rec.rrects[3].3, TERM_DOT_WARNING);
592 assert_eq!(rec.rrects[4].3, TERM_DOT_SUCCESS);
593 }
594
595 #[test]
596 fn brightness_invariance_recording_scene_identical_dark_vs_light() {
597 let dark = crate::baseline();
600 let light = dark.clone().with_brightness(frust::Brightness::Light);
601
602 let mut w_dark = build(demo_lines(), false);
603 let rec_dark = layout_and_paint(&mut w_dark, Some(&dark));
604 let mut w_light = build(demo_lines(), false);
605 let rec_light = layout_and_paint(&mut w_light, Some(&light));
606
607 assert_eq!(rec_dark.rrects, rec_light.rrects);
608 assert_eq!(rec_dark.strokes, rec_light.strokes);
609 assert_eq!(rec_dark.glyph_colors, rec_light.glyph_colors);
610 }
611
612 #[test]
613 fn themed_ink_resolves_from_the_glyph_baseline_extension() {
614 let theme = crate::baseline();
615 let mut w = build(demo_lines(), false);
616 let rec = layout_and_paint(&mut w, Some(&theme));
617 let ink = theme.extension::<GlyphInk>().unwrap();
618 assert_eq!(rec.rrects[0].3, ink.terminal_bg);
619 assert_eq!(rec.glyph_colors[0], ink.terminal_prompt);
620 }
621
622 #[test]
623 fn non_staggered_lines_render_at_full_opacity_immediately() {
624 let mut w = build(demo_lines(), false);
625 let rec = layout_and_paint(&mut w, None);
626 assert_eq!(rec.layers.len(), 3, "one push_layer per line");
627 for (_, _, alpha) in &rec.layers {
628 assert_eq!(*alpha, 1.0);
629 }
630 }
631
632 #[test]
633 fn staggered_reveal_matches_glyph_stagger_math() {
634 let lines = demo_lines();
635 let n = lines.len();
636 let mut w = build(lines, true);
637 {
641 let mut tcx = TextContext::new();
642 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
643 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
644 }
645 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 200.0));
646 let mut rec = Recorder::default();
647 w.paint(&mut ctx, &mut rec);
648 let expected = GlyphStagger::glyph().item_progress(0.0, 0, n, false);
651 assert_eq!(rec.layers.len(), 0, "alpha 0 items are skipped, not pushed");
652 assert!(expected.abs() < 1e-9);
653 assert!(ctx.needs_frame());
654 }
655
656 #[test]
657 fn staggered_reveal_advances_and_settles_fully_visible() {
658 let lines = demo_lines();
659 let n = lines.len();
660 let mut w = build(lines, true);
661 {
662 let mut tcx = TextContext::new();
663 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
664 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
665 }
666 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 200.0));
668 w.paint(&mut ctx, &mut Recorder::default());
669
670 let total_ms = GlyphStagger::glyph().total_duration(n);
676 let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(400.0, 200.0));
677 let ctrl = w
682 .stagger
683 .as_mut()
684 .expect("staggered widget carries a controller");
685 ctrl.advance(ft_ms(total_ms + 50.0));
686 let overall = ctrl.value_clamped();
687 assert_eq!(overall, 1.0, "settled at the end of the timeline");
688 let mut rec = Recorder::default();
689 w.paint(&mut ctx2, &mut rec);
690 assert_eq!(rec.layers.len(), n, "every line fully revealed");
691 for (_, _, alpha) in &rec.layers {
692 assert!((*alpha - 1.0).abs() < 1e-6);
693 }
694 }
695
696 #[test]
697 fn reduce_motion_synchronizes_lines_to_a_single_fade_no_cascade() {
698 let mut theme = crate::baseline();
703 theme.motion.reduce_motion = true;
704 let lines = demo_lines();
705 let n = lines.len();
706 let mut w = build(lines, true);
707 {
708 let mut tcx = TextContext::new();
709 let mut lctx =
710 LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(&theme as &dyn Any));
711 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 400.0)));
712 }
713 let total_ms = GlyphStagger::glyph().total_duration(n);
717 {
718 let ctrl = w
719 .stagger
720 .as_mut()
721 .expect("staggered widget carries a controller");
722 ctrl.advance(ft_ms(0.0));
723 ctrl.advance(ft_ms(total_ms / 2.0));
724 }
725 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 200.0)).with_theme(&theme);
726 let mut rec = Recorder::default();
727 w.paint(&mut ctx, &mut rec);
728 assert_eq!(rec.layers.len(), n);
729 let alphas: Vec<f32> = rec.layers.iter().map(|(_, _, a)| *a).collect();
730 for a in &alphas {
733 assert!((*a - alphas[0]).abs() < 1e-6, "lines must be synchronized");
734 }
735 assert!(alphas[0] > 0.0 && alphas[0] < 1.0, "mid-fade, not settled");
736 }
737
738 #[test]
739 fn semantics_reports_joined_line_text() {
740 fn logic(_s: &mut ()) -> TermBlockView {
741 term_block(demo_lines())
742 }
743 let mut root: frust_core::RenderRoot<(), TermBlockView> = frust_core::RenderRoot::new();
744 let mut state = ();
745 root.rebuild(&mut logic, &mut state);
746 let mut tcx = TextContext::new();
747 root.layout_with_text(Size::new(400.0, 400.0), &mut tcx as &mut dyn Any);
748 let update = root.semantics();
749 let (_, node) = update
750 .nodes
751 .iter()
752 .find(|(_, n)| n.role() == Role::Label)
753 .expect("term block contributes a Role::Label node");
754 assert_eq!(
755 node.label(),
756 Some(
757 "zellij attach dev-main\nattached to session \"dev-main\" \u{b7} 3 panes\n# build finished in 8.2s"
758 )
759 );
760 }
761}