a modern tui library written in zig
1const std = @import("std");
2const vaxis = @import("../main.zig");
3
4const assert = std.debug.assert;
5
6const Allocator = std.mem.Allocator;
7
8const vxfw = @import("vxfw.zig");
9
10const ListView = @This();
11
12pub const Builder = struct {
13 userdata: *const anyopaque,
14 buildFn: *const fn (*const anyopaque, idx: usize, cursor: usize) ?vxfw.Widget,
15
16 inline fn itemAtIdx(self: Builder, idx: usize, cursor: usize) ?vxfw.Widget {
17 return self.buildFn(self.userdata, idx, cursor);
18 }
19};
20
21pub const Source = union(enum) {
22 slice: []const vxfw.Widget,
23 builder: Builder,
24};
25
26const Scroll = struct {
27 /// Index of the first fully-in-view widget
28 top: u32 = 0,
29 /// Line offset within the top widget.
30 offset: i17 = 0,
31 /// Pending scroll amount
32 pending_lines: i17 = 0,
33 /// If there is more room to scroll down
34 has_more: bool = true,
35 /// The cursor must be in the viewport
36 wants_cursor: bool = false,
37
38 fn linesDown(self: *Scroll, n: u8) bool {
39 if (!self.has_more) return false;
40 self.pending_lines += n;
41 return true;
42 }
43
44 fn linesUp(self: *Scroll, n: u8) bool {
45 if (self.top == 0 and self.offset == 0) return false;
46 self.pending_lines -= @intCast(n);
47 return true;
48 }
49};
50
51const cursor_indicator: vaxis.Cell = .{ .char = .{ .grapheme = "▐", .width = 1 } };
52
53children: Source,
54cursor: u32 = 0,
55/// When true, the widget will draw a cursor next to the widget which has the cursor
56draw_cursor: bool = true,
57/// Lines to scroll for a mouse wheel
58wheel_scroll: u8 = 3,
59/// Set this if the exact item count is known.
60item_count: ?u32 = null,
61
62/// scroll position
63scroll: Scroll = .{},
64
65pub fn widget(self: *const ListView) vxfw.Widget {
66 return .{
67 .userdata = @constCast(self),
68 .eventHandler = typeErasedEventHandler,
69 .drawFn = typeErasedDrawFn,
70 };
71}
72
73fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
74 const self: *ListView = @ptrCast(@alignCast(ptr));
75 return self.handleEvent(ctx, event);
76}
77
78fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
79 const self: *ListView = @ptrCast(@alignCast(ptr));
80 return self.draw(ctx);
81}
82
83pub fn handleEvent(self: *ListView, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
84 switch (event) {
85 .mouse => |mouse| {
86 if (mouse.button == .wheel_up) {
87 if (self.scroll.linesUp(self.wheel_scroll))
88 ctx.consumeAndRedraw();
89 }
90 if (mouse.button == .wheel_down) {
91 if (self.scroll.linesDown(self.wheel_scroll))
92 ctx.consumeAndRedraw();
93 }
94 },
95 .key_press => |key| {
96 if (key.matches('j', .{}) or
97 key.matches('n', .{ .ctrl = true }) or
98 key.matches(vaxis.Key.down, .{}))
99 {
100 return self.nextItem(ctx);
101 }
102 if (key.matches('k', .{}) or
103 key.matches('p', .{ .ctrl = true }) or
104 key.matches(vaxis.Key.up, .{}))
105 {
106 return self.prevItem(ctx);
107 }
108 if (key.matches(vaxis.Key.escape, .{})) {
109 self.ensureScroll();
110 return ctx.consumeAndRedraw();
111 }
112 },
113 else => {},
114 }
115}
116
117pub fn draw(self: *ListView, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
118 std.debug.assert(ctx.max.width != null);
119 std.debug.assert(ctx.max.height != null);
120 switch (self.children) {
121 .slice => |slice| {
122 self.item_count = @intCast(slice.len);
123 const builder: SliceBuilder = .{ .slice = slice };
124 return self.drawBuilder(ctx, .{ .userdata = &builder, .buildFn = SliceBuilder.build });
125 },
126 .builder => |b| return self.drawBuilder(ctx, b),
127 }
128}
129
130pub fn nextItem(self: *ListView, ctx: *vxfw.EventContext) void {
131 // If we have a count, we can handle this directly
132 if (self.item_count) |count| {
133 if (self.cursor >= count -| 1) {
134 return ctx.consumeEvent();
135 }
136 self.cursor += 1;
137 } else {
138 switch (self.children) {
139 .slice => |slice| {
140 self.item_count = @intCast(slice.len);
141 // If we are already at the end, don't do anything
142 if (self.cursor == slice.len - 1) {
143 return ctx.consumeEvent();
144 }
145 // Advance the cursor
146 self.cursor += 1;
147 },
148 .builder => |builder| {
149 // Save our current state
150 const prev = self.cursor;
151 // Advance the cursor
152 self.cursor += 1;
153 // Check the bounds, reversing until we get the last item
154 while (builder.itemAtIdx(self.cursor, self.cursor) == null) {
155 self.cursor -|= 1;
156 }
157 // If we didn't change state, we don't redraw
158 if (self.cursor == prev) {
159 return ctx.consumeEvent();
160 }
161 },
162 }
163 }
164 // Reset scroll
165 self.ensureScroll();
166 ctx.consumeAndRedraw();
167}
168
169pub fn prevItem(self: *ListView, ctx: *vxfw.EventContext) void {
170 if (self.cursor == 0) {
171 return ctx.consumeEvent();
172 }
173
174 if (self.item_count) |count| {
175 // If for some reason our count changed, we handle it here
176 self.cursor = @min(self.cursor - 1, count - 1);
177 } else {
178 switch (self.children) {
179 .slice => |slice| {
180 self.item_count = @intCast(slice.len);
181 self.cursor = @min(self.cursor - 1, slice.len - 1);
182 },
183 .builder => |builder| {
184 // Save our current state
185 const prev = self.cursor;
186 // Decrement the cursor
187 self.cursor -= 1;
188 // Check the bounds, reversing until we get the last item
189 while (builder.itemAtIdx(self.cursor, self.cursor) == null) {
190 self.cursor -|= 1;
191 }
192 // If we didn't change state, we don't redraw
193 if (self.cursor == prev) {
194 return ctx.consumeEvent();
195 }
196 },
197 }
198 }
199
200 // Reset scroll
201 self.ensureScroll();
202 return ctx.consumeAndRedraw();
203}
204
205// Only call when cursor state has changed, or we want to ensure the cursored item is in view
206pub fn ensureScroll(self: *ListView) void {
207 if (self.cursor <= self.scroll.top) {
208 self.scroll.top = @intCast(self.cursor);
209 self.scroll.offset = 0;
210 } else {
211 self.scroll.wants_cursor = true;
212 }
213}
214
215/// Inserts children until add_height is < 0
216fn insertChildren(
217 self: *ListView,
218 ctx: vxfw.DrawContext,
219 builder: Builder,
220 child_list: *std.ArrayList(vxfw.SubSurface),
221 add_height: i17,
222) Allocator.Error!void {
223 assert(self.scroll.top > 0);
224 self.scroll.top -= 1;
225 var upheight = add_height;
226 while (self.scroll.top >= 0) : (self.scroll.top -= 1) {
227 // Get the child
228 const child = builder.itemAtIdx(self.scroll.top, self.cursor) orelse break;
229
230 const child_offset: u16 = if (self.draw_cursor) 2 else 0;
231 const max_size = ctx.max.size();
232
233 // Set up constraints. We let the child be the entire height if it wants
234 const child_ctx = ctx.withConstraints(
235 .{ .width = max_size.width - child_offset, .height = 0 },
236 .{ .width = max_size.width - child_offset, .height = null },
237 );
238
239 // Draw the child
240 const surf = try child.draw(child_ctx);
241
242 // Accumulate the height. Traversing backward so do this before setting origin
243 upheight -= surf.size.height;
244
245 // Insert the child to the beginning of the list
246 try child_list.insert(ctx.arena, 0, .{
247 .origin = .{ .col = if (self.draw_cursor) 2 else 0, .row = upheight },
248 .surface = surf,
249 .z_index = 0,
250 });
251
252 // Break if we went past the top edge, or are the top item
253 if (upheight <= 0 or self.scroll.top == 0) break;
254 }
255
256 // Our new offset is the "upheight"
257 self.scroll.offset = upheight;
258
259 // Reset origins if we overshot and put the top item too low
260 if (self.scroll.top == 0 and upheight > 0) {
261 self.scroll.offset = 0;
262 var row: i17 = 0;
263 for (child_list.items) |*child| {
264 child.origin.row = row;
265 row += child.surface.size.height;
266 }
267 }
268 // Our new offset is the "upheight"
269 self.scroll.offset = upheight;
270}
271
272fn totalHeight(list: *const std.ArrayList(vxfw.SubSurface)) usize {
273 var result: usize = 0;
274 for (list.items) |child| {
275 result += child.surface.size.height;
276 }
277 return result;
278}
279
280fn drawBuilder(self: *ListView, ctx: vxfw.DrawContext, builder: Builder) Allocator.Error!vxfw.Surface {
281 defer self.scroll.wants_cursor = false;
282
283 // Get the size. asserts neither constraint is null
284 const max_size = ctx.max.size();
285 // Set up surface.
286 var surface: vxfw.Surface = .{
287 .size = max_size,
288 .widget = self.widget(),
289 .buffer = &.{},
290 .children = &.{},
291 };
292 if (self.draw_cursor) {
293 // If we are drawing the cursor, we need to allocate a buffer so that we obscure anything
294 // underneath us
295 surface.buffer = try vxfw.Surface.createBuffer(ctx.arena, max_size);
296 }
297
298 // Set state
299 {
300 // Assume we have more. We only know we don't after drawing
301 self.scroll.has_more = true;
302 }
303
304 var child_list: std.ArrayList(vxfw.SubSurface) = .empty;
305
306 // Accumulated height tracks how much height we have drawn. It's initial state is
307 // (scroll.offset + scroll.pending_lines) lines _above_ the surface top edge.
308 // Example:
309 // 1. Scroll up 3 lines:
310 // pending_lines = -3
311 // offset = 0
312 // accumulated_height = -(0 + -3) = 3;
313 // Our first widget is placed at row 3, we will need to fill this in after the draw
314 // 2. Scroll up 3 lines, with an offset of 4
315 // pending_lines = -3
316 // offset = 4
317 // accumulated_height = -(4 + -3) = -1;
318 // Our first widget is placed at row -1
319 // 3. Scroll down 3 lines:
320 // pending_lines = 3
321 // offset = 0
322 // accumulated_height = -(0 + 3) = -3;
323 // Our first widget is placed at row -3. It's possible it consumes the entire widget. We
324 // will check for this at the end and only include visible children
325 var accumulated_height: i17 = -(self.scroll.offset + self.scroll.pending_lines);
326
327 // We handled the pending scroll by assigning accumulated_height. Reset it's state
328 self.scroll.pending_lines = 0;
329
330 // Set the initial index for our downard loop. We do this here because we might modify
331 // scroll.top before we traverse downward
332 var i: usize = self.scroll.top;
333
334 // If we are on the first item, and we have an upward scroll that consumed our offset, eg
335 // accumulated_height > 0, we reset state here. We can't scroll up anymore so we set
336 // accumulated_height to 0.
337 if (accumulated_height > 0 and self.scroll.top == 0) {
338 self.scroll.offset = 0;
339 accumulated_height = 0;
340 }
341
342 // If we are offset downward, insert widgets to the front of the list before traversing downard
343 if (accumulated_height > 0) {
344 try self.insertChildren(ctx, builder, &child_list, accumulated_height);
345 const last_child = child_list.items[child_list.items.len - 1];
346 accumulated_height = last_child.origin.row + last_child.surface.size.height;
347 }
348
349 const child_offset: u16 = if (self.draw_cursor) 2 else 0;
350
351 while (builder.itemAtIdx(i, self.cursor)) |child| {
352 // Defer the increment
353 defer i += 1;
354
355 // Set up constraints. We let the child be the entire height if it wants
356 const child_ctx = ctx.withConstraints(
357 .{ .width = max_size.width -| child_offset, .height = 0 },
358 .{ .width = max_size.width -| child_offset, .height = null },
359 );
360
361 // Draw the child
362 const surf = try child.draw(child_ctx);
363
364 // Add the child surface to our list. It's offset from parent is the accumulated height
365 try child_list.append(ctx.arena, .{
366 .origin = .{ .col = child_offset, .row = accumulated_height },
367 .surface = surf,
368 .z_index = 0,
369 });
370
371 // Accumulate the height
372 accumulated_height += surf.size.height;
373
374 if (self.scroll.wants_cursor and i < self.cursor)
375 continue // continue if we want the cursor and haven't gotten there yet
376 else if (accumulated_height >= max_size.height)
377 break; // Break if we drew enough
378 } else {
379 // This branch runs if we ran out of items. Set our state accordingly
380 self.scroll.has_more = false;
381 }
382
383 var total_height: usize = totalHeight(&child_list);
384
385 // If we reached the bottom, don't have enough height to fill the screen, and have room to add
386 // more, then we add more until out of items or filled the space. This can happen on a resize
387 if (!self.scroll.has_more and total_height < max_size.height and self.scroll.top > 0) {
388 try self.insertChildren(ctx, builder, &child_list, @intCast(max_size.height - total_height));
389 // Set the new total height
390 total_height = totalHeight(&child_list);
391 }
392
393 if (self.draw_cursor and self.cursor >= self.scroll.top) blk: {
394 // The index of the cursored widget in our child_list
395 const cursored_idx: u32 = self.cursor - self.scroll.top;
396 // Nothing to draw if our cursor is below our viewport
397 if (cursored_idx >= child_list.items.len) break :blk;
398
399 const sub = try ctx.arena.alloc(vxfw.SubSurface, 1);
400 const child = child_list.items[cursored_idx];
401 sub[0] = .{
402 .origin = .{ .col = child_offset, .row = 0 },
403 .surface = child.surface,
404 .z_index = 0,
405 };
406 const size = child.surface.size;
407 const cursor_surf = try vxfw.Surface.initWithChildren(
408 ctx.arena,
409 self.widget(),
410 .{ .width = child_offset + size.width, .height = size.height },
411 sub,
412 );
413 for (0..cursor_surf.size.height) |row| {
414 cursor_surf.writeCell(0, @intCast(row), cursor_indicator);
415 }
416 child_list.items[cursored_idx] = .{
417 .origin = .{ .col = 0, .row = child.origin.row },
418 .surface = cursor_surf,
419 .z_index = 0,
420 };
421 }
422
423 // If we want the cursor, we check that the cursored widget is fully in view. If it is too
424 // large, we position it so that it is the top item with a 0 offset
425 if (self.scroll.wants_cursor) {
426 const cursored_idx: u32 = self.cursor - self.scroll.top;
427 const sub = child_list.items[cursored_idx];
428 // The bottom row of the cursored widget
429 const bottom = sub.origin.row + sub.surface.size.height;
430 if (bottom > max_size.height) {
431 // Adjust the origin by the difference
432 // anchor bottom
433 var origin: i17 = max_size.height;
434 var idx: usize = cursored_idx + 1;
435 while (idx > 0) : (idx -= 1) {
436 var child = child_list.items[idx - 1];
437 origin -= child.surface.size.height;
438 child.origin.row = origin;
439 child_list.items[idx - 1] = child;
440 }
441 } else if (sub.surface.size.height >= max_size.height) {
442 // TODO: handle when the child is larger than our height.
443 // We need to change the max constraint to be optional sizes so that we can support
444 // unbounded drawing in scrollable areas
445 self.scroll.top = self.cursor;
446 self.scroll.offset = 0;
447 child_list.deinit(ctx.arena);
448 try child_list.append(ctx.arena, .{
449 .origin = .{ .col = 0, .row = 0 },
450 .surface = sub.surface,
451 .z_index = 0,
452 });
453 total_height = sub.surface.size.height;
454 }
455 }
456
457 // If we reached the bottom, we need to reset origins
458 if (!self.scroll.has_more and total_height < max_size.height) {
459 // anchor top
460 assert(self.scroll.top == 0);
461 self.scroll.offset = 0;
462 var origin: i17 = 0;
463 for (0..child_list.items.len) |idx| {
464 var child = child_list.items[idx];
465 child.origin.row = origin;
466 origin += child.surface.size.height;
467 child_list.items[idx] = child;
468 }
469 } else if (!self.scroll.has_more) {
470 // anchor bottom
471 var origin: i17 = max_size.height;
472 var idx: usize = child_list.items.len;
473 while (idx > 0) : (idx -= 1) {
474 var child = child_list.items[idx - 1];
475 origin -= child.surface.size.height;
476 child.origin.row = origin;
477 child_list.items[idx - 1] = child;
478 }
479 }
480
481 var start: usize = 0;
482 var end: usize = child_list.items.len;
483
484 for (child_list.items, 0..) |child, idx| {
485 if (child.origin.row <= 0 and child.origin.row + child.surface.size.height > 0) {
486 start = idx;
487 self.scroll.offset = -child.origin.row;
488 self.scroll.top += @intCast(idx);
489 }
490 if (child.origin.row > max_size.height) {
491 end = idx;
492 break;
493 }
494 }
495
496 surface.children = child_list.items[start..end];
497 return surface;
498}
499
500const SliceBuilder = struct {
501 slice: []const vxfw.Widget,
502
503 fn build(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget {
504 const self: *const SliceBuilder = @ptrCast(@alignCast(ptr));
505 if (idx >= self.slice.len) return null;
506 return self.slice[idx];
507 }
508};
509
510test ListView {
511 // Create child widgets
512 const Text = @import("Text.zig");
513 const abc: Text = .{ .text = "abc\n def\n ghi" };
514 const def: Text = .{ .text = "def" };
515 const ghi: Text = .{ .text = "ghi" };
516 const jklmno: Text = .{ .text = "jkl\n mno" };
517 // 0 |*abc
518 // 1 | def
519 // 2 | ghi
520 // 3 | def
521 // 4 ghi
522 // 5 jkl
523 // 6 mno
524
525 // Create the list view
526 const list_view: ListView = .{
527 .wheel_scroll = 1, // Set wheel scroll to one
528 .children = .{ .slice = &.{
529 abc.widget(),
530 def.widget(),
531 ghi.widget(),
532 jklmno.widget(),
533 } },
534 };
535
536 // Boiler plate draw context
537 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
538 defer arena.deinit();
539 vxfw.DrawContext.init(.unicode);
540
541 const list_widget = list_view.widget();
542 const draw_ctx: vxfw.DrawContext = .{
543 .arena = arena.allocator(),
544 .min = .{},
545 .max = .{ .width = 16, .height = 4 },
546 .cell_size = .{ .width = 10, .height = 20 },
547 };
548
549 var surface = try list_widget.draw(draw_ctx);
550 // ListView expands to max height and max width
551 try std.testing.expectEqual(4, surface.size.height);
552 try std.testing.expectEqual(16, surface.size.width);
553 // We have 2 children, because only visible children appear as a surface
554 try std.testing.expectEqual(2, surface.children.len);
555
556 var mouse_event: vaxis.Mouse = .{
557 .col = 0,
558 .row = 0,
559 .button = .wheel_up,
560 .mods = .{},
561 .type = .press,
562 };
563 // Event handlers need a context
564 var ctx: vxfw.EventContext = .{
565 .alloc = std.testing.allocator,
566 .cmds = .empty,
567 };
568 defer ctx.cmds.deinit(ctx.alloc);
569
570 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
571 // Wheel up doesn't adjust the scroll
572 try std.testing.expectEqual(0, list_view.scroll.top);
573 try std.testing.expectEqual(0, list_view.scroll.offset);
574
575 // Send a wheel down
576 mouse_event.button = .wheel_down;
577 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
578 // We have to draw the widget for scrolls to take effect
579 surface = try list_widget.draw(draw_ctx);
580 // 0 *abc
581 // 1 | def
582 // 2 | ghi
583 // 3 | def
584 // 4 | ghi
585 // 5 jkl
586 // 6 mno
587 // We should have gone down 1 line, and not changed our top widget
588 try std.testing.expectEqual(0, list_view.scroll.top);
589 try std.testing.expectEqual(1, list_view.scroll.offset);
590 // One more widget has scrolled into view
591 try std.testing.expectEqual(3, surface.children.len);
592
593 // Scroll down two more lines
594 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
595 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
596 surface = try list_widget.draw(draw_ctx);
597 // 0 *abc
598 // 1 def
599 // 2 ghi
600 // 3 | def
601 // 4 | ghi
602 // 5 | jkl
603 // 6 | mno
604 // We should have gone down 2 lines, which scrolls our top widget out of view
605 try std.testing.expectEqual(1, list_view.scroll.top);
606 try std.testing.expectEqual(0, list_view.scroll.offset);
607 try std.testing.expectEqual(3, surface.children.len);
608
609 // Scroll down again. We shouldn't advance anymore since we are at the bottom
610 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
611 surface = try list_widget.draw(draw_ctx);
612 try std.testing.expectEqual(1, list_view.scroll.top);
613 try std.testing.expectEqual(0, list_view.scroll.offset);
614 try std.testing.expectEqual(3, surface.children.len);
615
616 // Mouse wheel events don't change the cursor position. Let's press "escape" to reset the
617 // viewport and bring our cursor into view
618 try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.escape } });
619 surface = try list_widget.draw(draw_ctx);
620 try std.testing.expectEqual(0, list_view.scroll.top);
621 try std.testing.expectEqual(0, list_view.scroll.offset);
622 try std.testing.expectEqual(2, surface.children.len);
623
624 // Cursor down
625 try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } });
626 surface = try list_widget.draw(draw_ctx);
627 // 0 | abc
628 // 1 | def
629 // 2 | ghi
630 // 3 |*def
631 // 4 ghi
632 // 5 jkl
633 // 6 mno
634 // Scroll doesn't change
635 try std.testing.expectEqual(0, list_view.scroll.top);
636 try std.testing.expectEqual(0, list_view.scroll.offset);
637 try std.testing.expectEqual(2, surface.children.len);
638 try std.testing.expectEqual(1, list_view.cursor);
639
640 // Cursor down
641 try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } });
642 surface = try list_widget.draw(draw_ctx);
643 // 0 abc
644 // 1 | def
645 // 2 | ghi
646 // 3 | def
647 // 4 |*ghi
648 // 5 jkl
649 // 6 mno
650 // Scroll advances one row
651 try std.testing.expectEqual(0, list_view.scroll.top);
652 try std.testing.expectEqual(1, list_view.scroll.offset);
653 try std.testing.expectEqual(3, surface.children.len);
654 try std.testing.expectEqual(2, list_view.cursor);
655
656 // Cursor down
657 try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } });
658 surface = try list_widget.draw(draw_ctx);
659 // 0 abc
660 // 1 def
661 // 2 ghi
662 // 3 | def
663 // 4 | ghi
664 // 5 |*jkl
665 // 6 | mno
666 // We are cursored onto the last item. The entire last item comes into view, effectively
667 // advancing the scroll by 2
668 try std.testing.expectEqual(1, list_view.scroll.top);
669 try std.testing.expectEqual(0, list_view.scroll.offset);
670 try std.testing.expectEqual(3, surface.children.len);
671 try std.testing.expectEqual(3, list_view.cursor);
672}
673
674// @reykjalin found an issue on mac with ghostty where the scroll up and scroll down were uneven.
675// Ghostty has high precision scrolling and sends a lot of wheel events for each tick
676test "ListView: uneven scroll" {
677 // Create child widgets
678 const Text = @import("Text.zig");
679 const zero: Text = .{ .text = "0" };
680 const one: Text = .{ .text = "1" };
681 const two: Text = .{ .text = "2" };
682 const three: Text = .{ .text = "3" };
683 const four: Text = .{ .text = "4" };
684 const five: Text = .{ .text = "5" };
685 const six: Text = .{ .text = "6" };
686 // 0 |
687 // 1 |
688 // 2 |
689 // 3 |
690 // 4
691 // 5
692 // 6
693
694 // Create the list view
695 const list_view: ListView = .{
696 .wheel_scroll = 1, // Set wheel scroll to one
697 .children = .{ .slice = &.{
698 zero.widget(),
699 one.widget(),
700 two.widget(),
701 three.widget(),
702 four.widget(),
703 five.widget(),
704 six.widget(),
705 } },
706 };
707
708 // Boiler plate draw context
709 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
710 defer arena.deinit();
711 vxfw.DrawContext.init(.unicode);
712
713 const list_widget = list_view.widget();
714 const draw_ctx: vxfw.DrawContext = .{
715 .arena = arena.allocator(),
716 .min = .{},
717 .max = .{ .width = 16, .height = 4 },
718 .cell_size = .{ .width = 10, .height = 20 },
719 };
720
721 var surface = try list_widget.draw(draw_ctx);
722
723 var mouse_event: vaxis.Mouse = .{
724 .col = 0,
725 .row = 0,
726 .button = .wheel_up,
727 .mods = .{},
728 .type = .press,
729 };
730 // Event handlers need a context
731 var ctx: vxfw.EventContext = .{
732 .alloc = std.testing.allocator,
733 .cmds = .empty,
734 };
735 defer ctx.cmds.deinit(ctx.alloc);
736
737 // Send a wheel down x 3
738 mouse_event.button = .wheel_down;
739 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
740 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
741 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
742 // We have to draw the widget for scrolls to take effect
743 surface = try list_widget.draw(draw_ctx);
744 // 0
745 // 1
746 // 2
747 // 3 |
748 // 4 |
749 // 5 |
750 // 6 |
751 try std.testing.expectEqual(3, list_view.scroll.top);
752 try std.testing.expectEqual(0, list_view.scroll.offset);
753 try std.testing.expectEqual(4, surface.children.len);
754
755 // Now wheel_up two times should move us two lines up
756 mouse_event.button = .wheel_up;
757 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
758 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
759 surface = try list_widget.draw(draw_ctx);
760 try std.testing.expectEqual(1, list_view.scroll.top);
761 try std.testing.expectEqual(0, list_view.scroll.offset);
762 try std.testing.expectEqual(4, surface.children.len);
763}
764
765test "refAllDecls" {
766 std.testing.refAllDecls(@This());
767}