feat(scrollable): make Container scrollable through Element Scrollable
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m35s

This commit is contained in:
2025-02-19 20:32:26 +01:00
parent f55d71a7cb
commit 86b3e7d4ed
8 changed files with 191 additions and 69 deletions

View File

@@ -93,22 +93,25 @@ the primary use-case for myself to create this library in the first place.
- [x] separators
- [x] Rectangle
- [ ] User control
- [x] event handling
- [x] event loop handling
- [x] mouse support
- [x] user content
- [ ] Default `Element` implementations
- [ ] Scrollable
- [ ] user input handling
- [ ] vertical
- [ ] horizontal
- [x] user input handling
- [x] vertical
- [x] horizontal
- [x] mouse input
- [ ] scroll bar(s) rendering
- [ ] vertical
- [ ] horizontal
- [ ] Content alignment (i.e. standard calculations done with the provided `Size`)
- [ ] Text display
- [ ] User input
- [ ] single line
- [ ] multi line
- [ ] min size? (I don't have access to the `.resize` `Event`..)
- [x] User input
- [x] single line
- [x] multi line
- [x] min size (provide size to use which would be a minimal size - as if the actual size is smaller then the `Container` will scroll and otherwise the contents expand to the available space instead?)
- [ ] image support through kitty protocol
Decorations should respect the layout and the viewport accordingly. This means
that scrollbars are always visible (except there is no need to have a scrollbar)
@@ -117,6 +120,12 @@ cells of the content (and may be overwritten by child elements contents).
The border of an element should be around independent of the scrolling of the
contents, just like padding.
For most of the `Element`s a standalone implementation would not make a lot
of sense due to the complexity of the user application states. Therefore the
library should instead provide small examples to show how you can implement
such user fields yourself and connect them using your own event system loops to
communicate with other `Container`s and/or `Element`s.
### Scrollable contents
Contents that is scrollable should be done *virtually* through the contents of

View File

@@ -1,11 +1,32 @@
const std = @import("std");
const zterm = @import("zterm");
const App = zterm.App(union(enum) {});
const App = zterm.App(union(enum) {
send: []u21,
});
const log = std.log.scoped(.example);
pub const ExampleElement = packed struct {
pub const InputField = struct {
// TODO: I would need the following features:
// - current position of the input (i.e. a cursor)
// - gnu readline keybindings
// - truncate inputs?
input: std.ArrayList(u21),
/// The thread safe `App.Event` queue used to send events to the application's event loop.
queue: *App.Queue,
pub fn init(allocator: std.mem.Allocator, queue: *App.Queue) @This() {
return .{
.input = .init(allocator),
.queue = queue,
};
}
pub fn deinit(this: *@This()) void {
this.input.deinit();
}
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
@@ -16,27 +37,43 @@ pub const ExampleElement = packed struct {
};
}
// example function to render contents for a `Container`
fn content(ctx: *anyopaque, cells: []zterm.Cell, size: zterm.Size) !void {
_ = ctx;
std.debug.assert(cells.len == @as(usize, size.cols) * @as(usize, size.rows));
// example function to handle events for a `Container`
fn handle(ctx: *anyopaque, event: App.Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.key => |key| {
if (key.isAscii()) try this.input.append(key.cp);
// NOTE: error should only be returned here in case an in-recoverable exception has occurred
const row = size.rows / 2;
const col = size.cols / 2 -| 3;
if (key.eql(.{ .cp = zterm.input.Enter }) or key.eql(.{ .cp = zterm.input.KpEnter }))
this.queue.push(.{ .send = try this.input.toOwnedSlice() });
for (0..5) |c| {
cells[(row * size.cols) + col + c].style.fg = .black;
cells[(row * size.cols) + col + c].cp = '-';
if (key.eql(.{ .cp = zterm.input.Backspace }) or key.eql(.{ .cp = zterm.input.Delete }) or key.eql(.{ .cp = zterm.input.KpDelete }))
_ = this.input.popOrNull();
},
else => {},
}
}
// example function to handle events for a `Container`
fn handle(ctx: *anyopaque, event: App.Event) !void {
_ = ctx;
switch (event) {
.init => log.debug(".init event", .{}),
else => {},
// example function to render contents for a `Container`
fn content(ctx: *anyopaque, cells: []zterm.Cell, size: zterm.Size) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
std.debug.assert(cells.len == @as(usize, size.cols) * @as(usize, size.rows));
if (this.input.items.len == 0) return;
const row = size.rows / 2;
const col: u16 = 5;
for (this.input.items, 0..) |cp, idx| {
cells[(row * size.cols) + col + idx].style.fg = .black;
cells[(row * size.cols) + col + idx].cp = cp;
// NOTE: line wrapping happens automatically due to the way the container cells are constructed
// - it would also be possible to limit the line to cols you want to display (i.e. then only for a single row)
// - for areas you would rather go ahead and create another
// container and fit the text inside of that container (so it is dynamic to the screen size, etc.)
// NOTE: do not write over the contents of this `Container`'s `Size`
if ((row * size.cols) + col + idx == cells.len - 1) break;
}
}
};
@@ -58,7 +95,8 @@ pub fn main() !void {
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
var element_wrapper = ExampleElement{};
var element_wrapper: InputField = .init(allocator, &app.queue);
defer element_wrapper.deinit();
const element = element_wrapper.element();
var container = try App.Container.init(allocator, .{
@@ -73,7 +111,7 @@ pub fn main() !void {
.padding = .all(5),
.direction = .vertical,
},
}, element);
}, .{});
var box = try App.Container.init(allocator, .{
.rectangle = .{ .fill = .blue },
.layout = .{
@@ -82,15 +120,15 @@ pub fn main() !void {
.padding = .vertical(1),
},
}, .{});
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
}, element));
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
}, .{}));
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
}, element));
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
}, .{}));
try container.append(box);
try container.append(try App.Container.init(allocator, .{
.border = .{
@@ -124,6 +162,7 @@ pub fn main() !void {
if (key.eql(.{ .cp = 'n', .mod = .{ .ctrl = true } })) {
try app.interrupt();
defer app.start() catch @panic("could not start app event loop");
// FIX: the rendering is afterwards incorrect (wrong state of the double buffered renderer?)
var child = std.process.Child.init(&.{"hx"}, allocator);
_ = child.spawnAndWait() catch |err| app.postEvent(.{
.err = .{
@@ -133,6 +172,12 @@ pub fn main() !void {
});
}
},
.send => |input| {
// NOTE: as the input in owned by the caller, this means that it still needs to be freed
defer allocator.free(input);
log.info("accepted user input: {any}", .{input});
},
// NOTE: errors could be displayed in another container in case one was received, etc. to provide the user with feedback
.err => |err| log.err("Received {s} with message: {s}", .{ @errorName(err.err), err.msg }),
else => {},

View File

@@ -4,6 +4,7 @@ const code_point = @import("code_point");
const event = @import("event.zig");
const input = @import("input.zig");
const terminal = @import("terminal.zig");
const queue = @import("queue.zig");
const mergeTaggedUnions = event.mergeTaggedUnions;
const isTaggedUnion = event.isTaggedUnion;
@@ -11,7 +12,6 @@ const isTaggedUnion = event.isTaggedUnion;
const Mouse = input.Mouse;
const Key = input.Key;
const Size = @import("size.zig").Size;
const Queue = @import("queue.zig").Queue;
const log = std.log.scoped(.app);
@@ -44,8 +44,9 @@ pub fn App(comptime E: type) type {
const element = @import("element.zig");
pub const Element = element.Element(Event);
pub const Scrollable = element.Scrollable(Event);
pub const Queue = queue.Queue(Event, 256);
queue: Queue(Event, 256),
queue: Queue,
thread: ?std.Thread,
quit_event: std.Thread.ResetEvent,
termios: ?std.posix.termios = null,
@@ -175,6 +176,7 @@ pub fn App(comptime E: type) type {
while (true) {
// FIX: I still think that there is a race condition (I'm just waiting 'long' enough)
this.quit_event.timedWait(20 * std.time.ns_per_ms) catch {
// FIX: in case the queue is full -> the next user input should panic and quit the application? because something seems to clock up the event queue
const read_bytes = try terminal.read(buf[0..]);
// TODO: `break` should not terminate the reading of the user inputs, but instead only the received faulty input!
// escape key presses

View File

@@ -225,9 +225,8 @@ pub const Layout = packed struct {
};
pub fn Container(comptime Event: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Container(comptime Event: type)`");
}
if (!isTaggedUnion(Event)) @compileError("Provided user event `Event` for `Container(comptime Event: type)`");
const Element = @import("element.zig").Element(Event);
return struct {
allocator: std.mem.Allocator,
@@ -243,6 +242,7 @@ pub fn Container(comptime Event: type) type {
border: Border = .{},
rectangle: Rectangle = .{},
layout: Layout = .{},
min_size: Size = .{},
};
pub fn init(
@@ -270,6 +270,24 @@ pub fn Container(comptime Event: type) type {
try this.elements.append(element);
}
pub fn minSize(this: @This()) Size {
var size: Size = .{};
const len: u16 = @truncate(this.elements.items.len);
if (len > 0) {
for (this.elements.items) |element| {
size = size.merge(element.minSize());
}
switch (this.properties.layout.direction) {
.horizontal => size.cols += this.properties.layout.gap * (len - 1),
.vertical => size.rows += this.properties.layout.gap * (len - 1),
}
}
return .{
.cols = @max(size.cols, this.properties.min_size.cols),
.rows = @max(size.rows, this.properties.min_size.rows),
};
}
pub fn handle(this: *@This(), event: Event) !void {
switch (event) {
.resize => |size| resize: {

View File

@@ -1,9 +1,11 @@
//! Interface for Element's which describe the contents of a `Container`.
const std = @import("std");
const s = @import("size.zig");
const input = @import("input.zig");
const Container = @import("container.zig").Container;
const Cell = @import("cell.zig");
const Mouse = input.Mouse;
const Position = s.Position;
const Size = s.Size;
@@ -82,9 +84,12 @@ pub fn Scrollable(Event: type) type {
/// `Size` of the actual contents where the anchor and the size is
/// representing the size and location on screen.
size: Size = .{},
/// Anchor
/// `Size` of the `Container` content that is scrollable and mapped to
/// the *size* of the `Scrollable` `Element`.
container_size: Size = .{},
/// Anchor of the viewport of the scrollable `Container`.
anchor: Position = .{},
/// The actual container, that is *scrollable*
/// The actual container, that is scrollable.
container: Container(Event),
/// Enable horizontal scrolling. This also renders a scrollbar (along the bottom of the viewport).
horizontal: bool = false,
@@ -104,24 +109,42 @@ pub fn Scrollable(Event: type) type {
fn handle(ctx: *anyopaque, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.init => try this.container.handle(event),
// TODO: emit `.resize` event for the container to set the size for the scrollable `Container`
// - how would I determine the required or necessary `Size`?
.resize => |size| {
this.size = size;
// TODO: not just pass through the given size, but rather the size that is necessary for scrollable content
try this.container.handle(.{ .resize = size });
const min_size = this.container.minSize();
this.container_size = .{
.anchor = size.anchor,
.cols = @max(min_size.cols, size.cols),
.rows = @max(min_size.rows, size.rows),
};
try this.container.handle(.{ .resize = this.container_size });
},
.mouse => |mouse| {
std.log.debug("mouse event detected in scrollable element {any}", .{mouse.in(this.size)});
try this.container.handle(.{
.mouse => |mouse| switch (mouse.button) {
Mouse.Button.wheel_up => if (this.vertical) {
this.anchor.row -|= 1;
},
Mouse.Button.wheel_down => if (this.vertical) {
const max_anchor_row = this.container_size.rows -| this.size.rows;
this.anchor.row = @min(this.anchor.row + 1, max_anchor_row);
},
Mouse.Button.wheel_left => if (this.horizontal) {
this.anchor.col -|= 1;
},
Mouse.Button.wheel_right => if (this.horizontal) {
const max_anchor_col = this.container_size.cols -| this.size.cols;
this.anchor.col = @min(this.anchor.col + 1, max_anchor_col);
},
else => try this.container.handle(.{
.mouse = .{
.col = mouse.col + this.anchor.col,
.row = mouse.row + this.anchor.row,
.button = mouse.button,
.kind = mouse.kind,
},
});
}),
},
else => try this.container.handle(event),
}
@@ -131,39 +154,41 @@ pub fn Scrollable(Event: type) type {
const this: *@This() = @ptrCast(@alignCast(ctx));
std.debug.assert(cells.len == @as(usize, this.size.cols) * @as(usize, this.size.rows));
const container_cells = try this.container.allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows));
@memset(cells, .{});
const container_size = this.container.size;
const container_cells = try this.container.allocator.alloc(Cell, @as(usize, container_size.cols) * @as(usize, container_size.rows));
{
const container_cells_const = try this.container.contents();
defer this.container.allocator.free(container_cells_const);
const container_size = this.container.size;
std.debug.assert(container_cells_const.len == @as(usize, container_size.cols) * @as(usize, container_size.rows));
@memcpy(container_cells, container_cells_const);
}
// TODO: render correct view port into the container_cells
for (this.container.elements.items) |*e| {
const e_size = e.size;
const element_cells = try e.contents();
defer e.allocator.free(element_cells);
const anchor = (@as(usize, e_size.anchor.row -| size.anchor.row) * @as(usize, size.cols)) + @as(usize, e_size.anchor.col -| size.anchor.col);
const anchor = (@as(usize, e_size.anchor.row -| container_size.anchor.row) * @as(usize, container_size.cols)) + @as(usize, e_size.anchor.col -| container_size.anchor.col);
var idx: usize = 0;
blk: for (0..e_size.rows) |row| {
for (0..e_size.cols) |col| {
blk: for (0..container_size.rows) |row| {
for (0..container_size.cols) |col| {
const cell = element_cells[idx];
idx += 1;
container_cells[anchor + (row * size.cols) + col].style = cell.style;
container_cells[anchor + (row * size.cols) + col].cp = cell.cp;
container_cells[anchor + (row * container_size.cols) + col].style = cell.style;
container_cells[anchor + (row * container_size.cols) + col].cp = cell.cp;
if (element_cells.len == idx) break :blk;
}
}
}
const anchor = (@as(usize, this.anchor.row) * @as(usize, size.cols)) + @as(usize, this.anchor.col);
for (container_cells, 0..) |cell, idx| {
cells[anchor + idx] = cell;
const anchor = (@as(usize, this.anchor.row) * @as(usize, container_size.cols)) + @as(usize, this.anchor.col);
for (0..size.rows) |row| {
for (0..size.cols) |col| {
cells[(row * size.cols) + col] = container_cells[anchor + (row * container_size.cols) + col];
}
}
this.container.allocator.free(container_cells);
}

View File

@@ -60,17 +60,34 @@ pub const Key = packed struct {
/// ```zig
/// switch (event) {
/// .quit => break,
/// .key => |key| {
/// // ctrl+c to quit
/// if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } }))
/// app.quit.set();
/// },
/// .key => |key| if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) app.quit.set(),
/// else => {},
/// }
/// ```
pub fn eql(this: @This(), other: @This()) bool {
return std.meta.eql(this, other);
}
/// Determine if the `Key` is an ascii character that can be printed to
/// the screen. This means that the code point of the `Key` is an ascii
/// character between 32 - 255 (with the exception of 127 = Delete) and no
/// modifiers (alt and/or ctrl) are used.
///
/// # Example
///
/// Get user input's from the .key event from the application event loop:
///
/// ```zig
/// switch (event) {
/// .key => |key| if (key.isAscii()) try this.input.append(key.cp),
/// else => {},
/// }
/// ```
pub fn isAscii(this: @This()) bool {
return this.mod.alt == false and this.mod.ctrl == false and // no modifier keys
(this.cp >= 32 and this.cp <= 126 or // ascii printable characters (except for input.Delete)
this.cp >= 128 and this.cp <= 255); // extended ascii codes
}
};
// codepoints for keys

View File

@@ -42,15 +42,13 @@ pub const HelloWorldText = packed struct {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.init => log.debug(".init event", .{}),
.key => |key| {
if (key.eql(.{ .cp = input.Space })) {
var next_color_idx = @intFromEnum(this.text_color);
next_color_idx += 1;
next_color_idx %= 17; // iterate over the first 16 colors (but exclude `.default` == 0)
if (next_color_idx == 0) next_color_idx += 1;
this.text_color = @enumFromInt(next_color_idx);
log.debug("Next color: {s}", .{@tagName(this.text_color)});
}
.key => |key| if (key.eql(.{ .cp = input.Space })) {
var next_color_idx = @intFromEnum(this.text_color);
next_color_idx += 1;
next_color_idx %= 17;
if (next_color_idx == @intFromEnum(zterm.Color.default)) next_color_idx += 1;
this.text_color = @enumFromInt(next_color_idx);
log.debug("Next color: {s}", .{@tagName(this.text_color)});
},
else => {},
}
@@ -84,6 +82,7 @@ pub fn main() !void {
.direction = .vertical,
.padding = .vertical(1),
},
.min_size = .{ .rows = 100 },
}, .{});
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },

View File

@@ -2,6 +2,13 @@ pub const Size = packed struct {
anchor: Position = .{},
cols: u16 = 0,
rows: u16 = 0,
pub fn merge(this: @This(), other: @This()) Size {
return .{
.cols = this.cols + other.cols,
.rows = this.rows + other.rows,
};
}
};
pub const Position = packed struct {