Skip to main content

frust_glyph/
term_block.rs

1//! [`term_block`]/[`TermBlockView`]: a rounded terminal-style output block —
2//! the Glyph design system's `.term-block`/`.term-head`/
3//! `.term-body` rules (retrieved 2026-07-21): a three-dot header strip over a
4//! monospace body of [`TermLine`]s, each typed `Prompt`/`Output`/`Comment`
5//! with its own ink color.
6//!
7//! # Brightness invariance
8//!
9//! Every painted color here is either a [`GlyphInk`] field or one of this
10//! module's own literal fallback constants — **never** a brightness-swapped
11//! `ColorScheme`/`StatusPalette` role, even though the design system's own
12//! CSS does source the header dots from `var(--error)`/`var(--warning)`/
13//! `var(--success)` (brightness-swapped tokens) and the border/shadow from
14//! brightness-swapped `var(--border...)` roles. This is a deliberate
15//! deviation from literal source fidelity: this widget requires **identical
16//! paint under Light and Dark [`baseline`](crate::baseline)
17//! brightness**, so the dot/border colors below are fixed to the **dark**
18//! scheme's literal values rather than resolved dynamically —
19//! consistent with [`GlyphInk`]'s own "stays dark in both brightnesses"
20//! precedent ([`crate::tokens::color`]'s module docs).
21//!
22//! [`GlyphInk`] itself resolves themed (`theme.extension::<GlyphInk>()`) with
23//! an unthemed fallback of [`GlyphInk::default_ink`] — the catalog's normal
24//! token-resolution convention, just applied to a token table that happens to
25//! never vary by brightness.
26//!
27//! The body's family is likewise fixed — IBM Plex Mono, never a type-scale
28//! role: terminal output needs a fixed-width face under any theme, and
29//! `TypeScale` has no monospace slot to name
30//! (`docs/WIDGETS_CODE_STANDARDS.md`), so a theme swap does not re-face it.
31//!
32//! # Header dots and border
33//!
34//! The `border-bright` hairline (`rgba(242,234,217,.18)`) is pre-flattened
35//! over [`GlyphInk::terminal_bg`] (`#1c1912`) into [`TERM_BORDER`] — the same
36//! alpha-preflattening convention [`crate::tokens::color`]'s module docs
37//! use for opaque roles composited over a known background. The header strip
38//! paints as a plain rectangle (not clipped to the block's own rounded
39//! corners — the scene has no rounded-rect clip primitive), so its two top
40//! corners sit square against the block's rounded corner arc: a small,
41//! accepted v1 visual simplification, not a rounded clip.
42//!
43//! # Optional staggered line reveal
44//!
45//! [`TermBlockView::staggered`] opts into a one-shot, mount-time reveal built
46//! on [`crate::motion::GlyphStagger::glyph`] (the
47//! `StaggerSpec` idiom, explicitly documented there as a `TermBlock` consumer)
48//! — 90ms per-line delay, 150ms `effects`-eased per-line fade-in — driven by
49//! one `AnimationController` sized to
50//! [`GlyphStagger::total_duration`](crate::motion::GlyphStagger::total_duration)`(n)`
51//! and started with `.forward()` on build. `reduce_motion` is threaded
52//! straight into
53//! [`GlyphStagger::item_progress`](crate::motion::GlyphStagger::item_progress)'s
54//! own collapse: every line reveals together on the shared timeline with no
55//! per-line delay, a single fast synchronized fade rather than a cascade
56//! (a documented contract — not an instantaneous jump to fully
57//! shown). Lines render fully visible immediately, with no controller built
58//! at all, only when `staggered` is left at its `false` default.
59
60use 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
78// ---- Layout constants (from the design system's source CSS) --------------
79
80/// Header strip horizontal padding (`.term-head{padding:9px 12px}`).
81const HEAD_PAD_X: f64 = 12.0;
82/// Header strip vertical padding (`.term-head{padding:9px 12px}`).
83const HEAD_PAD_Y: f64 = 9.0;
84/// Header dot diameter (`.term-head .dot{width:8px;height:8px}`).
85const DOT_D: f64 = 8.0;
86/// Gap between header dots (`.term-head{gap:6px}`).
87const DOT_GAP: f64 = 6.0;
88/// Total header strip height, derived from the two constants above.
89const HEADER_HEIGHT: f64 = HEAD_PAD_Y * 2.0 + DOT_D;
90
91/// Body padding (`.term-body{padding:14px 16px}`).
92const BODY_PAD_X: f64 = 16.0;
93const BODY_PAD_Y: f64 = 14.0;
94/// Body font size (`.term-body{font-size:11.5px}`).
95const FONT_SIZE: f32 = 11.5;
96/// Body line height (`.term-body{line-height:1.8}`).
97const LINE_HEIGHT: f32 = 1.8;
98/// Border stroke width (`.term-block{border:1px solid ...}`).
99const BORDER_WIDTH: f64 = 1.0;
100/// Corner-rounding tolerance for the border stroke (matches
101/// `crate::badge`'s constant of the same name).
102const PATH_TOLERANCE: f64 = 0.1;
103
104/// The prompt marker glyph, with its trailing space (`<span
105/// class="term-prompt">$</span> <command>` — the space is plain HTML text
106/// between the marker span and the command, reproduced here as part of the
107/// marker run so the command text needs no leading space of its own).
108const PROMPT_MARKER: &str = "$ ";
109
110/// Unthemed corner-radius fallback — Glyph's canonical `--radius-md` (10px).
111const FALLBACK_RADIUS: f64 = 10.0;
112
113// ---- Fixed (never brightness-swapped) colors — see the module docs -------
114
115/// Header dot colors, in source order (error, warning, success — the
116/// classic traffic-light ordering). Fixed to the Glyph **dark** scheme's
117/// literal values regardless of the active brightness (see the module docs).
118const 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
122/// `border-bright` (`rgba(242,234,217,.18)`) pre-flattened over
123/// [`GlyphInk::terminal_bg`] (`#1c1912`) — see the module docs.
124const TERM_BORDER: Color = Color::from_rgb8(0x43, 0x3f, 0x36);
125
126/// Which kind of line a [`TermLine`] is — each has its own ink color.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum TermLineKind {
129    /// A command line: the amber `$ ` marker plus [`GlyphInk::terminal_fg`]
130    /// command text.
131    Prompt,
132    /// Command output, in [`GlyphInk::terminal_out`].
133    Output,
134    /// An annotation/comment, in [`GlyphInk::terminal_comment`] (the dimmest
135    /// tier).
136    Comment,
137}
138
139/// One line of a [`TermBlockView`]'s body. See the [module docs](self).
140#[derive(Clone, Debug, PartialEq)]
141pub struct TermLine {
142    kind: TermLineKind,
143    text: String,
144}
145
146impl TermLine {
147    /// A command line — rendered as an amber `$ ` marker followed by `text`.
148    pub fn prompt(text: impl Into<String>) -> Self {
149        Self {
150            kind: TermLineKind::Prompt,
151            text: text.into(),
152        }
153    }
154
155    /// A command-output line.
156    pub fn output(text: impl Into<String>) -> Self {
157        Self {
158            kind: TermLineKind::Output,
159            text: text.into(),
160        }
161    }
162
163    /// A comment/annotation line.
164    pub fn comment(text: impl Into<String>) -> Self {
165        Self {
166            kind: TermLineKind::Comment,
167            text: text.into(),
168        }
169    }
170}
171
172/// A declarative Glyph terminal block. See the [module docs](self).
173pub struct TermBlockView {
174    lines: Vec<TermLine>,
175    staggered: bool,
176}
177
178/// Create a terminal block over `lines` (no staggered reveal by default — opt
179/// in with [`TermBlockView::staggered`]).
180pub fn term_block(lines: impl Into<Vec<TermLine>>) -> TermBlockView {
181    TermBlockView {
182        lines: lines.into(),
183        staggered: false,
184    }
185}
186
187/// PascalCase alias for [`term_block`], matching the widget-fn vocabulary
188/// (`Button`/`Image`/…).
189#[allow(non_snake_case)]
190pub fn TermBlock(lines: impl Into<Vec<TermLine>>) -> TermBlockView {
191    term_block(lines)
192}
193
194impl TermBlockView {
195    /// Opt into the staggered per-line reveal (see the [module docs](self)).
196    pub fn staggered(mut self, staggered: bool) -> Self {
197        self.staggered = staggered;
198        self
199    }
200}
201
202/// A minimal retained text run — see `crate::badge`'s `GlyphLabel` for
203/// the full shape/rationale (duplicated per this crate's existing
204/// small-helper convention).
205struct 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
247/// The retained per-line state: the shaped marker (Prompt only)/content runs,
248/// plus their last-measured sizes (reused between `layout` and `paint`).
249struct 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
276/// The retained widget for a [`TermBlockView`]. See the [module docs](self).
277pub struct TermBlockWidget {
278    lines: Vec<TermLineRuns>,
279    staggered: bool,
280    /// Drives the one-shot stagger reveal (`Some` only while `staggered` and
281    /// not yet fully settled at `1.0` — kept around afterward too, since
282    /// re-advancing a settled controller is a cheap no-op and simpler than
283    /// tearing it down).
284    stagger: Option<AnimationController>,
285}
286
287fn mono_style(color: Color) -> TextStyle {
288    TextStyle {
289        // Kept explicit: terminal output needs a fixed-width face; no type-scale role is one.
290        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
297/// `GlyphInk`, themed (`theme.extension::<GlyphInk>()`) or the unthemed
298/// [`GlyphInk::default_ink`] fallback.
299fn 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
305/// The corner radius: themed `shape.medium`, or [`FALLBACK_RADIUS`] unthemed.
306fn resolve_radius(theme: Option<&Theme>) -> f64 {
307    theme.map(|t| t.shape.medium).unwrap_or(FALLBACK_RADIUS)
308}
309
310/// A line's content-run color, per [`TermLineKind`] — see the [module docs](self).
311fn 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        // Plain text content — see the module docs.
480        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        // Prompt line contributes marker ("$ ") + content runs.
574        assert_eq!(rec.glyph_colors[0], ink.terminal_prompt); // "$ " marker
575        assert_eq!(rec.glyph_colors[1], ink.terminal_fg); // command text
576        assert_eq!(rec.glyph_colors[2], ink.terminal_out); // output line
577        assert_eq!(rec.glyph_colors[3], ink.terminal_comment); // comment line
578    }
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); // background
586        assert_eq!(rec.strokes[0], TERM_BORDER);
587        // rrects[1] is the header strip (`fill_rect`, radius 0.0); the three
588        // dots follow at [2..=4].
589        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        // Identical paint under Light and Dark
598        // brightness of this crate's own baseline().
599        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        // Layout first so line sizes are populated (paint reads them for
638        // line-height stacking); the stagger controller itself is seeded on
639        // the first `advance` inside `paint`.
640        {
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        // First paint seeds the controller's clock (elapsed 0): every item's
649        // progress is 0 at the very start.
650        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        // Seed the clock.
667        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 200.0));
668        w.paint(&mut ctx, &mut Recorder::default());
669
670        // Advance far past the whole stagger timeline (`GlyphStagger`'s
671        // `total_duration`) via a second `RenderRoot`-free paint at a later
672        // synthetic frame time — done by re-invoking the widget's own
673        // controller directly through another `paint` call using a
674        // manufactured `PaintCtx` at a later `frame_time`.
675        let total_ms = GlyphStagger::glyph().total_duration(n);
676        let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(400.0, 200.0));
677        // `PaintCtx::new` seeds `frame_time` at `FrameTime::default()`
678        // (effectively zero); advance the controller directly to confirm the
679        // math instead, since `PaintCtx` in this harness has no way to inject
680        // an arbitrary later `frame_time` without a full `RenderRoot`.
681        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        // `GlyphStagger::item_progress`'s documented contract: under
699        // `reduce_motion` every item tracks the shared `overall` progress
700        // directly (no per-item delay) instead of cascading — a single fast
701        // fade, not an instantaneous full reveal.
702        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        // Advance the controller to the timeline midpoint directly, then
714        // paint at that same seeded frame time (a second `advance` at an
715        // identical timestamp is a zero-delta no-op, so the value holds).
716        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        // Every line shares the same alpha (synchronized), and it's strictly
731        // between 0 and 1 (mid-fade, not yet settled).
732        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}