quadraui 0.6.0

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
//! TUI rasteriser for [`crate::primitives::pipeline_view::PipelineView`].
//!
//! Paints a horizontal row of rounded stage boxes connected by `───▶`
//! arrow connectors. Corner glyphs `╭ ╮ ╰ ╯` give the boxes soft rounded
//! corners, matching the `border-radius` used by the GTK rasteriser. Each
//! box shows a status icon on the first row, the stage label on as many
//! rows as fit directly below it, bounded by the box border and the
//! action button row. See [`label_row_bounds`] for how label rows are
//! computed from the rows actually painted. An optional `[Action]` button
//! appears on the bottom row.
//!
//! ## Colour mapping
//!
//! | Status   | Icon | Colour                        |
//! |----------|------|-------------------------------|
//! | Done     | ✓    | green (`theme.success`)        |
//! | Active   | ●    | yellow (`theme.accent_bg`)     |
//! | Failed   | ✗    | red (`theme.error`)            |
//! | Pending  | ·    | dim (`theme.muted_fg`)         |
//! | Skipped  | ─    | grey (`theme.muted_fg`)        |
//!
//! The focused stage (keyboard navigation) shows a `▼` caret in the row
//! reserved above the box; the box border retains its per-status colour.

use ratatui::buffer::Buffer;
use ratatui::layout::Rect;

use super::{ratatui_color, set_cell};
use crate::primitives::pipeline_view::{
    status_color, status_glyph, PipelineView, PipelineViewLayout, PipelineViewMeasure,
};
use crate::theme::Theme;

/// Width of the arrow connector in TUI cells.
const TUI_ARROW_WIDTH: f32 = 4.0;
/// Height reserved for the action button row (cells). `0` = no actions.
const TUI_ACTION_HEIGHT: f32 = 1.0;
/// Height reserved above stage boxes for the focus indicator (cells).
const TUI_FOCUS_INDICATOR_H: u16 = 1;

/// Compute the TUI cell-unit layout for a [`PipelineView`] without painting.
pub fn tui_pipeline_view_layout(view: &PipelineView, area: Rect) -> PipelineViewLayout {
    let action_h = if view.stages.iter().any(|s| s.action.is_some()) {
        TUI_ACTION_HEIGHT
    } else {
        0.0
    };
    // Note: the returned layout (incl. `bounds`) is offset down by
    // `TUI_FOCUS_INDICATOR_H`, so `bounds.y` starts below the reserved caret
    // row. The focus caret is drawn in that reserved row (above `bounds.y`);
    // a host that clips drawing to `layout.bounds` would clip it — clip to the
    // original `area` instead.
    view.layout(
        area.x as f32,
        (area.y + TUI_FOCUS_INDICATOR_H) as f32,
        PipelineViewMeasure::new(
            area.width as f32,
            area.height.saturating_sub(TUI_FOCUS_INDICATOR_H) as f32,
            TUI_ARROW_WIDTH,
            action_h,
        ),
    )
}

/// Compute the first (inclusive) and last (exclusive) TUI row available for
/// the stage label, given the box's top row (`by`), its height in rows
/// (`bh`), and the stage's action-button bounds (if any).
///
/// `label_top` is pinned directly below the icon's single rendered row
/// (`icon_row = by + 1`, see [`draw_pipeline_view`]) to avoid a gap
/// between the icon and label as box height grows.
///
/// `label_bottom` stops before whichever comes first: the box's bottom
/// border row, or the action-button row (if the stage has one) — i.e. the
/// rows that are actually painted, not a recomputed proportional height.
fn label_row_bounds(by: u16, bh: u16, action_bounds: Option<crate::event::Rect>) -> (u16, u16) {
    let label_top = by + 2.min(bh.saturating_sub(2));
    let inner_bottom = by + bh.saturating_sub(1); // border row (exclusive)
    let label_bottom = match action_bounds {
        Some(ab) => (ab.y.round() as u16).min(inner_bottom),
        None => inner_bottom,
    };
    (label_top, label_bottom)
}

