Skip to content

Commit e090d3b

Browse files
TimBotrockorager
authored andcommitted
feat(vxfw): add ListView jumpToItem
1 parent 1dbbe57 commit e090d3b

1 file changed

Lines changed: 170 additions & 0 deletions

File tree

src/vxfw/ListView.zig

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,41 @@ pub fn ensureScroll(self: *ListView) void {
212212
}
213213
}
214214

215+
fn knownItemCount(self: *ListView) ?u32 {
216+
if (self.item_count) |count| return count;
217+
switch (self.children) {
218+
.slice => |slice| {
219+
self.item_count = @intCast(slice.len);
220+
return self.item_count;
221+
},
222+
.builder => return null,
223+
}
224+
}
225+
226+
/// Move the cursor directly to an item and start drawing from that item.
227+
///
228+
/// This is useful for large jumps where walking from the current scroll position to the cursor
229+
/// would require building every child between the two positions. If the item count is known, `idx`
230+
/// is clamped to the last item.
231+
pub fn jumpToItem(self: *ListView, idx: u32) void {
232+
const cursor = if (self.knownItemCount()) |count|
233+
if (count == 0) 0 else @min(idx, count - 1)
234+
else
235+
idx;
236+
237+
self.cursor = cursor;
238+
self.scroll = .{ .top = cursor };
239+
}
240+
241+
/// Scroll directly to the bottom when the item count is known.
242+
///
243+
/// This preserves the cursor. For builder-backed lists without `item_count`, the bottom is not
244+
/// known, so this does nothing.
245+
pub fn scrollToBottom(self: *ListView) void {
246+
const count = self.knownItemCount() orelse return;
247+
self.scroll = if (count == 0) .{} else .{ .top = count - 1 };
248+
}
249+
215250
/// Inserts children until add_height is < 0
216251
fn insertChildren(
217252
self: *ListView,
@@ -672,6 +707,141 @@ test ListView {
672707
try std.testing.expectEqual(3, list_view.cursor);
673708
}
674709

710+
test "ListView: jumpToItem avoids walking intermediate children" {
711+
const Text = @import("Text.zig");
712+
const text: Text = .{ .text = "item" };
713+
714+
const CountingBuilder = struct {
715+
len: usize,
716+
widget: vxfw.Widget,
717+
calls: *usize,
718+
719+
fn build(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget {
720+
const self: *const @This() = @ptrCast(@alignCast(ptr));
721+
self.calls.* += 1;
722+
if (idx >= self.len) return null;
723+
return self.widget;
724+
}
725+
};
726+
727+
var calls: usize = 0;
728+
const builder: CountingBuilder = .{
729+
.len = 1000,
730+
.widget = text.widget(),
731+
.calls = &calls,
732+
};
733+
var list_view: ListView = .{
734+
.item_count = @intCast(builder.len),
735+
.children = .{ .builder = .{
736+
.userdata = &builder,
737+
.buildFn = CountingBuilder.build,
738+
} },
739+
};
740+
741+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
742+
defer arena.deinit();
743+
vxfw.DrawContext.init(.unicode);
744+
745+
const draw_ctx: vxfw.DrawContext = .{
746+
.arena = arena.allocator(),
747+
.min = .{},
748+
.max = .{ .width = 16, .height = 4 },
749+
.cell_size = .{ .width = 10, .height = 20 },
750+
};
751+
752+
list_view.jumpToItem(999);
753+
const surface = try list_view.widget().draw(draw_ctx);
754+
755+
try std.testing.expectEqual(999, list_view.cursor);
756+
try std.testing.expectEqual(996, list_view.scroll.top);
757+
try std.testing.expectEqual(0, list_view.scroll.offset);
758+
try std.testing.expectEqual(4, surface.children.len);
759+
try std.testing.expect(calls < 10);
760+
}
761+
762+
test "ListView: jumpToItem clamps to item count" {
763+
var list_view: ListView = .{
764+
.item_count = 10,
765+
.children = .{ .slice = &.{} },
766+
};
767+
768+
list_view.jumpToItem(100);
769+
770+
try std.testing.expectEqual(9, list_view.cursor);
771+
try std.testing.expectEqual(9, list_view.scroll.top);
772+
try std.testing.expectEqual(0, list_view.scroll.offset);
773+
}
774+
775+
test "ListView: scrollToBottom avoids walking intermediate children" {
776+
const Text = @import("Text.zig");
777+
const text: Text = .{ .text = "item" };
778+
779+
const CountingBuilder = struct {
780+
len: usize,
781+
widget: vxfw.Widget,
782+
calls: *usize,
783+
784+
fn build(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget {
785+
const self: *const @This() = @ptrCast(@alignCast(ptr));
786+
self.calls.* += 1;
787+
if (idx >= self.len) return null;
788+
return self.widget;
789+
}
790+
};
791+
792+
var calls: usize = 0;
793+
const builder: CountingBuilder = .{
794+
.len = 1000,
795+
.widget = text.widget(),
796+
.calls = &calls,
797+
};
798+
var list_view: ListView = .{
799+
.item_count = @intCast(builder.len),
800+
.children = .{ .builder = .{
801+
.userdata = &builder,
802+
.buildFn = CountingBuilder.build,
803+
} },
804+
};
805+
806+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
807+
defer arena.deinit();
808+
vxfw.DrawContext.init(.unicode);
809+
810+
const draw_ctx: vxfw.DrawContext = .{
811+
.arena = arena.allocator(),
812+
.min = .{},
813+
.max = .{ .width = 16, .height = 4 },
814+
.cell_size = .{ .width = 10, .height = 20 },
815+
};
816+
817+
list_view.scrollToBottom();
818+
const surface = try list_view.widget().draw(draw_ctx);
819+
820+
try std.testing.expectEqual(0, list_view.cursor);
821+
try std.testing.expectEqual(996, list_view.scroll.top);
822+
try std.testing.expectEqual(0, list_view.scroll.offset);
823+
try std.testing.expectEqual(4, surface.children.len);
824+
try std.testing.expect(calls < 10);
825+
}
826+
827+
test "ListView: scrollToBottom gets count from slice" {
828+
const Text = @import("Text.zig");
829+
const zero: Text = .{ .text = "0" };
830+
const one: Text = .{ .text = "1" };
831+
const two: Text = .{ .text = "2" };
832+
833+
var list_view: ListView = .{
834+
.children = .{ .slice = &.{ zero.widget(), one.widget(), two.widget() } },
835+
};
836+
837+
list_view.scrollToBottom();
838+
839+
try std.testing.expectEqual(0, list_view.cursor);
840+
try std.testing.expectEqual(2, list_view.scroll.top);
841+
try std.testing.expectEqual(0, list_view.scroll.offset);
842+
try std.testing.expectEqual(3, list_view.item_count);
843+
}
844+
675845
// @reykjalin found an issue on mac with ghostty where the scroll up and scroll down were uneven.
676846
// Ghostty has high precision scrolling and sends a lot of wheel events for each tick
677847
test "ListView: uneven scroll" {

0 commit comments

Comments
 (0)