this repo has no description
13
fork

Configure Feed

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

vxfw: Add ScrollView widget

The widget will render its children in a container that can be scrolled
both horizontally and vertically. The widget itself does not render any
scroll bars or other indicators of current scroll position.

Since this view is heavily based on the ListView widget it inherits the
same `cursor` functionality to show the current position of a selected
widget.

Known Issues
============

1. The view currently does not enforce a maximum width on the content to
be able to correctly figure out whether the content can still be
scrolled horizontally. This will cause the widget to draw beyond its
boundaries horizontally.
2. When the last widget rendered is taller than a single row the whole
widget will be drawn. This will cause the widget to draw beyond its
boundaries vertically.

authored by

Kristófer R and committed by
Tim Culverhouse
ee6c47c5 9ec42325

+1091
+1090
src/vxfw/ScrollView.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("../main.zig"); 3 + 4 + const assert = std.debug.assert; 5 + 6 + const Allocator = std.mem.Allocator; 7 + 8 + const vxfw = @import("vxfw.zig"); 9 + 10 + const ScrollView = @This(); 11 + 12 + pub 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 + 21 + pub const Source = union(enum) { 22 + slice: []const vxfw.Widget, 23 + builder: Builder, 24 + }; 25 + 26 + const 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 + 66 + children: Source, 67 + cursor: u32 = 0, 68 + last_height: u8 = 0, 69 + /// When true, the widget will draw a cursor next to the widget which has the cursor 70 + draw_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. 73 + cursor_indicator: vaxis.Cell = .{ .char = .{ .grapheme = "▐", .width = 1 } }, 74 + /// Lines to scroll for a mouse wheel 75 + wheel_scroll: u8 = 3, 76 + /// Set this if the exact item count is known. 77 + item_count: ?u32 = null, 78 + 79 + /// scroll position 80 + scroll: Scroll = .{}, 81 + 82 + pub fn widget(self: *const ScrollView) vxfw.Widget { 83 + return .{ 84 + .userdata = @constCast(self), 85 + .eventHandler = typeErasedEventHandler, 86 + .drawFn = typeErasedDrawFn, 87 + }; 88 + } 89 + 90 + fn 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 + 95 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 96 + const self: *ScrollView = @ptrCast(@alignCast(ptr)); 97 + return self.draw(ctx); 98 + } 99 + 100 + pub 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 + 172 + pub 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 + 185 + pub 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 + 224 + pub 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 261 + pub 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 271 + fn 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(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 + 328 + fn 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 + 336 + fn 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 + surface.focusable = true; 352 + // Assume we have more. We only know we don't after drawing 353 + self.scroll.has_more_vertical = true; 354 + } 355 + 356 + var child_list = std.ArrayList(vxfw.SubSurface).init(ctx.arena); 357 + 358 + // Accumulated height tracks how much height we have drawn. It's initial state is 359 + // -(scroll.vertical_offset + scroll.pending_lines) lines _above_ the surface top edge. 360 + // Example: 361 + // 1. Scroll up 3 lines: 362 + // pending_lines = -3 363 + // offset = 0 364 + // accumulated_height = -(0 + -3) = 3; 365 + // Our first widget is placed at row 3, we will need to fill this in after the draw 366 + // 2. Scroll up 3 lines, with an offset of 4 367 + // pending_lines = -3 368 + // offset = 4 369 + // accumulated_height = -(4 + -3) = -1; 370 + // Our first widget is placed at row -1 371 + // 3. Scroll down 3 lines: 372 + // pending_lines = 3 373 + // offset = 0 374 + // accumulated_height = -(0 + 3) = -3; 375 + // Our first widget is placed at row -3. It's possible it consumes the entire widget. We 376 + // will check for this at the end and only include visible children 377 + var accumulated_height: i17 = -(self.scroll.vertical_offset + self.scroll.pending_lines); 378 + 379 + // We handled the pending scroll by assigning accumulated_height. Reset it's state 380 + self.scroll.pending_lines = 0; 381 + 382 + // Set the initial index for our downard loop. We do this here because we might modify 383 + // scroll.top before we traverse downward 384 + var i: usize = self.scroll.top; 385 + 386 + // If we are on the first item, and we have an upward scroll that consumed our offset, eg 387 + // accumulated_height > 0, we reset state here. We can't scroll up anymore so we set 388 + // accumulated_height to 0. 389 + if (accumulated_height > 0 and self.scroll.top == 0) { 390 + self.scroll.vertical_offset = 0; 391 + accumulated_height = 0; 392 + } 393 + 394 + // If we are offset downward, insert widgets to the front of the list before traversing downard 395 + if (accumulated_height > 0) { 396 + try self.insertChildren(ctx, builder, &child_list, accumulated_height); 397 + const last_child = child_list.items[child_list.items.len - 1]; 398 + accumulated_height = last_child.origin.row + last_child.surface.size.height; 399 + } 400 + 401 + const child_offset: u16 = if (self.draw_cursor) 2 else 0; 402 + 403 + while (builder.itemAtIdx(i, self.cursor)) |child| { 404 + // Defer the increment 405 + defer i += 1; 406 + 407 + // Set up constraints. We let the child be the entire height if it wants 408 + const child_ctx = ctx.withConstraints( 409 + .{ .width = max_size.width - child_offset, .height = 0 }, 410 + .{ .width = null, .height = null }, 411 + ); 412 + 413 + // Draw the child 414 + var surf = try child.draw(child_ctx); 415 + // We set the child to non-focusable so that we can manage where the keyevents go 416 + surf.focusable = false; 417 + 418 + // Add the child surface to our list. It's offset from parent is the accumulated height 419 + try child_list.append(.{ 420 + .origin = .{ .col = child_offset - @as(i17, @intCast(self.scroll.left)), .row = accumulated_height }, 421 + .surface = surf, 422 + .z_index = 0, 423 + }); 424 + 425 + // Accumulate the height 426 + accumulated_height += surf.size.height; 427 + 428 + if (self.scroll.wants_cursor and i < self.cursor) 429 + continue // continue if we want the cursor and haven't gotten there yet 430 + else if (accumulated_height >= max_size.height) 431 + break; // Break if we drew enough 432 + } else { 433 + // This branch runs if we ran out of items. Set our state accordingly 434 + self.scroll.has_more_vertical = false; 435 + } 436 + 437 + // If we've looped through all the items without hitting the end we check for one more item to 438 + // see if we just drew the last item on the bottom of the screen. If we just drew the last item 439 + // we can set `scroll.has_more` to false. 440 + if (self.scroll.has_more_vertical and accumulated_height <= max_size.height) { 441 + if (builder.itemAtIdx(i, self.cursor) == null) self.scroll.has_more_vertical = false; 442 + } 443 + 444 + var total_height: usize = totalHeight(&child_list); 445 + 446 + // If we reached the bottom, don't have enough height to fill the screen, and have room to add 447 + // more, then we add more until out of items or filled the space. This can happen on a resize 448 + if (!self.scroll.has_more_vertical and total_height < max_size.height and self.scroll.top > 0) { 449 + try self.insertChildren(ctx, builder, &child_list, @intCast(max_size.height - total_height)); 450 + // Set the new total height 451 + total_height = totalHeight(&child_list); 452 + } 453 + 454 + if (self.draw_cursor and self.cursor >= self.scroll.top) blk: { 455 + // The index of the cursored widget in our child_list 456 + const cursored_idx: u32 = self.cursor - self.scroll.top; 457 + // Nothing to draw if our cursor is below our viewport 458 + if (cursored_idx >= child_list.items.len) break :blk; 459 + 460 + const sub = try ctx.arena.alloc(vxfw.SubSurface, 1); 461 + const child = child_list.items[cursored_idx]; 462 + sub[0] = .{ 463 + .origin = .{ .col = child_offset - @as(i17, @intCast(self.scroll.left)), .row = 0 }, 464 + .surface = child.surface, 465 + .z_index = 0, 466 + }; 467 + const cursor_surf = try vxfw.Surface.initWithChildren( 468 + ctx.arena, 469 + self.widget(), 470 + .{ .width = child_offset, .height = child.surface.size.height }, 471 + sub, 472 + ); 473 + for (0..cursor_surf.size.height) |row| { 474 + cursor_surf.writeCell(0, @intCast(row), self.cursor_indicator); 475 + } 476 + child_list.items[cursored_idx] = .{ 477 + .origin = .{ .col = 0, .row = child.origin.row }, 478 + .surface = cursor_surf, 479 + .z_index = 0, 480 + }; 481 + } 482 + 483 + // If we want the cursor, we check that the cursored widget is fully in view. If it is too 484 + // large, we position it so that it is the top item with a 0 offset 485 + if (self.scroll.wants_cursor) { 486 + const cursored_idx: u32 = self.cursor - self.scroll.top; 487 + const sub = child_list.items[cursored_idx]; 488 + // The bottom row of the cursored widget 489 + const bottom = sub.origin.row + sub.surface.size.height; 490 + if (bottom > max_size.height) { 491 + // Adjust the origin by the difference 492 + // anchor bottom 493 + var origin: i17 = max_size.height; 494 + var idx: usize = cursored_idx + 1; 495 + while (idx > 0) : (idx -= 1) { 496 + var child = child_list.items[idx - 1]; 497 + origin -= child.surface.size.height; 498 + child.origin.row = origin; 499 + child_list.items[idx - 1] = child; 500 + } 501 + } else if (sub.surface.size.height >= max_size.height) { 502 + // TODO: handle when the child is larger than our height. 503 + // We need to change the max constraint to be optional sizes so that we can support 504 + // unbounded drawing in scrollable areas 505 + self.scroll.top = self.cursor; 506 + self.scroll.vertical_offset = 0; 507 + child_list.deinit(); 508 + try child_list.append(.{ 509 + .origin = .{ .col = 0 - @as(i17, @intCast(self.scroll.left)), .row = 0 }, 510 + .surface = sub.surface, 511 + .z_index = 0, 512 + }); 513 + total_height = sub.surface.size.height; 514 + } 515 + } 516 + 517 + // If we reached the bottom, we need to reset origins 518 + if (!self.scroll.has_more_vertical and total_height < max_size.height) { 519 + // anchor top 520 + assert(self.scroll.top == 0); 521 + self.scroll.vertical_offset = 0; 522 + var origin: i17 = 0; 523 + for (0..child_list.items.len) |idx| { 524 + var child = child_list.items[idx]; 525 + child.origin.row = origin; 526 + origin += child.surface.size.height; 527 + child_list.items[idx] = child; 528 + } 529 + } else if (!self.scroll.has_more_vertical) { 530 + // anchor bottom 531 + var origin: i17 = max_size.height; 532 + var idx: usize = child_list.items.len; 533 + while (idx > 0) : (idx -= 1) { 534 + var child = child_list.items[idx - 1]; 535 + origin -= child.surface.size.height; 536 + child.origin.row = origin; 537 + child_list.items[idx - 1] = child; 538 + } 539 + } 540 + 541 + // Reset horizontal scroll info. 542 + self.scroll.has_more_horizontal = false; 543 + for (child_list.items) |child| { 544 + if (child.surface.size.width -| self.scroll.left > max_size.width) { 545 + self.scroll.has_more_horizontal = true; 546 + break; 547 + } 548 + } 549 + 550 + var start: usize = 0; 551 + var end: usize = child_list.items.len; 552 + 553 + for (child_list.items, 0..) |child, idx| { 554 + if (child.origin.row <= 0 and child.origin.row + child.surface.size.height > 0) { 555 + start = idx; 556 + self.scroll.vertical_offset = -child.origin.row; 557 + self.scroll.top += @intCast(idx); 558 + } 559 + if (child.origin.row > max_size.height) { 560 + end = idx; 561 + break; 562 + } 563 + } 564 + 565 + surface.children = child_list.items; 566 + 567 + // Update last known height. 568 + // If the bits from total_height don't fit u8 we won't get the right value from @intCast or 569 + // @truncate so we check manually. 570 + self.last_height = if (total_height > 255) 255 else @intCast(total_height); 571 + 572 + return surface; 573 + } 574 + 575 + const SliceBuilder = struct { 576 + slice: []const vxfw.Widget, 577 + 578 + fn build(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget { 579 + const self: *const SliceBuilder = @ptrCast(@alignCast(ptr)); 580 + if (idx >= self.slice.len) return null; 581 + return self.slice[idx]; 582 + } 583 + }; 584 + 585 + test ScrollView { 586 + // Create child widgets 587 + const Text = @import("Text.zig"); 588 + const abc: Text = .{ .text = "abc\n def\n ghi" }; 589 + const def: Text = .{ .text = "def" }; 590 + const ghi: Text = .{ .text = "ghi" }; 591 + const jklmno: Text = .{ .text = "jkl\n mno" }; 592 + // 593 + // 0 |abc| 594 + // 1 | d|ef 595 + // 2 | g|hi 596 + // 3 |def| 597 + // 4 ghi 598 + // 5 jkl 599 + // 6 mno 600 + 601 + // Create the scroll view 602 + const scroll_view: ScrollView = .{ 603 + .wheel_scroll = 1, // Set wheel scroll to one 604 + .children = .{ .slice = &.{ 605 + abc.widget(), 606 + def.widget(), 607 + ghi.widget(), 608 + jklmno.widget(), 609 + } }, 610 + }; 611 + 612 + // Boiler plate draw context 613 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 614 + defer arena.deinit(); 615 + const ucd = try vaxis.Unicode.init(arena.allocator()); 616 + vxfw.DrawContext.init(&ucd, .unicode); 617 + 618 + const scroll_widget = scroll_view.widget(); 619 + const draw_ctx: vxfw.DrawContext = .{ 620 + .arena = arena.allocator(), 621 + .min = .{}, 622 + .max = .{ .width = 3, .height = 4 }, 623 + .cell_size = .{ .width = 10, .height = 20 }, 624 + }; 625 + 626 + var surface = try scroll_widget.draw(draw_ctx); 627 + // ScrollView expands to max height and max width 628 + try std.testing.expectEqual(4, surface.size.height); 629 + try std.testing.expectEqual(3, surface.size.width); 630 + // We have 2 children, because only visible children appear as a surface 631 + try std.testing.expectEqual(2, surface.children.len); 632 + 633 + // ScrollView starts at the top and left. 634 + try std.testing.expectEqual(0, scroll_view.scroll.top); 635 + try std.testing.expectEqual(0, scroll_view.scroll.left); 636 + 637 + // With the widgets provided the scroll view should have both more content to scroll vertically 638 + // and horizontally. 639 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_vertical); 640 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 641 + 642 + var mouse_event: vaxis.Mouse = .{ 643 + .col = 0, 644 + .row = 0, 645 + .button = .wheel_up, 646 + .mods = .{}, 647 + .type = .press, 648 + }; 649 + // Event handlers need a context 650 + var ctx: vxfw.EventContext = .{ 651 + .cmds = std.ArrayList(vxfw.Command).init(std.testing.allocator), 652 + }; 653 + defer ctx.cmds.deinit(); 654 + 655 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 656 + // Wheel up doesn't adjust the scroll 657 + try std.testing.expectEqual(0, scroll_view.scroll.top); 658 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 659 + 660 + // Wheel right doesn't adjust the horizontal scroll 661 + mouse_event.button = .wheel_right; 662 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 663 + try std.testing.expectEqual(0, scroll_view.scroll.left); 664 + 665 + // Scroll right with 'h' doesn't adjust the horizontal scroll 666 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'h' } }); 667 + try std.testing.expectEqual(0, scroll_view.scroll.left); 668 + 669 + // Scroll right with '<c-b>' doesn't adjust the horizontal scroll 670 + try scroll_widget.handleEvent( 671 + &ctx, 672 + .{ .key_press = .{ .codepoint = 'c', .mods = .{ .ctrl = true } } }, 673 + ); 674 + try std.testing.expectEqual(0, scroll_view.scroll.left); 675 + 676 + // === TEST SCROLL DOWN === // 677 + 678 + // Send a wheel down to scroll down one line 679 + mouse_event.button = .wheel_down; 680 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 681 + // We have to draw the widget for scrolls to take effect 682 + surface = try scroll_widget.draw(draw_ctx); 683 + // 0 abc 684 + // 1 | d|ef 685 + // 2 | g|hi 686 + // 3 |def| 687 + // 4 |ghi| 688 + // 5 jkl 689 + // 6 mno 690 + // We should have gone down 1 line, and not changed our top widget 691 + try std.testing.expectEqual(0, scroll_view.scroll.top); 692 + try std.testing.expectEqual(1, scroll_view.scroll.vertical_offset); 693 + // One more widget has scrolled into view 694 + try std.testing.expectEqual(3, surface.children.len); 695 + 696 + // Send a 'j' to scroll down one more line. 697 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 698 + surface = try scroll_widget.draw(draw_ctx); 699 + // 0 abc 700 + // 1 def 701 + // 2 | g|hi 702 + // 3 |def| 703 + // 4 |ghi| 704 + // 5 |jkl| 705 + // 6 mno 706 + // We should have gone down 1 line, and not changed our top widget 707 + try std.testing.expectEqual(0, scroll_view.scroll.top); 708 + try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset); 709 + // One more widget has scrolled into view 710 + try std.testing.expectEqual(4, surface.children.len); 711 + 712 + // Send `<c-n> to scroll down one more line 713 + try scroll_widget.handleEvent( 714 + &ctx, 715 + .{ .key_press = .{ .codepoint = 'n', .mods = .{ .ctrl = true } } }, 716 + ); 717 + surface = try scroll_widget.draw(draw_ctx); 718 + // 0 abc 719 + // 1 def 720 + // 2 ghi 721 + // 3 |def| 722 + // 4 |ghi| 723 + // 5 |jkl| 724 + // 6 | m|no 725 + // We should have gone down 1 line, which scrolls our top widget out of view 726 + try std.testing.expectEqual(1, scroll_view.scroll.top); 727 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 728 + // The top widget has now scrolled out of view, but is still rendered out of view because of 729 + // how pending scroll events are handled. 730 + try std.testing.expectEqual(4, surface.children.len); 731 + 732 + // We've scrolled to the bottom. 733 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_vertical); 734 + 735 + // Scroll down one more line, this shouldn't do anything. 736 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 737 + surface = try scroll_widget.draw(draw_ctx); 738 + // 0 abc 739 + // 1 def 740 + // 2 ghi 741 + // 3 |def| 742 + // 4 |ghi| 743 + // 5 |jkl| 744 + // 6 | m|no 745 + try std.testing.expectEqual(1, scroll_view.scroll.top); 746 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 747 + // The top widget was scrolled out of view on the last render, so we should no longer be 748 + // drawing it right above the current view. 749 + try std.testing.expectEqual(3, surface.children.len); 750 + 751 + // We've scrolled to the bottom. 752 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_vertical); 753 + 754 + // === TEST SCROLL UP === // 755 + 756 + mouse_event.button = .wheel_up; 757 + 758 + // Send mouse up, now the top widget is in view. 759 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 760 + surface = try scroll_widget.draw(draw_ctx); 761 + // 0 abc 762 + // 1 def 763 + // 2 | g|hi 764 + // 3 |def| 765 + // 4 |ghi| 766 + // 5 |jkl| 767 + // 6 mno 768 + try std.testing.expectEqual(0, scroll_view.scroll.top); 769 + try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset); 770 + // The top widget was scrolled out of view on the last render, so we should no longer be 771 + // drawing it right above the current view. 772 + try std.testing.expectEqual(4, surface.children.len); 773 + 774 + // We've scrolled away from the bottom. 775 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_vertical); 776 + 777 + // Send 'k' to scroll up, now the bottom widget should be out of view. 778 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'k' } }); 779 + surface = try scroll_widget.draw(draw_ctx); 780 + // 0 abc 781 + // 1 | d|ef 782 + // 2 | g|hi 783 + // 3 |def| 784 + // 4 |ghi| 785 + // 5 jkl 786 + // 6 mno 787 + try std.testing.expectEqual(0, scroll_view.scroll.top); 788 + try std.testing.expectEqual(1, scroll_view.scroll.vertical_offset); 789 + // The top widget was scrolled out of view on the last render, so we should no longer be 790 + // drawing it right above the current view. 791 + try std.testing.expectEqual(3, surface.children.len); 792 + 793 + // Send '<c-p>' to scroll up, now we should be at the top. 794 + try scroll_widget.handleEvent( 795 + &ctx, 796 + .{ .key_press = .{ .codepoint = 'p', .mods = .{ .ctrl = true } } }, 797 + ); 798 + surface = try scroll_widget.draw(draw_ctx); 799 + // 0 |abc| 800 + // 1 | d|ef 801 + // 2 | g|hi 802 + // 3 |def| 803 + // 4 ghi 804 + // 5 jkl 805 + // 6 mno 806 + try std.testing.expectEqual(0, scroll_view.scroll.top); 807 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 808 + // The top widget was scrolled out of view on the last render, so we should no longer be 809 + // drawing it right above the current view. 810 + try std.testing.expectEqual(2, surface.children.len); 811 + 812 + // We should be at the top. 813 + try std.testing.expectEqual(0, scroll_view.scroll.top); 814 + // We should still have no horizontal scroll. 815 + try std.testing.expectEqual(0, scroll_view.scroll.left); 816 + 817 + // === TEST SCROLL LEFT - MOVES VIEW TO THE RIGHT === // 818 + 819 + mouse_event.button = .wheel_left; 820 + 821 + // Send `.wheel_left` to scroll the view to the right. 822 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 823 + surface = try scroll_widget.draw(draw_ctx); 824 + // 0 a|bc | 825 + // 1 | de|f 826 + // 2 | gh|i 827 + // 3 d|ef | 828 + // 4 ghi 829 + // 5 jkl 830 + // 6 mno 831 + try std.testing.expectEqual(1, scroll_view.scroll.left); 832 + // The number of children should be just the top 2 widgets. 833 + try std.testing.expectEqual(2, surface.children.len); 834 + // There is still more to draw horizontally. 835 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 836 + 837 + // Send `l` to scroll the view to the right. 838 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l' } }); 839 + surface = try scroll_widget.draw(draw_ctx); 840 + // 0 ab|c | 841 + // 1 |def| 842 + // 2 |ghi| 843 + // 3 de|f | 844 + // 4 ghi 845 + // 5 jkl 846 + // 6 mno 847 + try std.testing.expectEqual(2, scroll_view.scroll.left); 848 + // The number of children should be just the top 2 widgets. 849 + try std.testing.expectEqual(2, surface.children.len); 850 + // There is nothing more to draw horizontally. 851 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 852 + 853 + // Send `<c-f>` to scroll the view to the right, this should do nothing. 854 + try scroll_widget.handleEvent( 855 + &ctx, 856 + .{ .key_press = .{ .codepoint = 'f', .mods = .{ .ctrl = true } } }, 857 + ); 858 + surface = try scroll_widget.draw(draw_ctx); 859 + // 0 ab|c | 860 + // 1 |def| 861 + // 2 |ghi| 862 + // 3 de|f | 863 + // 4 ghi 864 + // 5 jkl 865 + // 6 mno 866 + try std.testing.expectEqual(2, scroll_view.scroll.left); 867 + // The number of children should be just the top 2 widgets. 868 + try std.testing.expectEqual(2, surface.children.len); 869 + // There is nothing more to draw horizontally. 870 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 871 + 872 + // Send `.wheel_right` to scroll the view to the left. 873 + mouse_event.button = .wheel_right; 874 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 875 + surface = try scroll_widget.draw(draw_ctx); 876 + // 0 a|bc | 877 + // 1 | de|f 878 + // 2 | gh|i 879 + // 3 d|ef | 880 + // 4 ghi 881 + // 5 jkl 882 + // 6 mno 883 + try std.testing.expectEqual(1, scroll_view.scroll.left); 884 + // The number of children should be just the top 2 widgets. 885 + try std.testing.expectEqual(2, surface.children.len); 886 + // There is still more to draw horizontally. 887 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 888 + 889 + // Processing 2 or more events before drawing may produce overscroll, because we need to draw 890 + // the children to determine whether there's more horizontal scrolling available. 891 + try scroll_widget.handleEvent( 892 + &ctx, 893 + .{ .key_press = .{ .codepoint = 'f', .mods = .{ .ctrl = true } } }, 894 + ); 895 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l' } }); 896 + surface = try scroll_widget.draw(draw_ctx); 897 + // 0 abc| | 898 + // 1 d|ef | 899 + // 2 g|hi | 900 + // 3 def| | 901 + // 4 ghi 902 + // 5 jkl 903 + // 6 mno 904 + try std.testing.expectEqual(3, scroll_view.scroll.left); 905 + // The number of children should be just the top 2 widgets. 906 + try std.testing.expectEqual(2, surface.children.len); 907 + // There is nothing more to draw horizontally. 908 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 909 + 910 + // === TEST SCROLL RIGHT - MOVES VIEW TO THE LEFT === // 911 + 912 + // Send `.wheel_right` to scroll the view to the left. 913 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 914 + surface = try scroll_widget.draw(draw_ctx); 915 + // 0 ab|c | 916 + // 1 |def| 917 + // 2 |ghi| 918 + // 3 de|f | 919 + // 4 ghi 920 + // 5 jkl 921 + // 6 mno 922 + try std.testing.expectEqual(2, scroll_view.scroll.left); 923 + // The number of children should be just the top 2 widgets. 924 + try std.testing.expectEqual(2, surface.children.len); 925 + // There is nothing more to draw horizontally. 926 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 927 + 928 + // Send `h` to scroll the view to the left. 929 + try scroll_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'h' } }); 930 + surface = try scroll_widget.draw(draw_ctx); 931 + // 0 a|bc | 932 + // 1 | de|f 933 + // 2 | gh|i 934 + // 3 d|ef | 935 + // 4 ghi 936 + // 5 jkl 937 + // 6 mno 938 + try std.testing.expectEqual(1, scroll_view.scroll.left); 939 + // The number of children should be just the top 2 widgets. 940 + try std.testing.expectEqual(2, surface.children.len); 941 + // There is now more to draw horizontally. 942 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 943 + 944 + // Send `<c-b>` to scroll the view to the left. 945 + try scroll_widget.handleEvent( 946 + &ctx, 947 + .{ .key_press = .{ .codepoint = 'b', .mods = .{ .ctrl = true } } }, 948 + ); 949 + surface = try scroll_widget.draw(draw_ctx); 950 + // 0 |abc| 951 + // 1 | d|ef 952 + // 2 | g|hi 953 + // 3 |def| 954 + // 4 ghi 955 + // 5 jkl 956 + // 6 mno 957 + try std.testing.expectEqual(0, scroll_view.scroll.left); 958 + // The number of children should be just the top 2 widgets. 959 + try std.testing.expectEqual(2, surface.children.len); 960 + // There is now more to draw horizontally. 961 + try std.testing.expectEqual(true, scroll_view.scroll.has_more_horizontal); 962 + 963 + // === TEST COMBINED HORIZONTAL AND VERTICAL SCROLL === // 964 + 965 + // Scroll 3 columns to the right and 2 rows down. 966 + mouse_event.button = .wheel_left; 967 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 968 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 969 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 970 + mouse_event.button = .wheel_down; 971 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 972 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 973 + surface = try scroll_widget.draw(draw_ctx); 974 + // 0 abc 975 + // 1 def 976 + // 2 g|hi | 977 + // 3 def| | 978 + // 4 ghi| | 979 + // 5 jkl| | 980 + // 6 mno 981 + try std.testing.expectEqual(3, scroll_view.scroll.left); 982 + try std.testing.expectEqual(0, scroll_view.scroll.top); 983 + try std.testing.expectEqual(2, scroll_view.scroll.vertical_offset); 984 + // Even though only 1 child is visible, we still draw all 4 children in the view. 985 + try std.testing.expectEqual(4, surface.children.len); 986 + // There is nothing more to draw horizontally. 987 + try std.testing.expectEqual(false, scroll_view.scroll.has_more_horizontal); 988 + } 989 + 990 + // @reykjalin found an issue on mac with ghostty where the scroll up and scroll down were uneven. 991 + // Ghostty has high precision scrolling and sends a lot of wheel events for each tick 992 + test "ScrollView: uneven scroll" { 993 + // Create child widgets 994 + const Text = @import("Text.zig"); 995 + const zero: Text = .{ .text = "0" }; 996 + const one: Text = .{ .text = "1" }; 997 + const two: Text = .{ .text = "2" }; 998 + const three: Text = .{ .text = "3" }; 999 + const four: Text = .{ .text = "4" }; 1000 + const five: Text = .{ .text = "5" }; 1001 + const six: Text = .{ .text = "6" }; 1002 + // 0 | 1003 + // 1 | 1004 + // 2 | 1005 + // 3 | 1006 + // 4 1007 + // 5 1008 + // 6 1009 + 1010 + // Create the list view 1011 + const scroll_view: ScrollView = .{ 1012 + .wheel_scroll = 1, // Set wheel scroll to one 1013 + .children = .{ .slice = &.{ 1014 + zero.widget(), 1015 + one.widget(), 1016 + two.widget(), 1017 + three.widget(), 1018 + four.widget(), 1019 + five.widget(), 1020 + six.widget(), 1021 + } }, 1022 + }; 1023 + 1024 + // Boiler plate draw context 1025 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1026 + defer arena.deinit(); 1027 + const ucd = try vaxis.Unicode.init(arena.allocator()); 1028 + vxfw.DrawContext.init(&ucd, .unicode); 1029 + 1030 + const scroll_widget = scroll_view.widget(); 1031 + const draw_ctx: vxfw.DrawContext = .{ 1032 + .arena = arena.allocator(), 1033 + .min = .{}, 1034 + .max = .{ .width = 16, .height = 4 }, 1035 + .cell_size = .{ .width = 10, .height = 20 }, 1036 + }; 1037 + 1038 + var surface = try scroll_widget.draw(draw_ctx); 1039 + 1040 + var mouse_event: vaxis.Mouse = .{ 1041 + .col = 0, 1042 + .row = 0, 1043 + .button = .wheel_up, 1044 + .mods = .{}, 1045 + .type = .press, 1046 + }; 1047 + // Event handlers need a context 1048 + var ctx: vxfw.EventContext = .{ 1049 + .cmds = std.ArrayList(vxfw.Command).init(std.testing.allocator), 1050 + }; 1051 + defer ctx.cmds.deinit(); 1052 + 1053 + // Send a wheel down x 3 1054 + mouse_event.button = .wheel_down; 1055 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1056 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1057 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1058 + // We have to draw the widget for scrolls to take effect 1059 + surface = try scroll_widget.draw(draw_ctx); 1060 + // 0 1061 + // 1 1062 + // 2 1063 + // 3 | 1064 + // 4 | 1065 + // 5 | 1066 + // 6 | 1067 + try std.testing.expectEqual(3, scroll_view.scroll.top); 1068 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 1069 + // The first time we draw again we still draw all 7 children due to how pending scroll events 1070 + // work. 1071 + try std.testing.expectEqual(7, surface.children.len); 1072 + 1073 + surface = try scroll_widget.draw(draw_ctx); 1074 + // By drawing again without any pending events there are now only the 4 visible elements 1075 + // rendered. 1076 + try std.testing.expectEqual(4, surface.children.len); 1077 + 1078 + // Now wheel_up two times should move us two lines up 1079 + mouse_event.button = .wheel_up; 1080 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1081 + try scroll_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 1082 + surface = try scroll_widget.draw(draw_ctx); 1083 + try std.testing.expectEqual(1, scroll_view.scroll.top); 1084 + try std.testing.expectEqual(0, scroll_view.scroll.vertical_offset); 1085 + try std.testing.expectEqual(4, surface.children.len); 1086 + } 1087 + 1088 + test "refAllDecls" { 1089 + std.testing.refAllDecls(@This()); 1090 + }
+1
src/vxfw/vxfw.zig
··· 19 19 pub const ListView = @import("ListView.zig"); 20 20 pub const Padding = @import("Padding.zig"); 21 21 pub const RichText = @import("RichText.zig"); 22 + pub const ScrollView = @import("ScrollView.zig"); 22 23 pub const SizedBox = @import("SizedBox.zig"); 23 24 pub const SplitView = @import("SplitView.zig"); 24 25 pub const Spinner = @import("Spinner.zig");