a modern tui library written in zig
at main 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}