2 Commits

Author SHA1 Message Date
4567963ff2 mod(example/text): improve event loop; add stick table heading for used emphasis
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 59s
This should be the way it should be done all the time, such that you
are not rendering for every input, but instead handle all `App.Event`s
that happened between the last render and the current. This shares
similarities with the continuous event loops, which also batches
the events only with the exception that it instead blocks (see
`App.Queue.poll`).
2025-11-02 16:10:11 +01:00
d4cc520826 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.
2025-11-02 16:05:53 +01:00
3 changed files with 198 additions and 27 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

@@ -24,6 +24,86 @@ const QuitText = struct {
}
};
const TableText = struct {
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
.vtable = &.{
.content = content,
},
};
}
fn content(ctx: *anyopaque, _: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void {
_ = ctx;
_ = size;
var idx: usize = 0;
{
const text = "Normal ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Bold ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Dim ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Italic ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Underl ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Blink ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Invert ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Hidden ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Strikethrough";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
}
};
const TextStyles = struct {
const text = "Example";
@@ -109,12 +189,21 @@ pub fn main() !void {
var container = try App.Container.init(allocator, .{
.layout = .{
.gap = 2,
// .gap = 2,
.padding = .{ .top = 5, .bottom = 3, .left = 3, .right = 3 },
.direction = .vertical,
},
}, element);
defer container.deinit();
var table_head: TableText = .{};
try container.append(try .init(allocator, .{
.size = .{
.dim = .{ .y = 1 },
.grow = .horizontal,
},
}, table_head.element()));
var box = try App.Container.init(allocator, .{
.layout = .{ .direction = .vertical },
}, text_styles.element());
@@ -126,28 +215,39 @@ pub fn main() !void {
try app.start();
defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err});
while (true) {
const event = app.nextEvent();
log.debug("received event: {s}", .{@tagName(event)});
// event loop
loop: while (true) {
// batch events since last iteration
const len = blk: {
app.queue.poll();
app.queue.lock();
defer app.queue.unlock();
break :blk app.queue.len();
};
// pre event handling
switch (event) {
.key => |key| if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) app.quit(),
.err => |err| log.err("Received {s} with message: {s}", .{ @errorName(err.err), err.msg }),
else => {},
}
// handle events
for (0..len) |_| {
const event = app.queue.pop();
log.debug("handling event: {s}", .{@tagName(event)});
// pre event handling
switch (event) {
.key => |key| if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) app.quit(),
.err => |err| log.err("Received {s} with message: {s}", .{ @errorName(err.err), err.msg }),
else => {},
}
container.handle(&app.model, event) catch |err| app.postEvent(.{
.err = .{
.err = err,
.msg = "Container Event handling failed",
},
});
container.handle(&app.model, event) catch |err| app.postEvent(.{
.err = .{
.err = err,
.msg = "Container Event handling failed",
},
});
// post event handling
switch (event) {
.quit => break,
else => {},
// post event handling
switch (event) {
.quit => break :loop,
else => {},
}
}
container.resize(try renderer.resize());

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" {