this repo has no description
13
fork

Configure Feed

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

at b698a3641da7f3886bde4aeda7fcecc8d3642580 1089 lines 40 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 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 const ucd = try vaxis.Unicode.init(arena.allocator()); 613 vxfw.DrawContext.init(&ucd, .unicode); 614 615 const scroll_widget = scroll_view.widget(); 616 const draw_ctx: vxfw.DrawContext = .{ 617 .arena = arena.allocator(), 618 .min = .{}, 619 .max = .{ .width = 3, .height = 4 }, 620 .cell_size = .{ .width = 10, .height = 20 }, 621 }; 622 623 var surface = try scroll_widget.draw(draw_ctx); 624 // ScrollView expands to max height and max width 625 try std.testing.expectEqual(4, surface.size.height); 626 try std.testing.expectEqual(3, surface.size.width); 627 // We have 2 children, because only visible children appear as a surface 628 try std.testing.expectEqual(2, surface.children.len); 629 630 // ScrollView starts at the top and left. 631 try std.testing.expectEqual(0, scroll_view.scroll.top); 632 try std.testing.expectEqual(0, scroll_view.scroll.left); 633 634 // With the widgets provided the scroll view should have both more content to scroll vertically 635 // and horizontally. 636 try std.testing.expectEqual(true, scroll_view.scroll.has_more_vertical); 637 try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 638 639 var mouse_event: vaxis.Mouse = .{ 640 .col = 0, 641 .row = 0, 642 .button = .wheel_up, 643 .mods = .{}, 644 .type = .press, 645 }; 646 // Event handlers need a context 647 var ctx: vxfw.EventContext = .{ 648 .alloc = std.testing.allocator, 649 .cmds = .empty, 650 }; 651 defer ctx.cmds.deinit(ctx.alloc); 652 653 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 654 // Wheel up doesn't adjust the scroll 655 try std.testing.expectEqual(0, scroll_view.scroll.top); 656 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 657 658 // Wheel right doesn't adjust the horizontal scroll 659 mouse_event.button = .wheel_right; 660 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 661 try std.testing.expectEqual(0, scroll_view.scroll.left); 662 663 // Scroll right with 'h' doesn't adjust the horizontal scroll 664 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'h' } }); 665 try std.testing.expectEqual(0, scroll_view.scroll.left); 666 667 // Scroll right with '<c-b>' doesn't adjust the horizontal scroll 668 try scroll_widget.handleEvent( 669 &ctx, 670 .{ .key_press = .{ .codepoint = 'c', .mods = .{ .ctrl = true } } }, 671 ); 672 try std.testing.expectEqual(0, scroll_view.scroll.left); 673 674 // === TEST SCROLL DOWN === // 675 676 // Send a wheel down to scroll down one line 677 mouse_event.button = .wheel_down; 678 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 679 // We have to draw the widget for scrolls to take effect 680 surface = try scroll_widget.draw(draw_ctx); 681 // 0 abc 682 // 1 | d|ef 683 // 2 | g|hi 684 // 3 |def| 685 // 4 |ghi| 686 // 5 jkl 687 // 6 mno 688 // We should have gone down 1 line, and not changed our top widget 689 try std.testing.expectEqual(0, scroll_view.scroll.top); 690 try std.testing.expectEqual(1, scroll_view.scroll.vertical_offset); 691 // One more widget has scrolled into view 692 try std.testing.expectEqual(3, surface.children.len); 693 694 // Send a 'j' to scroll down one more line. 695 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 696 surface = try scroll_widget.draw(draw_ctx); 697 // 0 abc 698 // 1 def 699 // 2 | g|hi 700 // 3 |def| 701 // 4 |ghi| 702 // 5 |jkl| 703 // 6 mno 704 // We should have gone down 1 line, and not changed our top widget 705 try std.testing.expectEqual(0, scroll_view.scroll.top); 706 try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset); 707 // One more widget has scrolled into view 708 try std.testing.expectEqual(4, surface.children.len); 709 710 // Send `<c-n> to scroll down one more line 711 try scroll_widget.handleEvent( 712 &ctx, 713 .{ .key_press = .{ .codepoint = 'n', .mods = .{ .ctrl = true } } }, 714 ); 715 surface = try scroll_widget.draw(draw_ctx); 716 // 0 abc 717 // 1 def 718 // 2 ghi 719 // 3 |def| 720 // 4 |ghi| 721 // 5 |jkl| 722 // 6 | m|no 723 // We should have gone down 1 line, which scrolls our top widget out of view 724 try std.testing.expectEqual(1, scroll_view.scroll.top); 725 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 726 // The top widget has now scrolled out of view, but is still rendered out of view because of 727 // how pending scroll events are handled. 728 try std.testing.expectEqual(4, surface.children.len); 729 730 // We've scrolled to the bottom. 731 try std.testing.expectEqual(false, scroll_view.scroll.has_more_vertical); 732 733 // Scroll down one more line, this shouldn't do anything. 734 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 735 surface = try scroll_widget.draw(draw_ctx); 736 // 0 abc 737 // 1 def 738 // 2 ghi 739 // 3 |def| 740 // 4 |ghi| 741 // 5 |jkl| 742 // 6 | m|no 743 try std.testing.expectEqual(1, scroll_view.scroll.top); 744 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 745 // The top widget was scrolled out of view on the last render, so we should no longer be 746 // drawing it right above the current view. 747 try std.testing.expectEqual(3, surface.children.len); 748 749 // We've scrolled to the bottom. 750 try std.testing.expectEqual(false, scroll_view.scroll.has_more_vertical); 751 752 // === TEST SCROLL UP === // 753 754 mouse_event.button = .wheel_up; 755 756 // Send mouse up, now the top widget is in view. 757 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 758 surface = try scroll_widget.draw(draw_ctx); 759 // 0 abc 760 // 1 def 761 // 2 | g|hi 762 // 3 |def| 763 // 4 |ghi| 764 // 5 |jkl| 765 // 6 mno 766 try std.testing.expectEqual(0, scroll_view.scroll.top); 767 try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset); 768 // The top widget was scrolled out of view on the last render, so we should no longer be 769 // drawing it right above the current view. 770 try std.testing.expectEqual(4, surface.children.len); 771 772 // We've scrolled away from the bottom. 773 try std.testing.expectEqual(true, scroll_view.scroll.has_more_vertical); 774 775 // Send 'k' to scroll up, now the bottom widget should be out of view. 776 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'k' } }); 777 surface = try scroll_widget.draw(draw_ctx); 778 // 0 abc 779 // 1 | d|ef 780 // 2 | g|hi 781 // 3 |def| 782 // 4 |ghi| 783 // 5 jkl 784 // 6 mno 785 try std.testing.expectEqual(0, scroll_view.scroll.top); 786 try std.testing.expectEqual(1, scroll_view.scroll.vertical_offset); 787 // The top widget was scrolled out of view on the last render, so we should no longer be 788 // drawing it right above the current view. 789 try std.testing.expectEqual(3, surface.children.len); 790 791 // Send '<c-p>' to scroll up, now we should be at the top. 792 try scroll_widget.handleEvent( 793 &ctx, 794 .{ .key_press = .{ .codepoint = 'p', .mods = .{ .ctrl = true } } }, 795 ); 796 surface = try scroll_widget.draw(draw_ctx); 797 // 0 |abc| 798 // 1 | d|ef 799 // 2 | g|hi 800 // 3 |def| 801 // 4 ghi 802 // 5 jkl 803 // 6 mno 804 try std.testing.expectEqual(0, scroll_view.scroll.top); 805 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 806 // The top widget was scrolled out of view on the last render, so we should no longer be 807 // drawing it right above the current view. 808 try std.testing.expectEqual(2, surface.children.len); 809 810 // We should be at the top. 811 try std.testing.expectEqual(0, scroll_view.scroll.top); 812 // We should still have no horizontal scroll. 813 try std.testing.expectEqual(0, scroll_view.scroll.left); 814 815 // === TEST SCROLL LEFT - MOVES VIEW TO THE RIGHT === // 816 817 mouse_event.button = .wheel_left; 818 819 // Send `.wheel_left` to scroll the view to the right. 820 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 821 surface = try scroll_widget.draw(draw_ctx); 822 // 0 a|bc | 823 // 1 | de|f 824 // 2 | gh|i 825 // 3 d|ef | 826 // 4 ghi 827 // 5 jkl 828 // 6 mno 829 try std.testing.expectEqual(1, scroll_view.scroll.left); 830 // The number of children should be just the top 2 widgets. 831 try std.testing.expectEqual(2, surface.children.len); 832 // There is still more to draw horizontally. 833 try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 834 835 // Send `l` to scroll the view to the right. 836 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l' } }); 837 surface = try scroll_widget.draw(draw_ctx); 838 // 0 ab|c | 839 // 1 |def| 840 // 2 |ghi| 841 // 3 de|f | 842 // 4 ghi 843 // 5 jkl 844 // 6 mno 845 try std.testing.expectEqual(2, scroll_view.scroll.left); 846 // The number of children should be just the top 2 widgets. 847 try std.testing.expectEqual(2, surface.children.len); 848 // There is nothing more to draw horizontally. 849 try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 850 851 // Send `<c-f>` to scroll the view to the right, this should do nothing. 852 try scroll_widget.handleEvent( 853 &ctx, 854 .{ .key_press = .{ .codepoint = 'f', .mods = .{ .ctrl = true } } }, 855 ); 856 surface = try scroll_widget.draw(draw_ctx); 857 // 0 ab|c | 858 // 1 |def| 859 // 2 |ghi| 860 // 3 de|f | 861 // 4 ghi 862 // 5 jkl 863 // 6 mno 864 try std.testing.expectEqual(2, scroll_view.scroll.left); 865 // The number of children should be just the top 2 widgets. 866 try std.testing.expectEqual(2, surface.children.len); 867 // There is nothing more to draw horizontally. 868 try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 869 870 // Send `.wheel_right` to scroll the view to the left. 871 mouse_event.button = .wheel_right; 872 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 873 surface = try scroll_widget.draw(draw_ctx); 874 // 0 a|bc | 875 // 1 | de|f 876 // 2 | gh|i 877 // 3 d|ef | 878 // 4 ghi 879 // 5 jkl 880 // 6 mno 881 try std.testing.expectEqual(1, scroll_view.scroll.left); 882 // The number of children should be just the top 2 widgets. 883 try std.testing.expectEqual(2, surface.children.len); 884 // There is still more to draw horizontally. 885 try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 886 887 // Processing 2 or more events before drawing may produce overscroll, because we need to draw 888 // the children to determine whether there's more horizontal scrolling available. 889 try scroll_widget.handleEvent( 890 &ctx, 891 .{ .key_press = .{ .codepoint = 'f', .mods = .{ .ctrl = true } } }, 892 ); 893 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l' } }); 894 surface = try scroll_widget.draw(draw_ctx); 895 // 0 abc| | 896 // 1 d|ef | 897 // 2 g|hi | 898 // 3 def| | 899 // 4 ghi 900 // 5 jkl 901 // 6 mno 902 try std.testing.expectEqual(3, scroll_view.scroll.left); 903 // The number of children should be just the top 2 widgets. 904 try std.testing.expectEqual(2, surface.children.len); 905 // There is nothing more to draw horizontally. 906 try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 907 908 // === TEST SCROLL RIGHT - MOVES VIEW TO THE LEFT === // 909 910 // Send `.wheel_right` to scroll the view to the left. 911 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 912 surface = try scroll_widget.draw(draw_ctx); 913 // 0 ab|c | 914 // 1 |def| 915 // 2 |ghi| 916 // 3 de|f | 917 // 4 ghi 918 // 5 jkl 919 // 6 mno 920 try std.testing.expectEqual(2, scroll_view.scroll.left); 921 // The number of children should be just the top 2 widgets. 922 try std.testing.expectEqual(2, surface.children.len); 923 // There is nothing more to draw horizontally. 924 try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 925 926 // Send `h` to scroll the view to the left. 927 try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'h' } }); 928 surface = try scroll_widget.draw(draw_ctx); 929 // 0 a|bc | 930 // 1 | de|f 931 // 2 | gh|i 932 // 3 d|ef | 933 // 4 ghi 934 // 5 jkl 935 // 6 mno 936 try std.testing.expectEqual(1, scroll_view.scroll.left); 937 // The number of children should be just the top 2 widgets. 938 try std.testing.expectEqual(2, surface.children.len); 939 // There is now more to draw horizontally. 940 try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 941 942 // Send `<c-b>` to scroll the view to the left. 943 try scroll_widget.handleEvent( 944 &ctx, 945 .{ .key_press = .{ .codepoint = 'b', .mods = .{ .ctrl = true } } }, 946 ); 947 surface = try scroll_widget.draw(draw_ctx); 948 // 0 |abc| 949 // 1 | d|ef 950 // 2 | g|hi 951 // 3 |def| 952 // 4 ghi 953 // 5 jkl 954 // 6 mno 955 try std.testing.expectEqual(0, scroll_view.scroll.left); 956 // The number of children should be just the top 2 widgets. 957 try std.testing.expectEqual(2, surface.children.len); 958 // There is now more to draw horizontally. 959 try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 960 961 // === TEST COMBINED HORIZONTAL AND VERTICAL SCROLL === // 962 963 // Scroll 3 columns to the right and 2 rows down. 964 mouse_event.button = .wheel_left; 965 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 966 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 967 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 968 mouse_event.button = .wheel_down; 969 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 970 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 971 surface = try scroll_widget.draw(draw_ctx); 972 // 0 abc 973 // 1 def 974 // 2 g|hi | 975 // 3 def| | 976 // 4 ghi| | 977 // 5 jkl| | 978 // 6 mno 979 try std.testing.expectEqual(3, scroll_view.scroll.left); 980 try std.testing.expectEqual(0, scroll_view.scroll.top); 981 try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset); 982 // Even though only 1 child is visible, we still draw all 4 children in the view. 983 try std.testing.expectEqual(4, surface.children.len); 984 // There is nothing more to draw horizontally. 985 try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 986} 987 988// @reykjalin found an issue on mac with ghostty where the scroll up and scroll down were uneven. 989// Ghostty has high precision scrolling and sends a lot of wheel events for each tick 990test "ScrollView: uneven scroll" { 991 // Create child widgets 992 const Text = @import("Text.zig"); 993 const zero: Text = .{ .text = "0" }; 994 const one: Text = .{ .text = "1" }; 995 const two: Text = .{ .text = "2" }; 996 const three: Text = .{ .text = "3" }; 997 const four: Text = .{ .text = "4" }; 998 const five: Text = .{ .text = "5" }; 999 const six: Text = .{ .text = "6" }; 1000 // 0 | 1001 // 1 | 1002 // 2 | 1003 // 3 | 1004 // 4 1005 // 5 1006 // 6 1007 1008 // Create the list view 1009 const scroll_view: ScrollView = .{ 1010 .wheel_scroll = 1, // Set wheel scroll to one 1011 .children = .{ .slice = &.{ 1012 zero.widget(), 1013 one.widget(), 1014 two.widget(), 1015 three.widget(), 1016 four.widget(), 1017 five.widget(), 1018 six.widget(), 1019 } }, 1020 }; 1021 1022 // Boiler plate draw context 1023 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1024 defer arena.deinit(); 1025 const ucd = try vaxis.Unicode.init(arena.allocator()); 1026 vxfw.DrawContext.init(&ucd, .unicode); 1027 1028 const scroll_widget = scroll_view.widget(); 1029 const draw_ctx: vxfw.DrawContext = .{ 1030 .arena = arena.allocator(), 1031 .min = .{}, 1032 .max = .{ .width = 16, .height = 4 }, 1033 .cell_size = .{ .width = 10, .height = 20 }, 1034 }; 1035 1036 var surface = try scroll_widget.draw(draw_ctx); 1037 1038 var mouse_event: vaxis.Mouse = .{ 1039 .col = 0, 1040 .row = 0, 1041 .button = .wheel_up, 1042 .mods = .{}, 1043 .type = .press, 1044 }; 1045 // Event handlers need a context 1046 var ctx: vxfw.EventContext = .{ 1047 .alloc = std.testing.allocator, 1048 .cmds = .empty, 1049 }; 1050 defer ctx.cmds.deinit(ctx.alloc); 1051 1052 // Send a wheel down x 3 1053 mouse_event.button = .wheel_down; 1054 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1055 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1056 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1057 // We have to draw the widget for scrolls to take effect 1058 surface = try scroll_widget.draw(draw_ctx); 1059 // 0 1060 // 1 1061 // 2 1062 // 3 | 1063 // 4 | 1064 // 5 | 1065 // 6 | 1066 try std.testing.expectEqual(3, scroll_view.scroll.top); 1067 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 1068 // The first time we draw again we still draw all 7 children due to how pending scroll events 1069 // work. 1070 try std.testing.expectEqual(7, surface.children.len); 1071 1072 surface = try scroll_widget.draw(draw_ctx); 1073 // By drawing again without any pending events there are now only the 4 visible elements 1074 // rendered. 1075 try std.testing.expectEqual(4, surface.children.len); 1076 1077 // Now wheel_up two times should move us two lines up 1078 mouse_event.button = .wheel_up; 1079 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1080 try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1081 surface = try scroll_widget.draw(draw_ctx); 1082 try std.testing.expectEqual(1, scroll_view.scroll.top); 1083 try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 1084 try std.testing.expectEqual(4, surface.children.len); 1085} 1086 1087test "refAllDecls" { 1088 std.testing.refAllDecls(@This()); 1089}