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 ScrollView = @This();
11
12pub const Builder = struct {
13 userdata: *const anyopaque,
14 buildFn: *const fn (*const anyopaque, idx: usize, cursor: usize) ?vxfw.Widget,
15
16 pub 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 vertical_offset: i17 = 0,
31 /// Pending vertical scroll amount.
32 pending_lines: i17 = 0,
33 /// If there is more room to scroll down.
34 has_more_vertical: bool = true,
35 /// The column of the first in-view column.
36 left: u32 = 0,
37 /// If there is more room to scroll right.
38 has_more_horizontal: bool = true,
39 /// The cursor must be in the viewport.
40 wants_cursor: bool = false,
41
42 pub fn linesDown(self: *Scroll, n: u8) bool {
43 if (!self.has_more_vertical) return false;
44 self.pending_lines += n;
45 return true;
46 }
47
48 pub fn linesUp(self: *Scroll, n: u8) bool {
49 if (self.top == 0 and self.vertical_offset == 0) return false;
50 self.pending_lines -= @intCast(n);
51 return true;
52 }
53
54 pub fn colsLeft(self: *Scroll, n: u8) bool {
55 if (self.left == 0) return false;
56 self.left -|= n;
57 return true;
58 }
59 pub fn colsRight(self: *Scroll, n: u8) bool {
60 if (!self.has_more_horizontal) return false;
61 self.left +|= n;
62 return true;
63 }
64};
65
66children: Source,
67cursor: u32 = 0,
68last_height: u8 = 0,
69/// When true, the widget will draw a cursor next to the widget which has the cursor
70draw_cursor: bool = false,
71/// The cell that will be drawn to represent the scroll view's cursor. Replace this to customize the
72/// cursor indicator. Must have a 1 column width.
73cursor_indicator: vaxis.Cell = .{ .char = .{ .grapheme = "▐", .width = 1 } },
74/// Lines to scroll for a mouse wheel
75wheel_scroll: u8 = 3,
76/// Set this if the exact item count is known.
77item_count: ?u32 = null,
78
79/// scroll position
80scroll: Scroll = .{},
81
82pub fn widget(self: *const ScrollView) vxfw.Widget {
83 return .{
84 .userdata = @constCast(self),
85 .eventHandler = typeErasedEventHandler,
86 .drawFn = typeErasedDrawFn,
87 };
88}
89
90fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
91 const self: *ScrollView = @ptrCast(@alignCast(ptr));
92 return self.handleEvent(ctx, event);
93}
94
95fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
96 const self: *ScrollView = @ptrCast(@alignCast(ptr));
97 return self.draw(ctx);
98}
99
100pub fn handleEvent(self: *ScrollView, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
101 switch (event) {
102 .mouse => |mouse| {
103 if (mouse.button == .wheel_up) {
104 if (self.scroll.linesUp(self.wheel_scroll))
105 return ctx.consumeAndRedraw();
106 }
107 if (mouse.button == .wheel_down) {
108 if (self.scroll.linesDown(self.wheel_scroll))
109 return ctx.consumeAndRedraw();
110 }
111 if (mouse.button == .wheel_left) {
112 if (self.scroll.colsRight(self.wheel_scroll))
113 return ctx.consumeAndRedraw();
114 }
115 if (mouse.button == .wheel_right) {
116 if (self.scroll.colsLeft(self.wheel_scroll))
117 return ctx.consumeAndRedraw();
118 }
119 },
120 .key_press => |key| {
121 if (key.matches(vaxis.Key.down, .{}) or
122 key.matches('j', .{}) or
123 key.matches('n', .{ .ctrl = true }))
124 {
125 // If we're drawing the cursor, move it to the next item.
126 if (self.draw_cursor) return self.nextItem(ctx);
127
128 // Otherwise scroll the view down.
129 if (self.scroll.linesDown(1)) ctx.consumeAndRedraw();
130 }
131 if (key.matches(vaxis.Key.up, .{}) or
132 key.matches('k', .{}) or
133 key.matches('p', .{ .ctrl = true }))
134 {
135 // If we're drawing the cursor, move it to the previous item.
136 if (self.draw_cursor) return self.prevItem(ctx);
137
138 // Otherwise scroll the view up.
139 if (self.scroll.linesUp(1)) ctx.consumeAndRedraw();
140 }
141 if (key.matches(vaxis.Key.right, .{}) or
142 key.matches('l', .{}) or
143 key.matches('f', .{ .ctrl = true }))
144 {
145 if (self.scroll.colsRight(1)) ctx.consumeAndRedraw();
146 }
147 if (key.matches(vaxis.Key.left, .{}) or
148 key.matches('h', .{}) or
149 key.matches('b', .{ .ctrl = true }))
150 {
151 if (self.scroll.colsLeft(1)) ctx.consumeAndRedraw();
152 }
153 if (key.matches('d', .{ .ctrl = true })) {
154 const scroll_lines = @max(self.last_height / 2, 1);
155 if (self.scroll.linesDown(scroll_lines))
156 ctx.consumeAndRedraw();
157 }
158 if (key.matches('u', .{ .ctrl = true })) {
159 const scroll_lines = @max(self.last_height / 2, 1);
160 if (self.scroll.linesUp(scroll_lines))
161 ctx.consumeAndRedraw();
162 }
163 if (key.matches(vaxis.Key.escape, .{})) {
164 self.ensureScroll();
165 return ctx.consumeAndRedraw();
166 }
167 },
168 else => {},
169 }
170}
171
172pub fn draw(self: *ScrollView, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
173 std.debug.assert(ctx.max.width != null);
174 std.debug.assert(ctx.max.height != null);
175 switch (self.children) {
176 .slice => |slice| {
177 self.item_count = @intCast(slice.len);
178 const builder: SliceBuilder = .{ .slice = slice };
179 return self.drawBuilder(ctx, .{ .userdata = &builder, .buildFn = SliceBuilder.build });
180 },
181 .builder => |b| return self.drawBuilder(ctx, b),
182 }
183}
184
185pub fn nextItem(self: *ScrollView, ctx: *vxfw.EventContext) void {
186 // If we have a count, we can handle this directly
187 if (self.item_count) |count| {
188 if (self.cursor >= count - 1) {
189 return ctx.consumeEvent();
190 }
191 self.cursor += 1;
192 } else {
193 switch (self.children) {
194 .slice => |slice| {
195 self.item_count = @intCast(slice.len);
196 // If we are already at the end, don't do anything
197 if (self.cursor == slice.len - 1) {
198 return ctx.consumeEvent();
199 }
200 // Advance the cursor
201 self.cursor += 1;
202 },
203 .builder => |builder| {
204 // Save our current state
205 const prev = self.cursor;
206 // Advance the cursor
207 self.cursor += 1;
208 // Check the bounds, reversing until we get the last item
209 while (builder.itemAtIdx(self.cursor, self.cursor) == null) {
210 self.cursor -|= 1;
211 }
212 // If we didn't change state, we don't redraw
213 if (self.cursor == prev) {
214 return ctx.consumeEvent();
215 }
216 },
217 }
218 }
219 // Reset scroll
220 self.ensureScroll();
221 ctx.consumeAndRedraw();
222}
223
224pub fn prevItem(self: *ScrollView, ctx: *vxfw.EventContext) void {
225 if (self.cursor == 0) {
226 return ctx.consumeEvent();
227 }
228
229 if (self.item_count) |count| {
230 // If for some reason our count changed, we handle it here
231 self.cursor = @min(self.cursor - 1, count - 1);
232 } else {
233 switch (self.children) {
234 .slice => |slice| {
235 self.item_count = @intCast(slice.len);
236 self.cursor = @min(self.cursor - 1, slice.len - 1);
237 },
238 .builder => |builder| {
239 // Save our current state
240 const prev = self.cursor;
241 // Decrement the cursor
242 self.cursor -= 1;
243 // Check the bounds, reversing until we get the last item
244 while (builder.itemAtIdx(self.cursor, self.cursor) == null) {
245 self.cursor -|= 1;
246 }
247 // If we didn't change state, we don't redraw
248 if (self.cursor == prev) {
249 return ctx.consumeEvent();
250 }
251 },
252 }
253 }
254
255 // Reset scroll
256 self.ensureScroll();
257 return ctx.consumeAndRedraw();
258}
259
260// Only call when cursor state has changed, or we want to ensure the cursored item is in view
261pub fn ensureScroll(self: *ScrollView) void {
262 if (self.cursor <= self.scroll.top) {
263 self.scroll.top = @intCast(self.cursor);
264 self.scroll.vertical_offset = 0;
265 } else {
266 self.scroll.wants_cursor = true;
267 }
268}
269
270/// Inserts children until add_height is < 0
271fn insertChildren(
272 self: *ScrollView,
273 ctx: vxfw.DrawContext,
274 builder: Builder,
275 child_list: *std.ArrayList(vxfw.SubSurface),
276 add_height: i17,
277) Allocator.Error!void {
278 assert(self.scroll.top > 0);
279 self.scroll.top -= 1;
280 var upheight = add_height;
281 while (self.scroll.top >= 0) : (self.scroll.top -= 1) {
282 // Get the child
283 const child = builder.itemAtIdx(self.scroll.top, self.cursor) orelse break;
284
285 const child_offset: u16 = if (self.draw_cursor) 2 else 0;
286 const max_size = ctx.max.size();
287
288 // Set up constraints. We let the child be the entire height if it wants
289 const child_ctx = ctx.withConstraints(
290 .{ .width = max_size.width - child_offset, .height = 0 },
291 .{ .width = null, .height = null },
292 );
293
294 // Draw the child
295 const surf = try child.draw(child_ctx);
296
297 // Accumulate the height. Traversing backward so do this before setting origin
298 upheight -= surf.size.height;
299
300 // Insert the child to the beginning of the list
301 const col_offset: i17 = if (self.draw_cursor) 2 else 0;
302 try child_list.insert(ctx.arena, 0, .{
303 .origin = .{ .col = col_offset - @as(i17, @intCast(self.scroll.left)), .row = upheight },
304 .surface = surf,
305 .z_index = 0,
306 });
307
308 // Break if we went past the top edge, or are the top item
309 if (upheight <= 0 or self.scroll.top == 0) break;
310 }
311
312 // Our new offset is the "upheight"
313 self.scroll.vertical_offset = upheight;
314
315 // Reset origins if we overshot and put the top item too low
316 if (self.scroll.top == 0 and upheight > 0) {
317 self.scroll.vertical_offset = 0;
318 var row: i17 = 0;
319 for (child_list.items) |*child| {
320 child.origin.row = row;
321 row += child.surface.size.height;
322 }
323 }
324 // Our new offset is the "upheight"
325 self.scroll.vertical_offset = upheight;
326}
327
328fn totalHeight(list: *const std.ArrayList(vxfw.SubSurface)) usize {
329 var result: usize = 0;
330 for (list.items) |child| {
331 result += child.surface.size.height;
332 }
333 return result;
334}
335
336fn drawBuilder(self: *ScrollView, ctx: vxfw.DrawContext, builder: Builder) Allocator.Error!vxfw.Surface {
337 defer self.scroll.wants_cursor = false;
338
339 // Get the size. asserts neither constraint is null
340 const max_size = ctx.max.size();
341 // Set up surface.
342 var surface: vxfw.Surface = .{
343 .size = max_size,
344 .widget = self.widget(),
345 .buffer = &.{},
346 .children = &.{},
347 };
348
349 // Set state
350 {
351 // Assume we have more. We only know we don't after drawing
352 self.scroll.has_more_vertical = true;
353 }
354
355 var child_list: std.ArrayList(vxfw.SubSurface) = .empty;
356
357 // Accumulated height tracks how much height we have drawn. It's initial state is
358 // -(scroll.vertical_offset + scroll.pending_lines) lines _above_ the surface top edge.
359 // Example:
360 // 1. Scroll up 3 lines:
361 // pending_lines = -3
362 // offset = 0
363 // accumulated_height = -(0 + -3) = 3;
364 // Our first widget is placed at row 3, we will need to fill this in after the draw
365 // 2. Scroll up 3 lines, with an offset of 4
366 // pending_lines = -3
367 // offset = 4
368 // accumulated_height = -(4 + -3) = -1;
369 // Our first widget is placed at row -1
370 // 3. Scroll down 3 lines:
371 // pending_lines = 3
372 // offset = 0
373 // accumulated_height = -(0 + 3) = -3;
374 // Our first widget is placed at row -3. It's possible it consumes the entire widget. We
375 // will check for this at the end and only include visible children
376 var accumulated_height: i17 = -(self.scroll.vertical_offset + self.scroll.pending_lines);
377
378 // We handled the pending scroll by assigning accumulated_height. Reset it's state
379 self.scroll.pending_lines = 0;
380
381 // Set the initial index for our downard loop. We do this here because we might modify
382 // scroll.top before we traverse downward
383 var i: usize = self.scroll.top;
384
385 // If we are on the first item, and we have an upward scroll that consumed our offset, eg
386 // accumulated_height > 0, we reset state here. We can't scroll up anymore so we set
387 // accumulated_height to 0.
388 if (accumulated_height > 0 and self.scroll.top == 0) {
389 self.scroll.vertical_offset = 0;
390 accumulated_height = 0;
391 }
392
393 // If we are offset downward, insert widgets to the front of the list before traversing downard
394 if (accumulated_height > 0) {
395 try self.insertChildren(ctx, builder, &child_list, accumulated_height);
396 const last_child = child_list.items[child_list.items.len - 1];
397 accumulated_height = last_child.origin.row + last_child.surface.size.height;
398 }
399
400 const child_offset: u16 = if (self.draw_cursor) 2 else 0;
401
402 while (builder.itemAtIdx(i, self.cursor)) |child| {
403 // Defer the increment
404 defer i += 1;
405
406 // Set up constraints. We let the child be the entire height if it wants
407 const child_ctx = ctx.withConstraints(
408 .{ .width = max_size.width - child_offset, .height = 0 },
409 .{ .width = null, .height = null },
410 );
411
412 // Draw the child
413 const surf = try child.draw(child_ctx);
414
415 // Add the child surface to our list. It's offset from parent is the accumulated height
416 try child_list.append(ctx.arena, .{
417 .origin = .{ .col = child_offset - @as(i17, @intCast(self.scroll.left)), .row = accumulated_height },
418 .surface = surf,
419 .z_index = 0,
420 });
421
422 // Accumulate the height
423 accumulated_height += surf.size.height;
424
425 if (self.scroll.wants_cursor and i < self.cursor)
426 continue // continue if we want the cursor and haven't gotten there yet
427 else if (accumulated_height >= max_size.height)
428 break; // Break if we drew enough
429 } else {
430 // This branch runs if we ran out of items. Set our state accordingly
431 self.scroll.has_more_vertical = false;
432 }
433
434 // If we've looped through all the items without hitting the end we check for one more item to
435 // see if we just drew the last item on the bottom of the screen. If we just drew the last item
436 // we can set `scroll.has_more` to false.
437 if (self.scroll.has_more_vertical and accumulated_height <= max_size.height) {
438 if (builder.itemAtIdx(i, self.cursor) == null) self.scroll.has_more_vertical = false;
439 }
440
441 var total_height: usize = totalHeight(&child_list);
442
443 // If we reached the bottom, don't have enough height to fill the screen, and have room to add
444 // more, then we add more until out of items or filled the space. This can happen on a resize
445 if (!self.scroll.has_more_vertical and total_height < max_size.height and self.scroll.top > 0) {
446 try self.insertChildren(ctx, builder, &child_list, @intCast(max_size.height - total_height));
447 // Set the new total height
448 total_height = totalHeight(&child_list);
449 }
450
451 if (self.draw_cursor and self.cursor >= self.scroll.top) blk: {
452 // The index of the cursored widget in our child_list
453 const cursored_idx: u32 = self.cursor - self.scroll.top;
454 // Nothing to draw if our cursor is below our viewport
455 if (cursored_idx >= child_list.items.len) break :blk;
456
457 const sub = try ctx.arena.alloc(vxfw.SubSurface, 1);
458 const child = child_list.items[cursored_idx];
459 sub[0] = .{
460 .origin = .{ .col = child_offset - @as(i17, @intCast(self.scroll.left)), .row = 0 },
461 .surface = child.surface,
462 .z_index = 0,
463 };
464 const cursor_surf = try vxfw.Surface.initWithChildren(
465 ctx.arena,
466 self.widget(),
467 .{ .width = child_offset, .height = child.surface.size.height },
468 sub,
469 );
470 for (0..cursor_surf.size.height) |row| {
471 cursor_surf.writeCell(0, @intCast(row), self.cursor_indicator);
472 }
473 child_list.items[cursored_idx] = .{
474 .origin = .{ .col = 0, .row = child.origin.row },
475 .surface = cursor_surf,
476 .z_index = 0,
477 };
478 }
479
480 // If we want the cursor, we check that the cursored widget is fully in view. If it is too
481 // large, we position it so that it is the top item with a 0 offset
482 if (self.scroll.wants_cursor) {
483 const cursored_idx: u32 = self.cursor - self.scroll.top;
484 const sub = child_list.items[cursored_idx];
485 // The bottom row of the cursored widget
486 const bottom = sub.origin.row + sub.surface.size.height;
487 if (bottom > max_size.height) {
488 // Adjust the origin by the difference
489 // anchor bottom
490 var origin: i17 = max_size.height;
491 var idx: usize = cursored_idx + 1;
492 while (idx > 0) : (idx -= 1) {
493 var child = child_list.items[idx - 1];
494 origin -= child.surface.size.height;
495 child.origin.row = origin;
496 child_list.items[idx - 1] = child;
497 }
498 } else if (sub.surface.size.height >= max_size.height) {
499 // TODO: handle when the child is larger than our height.
500 // We need to change the max constraint to be optional sizes so that we can support
501 // unbounded drawing in scrollable areas
502 self.scroll.top = self.cursor;
503 self.scroll.vertical_offset = 0;
504 child_list.deinit(ctx.arena);
505 try child_list.append(ctx.arena, .{
506 .origin = .{ .col = 0 - @as(i17, @intCast(self.scroll.left)), .row = 0 },
507 .surface = sub.surface,
508 .z_index = 0,
509 });
510 total_height = sub.surface.size.height;
511 }
512 }
513
514 // If we reached the bottom, we need to reset origins
515 if (!self.scroll.has_more_vertical and total_height < max_size.height) {
516 // anchor top
517 assert(self.scroll.top == 0);
518 self.scroll.vertical_offset = 0;
519 var origin: i17 = 0;
520 for (0..child_list.items.len) |idx| {
521 var child = child_list.items[idx];
522 child.origin.row = origin;
523 origin += child.surface.size.height;
524 child_list.items[idx] = child;
525 }
526 } else if (!self.scroll.has_more_vertical) {
527 // anchor bottom
528 var origin: i17 = max_size.height;
529 var idx: usize = child_list.items.len;
530 while (idx > 0) : (idx -= 1) {
531 var child = child_list.items[idx - 1];
532 origin -= child.surface.size.height;
533 child.origin.row = origin;
534 child_list.items[idx - 1] = child;
535 }
536 }
537
538 // Reset horizontal scroll info.
539 self.scroll.has_more_horizontal = false;
540 for (child_list.items) |child| {
541 if (child.surface.size.width -| self.scroll.left > max_size.width) {
542 self.scroll.has_more_horizontal = true;
543 break;
544 }
545 }
546
547 var start: usize = 0;
548 var end: usize = child_list.items.len;
549
550 for (child_list.items, 0..) |child, idx| {
551 if (child.origin.row <= 0 and child.origin.row + child.surface.size.height > 0) {
552 start = idx;
553 self.scroll.vertical_offset = -child.origin.row;
554 self.scroll.top += @intCast(idx);
555 }
556 if (child.origin.row > max_size.height) {
557 end = idx;
558 break;
559 }
560 }
561
562 surface.children = child_list.items;
563
564 // Update last known height.
565 // If the bits from total_height don't fit u8 we won't get the right value from @intCast or
566 // @truncate so we check manually.
567 self.last_height = if (total_height > 255) 255 else @intCast(total_height);
568
569 return surface;
570}
571
572const SliceBuilder = struct {
573 slice: []const vxfw.Widget,
574
575 fn build(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget {
576 const self: *const SliceBuilder = @ptrCast(@alignCast(ptr));
577 if (idx >= self.slice.len) return null;
578 return self.slice[idx];
579 }
580};
581
582test ScrollView {
583 // Create child widgets
584 const Text = @import("Text.zig");
585 const abc: Text = .{ .text = "abc\n def\n ghi" };
586 const def: Text = .{ .text = "def" };
587 const ghi: Text = .{ .text = "ghi" };
588 const jklmno: Text = .{ .text = "jkl\n mno" };
589 //
590 // 0 |abc|
591 // 1 | d|ef
592 // 2 | g|hi
593 // 3 |def|
594 // 4 ghi
595 // 5 jkl
596 // 6 mno
597
598 // Create the scroll view
599 const scroll_view: ScrollView = .{
600 .wheel_scroll = 1, // Set wheel scroll to one
601 .children = .{ .slice = &.{
602 abc.widget(),
603 def.widget(),
604 ghi.widget(),
605 jklmno.widget(),
606 } },
607 };
608
609 // Boiler plate draw context
610 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
611 defer arena.deinit();
612 vxfw.DrawContext.init(.unicode);
613
614 const scroll_widget = scroll_view.widget();
615 const draw_ctx: vxfw.DrawContext = .{
616 .arena = arena.allocator(),
617 .min = .{},
618 .max = .{ .width = 3, .height = 4 },
619 .cell_size = .{ .width = 10, .height = 20 },
620 };
621
622 var surface = try scroll_widget.draw(draw_ctx);
623 // ScrollView expands to max height and max width
624 try std.testing.expectEqual(4, surface.size.height);
625 try std.testing.expectEqual(3, surface.size.width);
626 // We have 2 children, because only visible children appear as a surface
627 try std.testing.expectEqual(2, surface.children.len);
628
629 // ScrollView starts at the top and left.
630 try std.testing.expectEqual(0, scroll_view.scroll.top);
631 try std.testing.expectEqual(0, scroll_view.scroll.left);
632
633 // With the widgets provided the scroll view should have both more content to scroll vertically
634 // and horizontally.
635 try std.testing.expectEqual(true, scroll_view.scroll.has_more_vertical);
636 try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal);
637
638 var mouse_event: vaxis.Mouse = .{
639 .col = 0,
640 .row = 0,
641 .button = .wheel_up,
642 .mods = .{},
643 .type = .press,
644 };
645 // Event handlers need a context
646 var ctx: vxfw.EventContext = .{
647 .alloc = std.testing.allocator,
648 .cmds = .empty,
649 };
650 defer ctx.cmds.deinit(ctx.alloc);
651
652 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
653 // Wheel up doesn't adjust the scroll
654 try std.testing.expectEqual(0, scroll_view.scroll.top);
655 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset);
656
657 // Wheel right doesn't adjust the horizontal scroll
658 mouse_event.button = .wheel_right;
659 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
660 try std.testing.expectEqual(0, scroll_view.scroll.left);
661
662 // Scroll right with 'h' doesn't adjust the horizontal scroll
663 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'h' } });
664 try std.testing.expectEqual(0, scroll_view.scroll.left);
665
666 // Scroll right with '<c-b>' doesn't adjust the horizontal scroll
667 try scroll_widget.handleEvent(
668 &ctx,
669 .{ .key_press = .{ .codepoint = 'c', .mods = .{ .ctrl = true } } },
670 );
671 try std.testing.expectEqual(0, scroll_view.scroll.left);
672
673 // === TEST SCROLL DOWN === //
674
675 // Send a wheel down to scroll down one line
676 mouse_event.button = .wheel_down;
677 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
678 // We have to draw the widget for scrolls to take effect
679 surface = try scroll_widget.draw(draw_ctx);
680 // 0 abc
681 // 1 | d|ef
682 // 2 | g|hi
683 // 3 |def|
684 // 4 |ghi|
685 // 5 jkl
686 // 6 mno
687 // We should have gone down 1 line, and not changed our top widget
688 try std.testing.expectEqual(0, scroll_view.scroll.top);
689 try std.testing.expectEqual(1, scroll_view.scroll.vertical_offset);
690 // One more widget has scrolled into view
691 try std.testing.expectEqual(3, surface.children.len);
692
693 // Send a 'j' to scroll down one more line.
694 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } });
695 surface = try scroll_widget.draw(draw_ctx);
696 // 0 abc
697 // 1 def
698 // 2 | g|hi
699 // 3 |def|
700 // 4 |ghi|
701 // 5 |jkl|
702 // 6 mno
703 // We should have gone down 1 line, and not changed our top widget
704 try std.testing.expectEqual(0, scroll_view.scroll.top);
705 try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset);
706 // One more widget has scrolled into view
707 try std.testing.expectEqual(4, surface.children.len);
708
709 // Send `<c-n> to scroll down one more line
710 try scroll_widget.handleEvent(
711 &ctx,
712 .{ .key_press = .{ .codepoint = 'n', .mods = .{ .ctrl = true } } },
713 );
714 surface = try scroll_widget.draw(draw_ctx);
715 // 0 abc
716 // 1 def
717 // 2 ghi
718 // 3 |def|
719 // 4 |ghi|
720 // 5 |jkl|
721 // 6 | m|no
722 // We should have gone down 1 line, which scrolls our top widget out of view
723 try std.testing.expectEqual(1, scroll_view.scroll.top);
724 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset);
725 // The top widget has now scrolled out of view, but is still rendered out of view because of
726 // how pending scroll events are handled.
727 try std.testing.expectEqual(4, surface.children.len);
728
729 // We've scrolled to the bottom.
730 try std.testing.expectEqual(false, scroll_view.scroll.has_more_vertical);
731
732 // Scroll down one more line, this shouldn't do anything.
733 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
734 surface = try scroll_widget.draw(draw_ctx);
735 // 0 abc
736 // 1 def
737 // 2 ghi
738 // 3 |def|
739 // 4 |ghi|
740 // 5 |jkl|
741 // 6 | m|no
742 try std.testing.expectEqual(1, scroll_view.scroll.top);
743 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset);
744 // The top widget was scrolled out of view on the last render, so we should no longer be
745 // drawing it right above the current view.
746 try std.testing.expectEqual(3, surface.children.len);
747
748 // We've scrolled to the bottom.
749 try std.testing.expectEqual(false, scroll_view.scroll.has_more_vertical);
750
751 // === TEST SCROLL UP === //
752
753 mouse_event.button = .wheel_up;
754
755 // Send mouse up, now the top widget is in view.
756 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
757 surface = try scroll_widget.draw(draw_ctx);
758 // 0 abc
759 // 1 def
760 // 2 | g|hi
761 // 3 |def|
762 // 4 |ghi|
763 // 5 |jkl|
764 // 6 mno
765 try std.testing.expectEqual(0, scroll_view.scroll.top);
766 try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset);
767 // The top widget was scrolled out of view on the last render, so we should no longer be
768 // drawing it right above the current view.
769 try std.testing.expectEqual(4, surface.children.len);
770
771 // We've scrolled away from the bottom.
772 try std.testing.expectEqual(true, scroll_view.scroll.has_more_vertical);
773
774 // Send 'k' to scroll up, now the bottom widget should be out of view.
775 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'k' } });
776 surface = try scroll_widget.draw(draw_ctx);
777 // 0 abc
778 // 1 | d|ef
779 // 2 | g|hi
780 // 3 |def|
781 // 4 |ghi|
782 // 5 jkl
783 // 6 mno
784 try std.testing.expectEqual(0, scroll_view.scroll.top);
785 try std.testing.expectEqual(1, scroll_view.scroll.vertical_offset);
786 // The top widget was scrolled out of view on the last render, so we should no longer be
787 // drawing it right above the current view.
788 try std.testing.expectEqual(3, surface.children.len);
789
790 // Send '<c-p>' to scroll up, now we should be at the top.
791 try scroll_widget.handleEvent(
792 &ctx,
793 .{ .key_press = .{ .codepoint = 'p', .mods = .{ .ctrl = true } } },
794 );
795 surface = try scroll_widget.draw(draw_ctx);
796 // 0 |abc|
797 // 1 | d|ef
798 // 2 | g|hi
799 // 3 |def|
800 // 4 ghi
801 // 5 jkl
802 // 6 mno
803 try std.testing.expectEqual(0, scroll_view.scroll.top);
804 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset);
805 // The top widget was scrolled out of view on the last render, so we should no longer be
806 // drawing it right above the current view.
807 try std.testing.expectEqual(2, surface.children.len);
808
809 // We should be at the top.
810 try std.testing.expectEqual(0, scroll_view.scroll.top);
811 // We should still have no horizontal scroll.
812 try std.testing.expectEqual(0, scroll_view.scroll.left);
813
814 // === TEST SCROLL LEFT - MOVES VIEW TO THE RIGHT === //
815
816 mouse_event.button = .wheel_left;
817
818 // Send `.wheel_left` to scroll the view to the right.
819 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
820 surface = try scroll_widget.draw(draw_ctx);
821 // 0 a|bc |
822 // 1 | de|f
823 // 2 | gh|i
824 // 3 d|ef |
825 // 4 ghi
826 // 5 jkl
827 // 6 mno
828 try std.testing.expectEqual(1, scroll_view.scroll.left);
829 // The number of children should be just the top 2 widgets.
830 try std.testing.expectEqual(2, surface.children.len);
831 // There is still more to draw horizontally.
832 try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal);
833
834 // Send `l` to scroll the view to the right.
835 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l' } });
836 surface = try scroll_widget.draw(draw_ctx);
837 // 0 ab|c |
838 // 1 |def|
839 // 2 |ghi|
840 // 3 de|f |
841 // 4 ghi
842 // 5 jkl
843 // 6 mno
844 try std.testing.expectEqual(2, scroll_view.scroll.left);
845 // The number of children should be just the top 2 widgets.
846 try std.testing.expectEqual(2, surface.children.len);
847 // There is nothing more to draw horizontally.
848 try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal);
849
850 // Send `<c-f>` to scroll the view to the right, this should do nothing.
851 try scroll_widget.handleEvent(
852 &ctx,
853 .{ .key_press = .{ .codepoint = 'f', .mods = .{ .ctrl = true } } },
854 );
855 surface = try scroll_widget.draw(draw_ctx);
856 // 0 ab|c |
857 // 1 |def|
858 // 2 |ghi|
859 // 3 de|f |
860 // 4 ghi
861 // 5 jkl
862 // 6 mno
863 try std.testing.expectEqual(2, scroll_view.scroll.left);
864 // The number of children should be just the top 2 widgets.
865 try std.testing.expectEqual(2, surface.children.len);
866 // There is nothing more to draw horizontally.
867 try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal);
868
869 // Send `.wheel_right` to scroll the view to the left.
870 mouse_event.button = .wheel_right;
871 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
872 surface = try scroll_widget.draw(draw_ctx);
873 // 0 a|bc |
874 // 1 | de|f
875 // 2 | gh|i
876 // 3 d|ef |
877 // 4 ghi
878 // 5 jkl
879 // 6 mno
880 try std.testing.expectEqual(1, scroll_view.scroll.left);
881 // The number of children should be just the top 2 widgets.
882 try std.testing.expectEqual(2, surface.children.len);
883 // There is still more to draw horizontally.
884 try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal);
885
886 // Processing 2 or more events before drawing may produce overscroll, because we need to draw
887 // the children to determine whether there's more horizontal scrolling available.
888 try scroll_widget.handleEvent(
889 &ctx,
890 .{ .key_press = .{ .codepoint = 'f', .mods = .{ .ctrl = true } } },
891 );
892 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l' } });
893 surface = try scroll_widget.draw(draw_ctx);
894 // 0 abc| |
895 // 1 d|ef |
896 // 2 g|hi |
897 // 3 def| |
898 // 4 ghi
899 // 5 jkl
900 // 6 mno
901 try std.testing.expectEqual(3, scroll_view.scroll.left);
902 // The number of children should be just the top 2 widgets.
903 try std.testing.expectEqual(2, surface.children.len);
904 // There is nothing more to draw horizontally.
905 try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal);
906
907 // === TEST SCROLL RIGHT - MOVES VIEW TO THE LEFT === //
908
909 // Send `.wheel_right` to scroll the view to the left.
910 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
911 surface = try scroll_widget.draw(draw_ctx);
912 // 0 ab|c |
913 // 1 |def|
914 // 2 |ghi|
915 // 3 de|f |
916 // 4 ghi
917 // 5 jkl
918 // 6 mno
919 try std.testing.expectEqual(2, scroll_view.scroll.left);
920 // The number of children should be just the top 2 widgets.
921 try std.testing.expectEqual(2, surface.children.len);
922 // There is nothing more to draw horizontally.
923 try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal);
924
925 // Send `h` to scroll the view to the left.
926 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'h' } });
927 surface = try scroll_widget.draw(draw_ctx);
928 // 0 a|bc |
929 // 1 | de|f
930 // 2 | gh|i
931 // 3 d|ef |
932 // 4 ghi
933 // 5 jkl
934 // 6 mno
935 try std.testing.expectEqual(1, scroll_view.scroll.left);
936 // The number of children should be just the top 2 widgets.
937 try std.testing.expectEqual(2, surface.children.len);
938 // There is now more to draw horizontally.
939 try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal);
940
941 // Send `<c-b>` to scroll the view to the left.
942 try scroll_widget.handleEvent(
943 &ctx,
944 .{ .key_press = .{ .codepoint = 'b', .mods = .{ .ctrl = true } } },
945 );
946 surface = try scroll_widget.draw(draw_ctx);
947 // 0 |abc|
948 // 1 | d|ef
949 // 2 | g|hi
950 // 3 |def|
951 // 4 ghi
952 // 5 jkl
953 // 6 mno
954 try std.testing.expectEqual(0, scroll_view.scroll.left);
955 // The number of children should be just the top 2 widgets.
956 try std.testing.expectEqual(2, surface.children.len);
957 // There is now more to draw horizontally.
958 try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal);
959
960 // === TEST COMBINED HORIZONTAL AND VERTICAL SCROLL === //
961
962 // Scroll 3 columns to the right and 2 rows down.
963 mouse_event.button = .wheel_left;
964 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
965 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
966 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
967 mouse_event.button = .wheel_down;
968 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
969 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
970 surface = try scroll_widget.draw(draw_ctx);
971 // 0 abc
972 // 1 def
973 // 2 g|hi |
974 // 3 def| |
975 // 4 ghi| |
976 // 5 jkl| |
977 // 6 mno
978 try std.testing.expectEqual(3, scroll_view.scroll.left);
979 try std.testing.expectEqual(0, scroll_view.scroll.top);
980 try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset);
981 // Even though only 1 child is visible, we still draw all 4 children in the view.
982 try std.testing.expectEqual(4, surface.children.len);
983 // There is nothing more to draw horizontally.
984 try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal);
985}
986
987// @reykjalin found an issue on mac with ghostty where the scroll up and scroll down were uneven.
988// Ghostty has high precision scrolling and sends a lot of wheel events for each tick
989test "ScrollView: uneven scroll" {
990 // Create child widgets
991 const Text = @import("Text.zig");
992 const zero: Text = .{ .text = "0" };
993 const one: Text = .{ .text = "1" };
994 const two: Text = .{ .text = "2" };
995 const three: Text = .{ .text = "3" };
996 const four: Text = .{ .text = "4" };
997 const five: Text = .{ .text = "5" };
998 const six: Text = .{ .text = "6" };
999 // 0 |
1000 // 1 |
1001 // 2 |
1002 // 3 |
1003 // 4
1004 // 5
1005 // 6
1006
1007 // Create the list view
1008 const scroll_view: ScrollView = .{
1009 .wheel_scroll = 1, // Set wheel scroll to one
1010 .children = .{ .slice = &.{
1011 zero.widget(),
1012 one.widget(),
1013 two.widget(),
1014 three.widget(),
1015 four.widget(),
1016 five.widget(),
1017 six.widget(),
1018 } },
1019 };
1020
1021 // Boiler plate draw context
1022 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
1023 defer arena.deinit();
1024 vxfw.DrawContext.init(.unicode);
1025
1026 const scroll_widget = scroll_view.widget();
1027 const draw_ctx: vxfw.DrawContext = .{
1028 .arena = arena.allocator(),
1029 .min = .{},
1030 .max = .{ .width = 16, .height = 4 },
1031 .cell_size = .{ .width = 10, .height = 20 },
1032 };
1033
1034 var surface = try scroll_widget.draw(draw_ctx);
1035
1036 var mouse_event: vaxis.Mouse = .{
1037 .col = 0,
1038 .row = 0,
1039 .button = .wheel_up,
1040 .mods = .{},
1041 .type = .press,
1042 };
1043 // Event handlers need a context
1044 var ctx: vxfw.EventContext = .{
1045 .alloc = std.testing.allocator,
1046 .cmds = .empty,
1047 };
1048 defer ctx.cmds.deinit(ctx.alloc);
1049
1050 // Send a wheel down x 3
1051 mouse_event.button = .wheel_down;
1052 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
1053 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
1054 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
1055 // We have to draw the widget for scrolls to take effect
1056 surface = try scroll_widget.draw(draw_ctx);
1057 // 0
1058 // 1
1059 // 2
1060 // 3 |
1061 // 4 |
1062 // 5 |
1063 // 6 |
1064 try std.testing.expectEqual(3, scroll_view.scroll.top);
1065 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset);
1066 // The first time we draw again we still draw all 7 children due to how pending scroll events
1067 // work.
1068 try std.testing.expectEqual(7, surface.children.len);
1069
1070 surface = try scroll_widget.draw(draw_ctx);
1071 // By drawing again without any pending events there are now only the 4 visible elements
1072 // rendered.
1073 try std.testing.expectEqual(4, surface.children.len);
1074
1075 // Now wheel_up two times should move us two lines up
1076 mouse_event.button = .wheel_up;
1077 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
1078 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event });
1079 surface = try scroll_widget.draw(draw_ctx);
1080 try std.testing.expectEqual(1, scroll_view.scroll.top);
1081 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset);
1082 try std.testing.expectEqual(4, surface.children.len);
1083}
1084
1085test "refAllDecls" {
1086 std.testing.refAllDecls(@This());
1087}