From 4a3bec3edc69b34142b43a6c8c6a105bab70d70b Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Tue, 20 Jan 2026 11:23:06 +0100 Subject: [PATCH 1/6] add: `Direct` Renderer implementation rendering the fixed size of the root `Container` A corresponding example has been added, which is very minimalistic as of now, but will add further functionality to test the corresponding workflow that is usual in `zterm` such that it in the best case only a swap in the renderer to switch from alternate mode drawing to direct drawing. --- build.zig | 4 +++ examples/direct.zig | 46 +++++++++++++++++++++++++ src/render.zig | 82 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 examples/direct.zig diff --git a/build.zig b/build.zig index 4c84e38..c1573d7 100644 --- a/build.zig +++ b/build.zig @@ -24,6 +24,8 @@ pub fn build(b: *std.Build) void { palette, // error handling errors, + // non alternate screen applications + direct, }; const example = b.option(Examples, "example", "Example to build and/or run. (default: all)") orelse .all; @@ -73,6 +75,8 @@ pub fn build(b: *std.Build) void { .palette => "examples/styles/palette.zig", // error handling .errors => "examples/errors.zig", + // non-alternate screen + .direct => "examples/direct.zig", .all => unreachable, // should never happen }), .target = target, diff --git a/examples/direct.zig b/examples/direct.zig new file mode 100644 index 0000000..329aa5b --- /dev/null +++ b/examples/direct.zig @@ -0,0 +1,46 @@ +pub fn main() !void { + errdefer |err| log.err("Application Error: {any}", .{err}); + + var allocator: std.heap.DebugAllocator(.{}) = .init; + defer if (allocator.deinit() == .leak) log.err("memory leak", .{}); + + const gpa = allocator.allocator(); + + var app: App = .init(.{}, .{}); + var renderer = zterm.Renderer.Direct.init(gpa); + defer renderer.deinit(); + + var container: App.Container = try .init(gpa, .{ + .layout = .{ + .direction = .horizontal, + .separator = .{ .enabled = true }, + }, + .size = .{ + .dim = .{ .y = 30 }, + .grow = .horizontal_only, + }, + }, .{}); + defer container.deinit(); + + try container.append(try .init(gpa, .{ + .rectangle = .{ .fill = .blue }, + }, .{})); + + try container.append(try .init(gpa, .{ + .rectangle = .{ .fill = .green }, + }, .{})); + + container.resize(&app.model, try renderer.resize()); + container.reposition(&app.model, .{}); + try renderer.render(@TypeOf(container), &container, App.Model, &app.model); + try renderer.flush(); +} + +pub const panic = App.panic_handler; +const log = std.log.scoped(.default); + +const std = @import("std"); +const assert = std.debug.assert; +const zterm = @import("zterm"); +const input = zterm.input; +const App = zterm.App(struct {}, union(enum) {}); diff --git a/src/render.zig b/src/render.zig index 74f167d..48b5ee8 100644 --- a/src/render.zig +++ b/src/render.zig @@ -91,6 +91,7 @@ pub const Buffered = struct { var writer = terminal.writer(); const s = this.screen; const vs = this.virtual_screen; + for (0..this.size.y) |row| { for (0..this.size.x) |col| { const idx = (row * this.size.x) + col; @@ -123,6 +124,87 @@ pub const Buffered = struct { } }; +pub const Direct = struct { + gpa: Allocator, + size: Point, + resized: bool, + screen: []Cell, + + pub fn init(gpa: Allocator) @This() { + return .{ + .gpa = gpa, + .size = .{}, + .resized = true, + .screen = undefined, + }; + } + + pub fn deinit(this: *@This()) void { + this.gpa.free(this.screen); + } + + pub fn resize(this: *@This()) !Point { + // TODO the size in the y-axis is *not fixed* and *not limited* + // how can I achive this? + this.size = .{}; + if (!this.resized) { + this.gpa.free(this.screen); + this.screen = undefined; + } + this.resized = true; + return terminal.getTerminalSize(); + } + + pub fn clear(this: *@This()) !void { + _ = this; + try terminal.clearScreen(); + } + + /// Render provided cells at size (anchor and dimension) into the *virtual screen*. + pub fn render(this: *@This(), comptime Container: type, container: *Container, comptime Model: type, model: *const Model) !void { + const size: Point = container.size; + const origin: Point = container.origin; + + if (this.resized) { + this.size = size; + const n = @as(usize, this.size.x) * @as(usize, this.size.y); + this.screen = try this.gpa.alloc(Cell, n); + @memset(this.screen, .{}); + this.resized = false; + } + + const cells: []const Cell = try container.content(model); + + var idx: usize = 0; + var vs = this.screen; + const anchor: usize = (@as(usize, origin.y) * @as(usize, this.size.x)) + @as(usize, origin.x); + + + blk: for (0..size.y) |row| { + for (0..size.x) |col| { + vs[anchor + (row * this.size.x) + col] = cells[idx]; + idx += 1; + + if (cells.len == idx) break :blk; + } + } + // free immediately + container.allocator.free(cells); + for (container.elements.items) |*element| try this.render(Container, element, Model, model); + } + + pub fn flush(this: *@This()) !void { + var writer = terminal.writer(); + for (0..this.size.y) |row| { + for (0..this.size.x) |col| { + const idx = (row * this.size.x) + col; + const cvs = this.screen[idx]; + try cvs.value(&writer); + } + } + } +}; + const std = @import("std"); const meta = std.meta; const assert = std.debug.assert; -- 2.49.1 From c29c60bd898a7d17360919c28ac9932038fe7b6a Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Tue, 20 Jan 2026 11:54:28 +0100 Subject: [PATCH 2/6] WIP make example interactive Handle inputs as per usual (which however is a bit weak, is it goes through key by key and not the entire line), batch all events such that all events are handled before the next frame is rendered. For this the `App.start` function needs to become configurable, such that it changes the termios as configured (hence it is currently all commented out for testing). --- examples/direct.zig | 59 ++++++++++++++++++++++++++++++++++++++++++--- src/app.zig | 20 +++++++-------- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/examples/direct.zig b/examples/direct.zig index 329aa5b..817a2e4 100644 --- a/examples/direct.zig +++ b/examples/direct.zig @@ -11,6 +11,9 @@ pub fn main() !void { defer renderer.deinit(); var container: App.Container = try .init(gpa, .{ + .border = .{ + .sides = .all, + }, .layout = .{ .direction = .horizontal, .separator = .{ .enabled = true }, @@ -30,10 +33,58 @@ pub fn main() !void { .rectangle = .{ .fill = .green }, }, .{})); - container.resize(&app.model, try renderer.resize()); - container.reposition(&app.model, .{}); - try renderer.render(@TypeOf(container), &container, App.Model, &app.model); - try renderer.flush(); + try app.start(); // needs to become configurable, as what should be enabled / disabled (i.e. show cursor, hide cursor, use alternate screen, etc.) + defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); + + // event loop + event: 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(); + }; + + // handle events + for (0..len) |_| { + const event = app.queue.pop(); + //log.debug("received event: {s}", .{@tagName(event)}); + + // pre event handling + 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! + // otherwise just use gnu's `readline`? + .key => |key| { + if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) app.quit(); + }, + // 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 => {}, + } + + // NOTE returned errors should be propagated back to the application + container.handle(&app.model, event) catch |err| app.postEvent(.{ + .err = .{ + .err = err, + .msg = "Container Event handling failed", + }, + }); + + // post event handling + switch (event) { + .quit => break :event, + else => {}, + } + } + // if there are more events to process continue handling them otherwise I can render the next frame + if (app.queue.len() > 0) continue :event; + + container.resize(&app.model, try renderer.resize()); + container.reposition(&app.model, .{}); + try renderer.render(@TypeOf(container), &container, App.Model, &app.model); + try renderer.flush(); + } } pub const panic = App.panic_handler; diff --git a/src/app.zig b/src/app.zig index 92e9eae..d79db80 100644 --- a/src/app.zig +++ b/src/app.zig @@ -71,21 +71,21 @@ pub fn App(comptime M: type, comptime E: type) type { this.quit_event.reset(); this.thread = try Thread.spawn(.{}, @This().run, .{this}); - var termios: posix.termios = undefined; - try terminal.enableRawMode(&termios); - if (this.termios) |_| {} else this.termios = termios; + //var termios: posix.termios = undefined; + //try terminal.enableRawMode(&termios); + //if (this.termios) |_| {} else this.termios = termios; - try terminal.enterAltScreen(); - try terminal.saveScreen(); - try terminal.hideCursor(); - try terminal.enableMouseSupport(); + //try terminal.enterAltScreen(); + //try terminal.saveScreen(); + //try terminal.hideCursor(); + //try terminal.enableMouseSupport(); } pub fn interrupt(this: *@This()) !void { this.quit_event.set(); - try terminal.disableMouseSupport(); - try terminal.restoreScreen(); - try terminal.exitAltScreen(); + //try terminal.disableMouseSupport(); + //try terminal.restoreScreen(); + //try terminal.exitAltScreen(); if (this.thread) |*thread| { thread.join(); this.thread = null; -- 2.49.1 From 97a240c54d6f2efcc027e10e63d6bd0a3a94ff67 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Tue, 20 Jan 2026 13:57:55 +0100 Subject: [PATCH 3/6] mod: example shows how dynamic sizing is achived that can be independent form the reported terminal size Setting the cursor with the `Direct` handler will cause the rendering to halt at that point and leave the cursor at point. Due to not enabling *raw mode* with the newly introduced `App.start` configuration options corresponding inputs are only visible to `zterm` once the input has been completed with a newline. With this it is not necessary for the renderer to know nothing more than the width of the terminal (which is implied through the `Container` sizes). Making it very trivial to implement. --- examples/direct.zig | 108 +++++++++++++++++++++++++++++++++++++++----- src/app.zig | 55 +++++++++++++++------- src/render.zig | 16 +++---- src/terminal.zig | 2 +- 4 files changed, 143 insertions(+), 38 deletions(-) diff --git a/examples/direct.zig b/examples/direct.zig index 817a2e4..f85aa84 100644 --- a/examples/direct.zig +++ b/examples/direct.zig @@ -1,3 +1,75 @@ +const QuitText = struct { + const text = "Press ctrl+c to quit."; + + pub fn element(this: *@This()) App.Element { + return .{ + .ptr = this, + .vtable = &.{ + .minSize = minSize, + .content = content, + }, + }; + } + + fn minSize(_: *anyopaque, _: *const App.Model, size: zterm.Point) zterm.Point { + return .{ .x = size.x, .y = 10 }; // this includes the border + } + + pub fn content(ctx: *anyopaque, _: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void { + _ = ctx; + assert(cells.len == @as(usize, size.x) * @as(usize, size.y)); + + const y = 0; + const x = size.x / 2 -| (text.len / 2); + const anchor = (y * size.x) + x; + + for (text, 0..) |cp, idx| { + cells[anchor + idx].style.fg = .white; + cells[anchor + idx].style.emphasis = &.{.bold}; + cells[anchor + idx].cp = cp; + + // NOTE do not write over the contents of this `Container`'s `Size` + if (anchor + idx == cells.len - 1) break; + } + } +}; + +const Prompt = struct { + len: u16 = 5, + pub fn element(this: *@This()) App.Element { + return .{ + .ptr = this, + .vtable = &.{ + // .minSize = minSize, + .content = content, + }, + }; + } + + // fn minSize(ctx: *anyopaque, _: *const App.Model, _: zterm.Point) zterm.Point { + // const this: *@This() = @ptrCast(@alignCast(ctx)); + // return .{ .x = this.len, .y = 1 }; + // } + + pub fn content(ctx: *anyopaque, _: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void { + const this: *@This() = @ptrCast(@alignCast(ctx)); + assert(cells.len > 2); // expect at least two cells + assert(cells.len == @as(usize, size.x) * @as(usize, size.y)); + + for (0..this.len) |idx| { + cells[idx].style.bg = .red; + cells[idx].style.fg = .black; + cells[idx].style.emphasis = &.{.bold}; + } + cells[this.len - 1].style.cursor = true; // marks the actual end of the rendering! + + const anchor = 1; + cells[anchor + 0].cp = 'N'; + cells[anchor + 1].cp = 'O'; + cells[anchor + 2].cp = 'R'; + } +}; + pub fn main() !void { errdefer |err| log.err("Application Error: {any}", .{err}); @@ -11,29 +83,42 @@ pub fn main() !void { defer renderer.deinit(); var container: App.Container = try .init(gpa, .{ - .border = .{ - .sides = .all, - }, .layout = .{ - .direction = .horizontal, - .separator = .{ .enabled = true }, + .direction = .vertical, }, .size = .{ - .dim = .{ .y = 30 }, .grow = .horizontal_only, }, }, .{}); defer container.deinit(); - try container.append(try .init(gpa, .{ + var quit_text: QuitText = .{}; + var intermediate: App.Container = try .init(gpa, .{ + .border = .{ + .sides = .all, + }, + .layout = .{ + .direction = .horizontal, + }, + }, quit_text.element()); + try intermediate.append(try .init(gpa, .{ .rectangle = .{ .fill = .blue }, }, .{})); - try container.append(try .init(gpa, .{ + try intermediate.append(try .init(gpa, .{ .rectangle = .{ .fill = .green }, }, .{})); + try container.append(intermediate); - try app.start(); // needs to become configurable, as what should be enabled / disabled (i.e. show cursor, hide cursor, use alternate screen, etc.) + var prompt: Prompt = .{}; + try container.append(try .init(gpa, .{ + .rectangle = .{ .fill = .red }, + .size = .{ + .dim = .{ .y = 1 }, + }, + }, prompt.element())); + + try app.start(.direct); // needs to become configurable, as what should be enabled / disabled (i.e. show cursor, hide cursor, use alternate screen, etc.) defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop @@ -49,14 +134,15 @@ pub fn main() !void { // handle events for (0..len) |_| { const event = app.queue.pop(); - //log.debug("received event: {s}", .{@tagName(event)}); + log.debug("received event: {s}", .{@tagName(event)}); // pre event handling 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! // otherwise just use gnu's `readline`? .key => |key| { - if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) app.quit(); + // if (key.eql(.{ .cp = 'q' })) app.quit(); + _ = key; }, // 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 }), diff --git a/src/app.zig b/src/app.zig index d79db80..7137c7f 100644 --- a/src/app.zig +++ b/src/app.zig @@ -31,6 +31,28 @@ pub fn App(comptime M: type, comptime E: type) type { quit_event: Thread.ResetEvent, termios: ?posix.termios = null, winch_registered: bool = false, + config: TerminalConfiguration, + + pub const TerminalConfiguration = struct { + altScreen: bool, + saveScreen: bool, + rawMode: bool, + hideCursor: bool, + + pub const full: @This() = .{ + .altScreen = true, + .saveScreen = true, + .rawMode = true, + .hideCursor = true, + }; + + pub const direct: @This() = .{ + .altScreen = false, + .saveScreen = false, + .rawMode = false, + .hideCursor = false, + }; + }; // global variable for the registered handler for WINCH var handler_ctx: *anyopaque = undefined; @@ -48,10 +70,12 @@ pub fn App(comptime M: type, comptime E: type) type { .model = model, .queue = .{}, .quit_event = .{}, + .config = undefined, }; } - pub fn start(this: *@This()) !void { + pub fn start(this: *@This(), config: TerminalConfiguration) !void { + this.config = config; if (this.thread) |_| return; // post init event (as the very first element to be in the queue - event loop) @@ -71,21 +95,21 @@ pub fn App(comptime M: type, comptime E: type) type { this.quit_event.reset(); this.thread = try Thread.spawn(.{}, @This().run, .{this}); - //var termios: posix.termios = undefined; - //try terminal.enableRawMode(&termios); - //if (this.termios) |_| {} else this.termios = termios; + var termios: posix.termios = undefined; + if (this.config.rawMode) try terminal.enableRawMode(&termios); + if (this.termios) |_| {} else this.termios = termios; - //try terminal.enterAltScreen(); - //try terminal.saveScreen(); - //try terminal.hideCursor(); - //try terminal.enableMouseSupport(); + if (this.config.altScreen) try terminal.enterAltScreen(); + if (this.config.saveScreen) try terminal.saveScreen(); + if (this.config.hideCursor) try terminal.hideCursor(); + if (this.config.altScreen and this.config.rawMode) try terminal.enableMouseSupport(); } pub fn interrupt(this: *@This()) !void { this.quit_event.set(); - //try terminal.disableMouseSupport(); - //try terminal.restoreScreen(); - //try terminal.exitAltScreen(); + if (this.config.altScreen and this.config.rawMode) try terminal.disableMouseSupport(); + if (this.config.saveScreen) try terminal.restoreScreen(); + if (this.config.altScreen) try terminal.exitAltScreen(); if (this.thread) |*thread| { thread.join(); this.thread = null; @@ -94,13 +118,10 @@ pub fn App(comptime M: type, comptime E: type) type { pub fn stop(this: *@This()) !void { try this.interrupt(); + if (this.config.hideCursor) try terminal.showCursor(); + if (this.config.saveScreen) try terminal.resetCursor(); if (this.termios) |termios| { - try terminal.disableMouseSupport(); - try terminal.showCursor(); - try terminal.resetCursor(); - try terminal.restoreScreen(); - try terminal.disableRawMode(&termios); - try terminal.exitAltScreen(); + if (this.config.rawMode) try terminal.disableRawMode(&termios); this.termios = null; } } diff --git a/src/render.zig b/src/render.zig index 48b5ee8..4c3e01e 100644 --- a/src/render.zig +++ b/src/render.zig @@ -144,8 +144,6 @@ pub const Direct = struct { } pub fn resize(this: *@This()) !Point { - // TODO the size in the y-axis is *not fixed* and *not limited* - // how can I achive this? this.size = .{}; if (!this.resized) { this.gpa.free(this.screen); @@ -160,17 +158,17 @@ pub const Direct = struct { try terminal.clearScreen(); } - /// Render provided cells at size (anchor and dimension) into the *virtual 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 { const size: Point = container.size; const origin: Point = container.origin; if (this.resized) { - this.size = size; - const n = @as(usize, this.size.x) * @as(usize, this.size.y); - this.screen = try this.gpa.alloc(Cell, n); - @memset(this.screen, .{}); - this.resized = false; + this.size = size; + const n = @as(usize, this.size.x) * @as(usize, this.size.y); + this.screen = try this.gpa.alloc(Cell, n); + @memset(this.screen, .{}); + this.resized = false; } const cells: []const Cell = try container.content(model); @@ -179,7 +177,6 @@ pub const Direct = struct { var vs = this.screen; const anchor: usize = (@as(usize, origin.y) * @as(usize, this.size.x)) + @as(usize, origin.x); - blk: for (0..size.y) |row| { for (0..size.x) |col| { vs[anchor + (row * this.size.x) + col] = cells[idx]; @@ -200,6 +197,7 @@ pub const Direct = struct { const idx = (row * this.size.x) + col; const cvs = this.screen[idx]; try cvs.value(&writer); + if (cvs.style.cursor) return; // that's where the cursor should be left! } } } diff --git a/src/terminal.zig b/src/terminal.zig index 6d7811b..38bd78d 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -98,7 +98,7 @@ pub fn setCursorPosition(pos: Point) !void { _ = try posix.write(posix.STDIN_FILENO, value); } -pub fn getCursorPosition() !Size.Position { +pub fn getCursorPosition() !Size { // Needs Raw mode (no wait for \n) to work properly cause // control sequence will not be written without it. _ = try posix.write(posix.STDIN_FILENO, "\x1b[6n"); -- 2.49.1 From a71d808250ef2463cb10a1eff97d03a98686805f Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Tue, 20 Jan 2026 15:11:19 +0100 Subject: [PATCH 4/6] add: `.line` core event for line contents in non *raw mode* instead of `.key` core events The end of the `.line` event received contents is highlighted by a trailing newline (which cannot occur before, as that triggers the line event itself). Add signal handler for SIGCONT which forces a `.resize` event that should re-draw the contents after continuing a suspended application (i.e. ctrl+z followed by `fg`). --- examples/direct.zig | 6 +++--- src/app.zig | 34 +++++++++++++++++++++++++--------- src/event.zig | 2 ++ 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/examples/direct.zig b/examples/direct.zig index f85aa84..3aba856 100644 --- a/examples/direct.zig +++ b/examples/direct.zig @@ -46,6 +46,7 @@ const Prompt = struct { }; } + // NOTE size hint is not required as the `.size = .{ .dim = .{..} }` property is set accordingly which denotes the minimal size // fn minSize(ctx: *anyopaque, _: *const App.Model, _: zterm.Point) zterm.Point { // const this: *@This() = @ptrCast(@alignCast(ctx)); // return .{ .x = this.len, .y = 1 }; @@ -140,9 +141,8 @@ pub fn main() !void { 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! // otherwise just use gnu's `readline`? - .key => |key| { - // if (key.eql(.{ .cp = 'q' })) app.quit(); - _ = key; + .line => |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 .err => |err| log.err("Received {s} with message: {s}", .{ @errorName(err.err), err.msg }), diff --git a/src/app.zig b/src/app.zig index 7137c7f..1a95c7f 100644 --- a/src/app.zig +++ b/src/app.zig @@ -30,7 +30,7 @@ pub fn App(comptime M: type, comptime E: type) type { thread: ?Thread = null, quit_event: Thread.ResetEvent, termios: ?posix.termios = null, - winch_registered: bool = false, + handler_registered: bool = false, config: TerminalConfiguration, pub const TerminalConfiguration = struct { @@ -63,6 +63,13 @@ pub fn App(comptime M: type, comptime E: type) type { // -> the signal might not work correctly when hosting the application over ssh! this.postEvent(.resize); } + /// registered CONT handler to force a complete redraw + fn handleCont(_: i32) callconv(.c) void { + const this: *@This() = @ptrCast(@alignCast(handler_ctx)); + // NOTE this does not have to be done if in-band resize events are supported + // -> the signal might not work correctly when hosting the application over ssh! + this.postEvent(.resize); + } pub fn init(io: std.Io, model: Model) @This() { return .{ @@ -81,15 +88,19 @@ pub fn App(comptime M: type, comptime E: type) type { // post init event (as the very first element to be in the queue - event loop) this.postEvent(.init); - if (!this.winch_registered) { + if (!this.handler_registered) { handler_ctx = this; - var act = posix.Sigaction{ + posix.sigaction(posix.SIG.WINCH, &.{ .handler = .{ .handler = handleWinch }, .mask = posix.sigemptyset(), .flags = 0, - }; - posix.sigaction(posix.SIG.WINCH, &act, null); - this.winch_registered = true; + }, null); + posix.sigaction(posix.SIG.CONT, &.{ + .handler = .{ .handler = handleCont }, + .mask = posix.sigemptyset(), + .flags = 0, + }, null); + this.handler_registered = true; } this.quit_event.reset(); @@ -437,9 +448,14 @@ pub fn App(comptime M: type, comptime E: type) type { var len = read_bytes; while (!std.unicode.utf8ValidateSlice(buf[0..len])) len -= 1; remaining_bytes = read_bytes - len; - var iter: std.unicode.Utf8Iterator = .{ .bytes = buf[0..len], .i = 0 }; - while (iter.nextCodepoint()) |cp| this.postEvent(.{ .key = .{ .cp = cp } }); - continue; + if (this.config.rawMode) { + var iter: std.unicode.Utf8Iterator = .{ .bytes = buf[0..len], .i = 0 }; + while (iter.nextCodepoint()) |cp| this.postEvent(.{ .key = .{ .cp = cp } }); + continue; + } else { + this.postEvent(.{ .line = buf[0..len] }); + continue; + } }, }; this.postEvent(.{ .key = key }); diff --git a/src/event.zig b/src/event.zig index e97cb67..ed0d1f6 100644 --- a/src/event.zig +++ b/src/event.zig @@ -23,6 +23,8 @@ pub const SystemEvent = union(enum) { /// associated error message msg: []const u8, }, + /// Input line event received in non *raw mode* (instead of individual `key` events) + line: []const u8, /// Input key event received from the user key: Key, /// Mouse input event -- 2.49.1 From 89517b25468e6ce457ac313c5850ac4ee285cd99 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Tue, 20 Jan 2026 15:16:10 +0100 Subject: [PATCH 5/6] fix: add newly introduced parameter for `App.start` function to all corresponding usages --- examples/continuous.zig | 2 +- examples/demo.zig | 4 ++-- examples/elements/alignment.zig | 2 +- examples/elements/button.zig | 2 +- examples/elements/input.zig | 2 +- examples/elements/progress.zig | 2 +- examples/elements/radio-button.zig | 2 +- examples/elements/scrollable.zig | 2 +- examples/elements/selection.zig | 2 +- examples/errors.zig | 2 +- examples/layouts/grid.zig | 2 +- examples/layouts/horizontal.zig | 2 +- examples/layouts/mixed.zig | 2 +- examples/layouts/vertical.zig | 2 +- examples/styles/palette.zig | 2 +- examples/styles/text.zig | 2 +- src/app.zig | 2 +- 17 files changed, 18 insertions(+), 18 deletions(-) diff --git a/examples/continuous.zig b/examples/continuous.zig index 7f1177f..bb91d0c 100644 --- a/examples/continuous.zig +++ b/examples/continuous.zig @@ -165,7 +165,7 @@ pub fn main() !void { }, spinner.element()); try container.append(nested_container); - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); var framerate: u64 = 60; diff --git a/examples/demo.zig b/examples/demo.zig index d8dab81..d15e82f 100644 --- a/examples/demo.zig +++ b/examples/demo.zig @@ -133,7 +133,7 @@ pub fn main() !void { }, .{})); defer container.deinit(); // also de-initializes the children - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop @@ -149,7 +149,7 @@ pub fn main() !void { if (key.eql(.{ .cp = 'n', .mod = .{ .ctrl = true } })) { try app.interrupt(); renderer.size = .{}; // reset size, such that next resize will cause a full re-draw! - defer app.start() catch @panic("could not start app event loop"); + defer app.start(.full) catch @panic("could not start app event loop"); var child = std.process.Child.init(&.{"vim"}, allocator); _ = child.spawnAndWait() catch |err| app.postEvent(.{ .err = .{ diff --git a/examples/elements/alignment.zig b/examples/elements/alignment.zig index 3a35bcd..a96ed90 100644 --- a/examples/elements/alignment.zig +++ b/examples/elements/alignment.zig @@ -55,7 +55,7 @@ pub fn main() !void { var alignment: App.Alignment = .init(quit_container, .center); try container.append(try .init(allocator, .{}, alignment.element())); - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop diff --git a/examples/elements/button.zig b/examples/elements/button.zig index e150ae8..4214177 100644 --- a/examples/elements/button.zig +++ b/examples/elements/button.zig @@ -102,7 +102,7 @@ pub fn main() !void { try container.append(try App.Container.init(allocator, .{ .rectangle = .{ .fill = .lightgrey } }, element)); try container.append(try App.Container.init(allocator, .{ .rectangle = .{ .fill = .black } }, button.element())); - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop diff --git a/examples/elements/input.zig b/examples/elements/input.zig index bf04e3d..cd2e782 100644 --- a/examples/elements/input.zig +++ b/examples/elements/input.zig @@ -112,7 +112,7 @@ pub fn main() !void { }, second_mouse_draw.element())); try container.append(nested_container); - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop diff --git a/examples/elements/progress.zig b/examples/elements/progress.zig index dd899f4..1023637 100644 --- a/examples/elements/progress.zig +++ b/examples/elements/progress.zig @@ -85,7 +85,7 @@ pub fn main() !void { }); try container.append(try App.Container.init(allocator, .{}, progress.element())); } - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); var framerate: u64 = 60; diff --git a/examples/elements/radio-button.zig b/examples/elements/radio-button.zig index 15b882d..abcdf9d 100644 --- a/examples/elements/radio-button.zig +++ b/examples/elements/radio-button.zig @@ -51,7 +51,7 @@ pub fn main() !void { try container.append(try .init(allocator, .{}, radiobutton.element())); try container.append(try App.Container.init(allocator, .{ .rectangle = .{ .fill = .black } }, button.element())); - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop diff --git a/examples/elements/scrollable.zig b/examples/elements/scrollable.zig index ebb6305..a02a37b 100644 --- a/examples/elements/scrollable.zig +++ b/examples/elements/scrollable.zig @@ -151,7 +151,7 @@ pub fn main() !void { var scrollable_bottom: App.Scrollable = .init(bottom_box, .enabled(.white, true)); try container.append(try App.Container.init(allocator, .{}, scrollable_bottom.element())); - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop diff --git a/examples/elements/selection.zig b/examples/elements/selection.zig index c19a910..4d5f611 100644 --- a/examples/elements/selection.zig +++ b/examples/elements/selection.zig @@ -52,7 +52,7 @@ pub fn main() !void { defer container.deinit(); try container.append(try .init(allocator, .{}, selection.element())); - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop diff --git a/examples/errors.zig b/examples/errors.zig index 3a7bffb..91f1bac 100644 --- a/examples/errors.zig +++ b/examples/errors.zig @@ -116,7 +116,7 @@ pub fn main() !void { try container.append(try App.Container.init(allocator, .{}, info_text.element())); try container.append(try App.Container.init(allocator, .{}, error_notification.element())); - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); while (true) { diff --git a/examples/layouts/grid.zig b/examples/layouts/grid.zig index f6f3f08..5c73d25 100644 --- a/examples/layouts/grid.zig +++ b/examples/layouts/grid.zig @@ -69,7 +69,7 @@ pub fn main() !void { } defer container.deinit(); // also de-initializes the children - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop diff --git a/examples/layouts/horizontal.zig b/examples/layouts/horizontal.zig index 06c6154..116571e 100644 --- a/examples/layouts/horizontal.zig +++ b/examples/layouts/horizontal.zig @@ -61,7 +61,7 @@ pub fn main() !void { }, .{})); defer container.deinit(); // also de-initializes the children - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop diff --git a/examples/layouts/mixed.zig b/examples/layouts/mixed.zig index c5bb09e..5ab93e3 100644 --- a/examples/layouts/mixed.zig +++ b/examples/layouts/mixed.zig @@ -77,7 +77,7 @@ pub fn main() !void { } defer container.deinit(); // also de-initializes the children - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop diff --git a/examples/layouts/vertical.zig b/examples/layouts/vertical.zig index fc5c6b3..a828256 100644 --- a/examples/layouts/vertical.zig +++ b/examples/layouts/vertical.zig @@ -60,7 +60,7 @@ pub fn main() !void { }, .{})); defer container.deinit(); // also de-initializes the children - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop diff --git a/examples/styles/palette.zig b/examples/styles/palette.zig index 63ceb6c..d0e06cd 100644 --- a/examples/styles/palette.zig +++ b/examples/styles/palette.zig @@ -58,7 +58,7 @@ pub fn main() !void { var scrollable: App.Scrollable = .init(box, .disabled); try container.append(try App.Container.init(allocator, .{}, scrollable.element())); - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); while (true) { diff --git a/examples/styles/text.zig b/examples/styles/text.zig index f0f01b8..58f94ac 100644 --- a/examples/styles/text.zig +++ b/examples/styles/text.zig @@ -209,7 +209,7 @@ pub fn main() !void { }, text_styles.element()), .enabled(.white, true)); try container.append(try App.Container.init(allocator, .{}, scrollable.element())); - try app.start(); + try app.start(.full); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); // event loop diff --git a/src/app.zig b/src/app.zig index 1a95c7f..9acc49e 100644 --- a/src/app.zig +++ b/src/app.zig @@ -17,7 +17,7 @@ /// ); /// // later on create an `App` instance and start the event loop /// var app: App = .init(io, .{}); // provide instance of the `std.Io` and `App` model that shall be used -/// try app.start(); +/// try app.start(.full); /// defer app.stop() catch unreachable; // does not clean-up the resources used in the model /// ``` pub fn App(comptime M: type, comptime E: type) type { -- 2.49.1 From bfbe75f8d36d9e068e63dc264c2896599d6db453 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Tue, 20 Jan 2026 23:09:16 +0100 Subject: [PATCH 6/6] feat(inline): rendering without alternate screen Fix issue with growth resize for containers with corresponding borders, padding and gaps. --- examples/direct.zig | 34 +++++++++++++++++++++------------- src/container.zig | 30 +++++++++++++++++++----------- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/examples/direct.zig b/examples/direct.zig index 3aba856..6fd4e7c 100644 --- a/examples/direct.zig +++ b/examples/direct.zig @@ -20,12 +20,12 @@ const QuitText = struct { assert(cells.len == @as(usize, size.x) * @as(usize, size.y)); const y = 0; - const x = size.x / 2 -| (text.len / 2); + const x = 2; const anchor = (y * size.x) + x; for (text, 0..) |cp, idx| { cells[anchor + idx].style.fg = .white; - cells[anchor + idx].style.emphasis = &.{.bold}; + cells[anchor + idx].style.emphasis = &.{ .bold, .underline }; cells[anchor + idx].cp = cp; // NOTE do not write over the contents of this `Container`'s `Size` @@ -35,7 +35,7 @@ const QuitText = struct { }; const Prompt = struct { - len: u16 = 5, + len: u16 = 3, pub fn element(this: *@This()) App.Element { return .{ .ptr = this, @@ -58,16 +58,14 @@ const Prompt = struct { assert(cells.len == @as(usize, size.x) * @as(usize, size.y)); for (0..this.len) |idx| { - cells[idx].style.bg = .red; + cells[idx].style.bg = .blue; cells[idx].style.fg = .black; cells[idx].style.emphasis = &.{.bold}; } - cells[this.len - 1].style.cursor = true; // marks the actual end of the rendering! - - const anchor = 1; - cells[anchor + 0].cp = 'N'; - cells[anchor + 1].cp = 'O'; - cells[anchor + 2].cp = 'R'; + cells[1].cp = '>'; + // leave one clear whitespace after the prompt + cells[this.len].style.bg = .default; + cells[this.len].style.cursor = true; // marks the actual end of the rendering! } }; @@ -86,6 +84,7 @@ pub fn main() !void { var container: App.Container = try .init(gpa, .{ .layout = .{ .direction = .vertical, + .gap = 1, // show empty line between elements to allow navigation through paragraph jumping }, .size = .{ .grow = .horizontal_only, @@ -96,10 +95,12 @@ pub fn main() !void { var quit_text: QuitText = .{}; var intermediate: App.Container = try .init(gpa, .{ .border = .{ - .sides = .all, + .sides = .{ .left = true }, + .color = .grey, }, .layout = .{ .direction = .horizontal, + .padding = .{ .left = 1, .top = 1 }, }, }, quit_text.element()); try intermediate.append(try .init(gpa, .{ @@ -109,11 +110,18 @@ pub fn main() !void { try intermediate.append(try .init(gpa, .{ .rectangle = .{ .fill = .green }, }, .{})); - try container.append(intermediate); + + var padding_container: App.Container = try .init(gpa, .{ + .layout = .{ + .padding = .horizontal(1), + }, + }, .{}); + try padding_container.append(intermediate); + try container.append(padding_container); var prompt: Prompt = .{}; try container.append(try .init(gpa, .{ - .rectangle = .{ .fill = .red }, + .rectangle = .{ .fill = .grey }, .size = .{ .dim = .{ .y = 1 }, }, diff --git a/src/container.zig b/src/container.zig index 1a0b069..5afd7d1 100644 --- a/src/container.zig +++ b/src/container.zig @@ -764,23 +764,31 @@ pub fn Container(Model: type, Event: type) type { const sides = this.properties.border.sides; switch (layout.direction) { - .horizontal => { + .vertical => { if (sides.top) { - available -|= 1; remainder -|= 1; } if (sides.bottom) { - available -|= 1; remainder -|= 1; } - }, - .vertical => { if (sides.left) { available -|= 1; - remainder -|= 1; } if (sides.right) { available -|= 1; + } + }, + .horizontal => { + if (sides.top) { + available -|= 1; + } + if (sides.bottom) { + available -|= 1; + } + if (sides.left) { + remainder -|= 1; + } + if (sides.right) { remainder -|= 1; } }, @@ -815,7 +823,7 @@ pub fn Container(Model: type, Event: type) type { .horizontal => if (child.properties.size.grow == .vertical or child.properties.size.grow == .vertical_only or child.properties.size.grow == .both) { child.size.y = available; }, - .vertical => if (child.properties.size.grow == .horizontal or child.properties.size.grow == .vertical_only or child.properties.size.grow == .both) { + .vertical => if (child.properties.size.grow == .horizontal or child.properties.size.grow == .horizontal_only or child.properties.size.grow == .both) { child.size.x = available; }, } @@ -862,23 +870,23 @@ pub fn Container(Model: type, Event: type) type { }; if (child.properties.size.grow != .fixed and child_size == smallest_size) { switch (layout.direction) { - .horizontal => if (child.properties.size.grow != .vertical) { + .horizontal => if (child.properties.size.grow != .vertical and child.properties.size.grow != .vertical_only) { child.size.x += size_to_correct; remainder -|= size_to_correct; }, - .vertical => if (child.properties.size.grow != .horizontal) { + .vertical => if (child.properties.size.grow != .horizontal and child.properties.size.grow != .horizontal_only) { child.size.y += size_to_correct; remainder -|= size_to_correct; }, } if (overflow > 0) { switch (layout.direction) { - .horizontal => if (child.properties.size.grow != .vertical) { + .horizontal => if (child.properties.size.grow != .vertical and child.properties.size.grow != .vertical_only) { child.size.x += 1; overflow -|= 1; remainder -|= 1; }, - .vertical => if (child.properties.size.grow != .horizontal) { + .vertical => if (child.properties.size.grow != .horizontal and child.properties.size.grow != .horizontal_only) { child.size.y += 1; overflow -|= 1; remainder -|= 1; -- 2.49.1