const std = @import("std"); const vaxis = @import("../main.zig"); const vxfw = @import("vxfw.zig"); const Allocator = std.mem.Allocator; const ScrollBars = @This(); /// 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, /// The estimated width of all the content in the ScrollView. When provided this width 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 width of the elements rendered at any /// given time. This will cause inconsistent thumb sizes - and possibly inconsistent positioning - /// if different elements in the ScrollView have different widths. 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_width: ?u32 = null, /// The cell drawn for the vertical scroll thumb. Replace this to customize the scroll thumb. Must /// have a 1 column width. vertical_scrollbar_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "▐", .width = 1 } }, /// The cell drawn for the vertical scroll thumb while it's being hovered. Replace this to customize /// the scroll thumb. Must have a 1 column width. vertical_scrollbar_hover_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "█", .width = 1 } }, /// The cell drawn for the vertical scroll thumb while it's being dragged by the mouse. Replace this /// to customize the scroll thumb. Must have a 1 column width. vertical_scrollbar_drag_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "█", .width = 1 }, .style = .{ .fg = .{ .index = 4 } }, }, /// The cell drawn for the vertical scroll thumb. Replace this to customize the scroll thumb. Must /// have a 1 column width. horizontal_scrollbar_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "▃", .width = 1 } }, /// The cell drawn for the horizontal scroll thumb while it's being hovered. Replace this to /// customize the scroll thumb. Must have a 1 column width. horizontal_scrollbar_hover_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "█", .width = 1 } }, /// The cell drawn for the horizontal scroll thumb while it's being dragged by the mouse. Replace /// this to customize the scroll thumb. Must have a 1 column width. horizontal_scrollbar_drag_thumb: vaxis.Cell = .{ .char = .{ .grapheme = "█", .width = 1 }, .style = .{ .fg = .{ .index = 4 } }, }, /// You should not change this variable, treat it as private to the implementation. Used to track /// the size of the widget so we can locate scroll bars for mouse interaction. last_frame_size: vxfw.Size = .{ .width = 0, .height = 0 }, /// You should not change this variable, treat it as private to the implementation. Used to track /// the width of the content so we map horizontal scroll thumb position to view position. last_frame_max_content_width: u32 = 0, /// You should not change this variable, treat it as private to the implementation. Used to track /// the position of the mouse relative to the scroll thumb for mouse interaction. mouse_offset_into_thumb: u8 = 0, /// You should not change this variable, treat it as private to the implementation. Used to track /// the position of the scroll thumb for mouse interaction. vertical_thumb_top_row: u32 = 0, /// You should not change this variable, treat it as private to the implementation. Used to track /// the position of the scroll thumb for mouse interaction. vertical_thumb_bottom_row: u32 = 0, /// You should not change this variable, treat it as private to the implementation. Used to track /// whether the scroll thumb is hovered or not so we can set the right hover style for the thumb. is_hovering_vertical_thumb: bool = false, /// You should not change this variable, treat it as private to the implementation. Used to track /// whether the thumb is currently being dragged, which is important to allowing the mouse to leave /// the scroll thumb while it's being dragged. is_dragging_vertical_thumb: bool = false, /// You should not change this variable, treat it as private to the implementation. Used to track /// the position of the scroll thumb for mouse interaction. horizontal_thumb_start_col: u32 = 0, /// You should not change this variable, treat it as private to the implementation. Used to track /// the position of the scroll thumb for mouse interaction. horizontal_thumb_end_col: u32 = 0, /// You should not change this variable, treat it as private to the implementation. Used to track /// whether the scroll thumb is hovered or not so we can set the right hover style for the thumb. is_hovering_horizontal_thumb: bool = false, /// You should not change this variable, treat it as private to the implementation. Used to track /// whether the thumb is currently being dragged, which is important to allowing the mouse to leave /// the scroll thumb while it's being dragged. is_dragging_horizontal_thumb: bool = false, pub fn widget(self: *const ScrollBars) vxfw.Widget { return .{ .userdata = @constCast(self), .eventHandler = typeErasedEventHandler, .captureHandler = typeErasedCaptureHandler, .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 typeErasedCaptureHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { const self: *ScrollBars = @ptrCast(@alignCast(ptr)); return self.handleCapture(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 handleCapture(self: *ScrollBars, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { switch (event) { .mouse => |mouse| { if (self.is_dragging_vertical_thumb) { // Stop dragging the thumb when the mouse is released. if (mouse.type == .release and mouse.button == .left and self.is_dragging_vertical_thumb) { // If we just let the scroll thumb go after dragging we need to make sure we // redraw so the right style is immediately applied to the thumb. if (self.is_dragging_vertical_thumb) { self.is_dragging_vertical_thumb = false; ctx.redraw = true; } const is_mouse_over_vertical_thumb = mouse.col == self.last_frame_size.width -| 1 and mouse.row >= self.vertical_thumb_top_row and mouse.row < self.vertical_thumb_bottom_row; // If we're not hovering the scroll bar after letting it go, we should trigger a // redraw so it goes back to its narrow, non-active, state immediately. if (!is_mouse_over_vertical_thumb) { self.is_hovering_vertical_thumb = false; ctx.redraw = true; } // No need to redraw yet, but we must consume the event so ending the drag // action doesn't trigger some other event handler. return ctx.consumeEvent(); } // Process dragging the vertical thumb. if (mouse.type == .drag) { // Make sure we consume the event if we're currently dragging the mouse so other // events aren't sent in the mean time. ctx.consumeEvent(); // New scroll thumb position. const new_thumb_top = mouse.row -| self.mouse_offset_into_thumb; // If the new thumb position is at the top we know we've scrolled to the top of // the scroll view. if (new_thumb_top == 0) { self.scroll_view.scroll.top = 0; return ctx.consumeAndRedraw(); } const new_thumb_top_f: f32 = @floatFromInt(new_thumb_top); const widget_height_f: f32 = @floatFromInt(self.last_frame_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 new_top_child_idx_f = new_thumb_top_f * total_num_children_f / widget_height_f; self.scroll_view.scroll.top = @intFromFloat(new_top_child_idx_f); return ctx.consumeAndRedraw(); } } if (self.is_dragging_horizontal_thumb) { // Stop dragging the thumb when the mouse is released. if (mouse.type == .release and mouse.button == .left and self.is_dragging_horizontal_thumb) { // If we just let the scroll thumb go after dragging we need to make sure we // redraw so the right style is immediately applied to the thumb. if (self.is_dragging_horizontal_thumb) { self.is_dragging_horizontal_thumb = false; ctx.redraw = true; } const is_mouse_over_horizontal_thumb = mouse.row == self.last_frame_size.height -| 1 and mouse.col >= self.horizontal_thumb_start_col and mouse.col < self.horizontal_thumb_end_col; // If we're not hovering the scroll bar after letting it go, we should trigger a // redraw so it goes back to its narrow, non-active, state immediately. if (!is_mouse_over_horizontal_thumb) { self.is_hovering_horizontal_thumb = false; ctx.redraw = true; } // No need to redraw yet, but we must consume the event so ending the drag // action doesn't trigger some other event handler. return ctx.consumeEvent(); } // Process dragging the horizontal thumb. if (mouse.type == .drag) { // Make sure we consume the event if we're currently dragging the mouse so other // events aren't sent in the mean time. ctx.consumeEvent(); // New scroll thumb position. const new_thumb_col_start = mouse.col -| self.mouse_offset_into_thumb; // If the new thumb position is at the horizontal beginning of the current view // we know we've scrolled to the beginning of the scroll view. if (new_thumb_col_start == 0) { self.scroll_view.scroll.left = 0; return ctx.consumeAndRedraw(); } const new_thumb_col_start_f: f32 = @floatFromInt(new_thumb_col_start); const widget_width_f: f32 = @floatFromInt(self.last_frame_size.width); const max_content_width_f: f32 = @floatFromInt(self.last_frame_max_content_width); const new_view_col_start_f = new_thumb_col_start_f * max_content_width_f / widget_width_f; const new_view_col_start: u32 = @intFromFloat(@ceil(new_view_col_start_f)); self.scroll_view.scroll.left = @min(new_view_col_start, self.last_frame_max_content_width); return ctx.consumeAndRedraw(); } } }, else => {}, } } pub fn handleEvent(self: *ScrollBars, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { switch (event) { .mouse => |mouse| { // 1. Process vertical scroll thumb hover. const mouse_col: u16 = if (mouse.col < 0) 0 else @intCast(mouse.col); const mouse_row: u16 = if (mouse.row < 0) 0 else @intCast(mouse.row); const is_mouse_over_vertical_thumb = mouse_col == self.last_frame_size.width -| 1 and mouse_row >= self.vertical_thumb_top_row and mouse_row < self.vertical_thumb_bottom_row; // Make sure we only update the state and redraw when it's necessary. if (!self.is_hovering_vertical_thumb and is_mouse_over_vertical_thumb) { self.is_hovering_vertical_thumb = true; ctx.redraw = true; } else if (self.is_hovering_vertical_thumb and !is_mouse_over_vertical_thumb) { self.is_hovering_vertical_thumb = false; ctx.redraw = true; } const did_start_dragging_vertical_thumb = is_mouse_over_vertical_thumb and mouse.type == .press and mouse.button == .left; if (did_start_dragging_vertical_thumb) { self.is_dragging_vertical_thumb = true; self.mouse_offset_into_thumb = @intCast(mouse_row -| self.vertical_thumb_top_row); // No need to redraw yet, but we must consume the event. return ctx.consumeEvent(); } // 2. Process horizontal scroll thumb hover. const is_mouse_over_horizontal_thumb = mouse_row == self.last_frame_size.height -| 1 and mouse_col >= self.horizontal_thumb_start_col and mouse_col < self.horizontal_thumb_end_col; // Make sure we only update the state and redraw when it's necessary. if (!self.is_hovering_horizontal_thumb and is_mouse_over_horizontal_thumb) { self.is_hovering_horizontal_thumb = true; ctx.redraw = true; } else if (self.is_hovering_horizontal_thumb and !is_mouse_over_horizontal_thumb) { self.is_hovering_horizontal_thumb = false; ctx.redraw = true; } const did_start_dragging_horizontal_thumb = is_mouse_over_horizontal_thumb and mouse.type == .press and mouse.button == .left; if (did_start_dragging_horizontal_thumb) { self.is_dragging_horizontal_thumb = true; self.mouse_offset_into_thumb = @intCast( mouse_col -| self.horizontal_thumb_start_col, ); // No need to redraw yet, but we must consume the event. return ctx.consumeEvent(); } }, .mouse_leave => self.is_dragging_vertical_thumb = false, else => {}, } } pub fn draw(self: *ScrollBars, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { var children: std.ArrayList(vxfw.SubSurface) = .empty; // 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(ctx.arena, .{ .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(); self.last_frame_size = max; // 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(ctx.arena, .{ .origin = .{ .row = 0, .col = 0 }, .surface = scroll_view_surface, }); // 4. Draw the vertical scroll bar. if (self.draw_vertical_scrollbar) vertical: { // If we can't scroll, then there's no need to draw the scroll bar. if (self.scroll_view.scroll.top == 0 and !self.scroll_view.scroll.has_more_vertical) break :vertical; // 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_vertical) 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), if (self.is_dragging_vertical_thumb) self.vertical_scrollbar_drag_thumb else if (self.is_hovering_vertical_thumb) self.vertical_scrollbar_hover_thumb else self.vertical_scrollbar_thumb, ); } self.vertical_thumb_top_row = thumb_top; self.vertical_thumb_bottom_row = thumb_end_row; try children.append(ctx.arena, .{ .origin = .{ .row = 0, .col = max.width -| 1 }, .surface = scroll_bar, }); } // 5. Draw the horizontal scroll bar. const is_horizontally_scrolled = self.scroll_view.scroll.left > 0; const has_more_horizontal_content = self.scroll_view.scroll.has_more_horizontal; const should_draw_scrollbar = is_horizontally_scrolled or has_more_horizontal_content; if (self.draw_horizontal_scrollbar and should_draw_scrollbar) { const scroll_bar = try vxfw.Surface.init( ctx.arena, self.widget(), .{ .width = max.width, .height = 1 }, ); const widget_width_f: f32 = @floatFromInt(max.width); const max_content_width: u32 = width: { if (self.estimated_content_width) |w| break :width w; var max_content_width: u32 = 0; for (scroll_view_surface.children) |child| { max_content_width = @max(max_content_width, child.surface.size.width); } break :width max_content_width; }; const max_content_width_f: f32 = if (self.scroll_view.scroll.left + max.width > max_content_width) // If we've managed to overscroll horizontally for whatever reason - for example if the // content changes - we make sure the scroll thumb doesn't disappear by increasing the // max content width to match the current overscrolled position. @floatFromInt(self.scroll_view.scroll.left + max.width) else @floatFromInt(max_content_width); self.last_frame_max_content_width = max_content_width; const thumb_width_f: f32 = widget_width_f * widget_width_f / max_content_width_f; const thumb_width: u32 = @intFromFloat(@max(thumb_width_f, 1)); const view_start_col_f: f32 = @floatFromInt(self.scroll_view.scroll.left); const thumb_start_f = view_start_col_f * widget_width_f / max_content_width_f; const thumb_start: u32 = @intFromFloat(thumb_start_f); const thumb_end = thumb_start + thumb_width; for (thumb_start..thumb_end) |col| { scroll_bar.writeCell( @intCast(col), 0, if (self.is_dragging_horizontal_thumb) self.horizontal_scrollbar_drag_thumb else if (self.is_hovering_horizontal_thumb) self.horizontal_scrollbar_hover_thumb else self.horizontal_scrollbar_thumb, ); } self.horizontal_thumb_start_col = thumb_start; self.horizontal_thumb_end_col = thumb_end; try children.append(ctx.arena, .{ .origin = .{ .row = max.height -| 1, .col = 0 }, .surface = scroll_bar, }); } return .{ .size = ctx.max.size(), .widget = self.widget(), .buffer = &.{}, .children = children.items, }; } test ScrollBars { // Create child widgets const Text = @import("Text.zig"); const abc: Text = .{ .text = "abc\n def\n ghi" }; const def: Text = .{ .text = "def" }; const ghi: Text = .{ .text = "ghi" }; const jklmno: Text = .{ .text = "jkl\n mno" }; // // 0 |abc| // 1 | d|ef // 2 | g|hi // 3 |def| // 4 ghi // 5 jkl // 6 mno // Create the scroll view const ScrollView = @import("ScrollView.zig"); const scroll_view: ScrollView = .{ .wheel_scroll = 1, // Set wheel scroll to one .children = .{ .slice = &.{ abc.widget(), def.widget(), ghi.widget(), jklmno.widget(), } }, }; // Create the scroll bars. var scroll_bars: ScrollBars = .{ .scroll_view = scroll_view, .estimated_content_height = 7, .estimated_content_width = 5, }; // Boiler plate draw context var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); vxfw.DrawContext.init(.unicode); const scroll_widget = scroll_bars.widget(); const draw_ctx: vxfw.DrawContext = .{ .arena = arena.allocator(), .min = .{}, .max = .{ .width = 3, .height = 4 }, .cell_size = .{ .width = 10, .height = 20 }, }; var surface = try scroll_widget.draw(draw_ctx); // Scroll bars should have 3 children: both scrollbars and the scroll view. try std.testing.expectEqual(3, surface.children.len); // Hide only the horizontal scroll bar. scroll_bars.draw_horizontal_scrollbar = false; surface = try scroll_widget.draw(draw_ctx); // Scroll bars should have 2 children: vertical scroll bar and the scroll view. try std.testing.expectEqual(2, surface.children.len); // Hide only the vertical scroll bar. scroll_bars.draw_horizontal_scrollbar = true; scroll_bars.draw_vertical_scrollbar = false; surface = try scroll_widget.draw(draw_ctx); // Scroll bars should have 2 children: vertical scroll bar and the scroll view. try std.testing.expectEqual(2, surface.children.len); // Hide both scroll bars. scroll_bars.draw_horizontal_scrollbar = false; surface = try scroll_widget.draw(draw_ctx); // Scroll bars should have 1 child: the scroll view. try std.testing.expectEqual(1, surface.children.len); // Re-enable scroll bars. scroll_bars.draw_horizontal_scrollbar = true; scroll_bars.draw_vertical_scrollbar = true; // Even though the estimated size is smaller than the draw area, we still render the scroll // bars if the scroll view knows we haven't rendered everything. scroll_bars.estimated_content_height = 2; scroll_bars.estimated_content_width = 1; surface = try scroll_widget.draw(draw_ctx); // Scroll bars should have 3 children: both scrollbars and the scroll view. try std.testing.expectEqual(3, surface.children.len); // The scroll view should be able to tell whether the scroll bars need to be rendered or not // even if estimated content sizes aren't provided. scroll_bars.estimated_content_height = null; scroll_bars.estimated_content_width = null; surface = try scroll_widget.draw(draw_ctx); // Scroll bars should have 3 children: both scrollbars and the scroll view. try std.testing.expectEqual(3, surface.children.len); } test "refAllDecls" { std.testing.refAllDecls(@This()); }