diff --git a/examples/scroll.zig b/examples/scroll.zig index dabe6e12..15c7fb81 100644 --- a/examples/scroll.zig +++ b/examples/scroll.zig @@ -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 { @@ -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 => {}, } @@ -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); @@ -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 diff --git a/src/vxfw/ScrollBars.zig b/src/vxfw/ScrollBars.zig new file mode 100644 index 00000000..416e22d9 --- /dev/null +++ b/src/vxfw/ScrollBars.zig @@ -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()); +} diff --git a/src/vxfw/ScrollView.zig b/src/vxfw/ScrollView.zig index ec88bff8..afad03c8 100644 --- a/src/vxfw/ScrollView.zig +++ b/src/vxfw/ScrollView.zig @@ -13,7 +13,7 @@ pub const Builder = struct { userdata: *const anyopaque, buildFn: *const fn (*const anyopaque, idx: usize, cursor: usize) ?vxfw.Widget, - inline fn itemAtIdx(self: Builder, idx: usize, cursor: usize) ?vxfw.Widget { + pub inline fn itemAtIdx(self: Builder, idx: usize, cursor: usize) ?vxfw.Widget { return self.buildFn(self.userdata, idx, cursor); } }; @@ -60,19 +60,6 @@ draw_cursor: bool = false, wheel_scroll: u8 = 3, /// Set this if the exact item count is known. item_count: ?u32 = null, -/// When true, the widget will draw a vertical scrollbar on the right side of the contained widget. -/// Eventually this will be used as an indicator for a horizontal scrollbar as well. -draw_scrollbars: 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, /// scroll position scroll: Scroll = .{}, @@ -383,8 +370,8 @@ fn drawBuilder(self: *ScrollView, ctx: vxfw.DrawContext, builder: Builder) Alloc // Set up constraints. We let the child be the entire height if it wants const child_ctx = ctx.withConstraints( - .{ .width = max_size.width - 1 - child_offset, .height = 0 }, - .{ .width = max_size.width - 1 - child_offset, .height = null }, + .{ .width = max_size.width - child_offset, .height = 0 }, + .{ .width = max_size.width - child_offset, .height = null }, ); // Draw the child @@ -530,70 +517,7 @@ fn drawBuilder(self: *ScrollView, ctx: vxfw.DrawContext, builder: Builder) Alloc } } - var children_with_scrollbar = std.ArrayList(vxfw.SubSurface).init(ctx.arena); - - // We only show the scrollbar if the content height is larger than the widget height and - // drawing the scrollbars is requested. - if (self.draw_scrollbars) { - // The scroll bar surface needs to span the entire widget so dragging the scroll bar can work - // even if the mouse leaves the scrollbar itself. - const scroll_bar = try vxfw.Surface.init(ctx.arena, self.widget(), max_size); - - // For the sake of calculations we ensure we think we've rendered at least 1 child. - const num_children_rendered: usize = @max(end - start, 1); - - const child_count: usize = count: { - if (self.item_count) |count| break :count count; - - var idx: usize = 0; - while (builder.itemAtIdx(idx, self.cursor)) |_| { - idx += 1; - } - break :count idx; - }; - - const widget_height_f: f32 = @floatFromInt(max_size.height); - const num_children_rendered_f: f32 = @floatFromInt(num_children_rendered); - const total_height_f: f32 = @floatFromInt(child_count); - const scroll_top_f: f32 = @floatFromInt(self.scroll.top); - - const scroll_bar_height_f: f32 = height: { - if (self.estimated_content_height) |h| { - const h_f: f32 = @floatFromInt(h); - break :height widget_height_f * (widget_height_f / h_f); - } - - break :height widget_height_f * (num_children_rendered_f / total_height_f); - }; - // We need to ensure the scrollbar is at least 1 row high so it's visible. - const scroll_bar_height: u32 = @intFromFloat(@max(scroll_bar_height_f, 1)); - - const scroll_bar_top_f: f32 = widget_height_f * (scroll_top_f / total_height_f); - const scroll_bar_top: u32 = if (self.scroll.top == 0) - 0 // At the top. - else if (self.scroll.has_more) - @intFromFloat(scroll_bar_top_f) - else - max_size.height - scroll_bar_height; // At the bottom. - - // There's no need to draw the scrollbar if we're at the top and drew all the children. - // In other words; if we can't scroll, we don't need the scrollbar. - if (self.scroll.top != 0 or end != child_count or total_height > max_size.height) { - const scroll_bar_end = scroll_bar_top + scroll_bar_height; - for (scroll_bar_top..scroll_bar_end) |row| { - scroll_bar.writeCell(max_size.width - 1, @intCast(row), scrollbar_thumb); - } - - try children_with_scrollbar.append(.{ - .surface = scroll_bar, - .origin = .{ .row = 0, .col = 0 }, - }); - } - } - - try children_with_scrollbar.appendSlice(child_list.items[start..end]); - - surface.children = children_with_scrollbar.items; + surface.children = child_list.items; // Update last known height. // If the bits from total_height don't fit u8 we won't get the right value from @intCast or diff --git a/src/vxfw/vxfw.zig b/src/vxfw/vxfw.zig index e4187aa2..c1bbeb74 100644 --- a/src/vxfw/vxfw.zig +++ b/src/vxfw/vxfw.zig @@ -20,6 +20,7 @@ pub const ListView = @import("ListView.zig"); pub const Padding = @import("Padding.zig"); pub const RichText = @import("RichText.zig"); pub const ScrollView = @import("ScrollView.zig"); +pub const ScrollBars = @import("ScrollBars.zig"); pub const SizedBox = @import("SizedBox.zig"); pub const SplitView = @import("SplitView.zig"); pub const Spinner = @import("Spinner.zig");