fix(element/scrollable): display and user interaction

Fix initial render to show scrollbar immediately if required. Show and
hide scrollbar correctly when content size or terminal size changes. Add
keybindings for scrolling similar to pager keybindings.
This commit is contained in:
2025-11-02 16:05:53 +01:00
parent b3dc8096d7
commit d4cc520826
2 changed files with 78 additions and 7 deletions

View File

@@ -33,8 +33,6 @@ const zterm: *Dependency = b.dependency("zterm", .{
.target = target,
.optimize = optimize,
});
// ...
exe.root_module.addImport("zterm", zterm.module("zterm"));
```
### Documentation

View File

@@ -267,8 +267,8 @@ pub fn Scrollable(Model: type, Event: type) type {
this.size = 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;
this.configuration.y_axis = this.container.properties.size.dim.x > this.size.x or this.container.size.x > this.size.x;
this.configuration.x_axis = this.container.properties.size.dim.y > this.size.y or this.container.size.y > this.size.y;
const min_size = this.container.element.minSize(size);
this.container.resize(.{
@@ -295,7 +295,43 @@ pub fn Scrollable(Model: type, Event: type) type {
fn handle(ctx: *anyopaque, model: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
// TODO other means to scroll except with the mouse? (i.e. Ctrl-u/d, k/j, etc.?)
.init => this.container.resize(this.container.element.minSize(this.size)),
// TODO what about multiple scrollable `Element` usages? mouse
// can differ between them (due to the event being only send to
// the `Container` / `Element` one under the cursor!)
.key => |key| {
if (key.eql(.{ .cp = 'j' }) or key.eql(.{ .cp = input.Down })) {
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(this.anchor.y + 1, max_anchor_y);
}
if (key.eql(.{ .cp = 'k' }) or key.eql(.{ .cp = input.Up })) {
this.anchor.y -|= 1;
}
if (key.eql(.{ .cp = 'd', .mod = .{ .ctrl = true } })) {
// half page down
const half_page = this.size.y / 2;
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(this.anchor.y + half_page, max_anchor_y);
}
if (key.eql(.{ .cp = 'u', .mod = .{ .ctrl = true } })) {
// half page up
const half_page = this.size.y / 2;
this.anchor.y -|= half_page;
}
if (key.eql(.{ .cp = 'f', .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.PageDown })) {
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(this.anchor.y + this.size.y, max_anchor_y);
}
if (key.eql(.{ .cp = 'b', .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.PageDown })) {
this.anchor.y -|= this.size.y;
}
if (key.eql(.{ .cp = 'g' }) or key.eql(.{ .cp = input.Home })) {
this.anchor.y = 0;
}
if (key.eql(.{ .cp = 'G' }) or key.eql(.{ .cp = input.End })) {
this.anchor.y = this.container_size.y -| this.size.y;
}
},
.mouse => |mouse| switch (mouse.button) {
Mouse.Button.wheel_up => if (this.container_size.y > this.size.y) {
this.anchor.y -|= 1;
@@ -1073,8 +1109,8 @@ test "scrollable vertical" {
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.top.zon"), renderer.screen);
// scroll down 15 times (exactly to the end)
for (0..15) |_| try container.handle(&model, .{
// scroll down 15 times (exactly to the end) (with both mouse and key inputs)
for (0..7) |_| try container.handle(&model, .{
.mouse = .{
.button = .wheel_down,
.kind = .press,
@@ -1082,6 +1118,9 @@ test "scrollable vertical" {
.y = 5,
},
});
for (7..15) |_| try container.handle(&model, .{
.key = .{ .cp = 'j' },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
@@ -1096,6 +1135,40 @@ test "scrollable vertical" {
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
// scrolling up and down again
for (0..5) |_| try container.handle(&model, .{
.key = .{ .cp = 'k' },
});
for (0..5) |_| try container.handle(&model, .{
.key = .{ .cp = 'j' },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
// scrolling half page up and down again
try container.handle(&model, .{
.key = .{ .cp = 'u', .mod = .{ .ctrl = true } },
});
try container.handle(&model, .{
.key = .{ .cp = 'd', .mod = .{ .ctrl = true } },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
// scrolling page up
try container.handle(&model, .{
.key = .{ .cp = 'b', .mod = .{ .ctrl = true } },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.top.zon"), renderer.screen);
// and down again
try container.handle(&model, .{
.key = .{ .cp = 'f', .mod = .{ .ctrl = true } },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
}
test "scrollable vertical with scrollbar" {