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:
@@ -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 = .{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
247
src/element.zig
247
src/element.zig
@@ -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
1
src/test/element/scrollable.vertical.scrollbar.top.zon
Normal file
1
src/test/element/scrollable.vertical.scrollbar.top.zon
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user