6 Commits

Author SHA1 Message Date
bfbe75f8d3 feat(inline): rendering without alternate screen
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (pull_request) Successful in 1m10s
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 55s
Fix issue with growth resize for containers with corresponding
borders, padding and gaps.
2026-01-20 23:09:16 +01:00
89517b2546 fix: add newly introduced parameter for App.start function to all corresponding usages 2026-01-20 15:16:10 +01:00
a71d808250 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`).
2026-01-20 15:11:19 +01:00
97a240c54d 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.
2026-01-20 13:57:55 +01:00
c29c60bd89 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).
2026-01-20 11:54:28 +01:00
4a3bec3edc 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.
2026-01-20 11:28:37 +01:00
23 changed files with 376 additions and 54 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 = .{

191
examples/direct.zig Normal file
View File

@@ -0,0 +1,191 @@
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 = 2;
const anchor = (y * size.x) + x;
for (text, 0..) |cp, idx| {
cells[anchor + idx].style.fg = .white;
cells[anchor + idx].style.emphasis = &.{ .bold, .underline };
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 = 3,
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
.vtable = &.{
// .minSize = minSize,
.content = content,
},
};
}
// 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 };
// }
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 = .blue;
cells[idx].style.fg = .black;
cells[idx].style.emphasis = &.{.bold};
}
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!
}
};
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 = .vertical,
.gap = 1, // show empty line between elements to allow navigation through paragraph jumping
},
.size = .{
.grow = .horizontal_only,
},
}, .{});
defer container.deinit();
var quit_text: QuitText = .{};
var intermediate: App.Container = try .init(gpa, .{
.border = .{
.sides = .{ .left = true },
.color = .grey,
},
.layout = .{
.direction = .horizontal,
.padding = .{ .left = 1, .top = 1 },
},
}, quit_text.element());
try intermediate.append(try .init(gpa, .{
.rectangle = .{ .fill = .blue },
}, .{}));
try intermediate.append(try .init(gpa, .{
.rectangle = .{ .fill = .green },
}, .{}));
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 = .grey },
.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
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`?
.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 }),
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;
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) {});

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 {
@@ -30,7 +30,29 @@ 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 {
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;
@@ -41,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 .{
@@ -48,44 +77,50 @@ 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)
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();
this.thread = try Thread.spawn(.{}, @This().run, .{this});
var termios: posix.termios = undefined;
try terminal.enableRawMode(&termios);
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 +129,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;
}
}
@@ -416,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 });

View File

@@ -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;

View File

@@ -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

View File

@@ -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,85 @@ 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 {
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 *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);
if (cvs.style.cursor) return; // that's where the cursor should be left!
}
}
}
};
const std = @import("std");
const meta = std.meta;
const assert = std.debug.assert;

View File

@@ -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");