this repo has no description
13
fork

Configure Feed

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

vxfw: add ListView widget

Add the ListView widget. ListView is a scrollable list of widgets. It
has a cursor and mouse support.

+687
+686
src/vxfw/ListView.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 ListView = @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 + 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 + offset: i17 = 0, 31 + /// Pending scroll amount 32 + pending_lines: i17 = 0, 33 + /// If there is more room to scroll down 34 + has_more: bool = true, 35 + /// The cursor must be in the viewport 36 + wants_cursor: bool = false, 37 + 38 + fn linesDown(self: *Scroll, n: u8) bool { 39 + if (!self.has_more) return false; 40 + self.pending_lines += n; 41 + return true; 42 + } 43 + 44 + fn linesUp(self: *Scroll, n: u8) bool { 45 + if (self.top == 0 and self.offset == 0) return false; 46 + self.pending_lines = -1 * @as(i17, @intCast(n)); 47 + return true; 48 + } 49 + }; 50 + 51 + const cursor_indicator: vaxis.Cell = .{ .char = .{ .grapheme = "▐", .width = 1 } }; 52 + 53 + children: Source, 54 + cursor: u32 = 0, 55 + /// When true, the widget will draw a cursor next to the widget which has the cursor 56 + draw_cursor: bool = true, 57 + /// Lines to scroll for a mouse wheel 58 + wheel_scroll: u8 = 3, 59 + /// Set this if the exact item count is known. 60 + item_count: ?u32 = null, 61 + 62 + /// scroll position 63 + scroll: Scroll = .{}, 64 + 65 + pub fn widget(self: *const ListView) vxfw.Widget { 66 + return .{ 67 + .userdata = @constCast(self), 68 + .eventHandler = typeErasedEventHandler, 69 + .drawFn = typeErasedDrawFn, 70 + }; 71 + } 72 + 73 + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 74 + const self: *ListView = @ptrCast(@alignCast(ptr)); 75 + return self.handleEvent(ctx, event); 76 + } 77 + 78 + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 79 + const self: *ListView = @ptrCast(@alignCast(ptr)); 80 + return self.draw(ctx); 81 + } 82 + 83 + pub fn handleEvent(self: *ListView, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 84 + switch (event) { 85 + .mouse => |mouse| { 86 + if (mouse.button == .wheel_up) { 87 + if (self.scroll.linesUp(self.wheel_scroll)) 88 + ctx.consumeAndRedraw(); 89 + } 90 + if (mouse.button == .wheel_down) { 91 + if (self.scroll.linesDown(self.wheel_scroll)) 92 + ctx.consumeAndRedraw(); 93 + } 94 + }, 95 + .key_press => |key| { 96 + if (key.matches('j', .{}) or 97 + key.matches('n', .{ .ctrl = true }) or 98 + key.matches(vaxis.Key.down, .{})) 99 + { 100 + return self.nextItem(ctx); 101 + } 102 + if (key.matches('k', .{}) or 103 + key.matches('p', .{ .ctrl = true }) or 104 + key.matches(vaxis.Key.up, .{})) 105 + { 106 + return self.prevItem(ctx); 107 + } 108 + if (key.matches(vaxis.Key.escape, .{})) { 109 + self.ensureScroll(); 110 + return ctx.consumeAndRedraw(); 111 + } 112 + 113 + // All other keypresses go to our focused child 114 + switch (self.children) { 115 + .slice => |slice| { 116 + const child = slice[self.cursor]; 117 + return child.handleEvent(ctx, event); 118 + }, 119 + .builder => |builder| { 120 + if (builder.itemAtIdx(self.cursor, self.cursor)) |child| { 121 + return child.handleEvent(ctx, event); 122 + } 123 + }, 124 + } 125 + }, 126 + else => {}, 127 + } 128 + } 129 + 130 + pub fn draw(self: *ListView, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 131 + std.debug.assert(ctx.max.width != null); 132 + std.debug.assert(ctx.max.height != null); 133 + switch (self.children) { 134 + .slice => |slice| { 135 + self.item_count = @intCast(slice.len); 136 + const builder: SliceBuilder = .{ .slice = slice }; 137 + return self.drawBuilder(ctx, .{ .userdata = &builder, .buildFn = SliceBuilder.build }); 138 + }, 139 + .builder => |b| return self.drawBuilder(ctx, b), 140 + } 141 + } 142 + 143 + pub fn nextItem(self: *ListView, ctx: *vxfw.EventContext) void { 144 + // If we have a count, we can handle this directly 145 + if (self.item_count) |count| { 146 + if (self.cursor >= count - 1) { 147 + return ctx.consumeEvent(); 148 + } 149 + self.cursor += 1; 150 + } else { 151 + switch (self.children) { 152 + .slice => |slice| { 153 + self.item_count = @intCast(slice.len); 154 + // If we are already at the end, don't do anything 155 + if (self.cursor == slice.len - 1) { 156 + return ctx.consumeEvent(); 157 + } 158 + // Advance the cursor 159 + self.cursor += 1; 160 + }, 161 + .builder => |builder| { 162 + // Save our current state 163 + const prev = self.cursor; 164 + // Advance the cursor 165 + self.cursor += 1; 166 + // Check the bounds, reversing until we get the last item 167 + while (builder.itemAtIdx(self.cursor, self.cursor) == null) { 168 + self.cursor -|= 1; 169 + } 170 + // If we didn't change state, we don't redraw 171 + if (self.cursor == prev) { 172 + return ctx.consumeEvent(); 173 + } 174 + }, 175 + } 176 + } 177 + // Reset scroll 178 + self.ensureScroll(); 179 + ctx.consumeAndRedraw(); 180 + } 181 + 182 + pub fn prevItem(self: *ListView, ctx: *vxfw.EventContext) void { 183 + if (self.cursor == 0) { 184 + return ctx.consumeEvent(); 185 + } 186 + 187 + if (self.item_count) |count| { 188 + // If for some reason our count changed, we handle it here 189 + self.cursor = @min(self.cursor - 1, count - 1); 190 + } else { 191 + switch (self.children) { 192 + .slice => |slice| { 193 + self.item_count = @intCast(slice.len); 194 + self.cursor = @min(self.cursor - 1, slice.len - 1); 195 + }, 196 + .builder => |builder| { 197 + // Save our current state 198 + const prev = self.cursor; 199 + // Decrement the cursor 200 + self.cursor -= 1; 201 + // Check the bounds, reversing until we get the last item 202 + while (builder.itemAtIdx(self.cursor, self.cursor) == null) { 203 + self.cursor -|= 1; 204 + } 205 + // If we didn't change state, we don't redraw 206 + if (self.cursor == prev) { 207 + return ctx.consumeEvent(); 208 + } 209 + }, 210 + } 211 + } 212 + 213 + // Reset scroll 214 + self.ensureScroll(); 215 + return ctx.consumeAndRedraw(); 216 + } 217 + 218 + // Only call when cursor state has changed, or we want to ensure the cursored item is in view 219 + pub fn ensureScroll(self: *ListView) void { 220 + if (self.cursor <= self.scroll.top) { 221 + self.scroll.top = @intCast(self.cursor); 222 + self.scroll.offset = 0; 223 + } else { 224 + self.scroll.wants_cursor = true; 225 + } 226 + } 227 + 228 + /// Inserts children until add_height is < 0 229 + fn insertChildren( 230 + self: *ListView, 231 + ctx: vxfw.DrawContext, 232 + builder: Builder, 233 + child_list: *std.ArrayList(vxfw.SubSurface), 234 + add_height: i17, 235 + ) Allocator.Error!void { 236 + assert(self.scroll.top > 0); 237 + self.scroll.top -= 1; 238 + var upheight = add_height; 239 + while (self.scroll.top >= 0) : (self.scroll.top -= 1) { 240 + // Get the child 241 + const child = builder.itemAtIdx(self.scroll.top, self.cursor) orelse break; 242 + 243 + const child_offset: u16 = if (self.draw_cursor) 2 else 0; 244 + const max_size = ctx.max.size(); 245 + 246 + // Set up constraints. We let the child be the entire height if it wants 247 + const child_ctx = ctx.withConstraints( 248 + .{ .width = max_size.width - child_offset, .height = 0 }, 249 + .{ .width = max_size.width - child_offset, .height = null }, 250 + ); 251 + 252 + // Draw the child 253 + const surf = try child.draw(child_ctx); 254 + 255 + // Accumulate the height. Traversing backward so do this before setting origin 256 + upheight -= surf.size.height; 257 + 258 + // Insert the child to the beginning of the list 259 + try child_list.insert(0, .{ 260 + .origin = .{ .col = 2, .row = upheight }, 261 + .surface = surf, 262 + .z_index = 0, 263 + }); 264 + 265 + // Break if we went past the top edge, or are the top item 266 + if (upheight <= 0 or self.scroll.top == 0) break; 267 + } 268 + 269 + // Our new offset is the "upheight" 270 + self.scroll.offset = upheight; 271 + 272 + // Reset origins if we overshot and put the top item too low 273 + if (self.scroll.top == 0 and upheight > 0) { 274 + self.scroll.offset = 0; 275 + var row: i17 = 0; 276 + for (child_list.items) |*child| { 277 + child.origin.row = row; 278 + row += child.surface.size.height; 279 + } 280 + } 281 + // Our new offset is the "upheight" 282 + self.scroll.offset = upheight; 283 + } 284 + 285 + fn totalHeight(list: *const std.ArrayList(vxfw.SubSurface)) usize { 286 + var result: usize = 0; 287 + for (list.items) |child| { 288 + result += child.surface.size.height; 289 + } 290 + return result; 291 + } 292 + 293 + fn drawBuilder(self: *ListView, ctx: vxfw.DrawContext, builder: Builder) Allocator.Error!vxfw.Surface { 294 + defer self.scroll.wants_cursor = false; 295 + 296 + // Get the size. asserts neither constraint is null 297 + const max_size = ctx.max.size(); 298 + // Set up surface. 299 + var surface: vxfw.Surface = .{ 300 + .size = max_size, 301 + .widget = self.widget(), 302 + .buffer = &.{}, 303 + .children = &.{}, 304 + }; 305 + 306 + // Set state 307 + { 308 + surface.focusable = true; 309 + surface.handles_mouse = true; 310 + // Assume we have more. We only know we don't after drawing 311 + self.scroll.has_more = true; 312 + } 313 + 314 + var child_list = std.ArrayList(vxfw.SubSurface).init(ctx.arena); 315 + 316 + // Accumulated height tracks how much height we have drawn. It's initial state is 317 + // (scroll.offset + scroll.pending_lines) lines _above_ the surface top edge. 318 + // Example: 319 + // 1. Scroll up 3 lines: 320 + // pending_lines = -3 321 + // offset = 0 322 + // accumulated_height = -(0 + -3) = 3; 323 + // Our first widget is placed at row 3, we will need to fill this in after the draw 324 + // 2. Scroll up 3 lines, with an offset of 4 325 + // pending_lines = -3 326 + // offset = 4 327 + // accumulated_height = -(4 + -3) = -1; 328 + // Our first widget is placed at row -1 329 + // 3. Scroll down 3 lines: 330 + // pending_lines = 3 331 + // offset = 0 332 + // accumulated_height = -(0 + 3) = -3; 333 + // Our first widget is placed at row -3. It's possible it consumes the entire widget. We 334 + // will check for this at the end and only include visible children 335 + var accumulated_height: i17 = -(self.scroll.offset + self.scroll.pending_lines); 336 + 337 + // We handled the pending scroll by assigning accumulated_height. Reset it's state 338 + self.scroll.pending_lines = 0; 339 + 340 + // Set the initial index for our downard loop. We do this here because we might modify 341 + // scroll.top before we traverse downward 342 + var i: usize = self.scroll.top; 343 + 344 + // If we are on the first item, and we have an upward scroll that consumed our offset, eg 345 + // accumulated_height > 0, we reset state here. We can't scroll up anymore so we set 346 + // accumulated_height to 0. 347 + if (accumulated_height > 0 and self.scroll.top == 0) { 348 + self.scroll.offset = 0; 349 + accumulated_height = 0; 350 + } 351 + 352 + // If we are offset downward, insert widgets to the front of the list before traversing downard 353 + if (accumulated_height > 0) { 354 + try self.insertChildren(ctx, builder, &child_list, accumulated_height); 355 + const last_child = child_list.items[child_list.items.len - 1]; 356 + accumulated_height = last_child.origin.row + last_child.surface.size.height; 357 + } 358 + 359 + const child_offset: u16 = if (self.draw_cursor) 2 else 0; 360 + 361 + while (builder.itemAtIdx(i, self.cursor)) |child| { 362 + // Defer the increment 363 + defer i += 1; 364 + 365 + // Set up constraints. We let the child be the entire height if it wants 366 + const child_ctx = ctx.withConstraints( 367 + .{ .width = max_size.width - child_offset, .height = 0 }, 368 + .{ .width = max_size.width - child_offset, .height = null }, 369 + ); 370 + 371 + // Draw the child 372 + var surf = try child.draw(child_ctx); 373 + // We set the child to non-focusable so that we can manage where the keyevents go 374 + surf.focusable = false; 375 + 376 + // Add the child surface to our list. It's offset from parent is the accumulated height 377 + try child_list.append(.{ 378 + .origin = .{ .col = child_offset, .row = accumulated_height }, 379 + .surface = surf, 380 + .z_index = 0, 381 + }); 382 + 383 + // Accumulate the height 384 + accumulated_height += surf.size.height; 385 + 386 + if (self.scroll.wants_cursor and i < self.cursor) 387 + continue // continue if we want the cursor and haven't gotten there yet 388 + else if (accumulated_height >= max_size.height) 389 + break; // Break if we drew enough 390 + } else { 391 + // This branch runs if we ran out of items. Set our state accordingly 392 + self.scroll.has_more = false; 393 + } 394 + 395 + var total_height: usize = totalHeight(&child_list); 396 + 397 + // If we reached the bottom, don't have enough height to fill the screen, and have room to add 398 + // more, then we add more until out of items or filled the space. This can happen on a resize 399 + if (!self.scroll.has_more and total_height < max_size.height and self.scroll.top > 0) { 400 + try self.insertChildren(ctx, builder, &child_list, @intCast(max_size.height - total_height)); 401 + // Set the new total height 402 + total_height = totalHeight(&child_list); 403 + } 404 + 405 + if (self.draw_cursor and self.cursor >= self.scroll.top) blk: { 406 + // The index of the cursored widget in our child_list 407 + const cursored_idx: u32 = self.cursor - self.scroll.top; 408 + // Nothing to draw if our cursor is below our viewport 409 + if (cursored_idx >= child_list.items.len) break :blk; 410 + 411 + const sub = try ctx.arena.alloc(vxfw.SubSurface, 1); 412 + const child = child_list.items[cursored_idx]; 413 + sub[0] = .{ 414 + .origin = .{ .col = child_offset, .row = 0 }, 415 + .surface = child.surface, 416 + .z_index = 0, 417 + }; 418 + const cursor_surf = try vxfw.Surface.initWithChildren( 419 + ctx.arena, 420 + self.widget(), 421 + .{ .width = child_offset, .height = child.surface.size.height }, 422 + sub, 423 + ); 424 + for (0..cursor_surf.size.height) |row| { 425 + cursor_surf.writeCell(0, @intCast(row), cursor_indicator); 426 + } 427 + child_list.items[cursored_idx] = .{ 428 + .origin = .{ .col = 0, .row = child.origin.row }, 429 + .surface = cursor_surf, 430 + .z_index = 0, 431 + }; 432 + } 433 + 434 + // If we want the cursor, we check that the cursored widget is fully in view. If it is too 435 + // large, we position it so that it is the top item with a 0 offset 436 + if (self.scroll.wants_cursor) { 437 + const cursored_idx: u32 = self.cursor - self.scroll.top; 438 + const sub = child_list.items[cursored_idx]; 439 + // The bottom row of the cursored widget 440 + const bottom = sub.origin.row + sub.surface.size.height; 441 + if (bottom > max_size.height) { 442 + // Adjust the origin by the difference 443 + // anchor bottom 444 + var origin: i17 = max_size.height; 445 + var idx: usize = cursored_idx + 1; 446 + while (idx > 0) : (idx -= 1) { 447 + var child = child_list.items[idx - 1]; 448 + origin -= child.surface.size.height; 449 + child.origin.row = origin; 450 + child_list.items[idx - 1] = child; 451 + } 452 + } else if (sub.surface.size.height >= max_size.height) { 453 + // TODO: handle when the child is larger than our height. 454 + // We need to change the max constraint to be optional sizes so that we can support 455 + // unbounded drawing in scrollable areas 456 + self.scroll.top = self.cursor; 457 + self.scroll.offset = 0; 458 + child_list.deinit(); 459 + try child_list.append(.{ 460 + .origin = .{ .col = 0, .row = 0 }, 461 + .surface = sub.surface, 462 + .z_index = 0, 463 + }); 464 + total_height = sub.surface.size.height; 465 + } 466 + } 467 + 468 + // If we reached the bottom, we need to reset origins 469 + if (!self.scroll.has_more and total_height < max_size.height) { 470 + // anchor top 471 + assert(self.scroll.top == 0); 472 + self.scroll.offset = 0; 473 + var origin: i17 = 0; 474 + for (0..child_list.items.len) |idx| { 475 + var child = child_list.items[idx]; 476 + child.origin.row = origin; 477 + origin += child.surface.size.height; 478 + child_list.items[idx] = child; 479 + } 480 + } else if (!self.scroll.has_more) { 481 + // anchor bottom 482 + var origin: i17 = max_size.height; 483 + var idx: usize = child_list.items.len; 484 + while (idx > 0) : (idx -= 1) { 485 + var child = child_list.items[idx - 1]; 486 + origin -= child.surface.size.height; 487 + child.origin.row = origin; 488 + child_list.items[idx - 1] = child; 489 + } 490 + } 491 + 492 + var start: usize = 0; 493 + var end: usize = child_list.items.len; 494 + 495 + for (child_list.items, 0..) |child, idx| { 496 + if (child.origin.row <= 0 and child.origin.row + child.surface.size.height > 0) { 497 + start = idx; 498 + self.scroll.offset = -child.origin.row; 499 + self.scroll.top += @intCast(idx); 500 + } 501 + if (child.origin.row > max_size.height) { 502 + end = idx; 503 + break; 504 + } 505 + } 506 + 507 + surface.children = child_list.items[start..end]; 508 + return surface; 509 + } 510 + 511 + const SliceBuilder = struct { 512 + slice: []const vxfw.Widget, 513 + 514 + fn build(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget { 515 + const self: *const SliceBuilder = @ptrCast(@alignCast(ptr)); 516 + if (idx >= self.slice.len) return null; 517 + return self.slice[idx]; 518 + } 519 + }; 520 + 521 + test ListView { 522 + // Create child widgets 523 + const Text = @import("Text.zig"); 524 + const abc: Text = .{ .text = "abc\n def\n ghi" }; 525 + const def: Text = .{ .text = "def" }; 526 + const ghi: Text = .{ .text = "ghi" }; 527 + const jklmno: Text = .{ .text = "jkl\n mno" }; 528 + // 0 |*abc 529 + // 1 | def 530 + // 2 | ghi 531 + // 3 | def 532 + // 4 ghi 533 + // 5 jkl 534 + // 6 mno 535 + 536 + // Create the list view 537 + const list_view: ListView = .{ 538 + .wheel_scroll = 1, // Set wheel scroll to one 539 + .children = .{ .slice = &.{ 540 + abc.widget(), 541 + def.widget(), 542 + ghi.widget(), 543 + jklmno.widget(), 544 + } }, 545 + }; 546 + 547 + // Boiler plate draw context 548 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 549 + defer arena.deinit(); 550 + const ucd = try vaxis.Unicode.init(arena.allocator()); 551 + vxfw.DrawContext.init(&ucd, .unicode); 552 + 553 + const list_widget = list_view.widget(); 554 + const draw_ctx: vxfw.DrawContext = .{ 555 + .arena = arena.allocator(), 556 + .min = .{}, 557 + .max = .{ .width = 16, .height = 4 }, 558 + }; 559 + 560 + var surface = try list_widget.draw(draw_ctx); 561 + // ListView expands to max height and max width 562 + try std.testing.expectEqual(4, surface.size.height); 563 + try std.testing.expectEqual(16, surface.size.width); 564 + // We have 2 children, because only visible children appear as a surface 565 + try std.testing.expectEqual(2, surface.children.len); 566 + 567 + var mouse_event: vaxis.Mouse = .{ 568 + .col = 0, 569 + .row = 0, 570 + .button = .wheel_up, 571 + .mods = .{}, 572 + .type = .press, 573 + }; 574 + // Event handlers need a context 575 + var ctx: vxfw.EventContext = .{ 576 + .cmds = std.ArrayList(vxfw.Command).init(std.testing.allocator), 577 + }; 578 + defer ctx.cmds.deinit(); 579 + 580 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 581 + // Wheel up doesn't adjust the scroll 582 + try std.testing.expectEqual(0, list_view.scroll.top); 583 + try std.testing.expectEqual(0, list_view.scroll.offset); 584 + 585 + // Send a wheel down 586 + mouse_event.button = .wheel_down; 587 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 588 + // We have to draw the widget for scrolls to take effect 589 + surface = try list_widget.draw(draw_ctx); 590 + // 0 *abc 591 + // 1 | def 592 + // 2 | ghi 593 + // 3 | def 594 + // 4 | ghi 595 + // 5 jkl 596 + // 6 mno 597 + // We should have gone down 1 line, and not changed our top widget 598 + try std.testing.expectEqual(0, list_view.scroll.top); 599 + try std.testing.expectEqual(1, list_view.scroll.offset); 600 + // One more widget has scrolled into view 601 + try std.testing.expectEqual(3, surface.children.len); 602 + 603 + // Scroll down two more lines 604 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 605 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 606 + surface = try list_widget.draw(draw_ctx); 607 + // 0 *abc 608 + // 1 def 609 + // 2 ghi 610 + // 3 | def 611 + // 4 | ghi 612 + // 5 | jkl 613 + // 6 | mno 614 + // We should have gone down 2 lines, which scrolls our top widget out of view 615 + try std.testing.expectEqual(1, list_view.scroll.top); 616 + try std.testing.expectEqual(0, list_view.scroll.offset); 617 + try std.testing.expectEqual(3, surface.children.len); 618 + 619 + // Scroll down again. We shouldn't advance anymore since we are at the bottom 620 + try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 621 + surface = try list_widget.draw(draw_ctx); 622 + try std.testing.expectEqual(1, list_view.scroll.top); 623 + try std.testing.expectEqual(0, list_view.scroll.offset); 624 + try std.testing.expectEqual(3, surface.children.len); 625 + 626 + // Mouse wheel events don't change the cursor position. Let's press "escape" to reset the 627 + // viewport and bring our cursor into view 628 + try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.escape } }); 629 + surface = try list_widget.draw(draw_ctx); 630 + try std.testing.expectEqual(0, list_view.scroll.top); 631 + try std.testing.expectEqual(0, list_view.scroll.offset); 632 + try std.testing.expectEqual(2, surface.children.len); 633 + 634 + // Cursor down 635 + try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 636 + surface = try list_widget.draw(draw_ctx); 637 + // 0 | abc 638 + // 1 | def 639 + // 2 | ghi 640 + // 3 |*def 641 + // 4 ghi 642 + // 5 jkl 643 + // 6 mno 644 + // Scroll doesn't change 645 + try std.testing.expectEqual(0, list_view.scroll.top); 646 + try std.testing.expectEqual(0, list_view.scroll.offset); 647 + try std.testing.expectEqual(2, surface.children.len); 648 + try std.testing.expectEqual(1, list_view.cursor); 649 + 650 + // Cursor down 651 + try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 652 + surface = try list_widget.draw(draw_ctx); 653 + // 0 abc 654 + // 1 | def 655 + // 2 | ghi 656 + // 3 | def 657 + // 4 |*ghi 658 + // 5 jkl 659 + // 6 mno 660 + // Scroll advances one row 661 + try std.testing.expectEqual(0, list_view.scroll.top); 662 + try std.testing.expectEqual(1, list_view.scroll.offset); 663 + try std.testing.expectEqual(3, surface.children.len); 664 + try std.testing.expectEqual(2, list_view.cursor); 665 + 666 + // Cursor down 667 + try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 668 + surface = try list_widget.draw(draw_ctx); 669 + // 0 abc 670 + // 1 def 671 + // 2 ghi 672 + // 3 | def 673 + // 4 | ghi 674 + // 5 |*jkl 675 + // 6 | mno 676 + // We are cursored onto the last item. The entire last item comes into view, effectively 677 + // advancing the scroll by 2 678 + try std.testing.expectEqual(1, list_view.scroll.top); 679 + try std.testing.expectEqual(0, list_view.scroll.offset); 680 + try std.testing.expectEqual(3, surface.children.len); 681 + try std.testing.expectEqual(3, list_view.cursor); 682 + } 683 + 684 + test "refAllDecls" { 685 + std.testing.refAllDecls(@This()); 686 + }
+1
src/vxfw/vxfw.zig
··· 11 11 pub const App = @import("App.zig"); 12 12 13 13 // Widgets 14 + pub const ListView = @import("ListView.zig"); 14 15 pub const RichText = @import("RichText.zig"); 15 16 pub const Text = @import("Text.zig"); 16 17