Skip to main content

dotzuki_renderer/layout_engine/elements/
list.rs

1//! Scrollable list element renderer.
2//!
3//! Renders a vertical list of items with cursor navigation, scroll
4//! indicators, and an optional footer. Items are resolved from the
5//! [`DataContext`] by the `items` key.
6//!
7//! ## Features
8//! - `max_visible` controls how many items are shown at once
9//! - Cursor ▶ highlights the active item
10//! - Scroll indicators ▲/▼ when content overflows
11//! - Footer text (e.g. "CANCEL") drawn below the list
12
13use dotzuki_engine::render::painter::Painter;
14use dotzuki_engine::render::{Rgba, TilePos};
15
16use crate::layout_engine::types::{DataContext, DataValue, ListParams, Theme};
17
18// ── Public API ──────────────────────────────────────────────────────────────
19
20/// Render a scrollable list into the framebuffer via `painter`.
21///
22/// # Arguments
23/// * `params` — Deserialised [`ListParams`] from the layout definition.
24/// * `tx`, `ty` — Top-left tile position of the list element.
25/// * `ctx` — Data context for resolving item values and template strings.
26/// * `painter` — Drawing backend.
27pub fn render_list(
28    params: &ListParams,
29    tx: u32,
30    ty: u32,
31    ctx: &DataContext,
32    theme: &Theme,
33    painter: &mut dyn Painter,
34) {
35    // ── Resolve items from the data context ──────────────────────────
36    // `items` may be written as a bare key (`items`) or a template
37    // reference (`{items}`); strip the surrounding braces before lookup.
38    let items_key = strip_braces(&params.items);
39    let items: Vec<DataValue> = match ctx.get(items_key) {
40        Some(DataValue::List(v)) => v.clone(),
41        // A plain string (e.g. from an editor variable override, where every
42        // value arrives as a string) is treated as one row per line.
43        Some(DataValue::Str(s)) => s
44            .lines()
45            .map(|l| l.trim_end())
46            .filter(|l| !l.is_empty())
47            .map(|l| DataValue::Str(l.to_string()))
48            .collect(),
49        _ => return,
50    };
51    if items.is_empty() {
52        return;
53    }
54
55    // Selected index — resolves a literal or a `{cursor}` template against the
56    // data context (so the live cursor follows menu state); defaults to the
57    // first item for static previews.
58    let cursor = params
59        .selected
60        .as_ref()
61        .map(|c| c.resolve(ctx))
62        .unwrap_or(0) as usize;
63    let max_visible = params.max_visible.unwrap_or(items.len()).max(1);
64    let row_height = params.item_template.height;
65    let gap = params.item_template.gap;
66
67    // ── Calculate visible scroll window ──────────────────────────────
68    let scroll_start = calculate_scroll_window(cursor, max_visible, items.len());
69    let visible_end = (scroll_start + max_visible).min(items.len());
70    let has_scroll_up = scroll_start > 0;
71    let has_scroll_down = visible_end < items.len();
72
73    // ── Proportional (pixel-precise) path — themed colours + CJK advance ──
74    if theme.proportional(painter.supports_proportional()) {
75        let ink = theme.ink_color();
76        let cur = theme.cursor_ink();
77        let base_px = tx * 8;
78        let row_pitch = (row_height + gap).max(1) * 8;
79        let cursor_adv = 10u32; // ▶ glyph advance
80        let mut y = ty * 8;
81        if has_scroll_up {
82            painter.draw_text_px(base_px, y, "\u{25B2}", ink);
83            y += row_pitch;
84        }
85        for item_idx in scroll_start..visible_end {
86            let item_text = data_value_to_string(&items[item_idx]);
87            if item_idx == cursor {
88                painter.draw_text_px(base_px, y, "\u{25B6}", cur);
89            }
90            painter.draw_text_px(base_px + cursor_adv, y, &item_text, ink);
91            y += row_pitch;
92        }
93        if has_scroll_down {
94            painter.draw_text_px(base_px, y, "\u{25BC}", ink);
95            y += row_pitch;
96        }
97        if let Some(footer) = &params.footer {
98            let footer_text = ctx.resolve(footer);
99            painter.draw_text_px(base_px, y, &footer_text, ink);
100        }
101        return;
102    }
103
104    let mut row_ty = ty;
105
106    // ── Scroll-up indicator ▲ ────────────────────────────────────────
107    if has_scroll_up {
108        painter.draw_glyph(
109            TilePos::new(tx, row_ty),
110            '\u{25B2}', // ▲
111            Rgba::INK_BLACK,
112        );
113        row_ty += 1;
114    }
115
116    // ── Visible items ────────────────────────────────────────────────
117    for item_idx in scroll_start..visible_end {
118        let item_text = data_value_to_string(&items[item_idx]);
119
120        if item_idx == cursor {
121            // ▶ cursor at active item
122            painter.draw_glyph(
123                TilePos::new(tx, row_ty),
124                '\u{25B6}', // ▶
125                Rgba::INK_BLACK,
126            );
127            painter.draw_text(
128                TilePos::new(tx + 1, row_ty),
129                &item_text,
130                Rgba::INK_BLACK,
131            );
132        } else {
133            // Non-active item — indented one tile to align with cursor row
134            painter.draw_text(
135                TilePos::new(tx + 1, row_ty),
136                &item_text,
137                Rgba::INK_BLACK,
138            );
139        }
140        row_ty += row_height + gap;
141    }
142
143    // ── Scroll-down indicator ▼ ──────────────────────────────────────
144    if has_scroll_down {
145        painter.draw_glyph(
146            TilePos::new(tx, row_ty),
147            '\u{25BC}', // ▼
148            Rgba::INK_BLACK,
149        );
150        row_ty += 1;
151    }
152
153    // ── Footer ───────────────────────────────────────────────────────
154    if let Some(footer) = &params.footer {
155        let footer_text = ctx.resolve(footer);
156        painter.draw_text(TilePos::new(tx, row_ty), &footer_text, Rgba::INK_BLACK);
157    }
158}
159
160// ── Helpers ────────────────────────────────────────────────────────────────
161
162/// Strip a single pair of surrounding `{ }` braces (and whitespace) from a
163/// variable reference, returning the bare key. `"{items}"` → `"items"`,
164/// `"items"` → `"items"`.
165pub(crate) fn strip_braces(s: &str) -> &str {
166    let t = s.trim();
167    t.strip_prefix('{')
168        .and_then(|r| r.strip_suffix('}'))
169        .unwrap_or(t)
170        .trim()
171}
172
173/// Compute the starting index of the visible scroll window so the cursor
174/// stays within the view.
175fn calculate_scroll_window(cursor: usize, max_visible: usize, total: usize) -> usize {
176    if total <= max_visible {
177        return 0;
178    }
179    if cursor <= max_visible.saturating_sub(1) {
180        0
181    } else {
182        // Cursor at bottom of visible window, or as far as we can scroll
183        cursor.saturating_sub(max_visible - 1).min(total - max_visible)
184    }
185}
186
187/// Convert a [`DataValue`] to its string representation.
188fn data_value_to_string(value: &DataValue) -> String {
189    match value {
190        DataValue::Str(s) => s.clone(),
191        DataValue::Int(n) => n.to_string(),
192        DataValue::Float(f) => f.to_string(),
193        DataValue::Bool(b) => b.to_string(),
194        DataValue::TileId(t) => t.to_string(),
195        DataValue::List(v) => {
196            v.iter()
197                .map(data_value_to_string)
198                .collect::<Vec<_>>()
199                .join(", ")
200        }
201    }
202}
203
204// ── Tests ──────────────────────────────────────────────────────────────────
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use dotzuki_engine::render::TileRect;
210    use std::cell::RefCell;
211
212    // ── Test double: MockPainter ─────────────────────────────────────
213
214    /// A recorded draw operation.
215    #[derive(Debug, Clone, PartialEq, Eq)]
216    enum DrawOp {
217        Text {
218            tx: u32,
219            ty: u32,
220            text: String,
221        },
222        Glyph {
223            tx: u32,
224            ty: u32,
225            glyph: char,
226        },
227        TextBox {
228            tx: u32,
229            ty: u32,
230            tw: u32,
231            th: u32,
232        },
233        PixelRect {
234            px: u32,
235            py: u32,
236            pw: u32,
237            ph: u32,
238        },
239    }
240
241    /// A [`Painter`] that records every call for verification.
242    struct MockPainter {
243        ops: RefCell<Vec<DrawOp>>,
244    }
245
246    impl MockPainter {
247        fn new() -> Self {
248            Self {
249                ops: RefCell::new(Vec::new()),
250            }
251        }
252
253        fn ops(&self) -> std::cell::Ref<'_, Vec<DrawOp>> {
254            self.ops.borrow()
255        }
256
257        fn find_text(&self, needle: &str) -> Vec<(u32, u32)> {
258            self.ops()
259                .iter()
260                .filter_map(|op| match op {
261                    DrawOp::Text { tx, ty, text } if text.contains(needle) => {
262                        Some((*tx, *ty))
263                    }
264                    _ => None,
265                })
266                .collect()
267        }
268
269        fn find_glyph(&self, needle: char) -> Option<(u32, u32)> {
270            self.ops()
271                .iter()
272                .find_map(|op| match op {
273                    DrawOp::Glyph { tx, ty, glyph } if *glyph == needle => {
274                        Some((*tx, *ty))
275                    }
276                    _ => None,
277                })
278        }
279    }
280
281    impl Painter for MockPainter {
282        fn clear(&mut self, _color: Rgba) {}
283
284        fn draw_text_box(&mut self, rect: TileRect, _color: Rgba) {
285            self.ops.borrow_mut().push(DrawOp::TextBox {
286                tx: rect.tx,
287                ty: rect.ty,
288                tw: rect.tw,
289                th: rect.th,
290            });
291        }
292
293        fn draw_text(&mut self, pos: TilePos, text: &str, color: Rgba) {
294            let _ = color;
295            self.ops.borrow_mut().push(DrawOp::Text {
296                tx: pos.tx,
297                ty: pos.ty,
298                text: text.to_string(),
299            });
300        }
301
302        fn draw_glyph(&mut self, pos: TilePos, glyph: char, color: Rgba) {
303            let _ = color;
304            self.ops.borrow_mut().push(DrawOp::Glyph {
305                tx: pos.tx,
306                ty: pos.ty,
307                glyph,
308            });
309        }
310
311        fn draw_pixel_rect(
312            &mut self,
313            px: u32,
314            py: u32,
315            pw: u32,
316            ph: u32,
317            _color: Rgba,
318        ) {
319            self.ops.borrow_mut().push(DrawOp::PixelRect { px, py, pw, ph });
320        }
321
322        fn draw_gb_tile(
323            &mut self,
324            _pos: TilePos,
325            _tile_id: u8,
326            _fallback: &str,
327            _color: Rgba,
328        ) {
329        }
330    }
331
332    // ── Helper: build ListParams ─────────────────────────────────────
333
334    fn make_list_params(
335        items_key: &str,
336        cursor: u32,
337        max_visible: usize,
338        footer: Option<&str>,
339    ) -> ListParams {
340        ListParams {
341            items: items_key.to_string(),
342            item_template: crate::layout_engine::types::ItemTemplate {
343                height: 1,
344                gap: 0,
345            },
346            cursor: crate::layout_engine::types::ListCursor::default(),
347            selected: Some(cursor.into()),
348            max_visible: Some(max_visible),
349            footer: footer.map(|s| s.to_string()),
350        }
351    }
352
353    // ── Tests: calculate_scroll_window ───────────────────────────────
354
355    #[test]
356    fn scroll_window_cursor_at_top() {
357        assert_eq!(calculate_scroll_window(0, 5, 20), 0);
358        assert_eq!(calculate_scroll_window(2, 5, 20), 0);
359    }
360
361    #[test]
362    fn scroll_window_cursor_in_middle() {
363        // cursor=5, max_visible=5 → window starts at 1 (items 1-5 visible)
364        assert_eq!(calculate_scroll_window(5, 5, 20), 1);
365    }
366
367    #[test]
368    fn scroll_window_cursor_at_bottom() {
369        // cursor=19, max_visible=5, total=20 → window starts at 15
370        assert_eq!(calculate_scroll_window(19, 5, 20), 15);
371    }
372
373    #[test]
374    fn scroll_window_small_list() {
375        assert_eq!(calculate_scroll_window(2, 5, 3), 0);
376    }
377
378    #[test]
379    fn scroll_window_empty_list() {
380        assert_eq!(calculate_scroll_window(0, 5, 0), 0);
381    }
382
383    #[test]
384    fn scroll_window_exact_fit() {
385        assert_eq!(calculate_scroll_window(3, 5, 5), 0);
386    }
387
388    // ── Tests: data_value_to_string ──────────────────────────────────
389
390    #[test]
391    fn string_value_to_string() {
392        assert_eq!(
393            data_value_to_string(&DataValue::Str("HELLO".into())),
394            "HELLO"
395        );
396    }
397
398    #[test]
399    fn int_value_to_string() {
400        assert_eq!(data_value_to_string(&DataValue::Int(42)), "42");
401    }
402
403    #[test]
404    fn bool_value_to_string() {
405        assert_eq!(data_value_to_string(&DataValue::Bool(true)), "true");
406    }
407
408    #[test]
409    fn list_value_to_string() {
410        let list = DataValue::List(vec![
411            DataValue::Str("A".into()),
412            DataValue::Str("B".into()),
413        ]);
414        assert_eq!(data_value_to_string(&list), "A, B");
415    }
416
417    // ── Tests: render_list ───────────────────────────────────────────
418
419    #[test]
420    fn renders_items_with_cursor() {
421        let mut ctx = DataContext::new();
422        ctx.set(
423            "my_items",
424            DataValue::List(vec![
425                DataValue::Str("BALL".into()),
426                DataValue::Str("POTION".into()),
427                DataValue::Str("ANTIDOTE".into()),
428            ]),
429        );
430
431        let params = make_list_params("my_items", 0, 5, None);
432        let mut painter = MockPainter::new();
433
434        render_list(&params, 2, 3, &ctx, &Theme::default(), &mut painter);
435
436        // First item should have a cursor ▶ at (2, 3)
437        assert!(painter.find_glyph('\u{25B6}').is_some());
438        // Text content should be present
439        assert!(!painter.find_text("BALL").is_empty());
440        assert!(!painter.find_text("POTION").is_empty());
441        assert!(!painter.find_text("ANTIDOTE").is_empty());
442    }
443
444    #[test]
445    fn cursor_on_second_item() {
446        let mut ctx = DataContext::new();
447        ctx.set(
448            "items",
449            DataValue::List(vec![
450                DataValue::Str("A".into()),
451                DataValue::Str("B".into()),
452                DataValue::Str("C".into()),
453            ]),
454        );
455
456        let params = make_list_params("items", 1, 5, None);
457        let mut painter = MockPainter::new();
458
459        render_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
460
461        // Cursor ▶ should be on the second row (ty=1, since height=1, gap=0)
462        let cursor_glyph = painter.find_glyph('\u{25B6}');
463        assert!(cursor_glyph.is_some());
464        let (_, cursor_ty) = cursor_glyph.unwrap();
465        assert_eq!(cursor_ty, 1);
466    }
467
468    #[test]
469    fn scroll_up_indicator_when_scrolled_down() {
470        let mut ctx = DataContext::new();
471        let items: Vec<DataValue> = (0..20)
472            .map(|i| DataValue::Str(format!("Item {}", i)))
473            .collect();
474        ctx.set("items", DataValue::List(items));
475
476        // cursor at item 10, max_visible=5 → scroll_start > 0
477        let params = make_list_params("items", 10, 5, None);
478        let mut painter = MockPainter::new();
479
480        render_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
481
482        // Should have ▲ (scroll up) indicator
483        assert!(painter.find_glyph('\u{25B2}').is_some());
484    }
485
486    #[test]
487    fn scroll_down_indicator_when_content_below() {
488        let mut ctx = DataContext::new();
489        let items: Vec<DataValue> = (0..20)
490            .map(|i| DataValue::Str(format!("Item {}", i)))
491            .collect();
492        ctx.set("items", DataValue::List(items));
493
494        // cursor at 0, max_visible=5, total=20 → content below
495        let params = make_list_params("items", 0, 5, None);
496        let mut painter = MockPainter::new();
497
498        render_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
499
500        assert!(painter.find_glyph('\u{25BC}').is_some());
501    }
502
503    #[test]
504    fn no_scroll_indicators_when_fits() {
505        let mut ctx = DataContext::new();
506        ctx.set(
507            "items",
508            DataValue::List(vec![
509                DataValue::Str("A".into()),
510                DataValue::Str("B".into()),
511            ]),
512        );
513
514        let params = make_list_params("items", 0, 5, None);
515        let mut painter = MockPainter::new();
516
517        render_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
518
519        assert!(painter.find_glyph('\u{25B2}').is_none());
520        assert!(painter.find_glyph('\u{25BC}').is_none());
521    }
522
523    #[test]
524    fn footer_rendered() {
525        let mut ctx = DataContext::new();
526        ctx.set("items", DataValue::List(vec![DataValue::Str("X".into())]));
527
528        let params = make_list_params("items", 0, 1, Some("CANCEL"));
529        let mut painter = MockPainter::new();
530
531        render_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
532
533        assert!(!painter.find_text("CANCEL").is_empty());
534    }
535
536    #[test]
537    fn footer_resolves_template() {
538        let mut ctx = DataContext::new();
539        ctx.set("items", DataValue::List(vec![DataValue::Str("X".into())]));
540        ctx.set("action", "CANCEL");
541
542        let params = make_list_params("items", 0, 1, Some("{action}"));
543        let mut painter = MockPainter::new();
544
545        render_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
546
547        assert!(!painter.find_text("CANCEL").is_empty());
548    }
549
550    #[test]
551    fn empty_items_no_render() {
552        let mut ctx = DataContext::new();
553        ctx.set("items", DataValue::List(vec![]));
554
555        let params = make_list_params("items", 0, 5, None);
556        let mut painter = MockPainter::new();
557
558        render_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
559
560        assert!(painter.ops().is_empty());
561    }
562
563    #[test]
564    fn missing_items_key_no_render() {
565        let ctx = DataContext::new();
566        let params = make_list_params("nonexistent", 0, 5, None);
567        let mut painter = MockPainter::new();
568
569        render_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
570
571        assert!(painter.ops().is_empty());
572    }
573}