this repo has no description
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 769 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 .io = std.testing.io, 566 .alloc = std.testing.allocator, 567 .cmds = .empty, 568 }; 569 defer ctx.cmds.deinit(ctx.alloc); 570 571 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 572 // Wheel up doesn't adjust the scroll 573 try std.testing.expectEqual(0, list_view.scroll.top); 574 try std.testing.expectEqual(0, list_view.scroll.offset); 575 576 // Send a wheel down 577 mouse_event.button = .wheel_down; 578 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 579 // We have to draw the widget for scrolls to take effect 580 surface = try list_widget.draw(draw_ctx); 581 // 0 *abc 582 // 1 | def 583 // 2 | ghi 584 // 3 | def 585 // 4 | ghi 586 // 5 jkl 587 // 6 mno 588 // We should have gone down 1 line, and not changed our top widget 589 try std.testing.expectEqual(0, list_view.scroll.top); 590 try std.testing.expectEqual(1, list_view.scroll.offset); 591 // One more widget has scrolled into view 592 try std.testing.expectEqual(3, surface.children.len); 593 594 // Scroll down two more lines 595 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 596 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 597 surface = try list_widget.draw(draw_ctx); 598 // 0 *abc 599 // 1 def 600 // 2 ghi 601 // 3 | def 602 // 4 | ghi 603 // 5 | jkl 604 // 6 | mno 605 // We should have gone down 2 lines, which scrolls our top widget out of view 606 try std.testing.expectEqual(1, list_view.scroll.top); 607 try std.testing.expectEqual(0, list_view.scroll.offset); 608 try std.testing.expectEqual(3, surface.children.len); 609 610 // Scroll down again. We shouldn't advance anymore since we are at the bottom 611 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 612 surface = try list_widget.draw(draw_ctx); 613 try std.testing.expectEqual(1, list_view.scroll.top); 614 try std.testing.expectEqual(0, list_view.scroll.offset); 615 try std.testing.expectEqual(3, surface.children.len); 616 617 // Mouse wheel events don't change the cursor position. Let's press "escape" to reset the 618 // viewport and bring our cursor into view 619 try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.escape } }); 620 surface = try list_widget.draw(draw_ctx); 621 try std.testing.expectEqual(0, list_view.scroll.top); 622 try std.testing.expectEqual(0, list_view.scroll.offset); 623 try std.testing.expectEqual(2, surface.children.len); 624 625 // Cursor down 626 try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 627 surface = try list_widget.draw(draw_ctx); 628 // 0 | abc 629 // 1 | def 630 // 2 | ghi 631 // 3 |*def 632 // 4 ghi 633 // 5 jkl 634 // 6 mno 635 // Scroll doesn't change 636 try std.testing.expectEqual(0, list_view.scroll.top); 637 try std.testing.expectEqual(0, list_view.scroll.offset); 638 try std.testing.expectEqual(2, surface.children.len); 639 try std.testing.expectEqual(1, list_view.cursor); 640 641 // Cursor down 642 try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 643 surface = try list_widget.draw(draw_ctx); 644 // 0 abc 645 // 1 | def 646 // 2 | ghi 647 // 3 | def 648 // 4 |*ghi 649 // 5 jkl 650 // 6 mno 651 // Scroll advances one row 652 try std.testing.expectEqual(0, list_view.scroll.top); 653 try std.testing.expectEqual(1, list_view.scroll.offset); 654 try std.testing.expectEqual(3, surface.children.len); 655 try std.testing.expectEqual(2, list_view.cursor); 656 657 // Cursor down 658 try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 659 surface = try list_widget.draw(draw_ctx); 660 // 0 abc 661 // 1 def 662 // 2 ghi 663 // 3 | def 664 // 4 | ghi 665 // 5 |*jkl 666 // 6 | mno 667 // We are cursored onto the last item. The entire last item comes into view, effectively 668 // advancing the scroll by 2 669 try std.testing.expectEqual(1, list_view.scroll.top); 670 try std.testing.expectEqual(0, list_view.scroll.offset); 671 try std.testing.expectEqual(3, surface.children.len); 672 try std.testing.expectEqual(3, list_view.cursor); 673} 674 675// @reykjalin found an issue on mac with ghostty where the scroll up and scroll down were uneven. 676// Ghostty has high precision scrolling and sends a lot of wheel events for each tick 677test "ListView: uneven scroll" { 678 // Create child widgets 679 const Text = @import("Text.zig"); 680 const zero: Text = .{ .text = "0" }; 681 const one: Text = .{ .text = "1" }; 682 const two: Text = .{ .text = "2" }; 683 const three: Text = .{ .text = "3" }; 684 const four: Text = .{ .text = "4" }; 685 const five: Text = .{ .text = "5" }; 686 const six: Text = .{ .text = "6" }; 687 // 0 | 688 // 1 | 689 // 2 | 690 // 3 | 691 // 4 692 // 5 693 // 6 694 695 // Create the list view 696 const list_view: ListView = .{ 697 .wheel_scroll = 1, // Set wheel scroll to one 698 .children = .{ .slice = &.{ 699 zero.widget(), 700 one.widget(), 701 two.widget(), 702 three.widget(), 703 four.widget(), 704 five.widget(), 705 six.widget(), 706 } }, 707 }; 708 709 // Boiler plate draw context 710 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 711 defer arena.deinit(); 712 vxfw.DrawContext.init(.unicode); 713 714 const list_widget = list_view.widget(); 715 const draw_ctx: vxfw.DrawContext = .{ 716 .arena = arena.allocator(), 717 .min = .{}, 718 .max = .{ .width = 16, .height = 4 }, 719 .cell_size = .{ .width = 10, .height = 20 }, 720 }; 721 722 var surface = try list_widget.draw(draw_ctx); 723 724 var mouse_event: vaxis.Mouse = .{ 725 .col = 0, 726 .row = 0, 727 .button = .wheel_up, 728 .mods = .{}, 729 .type = .press, 730 }; 731 // Event handlers need a context 732 var ctx: vxfw.EventContext = .{ 733 .io = std.testing.io, 734 .alloc = std.testing.allocator, 735 .cmds = .empty, 736 }; 737 defer ctx.cmds.deinit(ctx.alloc); 738 739 // Send a wheel down x 3 740 mouse_event.button = .wheel_down; 741 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 742 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 743 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 744 // We have to draw the widget for scrolls to take effect 745 surface = try list_widget.draw(draw_ctx); 746 // 0 747 // 1 748 // 2 749 // 3 | 750 // 4 | 751 // 5 | 752 // 6 | 753 try std.testing.expectEqual(3, list_view.scroll.top); 754 try std.testing.expectEqual(0, list_view.scroll.offset); 755 try std.testing.expectEqual(4, surface.children.len); 756 757 // Now wheel_up two times should move us two lines up 758 mouse_event.button = .wheel_up; 759 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 760 try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 761 surface = try list_widget.draw(draw_ctx); 762 try std.testing.expectEqual(1, list_view.scroll.top); 763 try std.testing.expectEqual(0, list_view.scroll.offset); 764 try std.testing.expectEqual(4, surface.children.len); 765} 766 767test "refAllDecls" { 768 std.testing.refAllDecls(@This()); 769}