feat(app): event line provides entire line contents as a single event; add(event): .cancel event
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m5s

Introduce `.cancel` event that is fired when the user sends EOF
in *non raw mode* renderings. The event `.line` now sends the
entire line (even if larger than the internal buffer) but requires
now an `Allocator`.
This commit is contained in:
2026-01-22 08:56:12 +01:00
parent 39fb8af174
commit 4441f1b933
20 changed files with 61 additions and 25 deletions

View File

@@ -133,7 +133,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -32,7 +32,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -77,7 +77,7 @@ pub fn main() !void {
const gpa = allocator.allocator(); const gpa = allocator.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(gpa, .{}, .{});
var renderer = zterm.Renderer.Direct.init(gpa); var renderer = zterm.Renderer.Direct.init(gpa);
defer renderer.deinit(); defer renderer.deinit();
@@ -147,9 +147,10 @@ pub fn main() !void {
// pre event handling // pre event handling
switch (event) { switch (event) {
// NOTE maybe I want to decouple the `key`s from the user input too? i.e. this only makes sense if the output is not echoed! // NOTE draw the character with the ctrl indication and a newline to make sure the rendering stays consistent
// otherwise just use gnu's `readline`? .cancel => try renderer.writeCtrlDWithNewline(),
.line => |line| { .line => |line| {
defer gpa.free(line);
log.debug("{s}", .{line}); log.debug("{s}", .{line});
}, },
// NOTE errors could be displayed in another container in case one was received, etc. to provide the user with feedback // NOTE errors could be displayed in another container in case one was received, etc. to provide the user with feedback

View File

@@ -32,7 +32,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -82,7 +82,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -65,7 +65,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -32,7 +32,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -32,7 +32,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -65,7 +65,7 @@ pub fn main() !void {
} }
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -32,7 +32,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -97,7 +97,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -35,7 +35,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -35,7 +35,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -35,7 +35,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -35,7 +35,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -32,7 +32,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -178,7 +178,7 @@ pub fn main() !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .init(.{}, .{}); var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();

View File

@@ -24,6 +24,7 @@ pub fn App(comptime M: type, comptime E: type) type {
if (!isStruct(M)) @compileError("Provided model `M` for `App(comptime M: type, comptime E: type)` is not of type `struct`"); if (!isStruct(M)) @compileError("Provided model `M` for `App(comptime M: type, comptime E: type)` is not of type `struct`");
if (!isTaggedUnion(E)) @compileError("Provided user event `E` for `App(comptime M: type, comptime E: type)` is not of type `union(enum)`."); if (!isTaggedUnion(E)) @compileError("Provided user event `E` for `App(comptime M: type, comptime E: type)` is not of type `union(enum)`.");
return struct { return struct {
gpa: Allocator,
io: std.Io, io: std.Io,
model: Model, model: Model,
queue: Queue, queue: Queue,
@@ -71,8 +72,9 @@ pub fn App(comptime M: type, comptime E: type) type {
this.postEvent(.resize); this.postEvent(.resize);
} }
pub fn init(io: std.Io, model: Model) @This() { pub fn init(gpa: Allocator, io: std.Io, model: Model) @This() {
return .{ return .{
.gpa = gpa,
.io = io, .io = io,
.model = model, .model = model,
.queue = .{}, .queue = .{},
@@ -158,7 +160,7 @@ pub fn App(comptime M: type, comptime E: type) type {
// thread to read user inputs // thread to read user inputs
var buf: [512]u8 = undefined; var buf: [512]u8 = undefined;
// NOTE set the `NONBLOCK` option for the stdin file, such that reading is not blocking! // NOTE set the `NONBLOCK` option for the stdin file, such that reading is not blocking!
{ if (this.config.rawMode) {
// TODO is there a better way to do this through the `std.Io` interface? // TODO is there a better way to do this through the `std.Io` interface?
var fl_flags = posix.fcntl(posix.STDIN_FILENO, posix.F.GETFL, 0) catch |err| switch (err) { var fl_flags = posix.fcntl(posix.STDIN_FILENO, posix.F.GETFL, 0) catch |err| switch (err) {
error.FileBusy => unreachable, error.FileBusy => unreachable,
@@ -178,7 +180,11 @@ pub fn App(comptime M: type, comptime E: type) type {
else => |e| return e, else => |e| return e,
}; };
} }
var remaining_bytes: usize = 0; var remaining_bytes: usize = 0;
var lines: std.ArrayList(u8) = .empty;
defer lines.deinit(this.gpa);
while (true) { while (true) {
this.quit_event.timedWait(20 * std.time.ns_per_ms) catch { this.quit_event.timedWait(20 * std.time.ns_per_ms) catch {
// non-blocking read // non-blocking read
@@ -192,6 +198,12 @@ pub fn App(comptime M: type, comptime E: type) type {
} + remaining_bytes; } + remaining_bytes;
remaining_bytes = 0; remaining_bytes = 0;
if (read_bytes == 0) {
// received <EOF>
this.postEvent(.cancel);
continue;
}
// escape key presses // escape key presses
if (buf[0] == 0x1b and read_bytes > 1) { if (buf[0] == 0x1b and read_bytes > 1) {
switch (buf[1]) { switch (buf[1]) {
@@ -448,14 +460,23 @@ pub fn App(comptime M: type, comptime E: type) type {
var len = read_bytes; var len = read_bytes;
while (!std.unicode.utf8ValidateSlice(buf[0..len])) len -= 1; while (!std.unicode.utf8ValidateSlice(buf[0..len])) len -= 1;
remaining_bytes = read_bytes - len; remaining_bytes = read_bytes - len;
if (this.config.rawMode) { if (this.config.rawMode) {
var iter: std.unicode.Utf8Iterator = .{ .bytes = buf[0..len], .i = 0 }; var iter: std.unicode.Utf8Iterator = .{ .bytes = buf[0..len], .i = 0 };
while (iter.nextCodepoint()) |cp| this.postEvent(.{ .key = .{ .cp = cp } }); while (iter.nextCodepoint()) |cp| this.postEvent(.{ .key = .{ .cp = cp } });
continue; } else try lines.appendSlice(this.gpa, buf[0..len]);
if (remaining_bytes > 0) {
@memmove(buf[0..remaining_bytes], buf[len .. len + remaining_bytes]);
} else { } else {
this.postEvent(.{ .line = buf[0..len] }); if (read_bytes != 512 and buf[len - 1] == '\n') this.postEvent(.{ .line = try lines.toOwnedSlice(this.gpa) });
continue; // NOTE line did not end with `\n` but with EOF, meaning the user canceled
if (read_bytes != 512 and buf[len - 1] != '\n') {
lines.clearRetainingCapacity();
this.postEvent(.cancel);
} }
}
continue; // this switch block does not return a `Key` we continue with loop
}, },
}; };
this.postEvent(.{ .key = key }); this.postEvent(.{ .key = key });
@@ -509,6 +530,7 @@ const mem = std.mem;
const fmt = std.fmt; const fmt = std.fmt;
const posix = std.posix; const posix = std.posix;
const Thread = std.Thread; const Thread = std.Thread;
const Allocator = mem.Allocator;
const assert = std.debug.assert; const assert = std.debug.assert;
const event = @import("event.zig"); const event = @import("event.zig");
const input = @import("input.zig"); const input = @import("input.zig");

View File

@@ -8,6 +8,11 @@ pub const SystemEvent = union(enum) {
init, init,
/// Quit event to signify the end of the event loop (rendering should stop afterwards) /// Quit event to signify the end of the event loop (rendering should stop afterwards)
quit, quit,
/// Cancel event to signify that the user provided an EOF
///
/// Usually this event is only triggered by the system in *non raw mode*
/// renderings otherwise the corresponding `.key` event would be fired instead.
cancel,
/// Resize event to signify that the application should re-draw to resize /// Resize event to signify that the application should re-draw to resize
/// ///
/// Usually no `Container` nor `Element` should act on that event, as it /// Usually no `Container` nor `Element` should act on that event, as it
@@ -23,7 +28,10 @@ pub const SystemEvent = union(enum) {
/// associated error message /// associated error message
msg: []const u8, msg: []const u8,
}, },
/// Input line event received in non *raw mode* (instead of individual `key` events) /// Input line event received in *non raw mode* (instead of individual `key` events)
///
/// This event contains the entire line until the ending newline character
/// (which is included in the payload of this event).
line: []const u8, line: []const u8,
/// Input key event received from the user /// Input key event received from the user
key: Key, key: Key,

View File

@@ -158,6 +158,11 @@ pub const Direct = struct {
try terminal.clearScreen(); try terminal.clearScreen();
} }
pub fn writeCtrlDWithNewline(this: *@This()) !void {
_ = this;
_ = try terminal.write("^D\n");
}
/// Render provided cells at size (anchor and dimension) into the *screen*. /// Render provided cells at size (anchor and dimension) into the *screen*.
pub fn render(this: *@This(), comptime Container: type, container: *Container, comptime Model: type, model: *const Model) !void { pub fn render(this: *@This(), comptime Container: type, container: *Container, comptime Model: type, model: *const Model) !void {
const size: Point = container.size; const size: Point = container.size;