Skip to main content

inkferro_core/render/
border.rs

1//! Border rendering — styled-frame slice.
2//!
3//! Port of ink's `render-border.ts`, including the `stylePiece` SGR coloring
4//! that the M1 char-only port dropped (M2-C restores it).
5//!
6//! # Design decisions
7//!
8//! ## Color (render-border.ts:7-20, 36-64)
9//! `stylePiece` wraps each border piece: colorize fg (innermost) → colorize bg
10//! → dim (outermost). Per-edge fg resolves `border{Edge}Color ?? borderColor`;
11//! per-edge dim resolves `border{Edge}DimColor ?? borderDimColor`, read from
12//! `Style` (these are style props that flow JS→core via Box's `...style` spread).
13//! With no color and no dim, `style_piece` is the identity transform, so plain
14//! borders stay byte-identical.
15//!
16//! ## Per-element wrapping (render-border.ts:74-131)
17//! Each corner+run top/bottom row is wrapped once (one SGR pair per row), and
18//! EACH vertical-bar cell is wrapped independently before the rows are joined
19//! with `\n`. The styled-grid `write` path (grid.rs) tokenizes each line with no
20//! carried SGR state, so a once-wrapped vertical strip would leave interior bars
21//! unstyled — every bar must carry its own open/close pair.
22//!
23//! ## Per-edge suppression (render-border.ts:66-69 citation)
24//! ```ts
25//! const showTopBorder    = node.style.borderTop    !== false;
26//! const showBottomBorder = node.style.borderBottom !== false;
27//! const showLeftBorder   = node.style.borderLeft   !== false;
28//! const showRightBorder  = node.style.borderRight  !== false;
29//! ```
30//! A missing/`true` value means "show"; only explicit `false` suppresses.
31//! In Rust: `style.border_top == Some(false)` → hide; anything else → show.
32//!
33//! ## Corner handling when an edge is off (render-border.ts:74-79 citation)
34//! ```ts
35//! let topBorder = showTopBorder
36//!   ? (showLeftBorder ? box.topLeft : '') +
37//!     box.top.repeat(contentWidth) +
38//!     (showRightBorder ? box.topRight : '')
39//!   : undefined;
40//! ```
41//! Corners appear only when BOTH the edge they terminate AND the perpendicular
42//! edge are shown. When the top edge is off the entire top row is omitted
43//! (topBorder = undefined). When the left edge is off, topLeft corner is ''.
44//! Same logic applies to bottom/right permutations.
45//!
46//! ## Vertical border height (render-border.ts:86-95 citation)
47//! ```ts
48//! let verticalBorderHeight = height;
49//! if (showTopBorder)    verticalBorderHeight -= 1;
50//! if (showBottomBorder) verticalBorderHeight -= 1;
51//! ```
52//! Left/right border strings span only the interior rows (total height minus
53//! the rows consumed by top/bottom border lines).
54
55use crate::dom::{BorderStyle, Style};
56use crate::render::cli_boxes::{BoxChars, CustomChars, named as named_box};
57use crate::render::colorize::{ColorLevel, Kind, colorize, dim as dim_modifier};
58use crate::render::grid::Grid;
59
60/// Style one border piece: colorize fg (innermost) → colorize bg → dim
61/// (outermost), mirroring `stylePiece` (render-border.ts:7-20).
62///
63/// With `fg`/`bg` both `None`/empty and `dim` false this is the identity
64/// transform (`colorize` passes through and dim is skipped), so plain borders
65/// stay byte-identical to the char-only slice. `level` is the detected color
66/// level (chalk's `chalk.level`): at [`ColorLevel::None`] every colorize/dim is a
67/// no-op, so a colored border in a non-color terminal emits plain box chars.
68fn style_piece(
69    segment: &str,
70    fg: Option<&str>,
71    bg: Option<&str>,
72    dim: bool,
73    level: ColorLevel,
74) -> String {
75    let styled = colorize(segment, fg, Kind::Fg, level);
76    let styled = colorize(&styled, bg, Kind::Bg, level);
77    if dim {
78        dim_modifier(&styled, level)
79    } else {
80        styled
81    }
82}
83
84/// Resolve a `BorderStyle` to its `BoxChars`.
85///
86/// Named styles return `Cow::Borrowed` (zero-copy). Custom styles return
87/// `Cow::Owned` (no leaking — freed when the `BoxChars` is dropped).
88///
89/// **Unknown name fallback:** an unrecognised `Named` style silently falls
90/// back to `"single"`.  Ink's JS equivalent throws a `TypeError`
91/// (`cliBoxes[name]` is `undefined`, so `box.topLeft` access crashes); Rust
92/// never panics — the caller always gets a valid frame.
93///
94/// Mirrors render-border.ts:32-34:
95/// ```ts
96/// const box = typeof node.style.borderStyle === 'string'
97///   ? cliBoxes[node.style.borderStyle]
98///   : node.style.borderStyle;
99/// ```
100fn resolve_box(style: &BorderStyle) -> BoxChars {
101    match style {
102        BorderStyle::Named(name) => named_box(name).unwrap_or_else(|| {
103            // Unknown named style: fall back to "single" so the renderer
104            // never panics. Document: unknown names behave like "single".
105            named_box("single").unwrap()
106        }),
107        BorderStyle::Custom {
108            top_left,
109            top,
110            top_right,
111            right,
112            bottom_right,
113            bottom,
114            bottom_left,
115            left,
116        } => BoxChars::from_custom(CustomChars {
117            top_left: top_left.clone(),
118            top: top.clone(),
119            top_right: top_right.clone(),
120            right: right.clone(),
121            bottom_right: bottom_right.clone(),
122            bottom: bottom.clone(),
123            bottom_left: bottom_left.clone(),
124            left: left.clone(),
125        }),
126    }
127}
128
129/// Draw the border of a box node into `grid`.
130///
131/// `(x, y)` is the top-left corner of the box (absolute grid coordinates).
132/// `width` / `height` are the full computed dimensions including border cells.
133///
134/// Mirrors `renderBorder` in render-border.ts:22-155, including `stylePiece`.
135pub fn render_border(
136    x: i32,
137    y: i32,
138    width: u16,
139    height: u16,
140    style: &Style,
141    grid: &mut Grid,
142    level: ColorLevel,
143) {
144    let Some(ref border_style) = style.border_style else {
145        return; // render-border.ts:28: early return if no borderStyle.
146    };
147
148    let bx = resolve_box(border_style);
149
150    // render-border.ts:36-42: per-edge fg color — border{Edge}Color ?? borderColor.
151    let top_color = style
152        .border_top_color
153        .as_deref()
154        .or(style.border_color.as_deref());
155    let bottom_color = style
156        .border_bottom_color
157        .as_deref()
158        .or(style.border_color.as_deref());
159    let left_color = style
160        .border_left_color
161        .as_deref()
162        .or(style.border_color.as_deref());
163    let right_color = style
164        .border_right_color
165        .as_deref()
166        .or(style.border_color.as_deref());
167
168    // render-border.ts:54-64: per-edge dim — border{Edge}DimColor ?? borderDimColor.
169    // Read from Style (these are style props, threaded JS→core via Box's `...style`).
170    let dim_top = style
171        .border_top_dim_color
172        .or(style.border_dim_color)
173        .unwrap_or(false);
174    let dim_bottom = style
175        .border_bottom_dim_color
176        .or(style.border_dim_color)
177        .unwrap_or(false);
178    let dim_left = style
179        .border_left_dim_color
180        .or(style.border_dim_color)
181        .unwrap_or(false);
182    let dim_right = style
183        .border_right_dim_color
184        .or(style.border_dim_color)
185        .unwrap_or(false);
186
187    // render-border.ts:44-52: per-edge bg — border{Edge}BackgroundColor ?? borderBackgroundColor.
188    let top_bg = style
189        .border_top_background_color
190        .as_deref()
191        .or(style.border_background_color.as_deref());
192    let bottom_bg = style
193        .border_bottom_background_color
194        .as_deref()
195        .or(style.border_background_color.as_deref());
196    let left_bg = style
197        .border_left_background_color
198        .as_deref()
199        .or(style.border_background_color.as_deref());
200    let right_bg = style
201        .border_right_background_color
202        .as_deref()
203        .or(style.border_background_color.as_deref());
204
205    // render-border.ts:66-69: edge visibility.
206    let show_top = style.border_top != Some(false);
207    let show_bottom = style.border_bottom != Some(false);
208    let show_left = style.border_left != Some(false);
209    let show_right = style.border_right != Some(false);
210
211    let w = width as i32;
212    let h = height as i32;
213
214    // render-border.ts:71-72: contentWidth = width − left_border − right_border.
215    let content_width = w - if show_left { 1 } else { 0 } - if show_right { 1 } else { 0 };
216
217    // render-border.ts:74-79: top border row.
218    // Top corners appear only when both the top AND the respective side are shown.
219    if show_top {
220        let mut top_str = String::new();
221        if show_left {
222            top_str.push_str(&bx.top_left);
223        }
224        for _ in 0..content_width.max(0) {
225            top_str.push_str(&bx.top);
226        }
227        if show_right {
228            top_str.push_str(&bx.top_right);
229        }
230        // render-border.ts:80-85: wrap the whole top row once (single line).
231        let top_str = style_piece(&top_str, top_color, top_bg, dim_top, level);
232        grid.write(x, y, &top_str);
233    }
234
235    // render-border.ts:86-95: vertical border height = total height minus
236    // the rows used by top and bottom borders.
237    let mut vert_height = h;
238    if show_top {
239        vert_height -= 1;
240    }
241    if show_bottom {
242        vert_height -= 1;
243    }
244
245    // render-border.ts:97-108: left border — one char per interior row.
246    // Written as a single multi-line string with \n separators.
247    // Borrow as &str so repeat clones cheap pointer+len, not the Cow itself.
248    if show_left && vert_height > 0 {
249        // render-border.ts:99-107: style ONE cell, then repeat — each line carries
250        // its own SGR pair so the per-line tokenizer styles every bar.
251        let one = style_piece(&bx.left, left_color, left_bg, dim_left, level);
252        let left_str = std::iter::repeat_n(one.as_str(), vert_height as usize)
253            .collect::<Vec<_>>()
254            .join("\n");
255        let offset_y = if show_top { 1 } else { 0 };
256        grid.write(x, y + offset_y, &left_str);
257    }
258
259    // render-border.ts:109-119: right border.
260    if show_right && vert_height > 0 {
261        // render-border.ts:111-118: style ONE cell, then repeat (see left edge).
262        let one = style_piece(&bx.right, right_color, right_bg, dim_right, level);
263        let right_str = std::iter::repeat_n(one.as_str(), vert_height as usize)
264            .collect::<Vec<_>>()
265            .join("\n");
266        let offset_y = if show_top { 1 } else { 0 };
267        // render-border.ts:143-146: x + width - 1.
268        grid.write(x + w - 1, y + offset_y, &right_str);
269    }
270
271    // render-border.ts:121-131: bottom border row.
272    if show_bottom {
273        let mut bot_str = String::new();
274        if show_left {
275            bot_str.push_str(&bx.bottom_left);
276        }
277        for _ in 0..content_width.max(0) {
278            bot_str.push_str(&bx.bottom);
279        }
280        if show_right {
281            bot_str.push_str(&bx.bottom_right);
282        }
283        // render-border.ts:126-131: wrap the whole bottom row once (single line).
284        let bot_str = style_piece(&bot_str, bottom_color, bottom_bg, dim_bottom, level);
285        // render-border.ts:148-151: y + height - 1.
286        grid.write(x, y + h - 1, &bot_str);
287    }
288}
289
290// ─── Tests ───────────────────────────────────────────────────────────────────
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::dom::Style;
296    use crate::render::grid::Grid;
297
298    fn style_with_border(name: &str) -> Style {
299        Style {
300            border_style: Some(BorderStyle::Named(name.to_owned())),
301            ..Style::default()
302        }
303    }
304
305    fn render_to_string(rows: usize, cols: usize, style: &Style, w: u16, h: u16) -> String {
306        let mut g = Grid::new(rows, cols);
307        // Truecolor (level 3) keeps these char-only border literals identical to
308        // the pre-#77 behavior (no color → identity transform either way).
309        render_border(0, 0, w, h, style, &mut g, ColorLevel::Truecolor);
310        g.get().0
311    }
312
313    // ── Named styles (pin exact frame literals from ink oracle) ──────────────
314
315    // ink: renderToString(<Box borderStyle="single" width={10} height={3}/>) ===
316    // "┌────────┐\n│        │\n└────────┘"
317    #[test]
318    fn single_border_10x3() {
319        let s = style_with_border("single");
320        let out = render_to_string(3, 10, &s, 10, 3);
321        assert_eq!(out, "┌────────┐\n│        │\n└────────┘");
322    }
323
324    // ink: renderToString(<Box borderStyle="double" width={10} height={3}/>) ===
325    // "╔════════╗\n║        ║\n╚════════╝"
326    #[test]
327    fn double_border_10x3() {
328        let s = style_with_border("double");
329        let out = render_to_string(3, 10, &s, 10, 3);
330        assert_eq!(out, "╔════════╗\n║        ║\n╚════════╝");
331    }
332
333    // ink: renderToString(<Box borderStyle="round" width={10} height={3}/>) ===
334    // "╭────────╮\n│        │\n╰────────╯"
335    #[test]
336    fn round_border_10x3() {
337        let s = style_with_border("round");
338        let out = render_to_string(3, 10, &s, 10, 3);
339        assert_eq!(out, "╭────────╮\n│        │\n╰────────╯");
340    }
341
342    // ink: renderToString(<Box borderStyle="bold" width={10} height={3}/>) ===
343    // "┏━━━━━━━━┓\n┃        ┃\n┗━━━━━━━━┛"
344    #[test]
345    fn bold_border_10x3() {
346        let s = style_with_border("bold");
347        let out = render_to_string(3, 10, &s, 10, 3);
348        assert_eq!(out, "┏━━━━━━━━┓\n┃        ┃\n┗━━━━━━━━┛");
349    }
350
351    // ink: renderToString(<Box borderStyle="classic" width={10} height={3}/>) ===
352    // "+--------+\n|        |\n+--------+"
353    #[test]
354    fn classic_border_10x3() {
355        let s = style_with_border("classic");
356        let out = render_to_string(3, 10, &s, 10, 3);
357        assert_eq!(out, "+--------+\n|        |\n+--------+");
358    }
359
360    // ink: renderToString(<Box borderStyle="singleDouble" width={10} height={3}/>) ===
361    // "╓────────╖\n║        ║\n╙────────╜"
362    #[test]
363    fn single_double_border_10x3() {
364        let s = style_with_border("singleDouble");
365        let out = render_to_string(3, 10, &s, 10, 3);
366        assert_eq!(out, "╓────────╖\n║        ║\n╙────────╜");
367    }
368
369    // ink: renderToString(<Box borderStyle="doubleSingle" width={10} height={3}/>) ===
370    // "╒════════╕\n│        │\n╘════════╛"
371    #[test]
372    fn double_single_border_10x3() {
373        let s = style_with_border("doubleSingle");
374        let out = render_to_string(3, 10, &s, 10, 3);
375        assert_eq!(out, "╒════════╕\n│        │\n╘════════╛");
376    }
377
378    // ink: renderToString(<Box borderStyle="arrow" width={10} height={3}/>) ===
379    // "↘↓↓↓↓↓↓↓↓↙\n→        ←\n↗↑↑↑↑↑↑↑↑↖"
380    #[test]
381    fn arrow_border_10x3() {
382        let s = style_with_border("arrow");
383        let out = render_to_string(3, 10, &s, 10, 3);
384        assert_eq!(out, "↘↓↓↓↓↓↓↓↓↙\n→        ←\n↗↑↑↑↑↑↑↑↑↖");
385    }
386
387    // ── Partial edges ────────────────────────────────────────────────────────
388
389    // ink: renderToString(<Box borderStyle="single" borderTop={false} width={10} height={3}/>) ===
390    // "│        │\n│        │\n└────────┘"
391    #[test]
392    fn single_no_top_10x3() {
393        let s = Style {
394            border_style: Some(BorderStyle::Named("single".to_owned())),
395            border_top: Some(false),
396            ..Style::default()
397        };
398        let out = render_to_string(3, 10, &s, 10, 3);
399        assert_eq!(out, "│        │\n│        │\n└────────┘");
400    }
401
402    // ink: renderToString(<Box borderStyle="single" borderLeft={false} width={10} height={3}/>) ===
403    // "─────────┐\n         │\n─────────┘"
404    #[test]
405    fn single_no_left_10x3() {
406        let s = Style {
407            border_style: Some(BorderStyle::Named("single".to_owned())),
408            border_left: Some(false),
409            ..Style::default()
410        };
411        let out = render_to_string(3, 10, &s, 10, 3);
412        assert_eq!(out, "─────────┐\n         │\n─────────┘");
413    }
414
415    // ink: renderToString(<Box borderStyle="single" borderRight={false} width={10} height={3}/>) ===
416    // "┌─────────\n│\n└─────────"
417    #[test]
418    fn single_no_right_10x3() {
419        let s = Style {
420            border_style: Some(BorderStyle::Named("single".to_owned())),
421            border_right: Some(false),
422            ..Style::default()
423        };
424        let out = render_to_string(3, 10, &s, 10, 3);
425        assert_eq!(out, "┌─────────\n│\n└─────────");
426    }
427
428    // ink: renderToString(<Box borderStyle="single" borderBottom={false} width={10} height={3}/>) ===
429    // "┌────────┐\n│        │\n│        │"
430    #[test]
431    fn single_no_bottom_10x3() {
432        let s = Style {
433            border_style: Some(BorderStyle::Named("single".to_owned())),
434            border_bottom: Some(false),
435            ..Style::default()
436        };
437        let out = render_to_string(3, 10, &s, 10, 3);
438        assert_eq!(out, "┌────────┐\n│        │\n│        │");
439    }
440
441    // No borderStyle → render_border is a no-op → grid stays all spaces.
442    #[test]
443    fn no_border_style_noop() {
444        let s = Style::default();
445        let out = render_to_string(3, 10, &s, 10, 3);
446        // All spaces trimmed → three empty lines joined by \n.
447        assert_eq!(out, "\n\n");
448    }
449
450    // Unknown named style falls back to "single" (ink throws TypeError; we never panic).
451    // "singel" is a deliberate misspelling — pins the fallback path.
452    // Expected frame matches the oracle for single at 10×3.
453    #[test]
454    fn unknown_named_falls_back_to_single() {
455        let s = style_with_border("singel"); // typo — not a known style
456        let out = render_to_string(3, 10, &s, 10, 3);
457        assert_eq!(out, "┌────────┐\n│        │\n└────────┘");
458    }
459
460    // ── M2-C: stylePiece SGR coloring ────────────────────────────────────────
461
462    fn render_styled(rows: usize, cols: usize, style: &Style, w: u16, h: u16) -> String {
463        let mut g = Grid::new(rows, cols);
464        // Level 3: the M2-C stylePiece SGR pins are all chalk@5 level-3 bytes.
465        render_border(0, 0, w, h, style, &mut g, ColorLevel::Truecolor);
466        g.get().0
467    }
468
469    // No color and no dim → style_piece is identity → plain frame is
470    // BYTE-IDENTICAL to the char-only slice (the ~37 plain tests must not move).
471    #[test]
472    fn plain_border_identity_no_sgr() {
473        let s = style_with_border("single");
474        let out = render_styled(3, 10, &s, 10, 3);
475        assert_eq!(out, "┌────────┐\n│        │\n└────────┘");
476        assert!(!out.contains('\x1b'), "plain border must emit no SGR bytes");
477    }
478
479    // #77: a hex-colored border honors the detected ColorLevel.
480    //  - None (0): NO SGR — plain box chars (matches ink in a non-color terminal).
481    //  - Basic (1): rgb→ansi16 downgrade (#ff8800 → 93 brightYellow).
482    //  - Ansi256 (2): rgb→ansi256 downgrade (#ff8800 → 214).
483    //  - Truecolor (3): 38;2 truecolor verbatim.
484    // Bytes are chalk@5/ansi-styles ground truth (oracle-pinned in colorize tests).
485    fn render_border_at(level: ColorLevel) -> String {
486        let s = Style {
487            border_style: Some(BorderStyle::Named("single".to_owned())),
488            border_color: Some("#ff8800".to_owned()),
489            ..Style::default()
490        };
491        let mut g = Grid::new(3, 10);
492        render_border(0, 0, 10, 3, &s, &mut g, level);
493        g.get().0
494    }
495
496    #[test]
497    fn hex_border_level_none_emits_no_sgr() {
498        let out = render_border_at(ColorLevel::None);
499        assert_eq!(out, "┌────────┐\n│        │\n└────────┘");
500        assert!(
501            !out.contains('\x1b'),
502            "level 0: a hex-colored border must emit NO SGR (plain chars)"
503        );
504    }
505
506    #[test]
507    fn hex_border_level_basic_downgrades_to_16() {
508        let out = render_border_at(ColorLevel::Basic);
509        // Top row, each bar, bottom row wrapped in 93/39 (brightYellow).
510        assert_eq!(
511            out,
512            "\x1b[93m┌────────┐\x1b[39m\n\x1b[93m│\x1b[39m        \x1b[93m│\x1b[39m\n\x1b[93m└────────┘\x1b[39m"
513        );
514    }
515
516    #[test]
517    fn hex_border_level_ansi256_downgrades_to_256() {
518        let out = render_border_at(ColorLevel::Ansi256);
519        assert_eq!(
520            out,
521            "\x1b[38;5;214m┌────────┐\x1b[39m\n\x1b[38;5;214m│\x1b[39m        \x1b[38;5;214m│\x1b[39m\n\x1b[38;5;214m└────────┘\x1b[39m"
522        );
523    }
524
525    #[test]
526    fn hex_border_level_truecolor_verbatim() {
527        let out = render_border_at(ColorLevel::Truecolor);
528        assert_eq!(
529            out,
530            "\x1b[38;2;255;136;0m┌────────┐\x1b[39m\n\x1b[38;2;255;136;0m│\x1b[39m        \x1b[38;2;255;136;0m│\x1b[39m\n\x1b[38;2;255;136;0m└────────┘\x1b[39m"
531        );
532    }
533
534    // Per-edge fg color resolution cascade: border_top_color wins over
535    // border_color for the top; other edges fall back to border_color.
536    #[test]
537    fn per_edge_color_resolution_cascade() {
538        let s = Style {
539            border_style: Some(BorderStyle::Named("single".to_owned())),
540            border_color: Some("red".to_owned()),       // base
541            border_top_color: Some("green".to_owned()), // top override
542            ..Style::default()
543        };
544        let out = render_styled(3, 10, &s, 10, 3);
545        // Top row green (32/39); each vertical bar and the bottom row red (31/39).
546        assert_eq!(
547            out,
548            "\x1b[32m┌────────┐\x1b[39m\n\x1b[31m│\x1b[39m        \x1b[31m│\x1b[39m\n\x1b[31m└────────┘\x1b[39m"
549        );
550    }
551
552    // Per-edge bg color resolution cascade: border_top_background_color wins over
553    // border_background_color for the top; other edges fall back to the general
554    // border_background_color. Mirrors the fg cascade (render-border.ts:44-52).
555    // No fg set → colorize-fg is passthrough → only the bg SGR wraps each piece.
556    // chalk level-3 named bg: blue → bgBlue = 44/49; red → bgRed = 41/49.
557    #[test]
558    fn per_edge_background_resolution_cascade() {
559        let s = Style {
560            border_style: Some(BorderStyle::Named("single".to_owned())),
561            border_background_color: Some("red".to_owned()), // base
562            border_top_background_color: Some("blue".to_owned()), // top override
563            ..Style::default()
564        };
565        let out = render_styled(3, 10, &s, 10, 3);
566        // Top row blue bg (44/49); each vertical bar and the bottom row red bg (41/49).
567        assert_eq!(
568            out,
569            "\x1b[44m┌────────┐\x1b[49m\n\x1b[41m│\x1b[49m        \x1b[41m│\x1b[49m\n\x1b[41m└────────┘\x1b[49m"
570        );
571    }
572
573    // Each vertical bar carries its OWN SGR pair — wrapping the strip once would
574    // leave the interior bar unstyled after the per-line tokenizer split.
575    #[test]
576    fn each_vertical_bar_wrapped_independently() {
577        let s = Style {
578            border_style: Some(BorderStyle::Named("single".to_owned())),
579            border_left_color: Some("green".to_owned()),
580            ..Style::default()
581        };
582        // height 4 → 2 interior rows on the left edge.
583        let out = render_styled(4, 10, &s, 10, 4);
584        assert_eq!(
585            out,
586            "┌────────┐\n\x1b[32m│\x1b[39m        │\n\x1b[32m│\x1b[39m        │\n└────────┘"
587        );
588    }
589
590    // Dim + color composition order: colorize fg (innermost) then dim (outermost).
591    // Bytes: \x1b[2m (dim open) \x1b[32m (green open) … \x1b[39m (green close)
592    // \x1b[22m (dim close) — color closes 39, dim closes 22, never crossed.
593    #[test]
594    fn dim_and_color_composition_order_bytes() {
595        let s = Style {
596            border_style: Some(BorderStyle::Named("single".to_owned())),
597            border_top_color: Some("green".to_owned()),
598            border_top_dim_color: Some(true),
599            ..Style::default()
600        };
601        let out = render_styled(3, 10, &s, 10, 3);
602        let top = out.lines().next().unwrap();
603        assert_eq!(top, "\x1b[2m\x1b[32m┌────────┐\x1b[39m\x1b[22m");
604    }
605
606    // Per-edge dim falls back to general borderDimColor (the ?? cascade), read
607    // from Style. General borderDimColor → every edge dims.
608    #[test]
609    fn dim_cascade_general_applies_to_all_edges() {
610        let s = Style {
611            border_style: Some(BorderStyle::Named("single".to_owned())),
612            border_dim_color: Some(true),
613            ..Style::default()
614        };
615        let out = render_styled(3, 10, &s, 10, 3);
616        assert_eq!(
617            out,
618            "\x1b[2m┌────────┐\x1b[22m\n\x1b[2m│\x1b[22m        \x1b[2m│\x1b[22m\n\x1b[2m└────────┘\x1b[22m"
619        );
620    }
621
622    // Style dim cascade: per-edge border{Edge}DimColor wins over the general
623    // borderDimColor; general applies to every edge that has no per-edge value;
624    // a None/false edge with no general falls through to no-dim. Mirrors the
625    // `border{Edge}DimColor ?? borderDimColor` resolution (render-border.ts:54-64),
626    // now sourced from Style (the style props flow JS→core via Box's `...style`).
627    #[test]
628    fn dim_cascade_resolution_style_based() {
629        // Per-edge true on TOP only, no general → only the top row dims; the
630        // bottom row and the vertical bars stay plain (no SGR).
631        let per_edge = Style {
632            border_style: Some(BorderStyle::Named("single".to_owned())),
633            border_top_dim_color: Some(true),
634            ..Style::default()
635        };
636        let out = render_styled(3, 10, &per_edge, 10, 3);
637        assert_eq!(out, "\x1b[2m┌────────┐\x1b[22m\n│        │\n└────────┘");
638
639        // Per-edge wins over general: TOP explicitly false suppresses dim on the
640        // top even though the general borderDimColor is true; all other edges
641        // dim from the general fallback.
642        let per_edge_wins = Style {
643            border_style: Some(BorderStyle::Named("single".to_owned())),
644            border_dim_color: Some(true),
645            border_top_dim_color: Some(false),
646            ..Style::default()
647        };
648        let out = render_styled(3, 10, &per_edge_wins, 10, 3);
649        assert_eq!(
650            out,
651            "┌────────┐\n\x1b[2m│\x1b[22m        \x1b[2m│\x1b[22m\n\x1b[2m└────────┘\x1b[22m"
652        );
653    }
654}