1use 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
20const CURSOR_WIDTH_TILES: u32 = 1;
22
23pub 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(¶ms.items)) {
43 Some(DataValue::List(v)) => v.clone(),
44 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 let cursor = params
61 .selected
62 .as_ref()
63 .map(|c| c.resolve(ctx))
64 .unwrap_or(0) as usize;
65 let padding = ¶ms.padding;
66 let gap = params.gap;
67
68 let content_tx = tx + padding.left;
70 let content_ty = ty + padding.top;
71
72 let column_positions = compute_column_positions(content_tx, ¶ms.item_layout);
74
75 if theme.proportional(painter.supports_proportional()) {
77 let ink = theme.ink_color();
78 let cur = theme.cursor_ink();
79 let cursor_adv = 10u32; 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 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 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
158fn 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
169fn 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
193fn 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
222fn 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 #[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 #[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 assert_eq!(x, 4);
458 }
459
460 #[test]
461 fn align_right() {
462 let x = align_text(0, 10, "HI", &TextAlign::Right);
463 assert_eq!(x, 8);
465 }
466
467 #[test]
468 fn align_center_long_text_does_not_underflow() {
469 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 #[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 #[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(¶ms, 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 let params = make_flex_list_params("shop", cols, 1, 0, default_padding());
589 let mut painter = MockPainter::new();
590
591 render_flex_list(¶ms, 0, 0, &ctx, &Theme::default(), &mut painter);
592
593 let cursors = painter.find_glyph('\u{25B6}');
594 assert_eq!(cursors.len(), 1);
595 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 let params = make_flex_list_params("shop", cols, 0, 2, default_padding());
620 let mut painter = MockPainter::new();
621
622 render_flex_list(¶ms, 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 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(¶ms, 5, 0, &ctx, &Theme::default(), &mut painter);
661
662 let item_texts = painter.find_text("ITEM");
663 assert!(!item_texts.is_empty());
664 let (item_tx, item_ty, _) = &item_texts[0];
666 assert_eq!(*item_tx, 9);
667 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 0, 0, &ctx, &Theme::default(), &mut painter);
740
741 let texts = painter.find_text("AB");
742 assert!(!texts.is_empty());
743 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(¶ms, 0, 0, &ctx, &Theme::default(), &mut painter);
769
770 let texts = painter.find_text("AB");
771 assert!(!texts.is_empty());
772 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(¶ms, 0, 0, &ctx, &Theme::default(), &mut painter);
798
799 let texts = painter.find_text("x5");
800 assert!(!texts.is_empty());
801 }
802}