Skip to main content

dotzuki_renderer/layout_engine/elements/
flex_list.rs

1//! Multi-column list element renderer.
2//!
3//! Renders a list where each row contains multiple columns (e.g. name,
4//! quantity, price). Columns are defined with widths and text alignment,
5//! and the cursor highlights the active row.
6//!
7//! ## Features
8//! - Multi-column item rows with named columns
9//! - Column widths and text alignment (Left / Center / Right)
10//! - `gap` between rows, `padding` around the content area
11//! - Cursor ▶ on the active row
12
13use dotzuki_engine::render::painter::Painter;
14use dotzuki_engine::render::{Rgba, TilePos};
15
16use crate::layout_engine::types::{
17    ColumnDef, DataContext, DataValue, FlexListParams, TextAlign, Theme,
18};
19
20/// Extra tile units consumed by the cursor glyph (▶) + a space.
21const CURSOR_WIDTH_TILES: u32 = 1;
22
23/// Render a multi-column flex list into the framebuffer via `painter`.
24///
25/// Each item in `items` must be a `DataValue::List` whose elements
26/// correspond to the columns defined in `item_layout` by position.
27///
28/// # Arguments
29/// * `params` — Deserialised [`FlexListParams`] from the layout definition.
30/// * `tx`, `ty` — Top-left tile position of the element.
31/// * `ctx` — Data context for resolving item values.
32/// * `painter` — Drawing backend.
33pub fn render_flex_list(
34    params: &FlexListParams,
35    tx: u32,
36    ty: u32,
37    ctx: &DataContext,
38    theme: &Theme,
39    painter: &mut dyn Painter,
40) {
41    let ncols = params.item_layout.len();
42    let items: Vec<DataValue> = match ctx.get(super::list::strip_braces(&params.items)) {
43        Some(DataValue::List(v)) => v.clone(),
44        // A plain string (e.g. from an editor variable override) is split into
45        // one row per line, then each line into columns.
46        Some(DataValue::Str(s)) => s
47            .lines()
48            .map(|l| l.trim_end())
49            .filter(|l| !l.is_empty())
50            .map(|l| split_row_into_columns(l, ncols))
51            .collect(),
52        _ => return,
53    };
54    if items.is_empty() || params.item_layout.is_empty() {
55        return;
56    }
57
58    // Selected index — resolves a literal or a `{cursor}` template against the
59    // data context; defaults to the first item for static previews.
60    let cursor = params
61        .selected
62        .as_ref()
63        .map(|c| c.resolve(ctx))
64        .unwrap_or(0) as usize;
65    let padding = &params.padding;
66    let gap = params.gap;
67
68    // Content area origin (after padding)
69    let content_tx = tx + padding.left;
70    let content_ty = ty + padding.top;
71
72    // Compute column X positions
73    let column_positions = compute_column_positions(content_tx, &params.item_layout);
74
75    // ── Proportional (pixel-precise) path — themed colours + CJK columns ──
76    if theme.proportional(painter.supports_proportional()) {
77        let ink = theme.ink_color();
78        let cur = theme.cursor_ink();
79        let cursor_adv = 10u32; // ▶ glyph advance
80        // Rows must clear the full CJK glyph height; `gap` (tiles) adds leading.
81        let row_pitch = ((1 + gap) * 8).max(13);
82        let mut y = content_ty * 8;
83        for (row_idx, item) in items.iter().enumerate() {
84            let row_values = item_to_strings(item);
85            let is_active = row_idx == cursor;
86            if is_active {
87                painter.draw_text_px(content_tx * 8, y, "\u{25B6}", cur);
88            }
89            for (col_idx, column) in params.item_layout.iter().enumerate() {
90                let value = row_values
91                    .get(col_idx)
92                    .cloned()
93                    .unwrap_or_else(|| "?".to_string());
94                let prefix = column.prefix.as_deref().unwrap_or("");
95                let display_text = format!("{prefix}{value}");
96                let align = column.align.as_ref().unwrap_or(&TextAlign::Left);
97                let col_x = column_positions[col_idx] * 8;
98                let col_w = column.width * 8;
99                let text_w = painter.measure_text_px(&display_text);
100                // Reserve the cursor gutter on the first column for EVERY row
101                // (not just the active one) so only the ▶ moves between rows and
102                // the text stays put.
103                let gutter = if col_idx == 0 { cursor_adv } else { 0 };
104                let avail = col_w.saturating_sub(gutter);
105                let x = gutter
106                    + match align {
107                        TextAlign::Left => col_x,
108                        TextAlign::Center => col_x + avail.saturating_sub(text_w) / 2,
109                        TextAlign::Right => col_x + avail.saturating_sub(text_w),
110                    };
111                painter.draw_text_px(x, y, &display_text, ink);
112            }
113            y += row_pitch;
114        }
115        return;
116    }
117
118    let mut row_ty = content_ty;
119
120    for (row_idx, item) in items.iter().enumerate() {
121        let row_values = item_to_strings(item);
122        let is_active = row_idx == cursor;
123
124        if is_active {
125            painter.draw_glyph(
126                TilePos::new(content_tx, row_ty),
127                '\u{25B6}',
128                Rgba::INK_BLACK,
129            );
130        }
131
132        for (col_idx, column) in params.item_layout.iter().enumerate() {
133            let value = row_values
134                .get(col_idx)
135                .cloned()
136                .unwrap_or_else(|| "?".to_string());
137
138            let prefix = column.prefix.as_deref().unwrap_or("");
139            let display_text = format!("{}{}", prefix, value);
140            let align = column.align.as_ref().unwrap_or(&TextAlign::Left);
141
142            let text_x = align_text(column_positions[col_idx], column.width, &display_text, align);
143
144            // First column text shifts right by cursor width when cursor is active
145            let col_x = if col_idx == 0 && is_active {
146                text_x + CURSOR_WIDTH_TILES
147            } else {
148                text_x
149            };
150
151            painter.draw_text(TilePos::new(col_x, row_ty), &display_text, Rgba::INK_BLACK);
152        }
153
154        row_ty += 1 + gap;
155    }
156}
157
158/// Compute the starting tile X position for each column.
159fn compute_column_positions(start_tx: u32, columns: &[ColumnDef]) -> Vec<u32> {
160    let mut positions = Vec::with_capacity(columns.len());
161    let mut x = start_tx;
162    for col in columns {
163        positions.push(x);
164        x += col.width;
165    }
166    positions
167}
168
169/// Compute the tile X position for text with the given alignment within a
170/// column of `width` tiles.
171fn align_text(col_x: u32, width: u32, text: &str, align: &TextAlign) -> u32 {
172    match align {
173        TextAlign::Left => col_x,
174        TextAlign::Center => {
175            let text_len = text.len() as u32;
176            if text_len >= width {
177                col_x
178            } else {
179                col_x + (width - text_len) / 2
180            }
181        }
182        TextAlign::Right => {
183            let text_len = text.len() as u32;
184            if text_len >= width {
185                col_x
186            } else {
187                col_x + width - text_len
188            }
189        }
190    }
191}
192
193/// Split a plain-string row into `ncols` column values.
194///
195/// Tokens are whitespace-separated. When there are more tokens than columns,
196/// the first column absorbs the extra leading tokens (item names may contain
197/// spaces, e.g. "BALL"), and the trailing tokens map to the remaining
198/// columns. When there are fewer, the missing columns are left empty.
199fn split_row_into_columns(s: &str, ncols: usize) -> DataValue {
200    if ncols <= 1 {
201        return DataValue::List(vec![DataValue::Str(s.trim().to_string())]);
202    }
203    let tokens: Vec<&str> = s.split_whitespace().collect();
204    let cols: Vec<DataValue> = if tokens.len() <= ncols {
205        let mut v: Vec<DataValue> =
206            tokens.iter().map(|t| DataValue::Str(t.to_string())).collect();
207        while v.len() < ncols {
208            v.push(DataValue::Str(String::new()));
209        }
210        v
211    } else {
212        let trailing = ncols - 1;
213        let split_at = tokens.len() - trailing;
214        let first = tokens[..split_at].join(" ");
215        let mut v = vec![DataValue::Str(first)];
216        v.extend(tokens[split_at..].iter().map(|t| DataValue::Str(t.to_string())));
217        v
218    };
219    DataValue::List(cols)
220}
221
222/// Extract per-column string values from a `DataValue` row item.
223///
224/// Expects the item to be a `DataValue::List`; each element corresponds to a
225/// column value. Non-list values are returned as a single-element vector.
226fn item_to_strings(item: &DataValue) -> Vec<String> {
227    match item {
228        DataValue::List(v) => v.iter().map(data_value_to_string).collect(),
229        other => vec![data_value_to_string(other)],
230    }
231}
232
233fn data_value_to_string(value: &DataValue) -> String {
234    match value {
235        DataValue::Str(s) => s.clone(),
236        DataValue::Int(n) => n.to_string(),
237        DataValue::Float(f) => f.to_string(),
238        DataValue::Bool(b) => b.to_string(),
239        DataValue::TileId(t) => t.to_string(),
240        DataValue::List(v) => v
241            .iter()
242            .map(data_value_to_string)
243            .collect::<Vec<_>>()
244            .join(", "),
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::layout_engine::types::EdgeInsets;
252    use dotzuki_engine::render::TileRect;
253    use std::cell::RefCell;
254
255    #[derive(Debug, Clone, PartialEq, Eq)]
256    enum DrawOp {
257        Text {
258            tx: u32,
259            ty: u32,
260            text: String,
261        },
262        Glyph {
263            tx: u32,
264            ty: u32,
265            glyph: char,
266        },
267        TextBox {
268            tx: u32,
269            ty: u32,
270            tw: u32,
271            th: u32,
272        },
273        PixelRect {
274            px: u32,
275            py: u32,
276            pw: u32,
277            ph: u32,
278        },
279    }
280
281    struct MockPainter {
282        ops: RefCell<Vec<DrawOp>>,
283    }
284
285    impl MockPainter {
286        fn new() -> Self {
287            Self {
288                ops: RefCell::new(Vec::new()),
289            }
290        }
291
292        fn ops(&self) -> std::cell::Ref<'_, Vec<DrawOp>> {
293            self.ops.borrow()
294        }
295
296        fn find_text(&self, needle: &str) -> Vec<(u32, u32, String)> {
297            self.ops()
298                .iter()
299                .filter_map(|op| match op {
300                    DrawOp::Text { tx, ty, text } if text.contains(needle) => {
301                        Some((*tx, *ty, text.clone()))
302                    }
303                    _ => None,
304                })
305                .collect()
306        }
307
308        fn find_glyph(&self, needle: char) -> Vec<(u32, u32)> {
309            self.ops()
310                .iter()
311                .filter_map(|op| match op {
312                    DrawOp::Glyph { tx, ty, glyph } if *glyph == needle => {
313                        Some((*tx, *ty))
314                    }
315                    _ => None,
316                })
317                .collect()
318        }
319    }
320
321    impl Painter for MockPainter {
322        fn clear(&mut self, _color: Rgba) {}
323        fn draw_text_box(&mut self, rect: TileRect, _color: Rgba) {
324            self.ops.borrow_mut().push(DrawOp::TextBox {
325                tx: rect.tx,
326                ty: rect.ty,
327                tw: rect.tw,
328                th: rect.th,
329            });
330        }
331        fn draw_text(&mut self, pos: TilePos, text: &str, _color: Rgba) {
332            self.ops.borrow_mut().push(DrawOp::Text {
333                tx: pos.tx,
334                ty: pos.ty,
335                text: text.to_string(),
336            });
337        }
338        fn draw_glyph(&mut self, pos: TilePos, glyph: char, _color: Rgba) {
339            self.ops.borrow_mut().push(DrawOp::Glyph {
340                tx: pos.tx,
341                ty: pos.ty,
342                glyph,
343            });
344        }
345        fn draw_pixel_rect(
346            &mut self,
347            px: u32,
348            py: u32,
349            pw: u32,
350            ph: u32,
351            _color: Rgba,
352        ) {
353            self.ops.borrow_mut().push(DrawOp::PixelRect { px, py, pw, ph });
354        }
355        fn draw_gb_tile(
356            &mut self,
357            _pos: TilePos,
358            _tile_id: u8,
359            _fallback: &str,
360            _color: Rgba,
361        ) {
362        }
363    }
364
365    fn make_flex_list_params(
366        items_key: &str,
367        columns: Vec<ColumnDef>,
368        cursor: u32,
369        gap: u32,
370        padding: EdgeInsets,
371    ) -> FlexListParams {
372        FlexListParams {
373            items: items_key.to_string(),
374            item_layout: columns,
375            padding,
376            gap,
377            cursor: crate::layout_engine::types::ListCursor::default(),
378            selected: Some(cursor.into()),
379        }
380    }
381
382    fn default_padding() -> EdgeInsets {
383        EdgeInsets {
384            top: 0,
385            bottom: 0,
386            left: 0,
387            right: 0,
388        }
389    }
390
391    // ── compute_column_positions ─────────────────────────────────────
392
393    #[test]
394    fn column_positions_single() {
395        let cols = vec![ColumnDef {
396            field: "name".into(),
397            width: 10,
398            align: None,
399            prefix: None,
400        }];
401        assert_eq!(compute_column_positions(0, &cols), vec![0]);
402    }
403
404    #[test]
405    fn column_positions_multiple() {
406        let cols = vec![
407            ColumnDef {
408                field: "name".into(),
409                width: 10,
410                align: None,
411                prefix: None,
412            },
413            ColumnDef {
414                field: "qty".into(),
415                width: 4,
416                align: None,
417                prefix: None,
418            },
419            ColumnDef {
420                field: "price".into(),
421                width: 6,
422                align: None,
423                prefix: None,
424            },
425        ];
426        assert_eq!(compute_column_positions(0, &cols), vec![0, 10, 14]);
427    }
428
429    #[test]
430    fn column_positions_with_offset() {
431        let cols = vec![ColumnDef {
432            field: "name".into(),
433            width: 8,
434            align: None,
435            prefix: None,
436        }];
437        assert_eq!(compute_column_positions(2, &cols), vec![2]);
438    }
439
440    #[test]
441    fn column_positions_empty() {
442        assert_eq!(compute_column_positions(0, &[]), Vec::<u32>::new());
443    }
444
445    // ── align_text ───────────────────────────────────────────────────
446
447    #[test]
448    fn align_left() {
449        let x = align_text(0, 10, "HI", &TextAlign::Left);
450        assert_eq!(x, 0);
451    }
452
453    #[test]
454    fn align_center() {
455        let x = align_text(0, 10, "HI", &TextAlign::Center);
456        // (10 - 2) / 2 = 4
457        assert_eq!(x, 4);
458    }
459
460    #[test]
461    fn align_right() {
462        let x = align_text(0, 10, "HI", &TextAlign::Right);
463        // 10 - 2 = 8
464        assert_eq!(x, 8);
465    }
466
467    #[test]
468    fn align_center_long_text_does_not_underflow() {
469        // text is longer than or equal to width: should anchor left
470        let x = align_text(0, 2, "LONG", &TextAlign::Center);
471        assert_eq!(x, 0);
472    }
473
474    #[test]
475    fn align_right_long_text_does_not_underflow() {
476        let x = align_text(0, 2, "LONG", &TextAlign::Right);
477        assert_eq!(x, 0);
478    }
479
480    // ── item_to_strings ──────────────────────────────────────────────
481
482    #[test]
483    fn item_list_to_strings() {
484        let item = DataValue::List(vec![
485            DataValue::Str("POTION".into()),
486            DataValue::Int(3),
487            DataValue::Str("¥300".into()),
488        ]);
489        let strings = item_to_strings(&item);
490        assert_eq!(strings, vec!["POTION", "3", "¥300"]);
491    }
492
493    #[test]
494    fn item_scalar_to_strings() {
495        let item = DataValue::Str("ONLY".into());
496        let strings = item_to_strings(&item);
497        assert_eq!(strings, vec!["ONLY"]);
498    }
499
500    // ── render_flex_list ─────────────────────────────────────────────
501
502    #[test]
503    fn renders_multi_column_items() {
504        let mut ctx = DataContext::new();
505        ctx.set(
506            "shop",
507            DataValue::List(vec![
508                DataValue::List(vec![
509                    DataValue::Str("BALL".into()),
510                    DataValue::Int(1),
511                    DataValue::Str("¥200".into()),
512                ]),
513                DataValue::List(vec![
514                    DataValue::Str("POTION".into()),
515                    DataValue::Int(3),
516                    DataValue::Str("¥300".into()),
517                ]),
518            ]),
519        );
520
521        let cols = vec![
522            ColumnDef {
523                field: "name".into(),
524                width: 10,
525                align: None,
526                prefix: None,
527            },
528            ColumnDef {
529                field: "qty".into(),
530                width: 3,
531                align: Some(TextAlign::Center),
532                prefix: Some("x".into()),
533            },
534            ColumnDef {
535                field: "price".into(),
536                width: 5,
537                align: Some(TextAlign::Right),
538                prefix: None,
539            },
540        ];
541
542        let params = make_flex_list_params("shop", cols, 0, 0, default_padding());
543        let mut painter = MockPainter::new();
544
545        render_flex_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
546
547        assert!(!painter.find_text("BALL").is_empty());
548        assert!(!painter.find_text("x1").is_empty());
549        assert!(!painter.find_text("¥200").is_empty());
550        assert!(!painter.find_text("POTION").is_empty());
551        assert!(!painter.find_text("x3").is_empty());
552        assert!(!painter.find_text("¥300").is_empty());
553    }
554
555    #[test]
556    fn cursor_on_active_row() {
557        let mut ctx = DataContext::new();
558        ctx.set(
559            "shop",
560            DataValue::List(vec![
561                DataValue::List(vec![
562                    DataValue::Str("A".into()),
563                    DataValue::Int(1),
564                ]),
565                DataValue::List(vec![
566                    DataValue::Str("B".into()),
567                    DataValue::Int(2),
568                ]),
569            ]),
570        );
571
572        let cols = vec![
573            ColumnDef {
574                field: "name".into(),
575                width: 5,
576                align: None,
577                prefix: None,
578            },
579            ColumnDef {
580                field: "qty".into(),
581                width: 3,
582                align: None,
583                prefix: None,
584            },
585        ];
586
587        // cursor on second row (index 1)
588        let params = make_flex_list_params("shop", cols, 1, 0, default_padding());
589        let mut painter = MockPainter::new();
590
591        render_flex_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
592
593        let cursors = painter.find_glyph('\u{25B6}');
594        assert_eq!(cursors.len(), 1);
595        // Cursor should be on row 1 (second item, since gap=0)
596        let (_, cursor_ty) = cursors[0];
597        assert_eq!(cursor_ty, 1);
598    }
599
600    #[test]
601    fn gap_between_rows() {
602        let mut ctx = DataContext::new();
603        ctx.set(
604            "shop",
605            DataValue::List(vec![
606                DataValue::List(vec![DataValue::Str("A".into())]),
607                DataValue::List(vec![DataValue::Str("B".into())]),
608            ]),
609        );
610
611        let cols = vec![ColumnDef {
612            field: "name".into(),
613            width: 5,
614            align: None,
615            prefix: None,
616        }];
617
618        // gap = 2 means row positions: 0, 3, 6, ...
619        let params = make_flex_list_params("shop", cols, 0, 2, default_padding());
620        let mut painter = MockPainter::new();
621
622        render_flex_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
623
624        let a_texts = painter.find_text("A");
625        let b_texts = painter.find_text("B");
626        assert!(!a_texts.is_empty());
627        assert!(!b_texts.is_empty());
628
629        // First row "A" at ty=0, second row "B" at ty=3 (1 + gap = 3)
630        let b_ty = b_texts[0].1;
631        assert_eq!(b_ty, 3);
632    }
633
634    #[test]
635    fn padding_applied() {
636        let mut ctx = DataContext::new();
637        ctx.set(
638            "shop",
639            DataValue::List(vec![DataValue::List(vec![DataValue::Str(
640                "ITEM".into(),
641            )])]),
642        );
643
644        let cols = vec![ColumnDef {
645            field: "name".into(),
646            width: 5,
647            align: None,
648            prefix: None,
649        }];
650
651        let padding = EdgeInsets {
652            top: 2,
653            bottom: 0,
654            left: 3,
655            right: 0,
656        };
657        let params = make_flex_list_params("shop", cols, 0, 0, padding);
658        let mut painter = MockPainter::new();
659
660        render_flex_list(&params, 5, 0, &ctx, &Theme::default(), &mut painter);
661
662        let item_texts = painter.find_text("ITEM");
663        assert!(!item_texts.is_empty());
664        // content_tx = tx(5) + padding.left(3) + cursor_width(1) = 9
665        let (item_tx, item_ty, _) = &item_texts[0];
666        assert_eq!(*item_tx, 9);
667        // content_ty = ty(0) + padding.top(2) = 2
668        assert_eq!(*item_ty, 2);
669    }
670
671    #[test]
672    fn empty_items_no_render() {
673        let mut ctx = DataContext::new();
674        ctx.set("shop", DataValue::List(vec![]));
675
676        let cols = vec![ColumnDef {
677            field: "name".into(),
678            width: 5,
679            align: None,
680            prefix: None,
681        }];
682        let params = make_flex_list_params("shop", cols, 0, 0, default_padding());
683        let mut painter = MockPainter::new();
684
685        render_flex_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
686        assert!(painter.ops().is_empty());
687    }
688
689    #[test]
690    fn empty_columns_no_render() {
691        let mut ctx = DataContext::new();
692        ctx.set(
693            "shop",
694            DataValue::List(vec![DataValue::Str("X".into())]),
695        );
696
697        let params = make_flex_list_params("shop", vec![], 0, 0, default_padding());
698        let mut painter = MockPainter::new();
699
700        render_flex_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
701        assert!(painter.ops().is_empty());
702    }
703
704    #[test]
705    fn missing_items_key_no_render() {
706        let ctx = DataContext::new();
707        let cols = vec![ColumnDef {
708            field: "name".into(),
709            width: 5,
710            align: None,
711            prefix: None,
712        }];
713        let params = make_flex_list_params("nonexistent", cols, 0, 0, default_padding());
714        let mut painter = MockPainter::new();
715
716        render_flex_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
717        assert!(painter.ops().is_empty());
718    }
719
720    #[test]
721    fn column_alignment_center() {
722        let mut ctx = DataContext::new();
723        ctx.set(
724            "shop",
725            DataValue::List(vec![DataValue::List(vec![
726                DataValue::Str("AB".into()),
727            ])]),
728        );
729
730        let cols = vec![ColumnDef {
731            field: "name".into(),
732            width: 8,
733            align: Some(TextAlign::Center),
734            prefix: None,
735        }];
736        let params = make_flex_list_params("shop", cols, 0, 0, default_padding());
737        let mut painter = MockPainter::new();
738
739        render_flex_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
740
741        let texts = painter.find_text("AB");
742        assert!(!texts.is_empty());
743        // text "AB" (len=2) centered in width=8: offset = (8-2)/2 = 3
744        // content_tx = 0 (no padding), cursor adds +1 → base=1, centered → 1+3 = 4
745        let (tx, _, _) = &texts[0];
746        assert_eq!(*tx, 4);
747    }
748
749    #[test]
750    fn column_alignment_right() {
751        let mut ctx = DataContext::new();
752        ctx.set(
753            "shop",
754            DataValue::List(vec![DataValue::List(vec![
755                DataValue::Str("AB".into()),
756            ])]),
757        );
758
759        let cols = vec![ColumnDef {
760            field: "name".into(),
761            width: 8,
762            align: Some(TextAlign::Right),
763            prefix: None,
764        }];
765        let params = make_flex_list_params("shop", cols, 0, 0, default_padding());
766        let mut painter = MockPainter::new();
767
768        render_flex_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
769
770        let texts = painter.find_text("AB");
771        assert!(!texts.is_empty());
772        // text "AB" (len=2) right-aligned in width=8: offset = 8-2 = 6
773        // content_tx=0, cursor adds +1 → base=1, right → 1+6 = 7
774        let (tx, _, _) = &texts[0];
775        assert_eq!(*tx, 7);
776    }
777
778    #[test]
779    fn prefix_appended_to_value() {
780        let mut ctx = DataContext::new();
781        ctx.set(
782            "shop",
783            DataValue::List(vec![DataValue::List(vec![DataValue::Int(
784                5,
785            )])]),
786        );
787
788        let cols = vec![ColumnDef {
789            field: "qty".into(),
790            width: 3,
791            align: None,
792            prefix: Some("x".into()),
793        }];
794        let params = make_flex_list_params("shop", cols, 0, 0, default_padding());
795        let mut painter = MockPainter::new();
796
797        render_flex_list(&params, 0, 0, &ctx, &Theme::default(), &mut painter);
798
799        let texts = painter.find_text("x5");
800        assert!(!texts.is_empty());
801    }
802}