a modern tui library written in zig
at main 767 lines 27 kB view raw
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}