1use dotzuki_engine::render::painter::Painter;
14use dotzuki_engine::render::{Rgba, TilePos};
15
16use crate::layout_engine::types::{DataContext, DataValue, ListParams, Theme};
17
18pub 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 let items_key = strip_braces(¶ms.items);
39 let items: Vec<DataValue> = match ctx.get(items_key) {
40 Some(DataValue::List(v)) => v.clone(),
41 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 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 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 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; 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) = ¶ms.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 if has_scroll_up {
108 painter.draw_glyph(
109 TilePos::new(tx, row_ty),
110 '\u{25B2}', Rgba::INK_BLACK,
112 );
113 row_ty += 1;
114 }
115
116 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 painter.draw_glyph(
123 TilePos::new(tx, row_ty),
124 '\u{25B6}', 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 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 if has_scroll_down {
145 painter.draw_glyph(
146 TilePos::new(tx, row_ty),
147 '\u{25BC}', Rgba::INK_BLACK,
149 );
150 row_ty += 1;
151 }
152
153 if let Some(footer) = ¶ms.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
160pub(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
173fn 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.saturating_sub(max_visible - 1).min(total - max_visible)
184 }
185}
186
187fn 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#[cfg(test)]
207mod tests {
208 use super::*;
209 use dotzuki_engine::render::TileRect;
210 use std::cell::RefCell;
211
212 #[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 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 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 #[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 assert_eq!(calculate_scroll_window(5, 5, 20), 1);
365 }
366
367 #[test]
368 fn scroll_window_cursor_at_bottom() {
369 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 #[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 #[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(¶ms, 2, 3, &ctx, &Theme::default(), &mut painter);
435
436 assert!(painter.find_glyph('\u{25B6}').is_some());
438 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(¶ms, 0, 0, &ctx, &Theme::default(), &mut painter);
460
461 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 let params = make_list_params("items", 10, 5, None);
478 let mut painter = MockPainter::new();
479
480 render_list(¶ms, 0, 0, &ctx, &Theme::default(), &mut painter);
481
482 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 let params = make_list_params("items", 0, 5, None);
496 let mut painter = MockPainter::new();
497
498 render_list(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 0, 0, &ctx, &Theme::default(), &mut painter);
570
571 assert!(painter.ops().is_empty());
572 }
573}