Skip to content

Commit

Permalink
scroll: move scrollbars into separate widget
Browse files Browse the repository at this point in the history
  • Loading branch information
reykjalin committed Jan 3, 2025
1 parent 11bae2d commit 4dd17d1
Show file tree
Hide file tree
Showing 4 changed files with 239 additions and 93 deletions.
43 changes: 30 additions & 13 deletions examples/scroll.zig
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const ModelRow = struct {
};

const Model = struct {
scroll_view: vxfw.ScrollView,
scroll_bars: vxfw.ScrollBars,
rows: std.ArrayList(ModelRow),

pub fn widget(self: *Model) vxfw.Widget {
Expand All @@ -81,25 +81,40 @@ const Model = struct {
for (self.rows.items) |*row| {
row.wrap_lines = !row.wrap_lines;
}
self.scroll_bars.estimated_content_height =
if (self.scroll_bars.estimated_content_height == 800)
@intCast(self.rows.items.len)
else
800;

return ctx.consumeAndRedraw();
}
if (key.matches('e', .{ .ctrl = true })) {
if (self.scroll_view.estimated_content_height == null)
self.scroll_view.estimated_content_height = 800
if (self.scroll_bars.estimated_content_height == null)
self.scroll_bars.estimated_content_height = 800
else
self.scroll_view.estimated_content_height = null;
self.scroll_bars.estimated_content_height = null;

return ctx.consumeAndRedraw();
}
if (key.matches(vaxis.Key.tab, .{})) {
self.scroll_view.draw_cursor = !self.scroll_view.draw_cursor;
self.scroll_bars.scroll_view.draw_cursor = !self.scroll_bars.scroll_view.draw_cursor;
return ctx.consumeAndRedraw();
}
if (key.matches('v', .{ .ctrl = true })) {
self.scroll_bars.draw_vertical_scrollbar = !self.scroll_bars.draw_vertical_scrollbar;
return ctx.consumeAndRedraw();
}
if (key.matches('h', .{ .ctrl = true })) {
self.scroll_bars.draw_horizontal_scrollbar = !self.scroll_bars.draw_horizontal_scrollbar;
return ctx.consumeAndRedraw();
}
if (key.matches(vaxis.Key.tab, .{ .shift = true })) {
self.scroll_view.draw_scrollbars = !self.scroll_view.draw_scrollbars;
self.scroll_bars.draw_vertical_scrollbar = !self.scroll_bars.draw_vertical_scrollbar;
self.scroll_bars.draw_horizontal_scrollbar = !self.scroll_bars.draw_horizontal_scrollbar;
return ctx.consumeAndRedraw();
}
return self.scroll_view.handleEvent(ctx, event);
return self.scroll_bars.scroll_view.handleEvent(ctx, event);
},
else => {},
}
Expand All @@ -111,7 +126,7 @@ const Model = struct {

const scroll_view: vxfw.SubSurface = .{
.origin = .{ .row = 0, .col = 0 },
.surface = try self.scroll_view.draw(ctx),
.surface = try self.scroll_bars.draw(ctx),
};

const children = try ctx.arena.alloc(vxfw.SubSurface, 1);
Expand Down Expand Up @@ -149,11 +164,13 @@ pub fn main() !void {
const model = try allocator.create(Model);
defer allocator.destroy(model);
model.* = .{
.scroll_view = .{
.children = .{
.builder = .{
.userdata = model,
.buildFn = Model.widgetBuilder,
.scroll_bars = .{
.scroll_view = .{
.children = .{
.builder = .{
.userdata = model,
.buildFn = Model.widgetBuilder,
},
},
},
// NOTE: This is not the actual content height, but rather an estimate. In reality
Expand Down
204 changes: 204 additions & 0 deletions src/vxfw/ScrollBars.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
const std = @import("std");
const vaxis = @import("../main.zig");
const vxfw = @import("vxfw.zig");

const Allocator = std.mem.Allocator;

const ScrollBars = @This();

const vertical_scrollbar_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "▐", .width = 1 } };
const horizontal_scrollbar_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "▃", .width = 1 } };

/// The ScrollBars widget must contain a ScrollView widget. The scroll bars drawn will be for the
/// scroll view contained in the ScrollBars widget.
scroll_view: vxfw.ScrollView,
/// If `true` a horizontal scroll bar will be drawn. Set to `false` to hide the horizontal scroll
/// bar. Defaults to `true`.
draw_horizontal_scrollbar: bool = true,
/// If `true` a vertical scroll bar will be drawn. Set to `false` to hide the vertical scroll bar.
/// Defaults to `true`.
draw_vertical_scrollbar: bool = true,
/// The estimated height of all the content in the ScrollView. When provided this height will be
/// used to calculate the size of the scrollbar's thumb. If this is not provided the widget will
/// make a best effort estimate of the size of the thumb using the number of elements rendered at
/// any given time. This will cause inconsistent thumb sizes - and possibly inconsistent
/// positioning - if different elements in the ScrollView have different heights. For the best user
/// experience, providing this estimate is strongly recommended.
///
/// Note that this doesn't necessarily have to be an accurate estimate and the tolerance for larger
/// views is quite forgiving, especially if you overshoot the estimate.
estimated_content_height: ?u32 = null,

pub fn widget(self: *const ScrollBars) vxfw.Widget {
return .{
.userdata = @constCast(self),
.eventHandler = typeErasedEventHandler,
.drawFn = typeErasedDrawFn,
};
}

fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
const self: *ScrollBars = @ptrCast(@alignCast(ptr));
return self.handleEvent(ctx, event);
}

fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
const self: *ScrollBars = @ptrCast(@alignCast(ptr));
return self.draw(ctx);
}

pub fn handleEvent(_: *ScrollBars, _: *vxfw.EventContext, _: vxfw.Event) anyerror!void {}

pub fn draw(self: *ScrollBars, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena);

// 1. If we're not drawing the scrollbars we can just draw the ScrollView directly.

if (!self.draw_vertical_scrollbar and !self.draw_horizontal_scrollbar) {
try children.append(.{
.origin = .{ .row = 0, .col = 0 },
.surface = try self.scroll_view.draw(ctx),
});

return .{
.size = ctx.max.size(),
.widget = self.widget(),
.buffer = &.{},
.children = children.items,
};
}

// 2. Otherwise we can draw the scrollbars.

const max = ctx.max.size();

// 3. Draw the scroll view itself.

const scroll_view_surface = try self.scroll_view.draw(ctx.withConstraints(
ctx.min,
.{
// We make sure to make room for the scrollbars if required.
.width = max.width -| @intFromBool(self.draw_vertical_scrollbar),
.height = max.height -| @intFromBool(self.draw_horizontal_scrollbar),
},
));

try children.append(.{
.origin = .{ .row = 0, .col = 0 },
.surface = scroll_view_surface,
});

// 4. Draw the vertical scroll bar.

if (self.draw_vertical_scrollbar) {
// To draw the vertical scrollbar we need to know how big the scroll bar thumb should be.
// If we've been provided with an estimated height we use that to figure out how big the
// thumb should be, otherwise we estimate the size based on how many of the children were
// actually drawn in the ScrollView.

const widget_height_f: f32 = @floatFromInt(scroll_view_surface.size.height);
const total_num_children_f: f32 = count: {
if (self.scroll_view.item_count) |c| break :count @floatFromInt(c);

switch (self.scroll_view.children) {
.slice => |slice| break :count @floatFromInt(slice.len),
.builder => |builder| {
var counter: usize = 0;
while (builder.itemAtIdx(counter, self.scroll_view.cursor)) |_|
counter += 1;

break :count @floatFromInt(counter);
},
}
};

const thumb_height: u16 = height: {
// If we know the height, we can use the height of the current view to determine the
// size of the thumb.
if (self.estimated_content_height) |h| {
const content_height_f: f32 = @floatFromInt(h);

const thumb_height_f = widget_height_f * widget_height_f / content_height_f;
break :height @intFromFloat(@max(thumb_height_f, 1));
}

// Otherwise we estimate the size of the thumb based on the number of child elements
// drawn in the scroll view, and the number of total child elements.

const num_children_rendered_f: f32 = @floatFromInt(scroll_view_surface.children.len);

const thumb_height_f = widget_height_f * num_children_rendered_f / total_num_children_f;
break :height @intFromFloat(@max(thumb_height_f, 1));
};

// We also need to know the position of the thumb in the scroll bar. To find that we use the
// index of the top-most child widget rendered in the ScrollView.

const thumb_top: u32 = if (self.scroll_view.scroll.top == 0)
0
else if (self.scroll_view.scroll.has_more) pos: {
const top_child_idx_f: f32 = @floatFromInt(self.scroll_view.scroll.top);
const thumb_top_f = widget_height_f * top_child_idx_f / total_num_children_f;

break :pos @intFromFloat(thumb_top_f);
} else max.height -| thumb_height;

// Once we know the thumb height and its position we can draw the scroll bar.

const scroll_bar = try vxfw.Surface.init(
ctx.arena,
self.widget(),
.{
.width = 1,
// We make sure to make room for the horizontal scroll bar if it's being drawn.
.height = max.height -| @intFromBool(self.draw_horizontal_scrollbar),
},
);

const thumb_end_row = thumb_top + thumb_height;
for (thumb_top..thumb_end_row) |row| {
scroll_bar.writeCell(
0,
@intCast(row),
vertical_scrollbar_thumb,
);
}

try children.append(.{
.origin = .{ .row = 0, .col = max.width -| 1 },
.surface = scroll_bar,
});
}

// 5. TODO: Draw the horizontal scroll bar.

// if (self.draw_horizontal_scrollbar) {
// const scroll_bar = try vxfw.Surface.init(
// ctx.arena,
// self.widget(),
// .{ .width = max.width, .height = 1 },
// );
// for (0..max.width / 2) |col| {
// scroll_bar.writeCell(
// @intCast(col),
// 0,
// horizontal_scrollbar_thumb,
// );
// }
// try children.append(.{
// .origin = .{ .row = max.height -| 1, .col = 0 },
// .surface = scroll_bar,
// });
// }

return .{
.size = ctx.max.size(),
.widget = self.widget(),
.buffer = &.{},
.children = children.items,
};
}

test "refAllDecls" {
std.testing.refAllDecls(@This());
}
Loading

0 comments on commit 4dd17d1

Please sign in to comment.