/// Draw a [`PipelineView`] into `area` on `buf`. Returns the layout for
/// host click dispatch.
pub fn draw_pipeline_view(
    buf: &mut Buffer,
    area: Rect,
    view: &PipelineView,
    theme: &Theme,
) -> PipelineViewLayout {
    let layout = tui_pipeline_view_layout(view, area);

    if area.width == 0 || area.height == 0 {
        return layout;
    }

    let bg = ratatui_color(theme.surface_bg);
    let fg = ratatui_color(theme.foreground);
    let muted = ratatui_color(theme.muted_fg);
    let accent = ratatui_color(theme.accent_bg);

    for sb in &layout.stages {
        let stage = &view.stages[sb.index];
        let is_focused = view.focused_stage == Some(sb.index);
        // Per-status border colour. Focus no longer overrides it — a ▼
        // indicator drawn in the reserved row above the box signals focus.
        // Stale gets the same dim (muted) border as Pending/Skipped so it
        // visually retreats — the prior verdict is shown but de-emphasised.
        let border_col = ratatui_color(status_color(&stage.status, theme));

        let bx = sb.box_bounds.x.round() as u16;
        let by = sb.box_bounds.y.round() as u16;
        let bw = sb.box_bounds.width.round() as u16;
        let bh = sb.box_bounds.height.round() as u16;

        if bw == 0 || bh == 0 {
            continue;
        }

        // ── Draw box border (rounded corners) ────────────────────────────
        // Top edge — use rounded-corner glyphs (╭ ╮ ╰ ╯) so the box reads
        // softer, matching the CSS `border-radius` used by the GTK rasteriser.
        set_cell(buf, bx, by, '╭', border_col, bg);
        for dx in 1..bw.saturating_sub(1) {
            set_cell(buf, bx + dx, by, '─', border_col, bg);
        }
        if bw >= 2 {
            set_cell(buf, bx + bw - 1, by, '╮', border_col, bg);
        }

        // Bottom edge.
        if bh >= 2 {
            let yb = by + bh - 1;
            set_cell(buf, bx, yb, '╰', border_col, bg);
            for dx in 1..bw.saturating_sub(1) {
                set_cell(buf, bx + dx, yb, '─', border_col, bg);
            }
            if bw >= 2 {
                set_cell(buf, bx + bw - 1, yb, '╯', border_col, bg);
            }
        }

        // Side edges.
        for dy in 1..bh.saturating_sub(1) {
            set_cell(buf, bx, by + dy, '│', border_col, bg);
            if bw >= 2 {
                set_cell(buf, bx + bw - 1, by + dy, '│', border_col, bg);
            }
        }

        // Fill interior background.
        for dy in 1..bh.saturating_sub(1) {
            for dx in 1..bw.saturating_sub(1) {
                set_cell(buf, bx + dx, by + dy, ' ', fg, bg);
            }
        }

        // ── Status icon (row 1 inside box, or centred if single-row) ─────
        // Skipped's glyph is a single-line dash (─) drawn dim like
        // Pending; Stale's `↻` also renders dim so its prior verdict
        // doesn't compete visually with a fresh Done downstream.
        let icon = status_glyph(&stage.status).chars().next().unwrap_or(' ');
        let icon_color = ratatui_color(status_color(&stage.status, theme));
        let icon_row = by + 1;
        if icon_row < by + bh.saturating_sub(1) {
            let icon_col = bx + bw / 2;
            if icon_col > bx && icon_col < bx + bw - 1 {
                set_cell(buf, icon_col, icon_row, icon, icon_color, bg);
            }
        }

        // ── Label (arbitrary N lines, directly below the icon row) ───────
        // The label may carry any number of newlines (e.g. "Review T12\n3:45\n+2"
        // — stage+turns, elapsed mm:ss, extra detail each on their own line).
        // Render every line, centred, with char-based (not byte-based) truncation
        // so a stray byte boundary never splits a multi-byte char.
        //
        // label_top is pinned right after the icon row (contiguous), and
        // label_bottom is derived from the box border / action button rows
        // actually painted — not from proportional layout bounds.
        let (label_top, label_bottom) = label_row_bounds(by, bh, sb.action_bounds);
        if label_top < label_bottom && !stage.label.is_empty() {
            let avail = bw.saturating_sub(2) as usize;
            let max_col = bx + bw.saturating_sub(1);
            for (line_idx, line) in stage.label.split('\n').enumerate() {
                let row = label_top + line_idx as u16;
                if row >= label_bottom {
                    break;
                }
                let chars: Vec<char> = line.chars().collect();
                let take = chars.len().min(avail);
                let pad = avail.saturating_sub(take) / 2;
                let start_col = bx + 1 + pad as u16;
                for (i, ch) in chars.iter().take(take).enumerate() {
                    let col = start_col + i as u16;
                    if col >= max_col {
                        break;
                    }
                    set_cell(buf, col, row, *ch, fg, bg);
                }
            }
        }

        // ── Action button at bottom ──────────────────────────────────────
        if let (Some(ab), Some(action_text)) = (sb.action_bounds, &stage.action) {
            let ay = ab.y.round() as u16;
            if ay > by && ay < by + bh {
                let btn_label = format!("[{}]", action_text);
                let avail = bw.saturating_sub(2) as usize;
                let chars: Vec<char> = btn_label.chars().collect();
                let take = chars.len().min(avail);
                let pad = avail.saturating_sub(take) / 2;
                let start_col = bx + 1 + pad as u16;
                let max_col = bx + bw.saturating_sub(1);
                for (i, ch) in chars.iter().take(take).enumerate() {
                    let col = start_col + i as u16;
                    if col >= max_col {
                        break;
                    }
                    set_cell(buf, col, ay, *ch, accent, bg);
                }
            }
        }

        // ── Arrow connector to the right ─────────────────────────────────
        if let Some(arrow) = sb.arrow_bounds {
            let ax = arrow.x.round() as u16;
            let mid_y = by + bh / 2;
            // Draw: ─── ▶  (3 dashes then arrowhead)
            let aw = arrow.width.round() as u16;
            for dx in 0..aw.saturating_sub(1) {
                set_cell(buf, ax + dx, mid_y, '─', muted, bg);
            }
            if aw >= 1 {
                set_cell(buf, ax + aw - 1, mid_y, '▶', muted, bg);
            }
        }

        // ── Focus indicator (drawn in the reserved row above the box) ────
        if is_focused && by > 0 {
            set_cell(buf, bx + bw / 2, by - 1, '▼', muted, bg);
        }
    }

    layout
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::primitives::pipeline_view::{PipelineHit, PipelineStage, StageStatus};
    use crate::types::WidgetId;

    fn cell_char(buf: &Buffer, x: u16, y: u16) -> char {
        buf[(x, y)].symbol().chars().next().unwrap_or(' ')
    }

    fn make_view() -> PipelineView {
        PipelineView {
            id: WidgetId::new("pipe"),
            stages: vec![
                PipelineStage {
                    label: "Build".into(),
                    status: StageStatus::Done,
                    action: None,
                },
                PipelineStage {
                    label: "Test".into(),
                    status: StageStatus::Active,
                    action: Some("Retry".into()),
                },
            ],
            focused_stage: None,
        }
    }

    #[test]
    fn draws_without_panic_and_has_borders() {
        let area = Rect::new(0, 0, 30, 5);
        let mut buf = Buffer::empty(area);
        let view = make_view();
        draw_pipeline_view(&mut buf, area, &view, &Theme::default());
        // Row 0 is reserved for the focus indicator; box starts at row 1.
        // Rounded-corner glyph ╭ is the top-left corner.
        assert_eq!(cell_char(&buf, 0, 1), '╭');
    }

    /// Shared body for the action↔click round trip, run at both the
    /// origin and a non-zero `area` origin. `tui_pipeline_view_layout`
    /// bakes `area.x`/`area.y` straight into the returned bounds (absolute
    /// frame, matching the GTK/macOS twins) and adds `TUI_FOCUS_INDICATOR_H`
    /// to `area.y` before laying out. Deriving `ab`/`bb` from the layout
    /// ensures this exercises the actual origin math the function performs,
    /// at any origin.
    fn layout_hit_test_action_round_trip_at(origin_x: u16, origin_y: u16) {
        let area = Rect::new(origin_x, origin_y, 40, 5);
        let mut buf = Buffer::empty(area);
        let view = make_view();
        let layout = draw_pipeline_view(&mut buf, area, &view, &Theme::default());

        // Box top must sit exactly one row below area.y + the reserved
        // focus-indicator row, not at a hardcoded absolute row — pins the
        // offset math independently of the hit_test round trip below.
        let bb0 = layout.stages[0].box_bounds;
        assert_eq!(
            bb0.y,
            (area.y + TUI_FOCUS_INDICATOR_H) as f32,
            "stage box top should be area.y + TUI_FOCUS_INDICATOR_H"
        );

        // Stage 1 has action bounds.
        let ab = layout.stages[1]
            .action_bounds
            .expect("action bounds for stage 1");
        let hit = layout.hit_test(ab.x + ab.width / 2.0, ab.y + ab.height / 2.0);
        assert_eq!(hit, PipelineHit::Action(1));
    }

    #[test]
    fn layout_hit_test_action_round_trip() {
        layout_hit_test_action_round_trip_at(0, 0);
    }

    /// Non-zero-origin regression guard.
    #[test]
    fn layout_hit_test_action_round_trip_at_nonzero_origin() {
        layout_hit_test_action_round_trip_at(7, 13);
    }

    fn layout_hit_test_body_round_trip_at(origin_x: u16, origin_y: u16) {
        let area = Rect::new(origin_x, origin_y, 40, 5);
        let mut buf = Buffer::empty(area);
        let view = make_view();
        let layout = draw_pipeline_view(&mut buf, area, &view, &Theme::default());

        let bb = layout.stages[0].box_bounds;
        assert_eq!(
            bb.y,
            (area.y + TUI_FOCUS_INDICATOR_H) as f32,
            "stage box top should be area.y + TUI_FOCUS_INDICATOR_H"
        );
        let hit = layout.hit_test(bb.x + 1.0, bb.y + 1.0);
        assert_eq!(hit, PipelineHit::Body(0));
    }

    #[test]
    fn layout_hit_test_body_round_trip() {
        layout_hit_test_body_round_trip_at(0, 0);
    }

    /// Non-zero-origin regression guard.
    #[test]
    fn layout_hit_test_body_round_trip_at_nonzero_origin() {
        layout_hit_test_body_round_trip_at(7, 13);
    }

    #[test]
    fn zero_size_area_is_noop() {
        let buf_area = Rect::new(0, 0, 10, 5);
        let mut buf = Buffer::empty(buf_area);
        let area = Rect::new(0, 0, 0, 0);
        let view = make_view();
        let _layout = draw_pipeline_view(&mut buf, area, &view, &Theme::default());
        // Buffer should remain empty.
        assert_eq!(cell_char(&buf, 0, 0), ' ');
    }

    /// Focused Done stage: ▼ appears in row 0 and the box border is still drawn.
    #[test]
    fn focused_done_stage_shows_indicator_and_retains_border() {
        let area = Rect::new(0, 0, 30, 6);
        let mut buf = Buffer::empty(area);
        let mut view = make_view();
        view.focused_stage = Some(0); // stage 0 is Done
        let layout = draw_pipeline_view(&mut buf, area, &view, &Theme::default());

        let bb = layout.stages[0].box_bounds;
        let bx = bb.x.round() as u16;
        let by = bb.y.round() as u16;
        let bw = bb.width.round() as u16;

        // Box must be pushed down by the indicator row.
        assert_eq!(by, 1, "box top must be at indicator row + 1");

        // The ▼ caret appears in the reserved row above the box.
        let ind_col = bx + bw / 2;
        assert_eq!(cell_char(&buf, ind_col, 0), '▼');

        // The box border is still drawn (rounded, status colour, not overridden).
        assert_eq!(cell_char(&buf, bx, by), '╭');
    }

    /// A label carrying a newline renders as two centred rows inside the box —
    /// no literal newline cell on row 1, second line on its own row. Regression
    /// guard for the off-box "black gap" glitch.
    #[test]
    fn two_line_label_renders_on_separate_rows() {
        let area = Rect::new(0, 0, 40, 8);
        let mut buf = Buffer::empty(area);
        let view = PipelineView {
            id: WidgetId::new("pipe"),
            stages: vec![PipelineStage {
                label: "Stage One\n3:45".into(),
                status: StageStatus::Active,
                action: None,
            }],
            focused_stage: None,
        };
        let layout = draw_pipeline_view(&mut buf, area, &view, &Theme::default());

        let bb = layout.stages[0].box_bounds;
        let by = bb.y.round() as u16;
        let bh = bb.height.round() as u16;
        let (first_row, _) = label_row_bounds(by, bh, layout.stages[0].action_bounds);

        // Label must start immediately below the icon's actual rendered row
        // (icon_row = by + 1) — no blank gap row, regardless of
        // `label_bounds`'s proportional icon-height assumption.
        assert_eq!(
            first_row,
            by + 2,
            "label should start directly below the icon row, with no gap"
        );

        // Collect the rendered glyphs of the two label rows.
        let row1: String = (0..area.width)
            .map(|x| cell_char(&buf, x, first_row))
            .collect();
        let row2: String = (0..area.width)
            .map(|x| cell_char(&buf, x, first_row + 1))
            .collect();

        // Line 1 holds the stage name; line 2 holds the elapsed time.
        assert!(row1.contains("Stage One"), "row1 = {row1:?}");
        assert!(row2.contains("3:45"), "row2 = {row2:?}");
        // The elapsed segment must NOT bleed onto row 1 (the old glitch).
        assert!(!row1.contains("3:45"), "elapsed leaked onto row1: {row1:?}");
        // No literal newline rendered as a cell anywhere on row 1.
        assert!(!row1.contains('\n'), "newline rendered as a cell: {row1:?}");
    }

    /// A 3-line label renders all three lines on their own rows when the box
    /// has room, ensuring arbitrary N-line labels are fully displayed.
    #[test]
    fn three_line_label_renders_on_separate_rows() {
        let area = Rect::new(0, 0, 40, 10);
        let mut buf = Buffer::empty(area);
        let view = PipelineView {
            id: WidgetId::new("pipe"),
            stages: vec![PipelineStage {
                label: "Line One\nLine Two\nLine Three".into(),
                status: StageStatus::Active,
                action: None,
            }],
            focused_stage: None,
        };
        let layout = draw_pipeline_view(&mut buf, area, &view, &Theme::default());

        let bb = layout.stages[0].box_bounds;
        let by = bb.y.round() as u16;
        let bh = bb.height.round() as u16;
        let (first_row, _) = label_row_bounds(by, bh, layout.stages[0].action_bounds);

        let row_text =
            |row: u16| -> String { (0..area.width).map(|x| cell_char(&buf, x, row)).collect() };

        assert!(
            row_text(first_row).contains("Line One"),
            "row {first_row} = {:?}",
            row_text(first_row)
        );
        assert!(
            row_text(first_row + 1).contains("Line Two"),
            "row {} = {:?}",
            first_row + 1,
            row_text(first_row + 1)
        );
        assert!(
            row_text(first_row + 2).contains("Line Three"),
            "row {} = {:?}",
            first_row + 2,
            row_text(first_row + 2)
        );
    }

    /// When the box is too short to fit every line, overflow lines are
    /// dropped and never painted past the box border.
    #[test]
    fn label_overflow_is_clamped_not_painted_past_the_box() {
        // A short box (action button reserves a row too) leaves only one row
        // for the label even though it carries two lines.
        let area = Rect::new(0, 0, 40, 5);
        let mut buf = Buffer::empty(area);
        let view = PipelineView {
            id: WidgetId::new("pipe"),
            stages: vec![PipelineStage {
                label: "Line One\nLine Two".into(),
                status: StageStatus::Active,
                action: Some("Go".into()),
            }],
            focused_stage: None,
        };
        let layout = draw_pipeline_view(&mut buf, area, &view, &Theme::default());

        let bb = layout.stages[0].box_bounds;
        let by = bb.y.round() as u16;
        let bh = bb.height.round() as u16;
        let (first_row, label_bottom) = label_row_bounds(by, bh, layout.stages[0].action_bounds);
        // Test setup assumption: the label area really is clamped to a
        // single row by icon+action reservation, so this test actually
        // exercises the clamp path.
        assert!(
            label_bottom.saturating_sub(first_row) <= 1,
            "test setup expects a 1-row label area, got {}..{}",
            first_row,
            label_bottom
        );

        let row_text =
            |row: u16| -> String { (0..area.width).map(|x| cell_char(&buf, x, row)).collect() };

        assert!(
            row_text(first_row).contains("Line One"),
            "row {first_row} = {:?}",
            row_text(first_row)
        );

        // The second line must never appear anywhere — including not
        // bleeding onto or past the bottom border row.
        for y in 0..bh {
            let row = by + y;
            assert!(
                !row_text(row).contains("Line Two"),
                "row {row} unexpectedly contains the overflow line: {:?}",
                row_text(row)
            );
        }
    }

    /// When no stage is focused the indicator row stays blank.
    #[test]
    fn no_indicator_when_not_focused() {
        let area = Rect::new(0, 0, 30, 6);
        let mut buf = Buffer::empty(area);
        let view = make_view(); // focused_stage = None
        let layout = draw_pipeline_view(&mut buf, area, &view, &Theme::default());

        let bb = layout.stages[0].box_bounds;
        let bx = bb.x.round() as u16;
        let bw = bb.width.round() as u16;
        let ind_col = bx + bw / 2;

        // Indicator row should be blank since nothing is focused.
        assert_eq!(cell_char(&buf, ind_col, 0), ' ');
    }
}