feat(element/scrollable): scrollbar rendering

Configuration to enable scrollbar rendering for scrollable `Element`s.
Currently only the fg `Color` of the scrollbar can be configured while
the background uses the same fg `Color` but adds the emphasis `.dim` to
make it obvious what the is the actual scrollbar. In the future it might
be necessary to provide the user with more options to configure the
representation of the scrollbar.

Tests have been added to test the scrollbar rendering and placement
accordingly.
This commit is contained in:
2025-05-21 18:20:52 +02:00
parent aa4adf20f9
commit ba25e6056c
9 changed files with 247 additions and 16 deletions

View File

@@ -67,7 +67,7 @@ pub fn main() !void {
}, .{}));
defer box.deinit();
var scrollable: App.Scrollable = .init(box);
var scrollable: App.Scrollable = .init(box, .disabled);
var container = try App.Container.init(allocator, .{
.layout = .{

View File

@@ -148,10 +148,10 @@ pub fn main() !void {
defer container.deinit();
// place empty container containing the element of the scrollable Container.
var scrollable_top: App.Scrollable = .init(top_box);
var scrollable_top: App.Scrollable = .init(top_box, .enabled(.grey));
try container.append(try App.Container.init(allocator, .{}, scrollable_top.element()));
var scrollable_bottom: App.Scrollable = .init(bottom_box);
var scrollable_bottom: App.Scrollable = .init(bottom_box, .enabled(.white));
try container.append(try App.Container.init(allocator, .{}, scrollable_bottom.element()));
try app.start();
@@ -182,7 +182,7 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.resize(try renderer.resize());
container.reposition(.{});
try renderer.render(@TypeOf(container), &container);
try renderer.flush();

View File

@@ -56,7 +56,7 @@ pub fn main() !void {
if (comptime field.value == 0) continue; // zterm.Color.default == 0 -> skip
try box.append(try App.Container.init(allocator, .{ .rectangle = .{ .fill = @enumFromInt(field.value) } }, .{}));
}
var scrollable: App.Scrollable = .init(box);
var scrollable: App.Scrollable = .init(box, .disabled);
try container.append(try App.Container.init(allocator, .{}, scrollable.element()));
try app.start();

View File

@@ -111,7 +111,7 @@ pub fn main() !void {
}, text_styles.element());
defer box.deinit();
var scrollable: App.Scrollable = .init(box);
var scrollable: App.Scrollable = .init(box, .disabled);
try container.append(try App.Container.init(allocator, .{}, scrollable.element()));
try app.start();

View File

@@ -70,9 +70,29 @@ pub fn Scrollable(Event: type) type {
anchor: Point = .{},
/// The actual `Container`, that is scrollable.
container: Container(Event),
/// Whether the scrollable contents should show a scroll bar or not.
/// The scroll bar will only be shown if required and enabled. With the
/// corresponding provided color if to be shown.
configuration: Configuration,
pub fn init(container: Container(Event)) @This() {
return .{ .container = container };
pub const Configuration = packed struct {
scrollbar: bool,
color: Color = .default,
x_axis: bool = false,
y_axis: bool = false,
pub const disabled: @This() = .{ .scrollbar = false };
pub fn enabled(color: Color) @This() {
return .{ .scrollbar = true, .color = color };
}
};
pub fn init(container: Container(Event), configuration: Configuration) @This() {
return .{
.container = container,
.configuration = configuration,
};
}
pub fn element(this: *@This()) Element(Event) {
@@ -91,8 +111,18 @@ pub fn Scrollable(Event: type) type {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.size = size;
// TODO scrollbar space - depending on configuration and only if necessary?
this.container.resize(this.size);
this.container.resize(size);
if (this.configuration.scrollbar) {
if (this.container.properties.size.dim.x > this.size.x or this.container.size.x > this.size.x) this.configuration.y_axis = true;
if (this.container.properties.size.dim.y > this.size.y or this.container.size.y > this.size.y) this.configuration.x_axis = true;
if (this.configuration.x_axis or this.configuration.y_axis)
this.container.resize(.{
.x = this.size.x - if (this.configuration.x_axis) @as(u16, 1) else @as(u16, 0),
.y = this.size.y - if (this.configuration.y_axis) @as(u16, 1) else @as(u16, 0),
});
}
this.container_size = this.container.size;
}
@@ -157,34 +187,75 @@ pub fn Scrollable(Event: type) type {
fn content(ctx: *anyopaque, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
std.debug.assert(cells.len == @as(usize, this.size.x) * @as(usize, this.size.y));
const offset_x: usize = if (this.configuration.x_axis) 1 else 0;
const offset_y: usize = if (this.configuration.y_axis) 1 else 0;
const container_size = this.container.size;
const container_cells = try this.container.allocator.alloc(Cell, @as(usize, container_size.x) * @as(usize, container_size.y));
{
const container_cells_const = try this.container.content();
defer this.container.allocator.free(container_cells_const);
std.debug.assert(container_cells_const.len == @as(usize, container_size.x) * @as(usize, container_size.y));
assert(container_cells_const.len == @as(usize, container_size.x) * @as(usize, container_size.y));
@memcpy(container_cells, container_cells_const);
}
for (this.container.elements.items) |child| try render_container(child, container_cells, container_size);
const anchor = (@as(usize, this.anchor.y) * @as(usize, container_size.x)) + @as(usize, this.anchor.x);
// TODO render scrollbar according to configuration!
for (0..size.y) |row| {
for (0..size.x) |col| {
for (0..size.y - offset_y) |row| {
for (0..size.x - offset_x) |col| {
cells[(row * size.x) + col] = container_cells[anchor + (row * container_size.x) + col];
}
}
if (this.configuration.scrollbar) {
if (this.configuration.x_axis) {
const ratio: f32 = @as(f32, @floatFromInt(this.size.y)) / @as(f32, @floatFromInt(this.container.size.y));
const scrollbar_size: usize = @intFromFloat(@as(f32, @floatFromInt(this.size.y)) * ratio);
const pos_ratio: f32 = @as(f32, @floatFromInt(this.anchor.y)) / @as(f32, @floatFromInt(this.container.size.y));
const scrollbar_starting_pos: usize = @intFromFloat(@as(f32, @floatFromInt(this.size.y)) * pos_ratio);
for (0..size.y) |row| {
cells[(row * size.x) + size.x - 1] = .{
.style = .{
.fg = this.configuration.color,
.emphasis = if (row >= scrollbar_starting_pos and row <= scrollbar_starting_pos + scrollbar_size)
&.{} // scrollbar itself
else
&.{.dim}, // background (around scrollbar)
},
.cp = '🮋', // 7/8 block right
};
}
}
if (this.configuration.y_axis) {
const ratio: f32 = @as(f32, @floatFromInt(this.size.x)) / @as(f32, @floatFromInt(this.container.size.x));
const scrollbar_size: usize = @intFromFloat(@as(f32, @floatFromInt(this.size.x)) * ratio);
const pos_ratio: f32 = @as(f32, @floatFromInt(this.anchor.x)) / @as(f32, @floatFromInt(this.container.size.x));
const scrollbar_starting_pos: usize = @intFromFloat(@as(f32, @floatFromInt(this.size.x)) * pos_ratio);
for (0..size.x) |col| {
cells[((size.y - 1) * size.x) + col] = .{
.style = .{
.fg = this.configuration.color,
.emphasis = if (col >= scrollbar_starting_pos and col <= scrollbar_starting_pos + scrollbar_size)
&.{} // scrollbar itself
else
&.{.dim}, // background (around scrollbar)
},
.cp = '🮄', // 5/8 block top (to make it look more equivalent to the vertical scrollbar)
};
}
}
}
this.container.allocator.free(container_cells);
}
};
}
const std = @import("std");
const assert = std.debug.assert;
const input = @import("input.zig");
const Container = @import("container.zig").Container;
const Cell = @import("cell.zig");
const Color = @import("color.zig").Color;
const Mouse = input.Mouse;
const Point = @import("point.zig").Point;
@@ -225,7 +296,7 @@ test "scrollable vertical" {
}, .{}));
defer box.deinit();
var scrollable: Scrollable(event.SystemEvent) = .init(box);
var scrollable: Scrollable(event.SystemEvent) = .init(box, .disabled);
var container: Container(event.SystemEvent) = try .init(allocator, .{
.border = .{
@@ -268,6 +339,84 @@ test "scrollable vertical" {
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
}
test "scrollable vertical with scrollbar" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const allocator = std.testing.allocator;
const size: Point = .{
.x = 30,
.y = 20,
};
var box: Container(event.SystemEvent) = try .init(allocator, .{
.border = .{
.sides = .all,
.color = .red,
},
.layout = .{
.separator = .{
.enabled = true,
.color = .red,
},
.direction = .vertical,
.padding = .all(1),
},
.size = .{
.dim = .{ .y = size.y + 15 },
},
}, .{});
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
defer box.deinit();
var scrollable: Scrollable(event.SystemEvent) = .init(box, .enabled(.white));
var container: Container(event.SystemEvent) = try .init(allocator, .{
.border = .{
.color = .green,
.sides = .vertical,
},
}, scrollable.element());
defer container.deinit();
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
try renderer.render(Container(event.SystemEvent), &container);
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.scrollbar.top.zon"), renderer.screen);
// scroll down 15 times (exactly to the end)
for (0..15) |_| try container.handle(.{
.mouse = .{
.button = .wheel_down,
.kind = .press,
.x = 5,
.y = 5,
},
});
try renderer.render(Container(event.SystemEvent), &container);
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.scrollbar.bottom.zon"), renderer.screen);
// further scrolling down will not change anything
try container.handle(.{
.mouse = .{
.button = .wheel_down,
.kind = .press,
.x = 5,
.y = 5,
},
});
try renderer.render(Container(event.SystemEvent), &container);
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.scrollbar.bottom.zon"), renderer.screen);
}
test "scrollable horizontal" {
const event = @import("event.zig");
const testing = @import("testing.zig");
@@ -303,7 +452,7 @@ test "scrollable horizontal" {
}, .{}));
defer box.deinit();
var scrollable: Scrollable(event.SystemEvent) = .init(box);
var scrollable: Scrollable(event.SystemEvent) = .init(box, .disabled);
var container: Container(event.SystemEvent) = try .init(allocator, .{
.border = .{
@@ -345,3 +494,81 @@ test "scrollable horizontal" {
try renderer.render(Container(event.SystemEvent), &container);
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.right.zon"), renderer.screen);
}
test "scrollable horizontal with scrollbar" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const allocator = std.testing.allocator;
const size: Point = .{
.x = 30,
.y = 20,
};
var box: Container(event.SystemEvent) = try .init(allocator, .{
.border = .{
.sides = .all,
.color = .red,
},
.layout = .{
.separator = .{
.enabled = true,
.color = .red,
},
.direction = .horizontal,
.padding = .all(1),
},
.size = .{
.dim = .{ .x = size.x + 15 },
},
}, .{});
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
defer box.deinit();
var scrollable: Scrollable(event.SystemEvent) = .init(box, .enabled(.white));
var container: Container(event.SystemEvent) = try .init(allocator, .{
.border = .{
.color = .green,
.sides = .horizontal,
},
}, scrollable.element());
defer container.deinit();
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
try renderer.render(Container(event.SystemEvent), &container);
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.scrollbar.left.zon"), renderer.screen);
// scroll right 15 times (exactly to the end)
for (0..15) |_| try container.handle(.{
.mouse = .{
.button = .wheel_right,
.kind = .press,
.x = 5,
.y = 5,
},
});
try renderer.render(Container(event.SystemEvent), &container);
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.scrollbar.right.zon"), renderer.screen);
// further scrolling right will not change anything
try container.handle(.{
.mouse = .{
.button = .wheel_right,
.kind = .press,
.x = 5,
.y = 5,
},
});
try renderer.render(Container(event.SystemEvent), &container);
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.scrollbar.right.zon"), renderer.screen);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long