this repo has no description
13
fork

Configure Feed

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

at 3943a6f42f7f4c73ab9493dfb0a4e123be15d302 1087 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 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}