43 Commits

Author SHA1 Message Date
6781c43fcc add(renderer/direct): write newline function to align view if necessary
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m8s
2026-01-27 15:33:35 +01:00
eb36c7c410 fix: correctly read inputs from *stdin* when not using *raw mode*
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m6s
2026-01-23 12:44:48 +01:00
4441f1b933 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`.
2026-01-22 08:56:12 +01:00
39fb8af174 fix(app): do not reset remaining bytes after reading
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m15s
2026-01-22 07:15:46 +01:00
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
836d7669e5 feat: introduce growth options .horizontal_only and .vertical_only
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m5s
Growing containers only in one specific direction while fixing the
other dimension (which is then required to be provided - just like
before).
2026-01-18 13:36:03 +01:00
1621715ad8 lint: correct typo
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m7s
2026-01-17 12:29:42 +01:00
4874252e8c mod: adapt implementation to zig version 0.15.2
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 54s
2026-01-17 12:05:20 +01:00
e1e8907848 doc: remove hint as builds now use latest zig version 2026-01-17 12:04:52 +01:00
1cb7fca701 mod(ci): change target zig version to *latest* 2026-01-17 12:04:19 +01:00
19d1602d3b ref: omit capture instead of dropping
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 49s
2026-01-12 23:28:30 +01:00
88c7eea356 feat(unicode): accept unicode characters through the app's input handler to be handled correctly; key introduce isUnicode method
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m21s
With the `isUnicode` method the read unicode characters from the user
can be checked against displayable text and rendered on the screen accordingly.
2026-01-12 22:47:20 +01:00
b1a0d60ae3 mod: bump zig master version
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m9s
2026-01-06 22:58:10 +01:00
c49c2a5c6d mod(app): export TextField from App to be used in other Element implementations similar to App.Input
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m1s
2025-12-27 19:56:44 +01:00
8b5d3757fc mod(element/Input): split into TextField without associated event to automatically trigger
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m9s
2025-12-27 17:06:07 +01:00
06ab32bdde mod(container): Size property handling for .fixed, .horizontal and .vertical check with assertions required values
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m0s
2025-12-17 22:58:58 +01:00
8e3b43fa61 mod(container): .horizontal and .vertical size.grow respect minSize value if no size.dim is used
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m0s
2025-12-17 22:48:02 +01:00
6b2797cd8c mod(container): .fixed sizing behavior with minSize Container / Element function
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m1s
2025-12-17 22:04:06 +01:00
e972a2ea0f mod(container): integrate minSize call when fit_resize is calculated
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 57s
2025-11-28 15:31:45 +01:00
67bfd90a2c WIP(container): fit_size call minSize for determining the minimal required size of a given Container
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 57s
2025-11-28 15:25:10 +01:00
40fa080af6 WIP(container): use another approach
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 57s
2025-11-28 15:08:58 +01:00
d92f562a57 WIP(container): propagate individual child sizes correctly
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 57s
2025-11-28 15:03:50 +01:00
f7025a0fc2 fix(container): minSize should not add children's sizes, but take max instead
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 57s
2025-11-28 14:34:32 +01:00
41229c13d3 mod(container): provide minSize function for Element implementation to refer for nested structures
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m6s
2025-11-28 14:27:41 +01:00
855594a8c8 mod(element): introduce deinit interface for deinitializing Elements automatically with the Container
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m34s
2025-11-27 21:13:03 +01:00
9488d0b64d feat(terminal/cursor): add support for cursor shape configuration
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m5s
2025-11-20 18:46:58 +01:00
424740d350 feat(terminal/osc12): define cursor color through style of cell that describes the cursor position
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m33s
The `.default` color will reset the cursor color to the terminal's default color
2025-11-19 18:42:13 +01:00
28c733352e feat(event): introduce .bell system event
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 16m37s
2025-11-17 18:30:31 +01:00
3b5507ec1e fix(lint): correct typo
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m4s
2025-11-10 17:18:36 +01:00
38d31fae72 feat(element): parameter *const App.Model
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 55s
The renderer will provide `resize`, `reposition` and `minSize` (for
the `Scrollable` `Element`) with a read-only pointer to the model of
the application (similar to how it is already done for `handle` and
`content`). Every interface function now has the same data that it can
each use for implementing its corresponding task based on local and
shared variables through the element instance and model pointer.
2025-11-10 17:09:29 +01:00
79a0d17a66 feat(io): introduce std.Io parameter for App.init
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m22s
The provided `std.Io` is used for running the input reading and the
rendering asynch. The user can provide their desired `std.Io`
implementation as they wish to use. The examples use `std.Io.Threaded`
as a simple threaded solution.
2025-11-10 16:44:02 +01:00
accbb4c97d mod: bump action dependencies
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 56s
2025-11-05 22:45:56 +01:00
ad32e46bc9 feat(element/scrollable): mouse support for scrollbar interaction
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 55s
Fix an issue with the scrollbar contents to work proper with statically
defined `Container` sizes through its `Properties` sizing option.
2025-11-05 22:08:35 +01:00
8ebab702ac fix(element/scrollable): reduce the number of minSize calls and correctly resize scrollable Container
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m26s
2025-11-05 18:33:16 +01:00
e53bb7880b mod: cleanup TODO and outdated comments 2025-11-05 18:32:49 +01:00
a83e86f8d9 feat(element/scrollable): background configuration
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m30s
2025-11-02 21:00:17 +01:00
4567963ff2 mod(example/text): improve event loop; add stick table heading for used emphasis
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 59s
This should be the way it should be done all the time, such that you
are not rendering for every input, but instead handle all `App.Event`s
that happened between the last render and the current. This shares
similarities with the continuous event loops, which also batches
the events only with the exception that it instead blocks (see
`App.Queue.poll`).
2025-11-02 16:10:11 +01:00
d4cc520826 fix(element/scrollable): display and user interaction
Fix initial render to show scrollbar immediately if required. Show and
hide scrollbar correctly when content size or terminal size changes. Add
keybindings for scrolling similar to pager keybindings.
2025-11-02 16:05:53 +01:00
34 changed files with 1330 additions and 519 deletions

View File

@@ -15,9 +15,15 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup zig installation
uses: mlugg/setup-zig@v2
uses: https://codeberg.org/mlugg/setup-zig@v2
with:
version: master
version: latest
- name: Lint check
run: zig fmt --check .
- name: Spell checking
uses: crate-ci/typos@v1.39.0
with:
config: ./.typos-config
- name: Run tests
run: zig build --release=fast
- name: Release build artifacts

View File

@@ -14,13 +14,13 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup zig installation
uses: mlugg/setup-zig@v2
uses: https://codeberg.org/mlugg/setup-zig@v2
with:
version: master
version: latest
- name: Lint check
run: zig fmt --check --exclude src/test .
- name: Spell checking
uses: crate-ci/typos@v1.25.0
uses: crate-ci/typos@v1.39.0
with:
config: ./.typos-config
- name: Run tests

View File

@@ -2,9 +2,6 @@
`zterm` is a terminal user interface library (*tui*) to implement terminal (fullscreen or inline) applications.
> [!caution]
> Only builds using the zig master version are tested to work.
## Demo
Clone this repository and run `zig build --help` to see the available examples. Run a given example as follows:
@@ -33,8 +30,6 @@ const zterm: *Dependency = b.dependency("zterm", .{
.target = target,
.optimize = optimize,
});
// ...
exe.root_module.addImport("zterm", zterm.module("zterm"));
```
### Documentation

View File

@@ -24,6 +24,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

@@ -87,7 +87,7 @@ const InputField = struct {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.key => |key| {
if (key.isAscii()) try this.input.append(this.allocator, key.cp);
if (key.isUnicode()) try this.input.append(this.allocator, key.cp);
if (key.eql(.{ .cp = zterm.input.Enter }) or key.eql(.{ .cp = zterm.input.KpEnter }))
this.queue.push(.{ .accept = try this.input.toOwnedSlice(this.allocator) });
@@ -133,7 +133,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -153,7 +153,7 @@ pub fn main() !void {
defer container.deinit();
try container.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_grey },
.rectangle = .{ .fill = .lightgrey },
.size = .{
.grow = .horizontal,
.dim = .{ .y = 10 },
@@ -161,11 +161,11 @@ pub fn main() !void {
}, input_field.element()));
const nested_container: App.Container = try .init(allocator, .{
.rectangle = .{ .fill = .light_grey },
.rectangle = .{ .fill = .lightgrey },
}, 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;
@@ -228,8 +228,8 @@ pub fn main() !void {
}
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -1,5 +1,5 @@
const QuitText = struct {
const text = "Press ctrl+c to quit. Press ctrl+n to launch helix.";
const text = "Press ctrl+c to quit. Press ctrl+n to launch `vim`.";
pub fn element(this: *@This()) App.Element {
return .{ .ptr = this, .vtable = &.{ .content = content } };
@@ -27,13 +27,12 @@ const QuitText = struct {
pub fn main() !void {
errdefer |err| log.err("Application Error: {any}", .{err});
// TODO maybe create own allocator as some sort of arena allocator to have consistent memory usage
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer if (gpa.deinit() == .leak) log.err("memory leak", .{});
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -57,15 +56,14 @@ pub fn main() !void {
},
}, .{});
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
.rectangle = .{ .fill = .lightgreen },
}, .{}));
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
.rectangle = .{ .fill = .lightgreen },
}, .{}));
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
.rectangle = .{ .fill = .lightgreen },
}, .{}));
defer box.deinit();
var scrollable: App.Scrollable = .init(box, .disabled);
@@ -92,7 +90,7 @@ pub fn main() !void {
.direction = .vertical,
},
.border = .{
.color = .light_blue,
.color = .lightblue,
.sides = .all,
},
}, .{});
@@ -135,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
@@ -151,8 +149,8 @@ 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");
var child = std.process.Child.init(&.{"hx"}, allocator);
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 = .{
.err = err,
@@ -181,8 +179,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

192
examples/direct.zig Normal file
View File

@@ -0,0 +1,192 @@
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(gpa, .{}, .{});
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 draw the character with the ctrl indication and a newline to make sure the rendering stays consistent
.cancel => try renderer.writeCtrlDWithNewline(),
.line => |line| {
defer gpa.free(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

@@ -32,7 +32,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -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
@@ -83,8 +83,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -82,7 +82,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -99,10 +99,10 @@ pub fn main() !void {
}, quit_text.element());
defer container.deinit();
try container.append(try App.Container.init(allocator, .{ .rectangle = .{ .fill = .light_grey } }, element));
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
@@ -132,8 +132,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -65,13 +65,11 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
var input_field: App.Input(.accept) = .init(allocator, &app.queue, .init(.black));
defer input_field.deinit();
var input_field: App.Input(.accept) = .init(allocator, &app.queue, .init(.black, .blue));
var mouse_draw: MouseDraw = .{};
var second_mouse_draw: MouseDraw = .{};
var quit_text: QuitText = .{};
@@ -86,7 +84,7 @@ pub fn main() !void {
defer container.deinit();
try container.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_grey },
.rectangle = .{ .fill = .lightgrey },
.size = .{
.grow = .horizontal,
.dim = .{ .y = 1 },
@@ -98,7 +96,7 @@ pub fn main() !void {
.sides = .all,
.color = .black,
},
.rectangle = .{ .fill = .light_grey },
.rectangle = .{ .fill = .lightgrey },
.layout = .{
.separator = .{
.enabled = true,
@@ -107,14 +105,14 @@ pub fn main() !void {
},
}, .{});
try nested_container.append(try .init(allocator, .{
.rectangle = .{ .fill = .light_grey },
.rectangle = .{ .fill = .lightgrey },
}, mouse_draw.element()));
try nested_container.append(try .init(allocator, .{
.rectangle = .{ .fill = .light_grey },
.rectangle = .{ .fill = .lightgrey },
}, 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
@@ -146,8 +144,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -32,7 +32,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -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;
@@ -152,8 +152,8 @@ pub fn main() !void {
}
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -32,7 +32,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -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
@@ -80,8 +80,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -56,7 +56,6 @@ const HelloWorldText = packed struct {
pub fn main() !void {
errdefer |err| log.err("Application Error: {any}", .{err});
// TODO maybe create own allocator as some sort of arena allocator to have consistent memory usage
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer {
const deinit_status = gpa.deinit();
@@ -66,7 +65,7 @@ pub fn main() !void {
}
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -87,24 +86,23 @@ pub fn main() !void {
},
}, .{});
try top_box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
.rectangle = .{ .fill = .lightgreen },
.size = .{
.dim = .{ .y = 30 },
},
}, .{}));
try top_box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
.rectangle = .{ .fill = .lightgreen },
.size = .{
.dim = .{ .y = 5 },
},
}, element));
try top_box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
.rectangle = .{ .fill = .lightgreen },
.size = .{
.dim = .{ .y = 2 },
},
}, .{}));
defer top_box.deinit();
var bottom_box = try App.Container.init(allocator, .{
.border = .{
@@ -132,7 +130,6 @@ pub fn main() !void {
try bottom_box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
defer bottom_box.deinit();
var container = try App.Container.init(allocator, .{
.layout = .{
@@ -148,13 +145,13 @@ pub fn main() !void {
defer container.deinit();
// place empty container containing the element of the scrollable Container.
var scrollable_top: App.Scrollable = .init(top_box, .enabled(.grey));
var scrollable_top: App.Scrollable = .init(top_box, .enabled(.default, false));
try container.append(try App.Container.init(allocator, .{}, scrollable_top.element()));
var scrollable_bottom: App.Scrollable = .init(bottom_box, .enabled(.white));
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
@@ -182,8 +179,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -32,7 +32,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -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
@@ -80,8 +80,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -97,7 +97,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -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) {
@@ -143,8 +143,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -35,7 +35,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -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
@@ -99,8 +99,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -35,7 +35,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -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
@@ -91,8 +91,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -35,7 +35,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -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
@@ -107,8 +107,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -35,7 +35,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -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
@@ -90,8 +90,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -32,7 +32,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -50,7 +50,6 @@ pub fn main() !void {
var box = try App.Container.init(allocator, .{
.layout = .{ .direction = .horizontal },
}, .{});
defer box.deinit();
inline for (std.meta.fields(zterm.Color)) |field| {
if (field.value == 0) continue; // zterm.Color.default == 0 -> skip
@@ -59,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) {
@@ -86,8 +85,8 @@ pub fn main() !void {
else => {},
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -24,6 +24,86 @@ const QuitText = struct {
}
};
const TableText = struct {
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
.vtable = &.{
.content = content,
},
};
}
fn content(ctx: *anyopaque, _: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void {
_ = ctx;
_ = size;
var idx: usize = 0;
{
const text = "Normal ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Bold ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Dim ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Italic ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Underl ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Blink ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Invert ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Hidden ";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
{
const text = "Strikethrough";
for (text) |cp| {
cells[idx].cp = cp;
idx += 1;
}
}
}
};
const TextStyles = struct {
const text = "Example";
@@ -37,7 +117,7 @@ const TextStyles = struct {
};
}
fn minSize(ctx: *anyopaque, size: zterm.Point) zterm.Point {
fn minSize(ctx: *anyopaque, _: *const App.Model, size: zterm.Point) zterm.Point {
_ = ctx;
_ = size;
return .{
@@ -98,7 +178,7 @@ pub fn main() !void {
const allocator = gpa.allocator();
var app: App = .init(.{});
var app: App = .init(allocator, .{}, .{});
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
@@ -109,27 +189,43 @@ pub fn main() !void {
var container = try App.Container.init(allocator, .{
.layout = .{
.gap = 2,
// .gap = 2,
.padding = .{ .top = 5, .bottom = 3, .left = 3, .right = 3 },
.direction = .vertical,
},
}, element);
defer container.deinit();
var box = try App.Container.init(allocator, .{
.layout = .{ .direction = .vertical },
}, text_styles.element());
defer box.deinit();
var table_head: TableText = .{};
try container.append(try .init(allocator, .{
.size = .{
.dim = .{ .y = 1 },
.grow = .horizontal,
},
}, table_head.element()));
var scrollable: App.Scrollable = .init(box, .disabled);
var scrollable: App.Scrollable = .init(try .init(allocator, .{
.layout = .{ .direction = .vertical },
}, 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});
while (true) {
const event = app.nextEvent();
log.debug("received event: {s}", .{@tagName(event)});
// event loop
loop: while (true) {
// batch events since last iteration
const len = blk: {
app.queue.poll();
app.queue.lock();
defer app.queue.unlock();
break :blk app.queue.len();
};
// handle events
for (0..len) |_| {
const event = app.queue.pop();
log.debug("handling event: {s}", .{@tagName(event)});
// pre event handling
switch (event) {
.key => |key| if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) app.quit(),
@@ -146,12 +242,13 @@ pub fn main() !void {
// post event handling
switch (event) {
.quit => break,
.quit => break :loop,
else => {},
}
}
container.resize(try renderer.resize());
container.reposition(.{});
container.resize(&app.model, try renderer.resize());
container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush();
}

View File

@@ -16,75 +16,114 @@
/// union(enum) {}, // no additional user event's
/// );
/// // later on create an `App` instance and start the event loop
/// var app: App = .init(.{}); // provide instance of the model that shall be used
/// try app.start();
/// var app: App = .init(io, .{}); // provide instance of the `std.Io` and `App` model that shall be used
/// 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 {
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)`.");
return struct {
gpa: Allocator,
io: std.Io,
model: Model,
queue: Queue,
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;
/// registered WINCH handler to report resize events
fn handleWinch(_: std.os.linux.SIG) callconv(.c) void {
fn handleWinch(_: 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);
}
/// 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(model: Model) @This() {
pub fn init(gpa: Allocator, io: std.Io, model: Model) @This() {
return .{
.gpa = gpa,
.io = io,
.model = model,
.queue = .{},
.quit_event = .unset,
.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.thread) |thread| {
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;
}
@@ -92,18 +131,16 @@ 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.restoreScreen();
try terminal.disableRawMode(&termios);
try terminal.exitAltScreen();
}
if (this.config.rawMode) try terminal.disableRawMode(&termios);
this.termios = null;
}
}
/// Quit the application loop.
/// This will stop the internal input thread and post a **.quit** `Event`.
/// This will cancel the internal input thread and post a **.quit** `Event`.
pub fn quit(this: *@This()) void {
this.quit_event.set();
this.postEvent(.quit);
@@ -121,13 +158,52 @@ pub fn App(comptime M: type, comptime E: type) type {
fn run(this: *@This()) !void {
// thread to read user inputs
var buf: [256]u8 = undefined;
var buf: [512]u8 = undefined;
// 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?
var fl_flags = posix.fcntl(posix.STDIN_FILENO, posix.F.GETFL, 0) catch |err| switch (err) {
error.FileBusy => unreachable,
error.Locked => unreachable,
error.PermissionDenied => unreachable,
error.DeadLock => unreachable,
error.LockedRegionLimitExceeded => unreachable,
else => |e| return e,
};
fl_flags |= 1 << @bitOffsetOf(posix.system.O, "NONBLOCK");
_ = posix.fcntl(posix.STDIN_FILENO, posix.F.SETFL, fl_flags) catch |err| switch (err) {
error.FileBusy => unreachable,
error.Locked => unreachable,
error.PermissionDenied => unreachable,
error.DeadLock => unreachable,
error.LockedRegionLimitExceeded => unreachable,
else => |e| return e,
};
}
var remaining_bytes: usize = 0;
var lines: std.ArrayList(u8) = .empty;
defer lines.deinit(this.gpa);
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!
// non-blocking read
const read_bytes = terminal.read(buf[remaining_bytes..]) catch |err| switch (err) {
error.WouldBlock => {
// wait a bit
std.Thread.sleep(20);
continue;
},
else => return err,
} + remaining_bytes;
remaining_bytes = 0;
if (read_bytes == 0) {
// received <EOF>
this.postEvent(.cancel);
continue;
}
// escape key presses
if (buf[0] == 0x1b and read_bytes > 1) {
switch (buf[1]) {
@@ -154,9 +230,9 @@ pub fn App(comptime M: type, comptime E: type) type {
if (read_bytes < 3) continue;
// We start iterating at index 2 to get past the '['
const sequence = for (buf[2..], 2..) |b, i| {
const sequence: []u8 = blk: for (buf[2..], 2..) |b, i| {
switch (b) {
0x40...0xFF => break buf[0 .. i + 1],
0x40...0xFF => break :blk buf[0 .. i + 1],
else => continue,
}
} else continue;
@@ -287,7 +363,8 @@ pub fn App(comptime M: type, comptime E: type) type {
// const alt = button_mask & mouse_bits.alt > 0;
// const ctrl = button_mask & mouse_bits.ctrl > 0;
const mouse: Mouse = .{
this.postEvent(.{
.mouse = .{
.button = button,
.x = px -| 1,
.y = py -| 1,
@@ -297,8 +374,8 @@ pub fn App(comptime M: type, comptime E: type) type {
if (sequence[sequence.len - 1] == 'm') break :blk .release;
break :blk .press;
},
};
this.postEvent(.{ .mouse = mouse });
},
});
},
'c' => {
// Primary DA (CSI ? Pm c)
@@ -366,6 +443,7 @@ pub fn App(comptime M: type, comptime E: type) type {
},
}
} else {
if (this.config.rawMode) {
const b = buf[0];
const key: Key = switch (b) {
0x00 => .{ .cp = '@', .mod = .{ .ctrl = true } },
@@ -380,12 +458,34 @@ pub fn App(comptime M: type, comptime E: type) type {
},
0x7f => .{ .cp = input.Backspace },
else => {
var iter: std.unicode.Utf8Iterator = .{ .bytes = buf[0..read_bytes], .i = 0 };
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 (remaining_bytes > 0) {
@memmove(buf[0..remaining_bytes], buf[len .. len + remaining_bytes]);
}
continue; // this switch block does not return a `Key` we continue with loop
},
};
this.postEvent(.{ .key = key });
} else {
var len = read_bytes;
while (!std.unicode.utf8ValidateSlice(buf[0..len])) len -= 1;
remaining_bytes = read_bytes - len;
try lines.appendSlice(this.gpa, buf[0..len]);
if (remaining_bytes > 0) {
@memmove(buf[0..remaining_bytes], buf[len .. len + remaining_bytes]);
} else {
if (read_bytes != 512 and buf[len - 1] == '\n') this.postEvent(.{ .line = try lines.toOwnedSlice(this.gpa) });
// 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;
};
@@ -396,6 +496,7 @@ pub fn App(comptime M: type, comptime E: type) type {
pub fn panic_handler(msg: []const u8, _: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn {
terminal.disableMouseSupport() catch {};
terminal.showCursor() catch {};
terminal.resetCursor() catch {};
terminal.restoreScreen() catch {};
terminal.disableRawMode(&.{
.iflag = .{},
@@ -418,12 +519,13 @@ pub fn App(comptime M: type, comptime E: type) type {
pub const Element = element.Element(Model, Event);
pub const Alignment = element.Alignment(Model, Event);
pub const Button = element.Button(Model, Event, Queue);
pub const TextField = element.TextField(Model, Event);
pub const Input = element.Input(Model, Event, Queue);
pub const Progress = element.Progress(Model, Event, Queue);
pub const RadioButton = element.RadioButton(Model, Event);
pub const Scrollable = element.Scrollable(Model, Event);
pub const Selection = element.Selection(Model, Event);
pub const Queue = queue.Queue(Event, 256);
pub const Queue = queue.Queue(Event, 512);
};
}
@@ -434,6 +536,7 @@ const mem = std.mem;
const fmt = std.fmt;
const posix = std.posix;
const Thread = std.Thread;
const Allocator = mem.Allocator;
const assert = std.debug.assert;
const event = @import("event.zig");
const input = @import("input.zig");

View File

@@ -1,6 +1,5 @@
//! Cell type containing content and formatting for each character in the terminal screen.
// TODO embrace `zg` dependency more due to utf-8 encoding
cp: u21 = ' ',
style: Style = .{ .emphasis = &.{} },
@@ -26,7 +25,7 @@ test "ascii styled text" {
.{ .cp = 'Y', .style = .{ .fg = .green, .bg = .grey, .emphasis = &.{} } },
.{ .cp = 'v', .style = .{ .emphasis = &.{ .bold, .underline } } },
.{ .cp = 'e', .style = .{ .emphasis = &.{.italic} } },
.{ .cp = 's', .style = .{ .fg = .light_green, .bg = .black, .emphasis = &.{.underline} } },
.{ .cp = 's', .style = .{ .fg = .lightgreen, .bg = .black, .emphasis = &.{.underline} } },
};
var writer = std.Io.Writer.Allocating.init(std.testing.allocator);
@@ -46,7 +45,7 @@ test "utf-8 styled text" {
.{ .cp = '╭', .style = .{ .fg = .green, .bg = .grey, .emphasis = &.{} } },
.{ .cp = '─', .style = .{ .emphasis = &.{} } },
.{ .cp = '┄', .style = .{ .emphasis = &.{} } },
.{ .cp = '┘', .style = .{ .fg = .light_green, .bg = .black, .emphasis = &.{.underline} } },
.{ .cp = '┘', .style = .{ .fg = .lightgreen, .bg = .black, .emphasis = &.{.underline} } },
};
var writer = std.Io.Writer.Allocating.init(std.testing.allocator);

View File

@@ -1,13 +1,13 @@
pub const Color = enum(u8) {
default = 0,
black = 16,
light_red = 1,
light_green,
light_yellow,
light_blue,
light_magenta,
light_cyan,
light_grey,
lightred = 1,
lightgreen,
lightyellow,
lightblue,
lightmagenta,
lightcyan,
lightgrey,
grey,
red,
green,
@@ -18,8 +18,6 @@ pub const Color = enum(u8) {
white,
// TODO add further colors as described in https://gist.github.com/ConnerWill/d4b6c776b509add763e17f9f113fd25b # Color / Graphics Mode - 256 Colors
// TODO might be useful to use the std.ascii stuff!
pub inline fn write(this: Color, writer: *std.Io.Writer, comptime coloring: enum { fg, bg, ul }) !void {
if (this == .default) {
switch (coloring) {

View File

@@ -599,9 +599,11 @@ pub const Layout = packed struct {
/// Sizing options which should be used by the `Container`
pub const Size = packed struct {
dim: Point = .{},
grow: enum(u2) {
grow: enum(u3) {
both,
fixed,
vertical_only,
horizontal_only,
vertical,
horizontal,
} = .both,
@@ -648,16 +650,17 @@ pub fn Container(Model: type, Event: type) type {
pub fn deinit(this: *@This()) void {
for (this.elements.items) |*element| element.deinit();
this.elements.deinit(this.allocator);
this.element.deinit();
}
pub fn append(this: *@This(), element: @This()) !void {
try this.elements.append(this.allocator, element);
}
pub fn reposition(this: *@This(), origin: Point) void {
pub fn reposition(this: *@This(), model: *const Model, origin: Point) void {
const layout = this.properties.layout;
this.origin = origin;
this.element.reposition(origin);
this.element.reposition(model, origin);
var offset = origin.add(.{
.x = Layout.getAbsolutePadding(layout.padding.left, this.size.x),
@@ -669,7 +672,7 @@ pub fn Container(Model: type, Event: type) type {
if (sides.top) offset.y += 1;
for (this.elements.items) |*child| {
child.reposition(offset);
child.reposition(model, offset);
switch (layout.direction) {
.horizontal => offset.x += child.size.x + layout.gap,
@@ -684,7 +687,7 @@ pub fn Container(Model: type, Event: type) type {
}
/// Resize all fit sized `Containers` to the necessary size required by its child elements.
fn fit_resize(this: *@This()) Point {
fn fit_resize(this: *@This(), model: *const Model) Point {
// NOTE this is supposed to be a simple and easy to understand algorithm, there are currently no optimizations done
const layout = this.properties.layout;
var size: Point = switch (layout.direction) {
@@ -709,7 +712,7 @@ pub fn Container(Model: type, Event: type) type {
};
for (this.elements.items) |*child| {
const child_size = child.fit_resize();
const child_size = child.fit_resize(model);
switch (layout.direction) {
.horizontal => {
size.x += child_size.x;
@@ -722,24 +725,29 @@ pub fn Container(Model: type, Event: type) type {
}
}
size = this.minSize(model, size);
// assign currently calculated size
this.size = switch (this.properties.size.grow) {
.both => Point.max(size, this.properties.size.dim),
.fixed => this.properties.size.dim,
.horizontal => .{
.x = @max(size.x, this.properties.size.dim.x),
.y = this.properties.size.dim.y,
.both => .max(size, this.properties.size.dim),
.fixed => blk: {
assert(this.properties.size.dim.x > 0 or size.x > 0);
assert(this.properties.size.dim.y > 0 or size.y > 0);
break :blk .max(size, this.properties.size.dim);
},
.vertical => .{
.x = this.properties.size.dim.x,
.y = @max(size.y, this.properties.size.dim.y),
.horizontal, .horizontal_only => blk: {
assert(this.properties.size.dim.y > 0 or size.y > 0);
break :blk .max(size, this.properties.size.dim);
},
.vertical, .vertical_only => blk: {
assert(this.properties.size.dim.x > 0 or size.x > 0);
break :blk .max(size, this.properties.size.dim);
},
};
return this.size;
}
/// growable implicitly requires the root `Container` to have a set a size property to the size of the available terminal screen
fn grow_resize(this: *@This(), max_size: Point) void {
fn grow_resize(this: *@This(), model: *const Model, max_size: Point) void {
const layout = this.properties.layout;
var remainder = switch (layout.direction) {
.horizontal => max_size.x -| (Layout.getAbsolutePadding(layout.padding.left, this.size.x) + Layout.getAbsolutePadding(layout.padding.right, this.size.x)),
@@ -756,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;
}
},
@@ -793,21 +809,21 @@ pub fn Container(Model: type, Event: type) type {
if (growable_children == 0) first_growable_child = child;
growable_children += 1;
},
.horizontal => if (layout.direction == .horizontal) {
.horizontal, .horizontal_only => if (layout.direction == .horizontal) {
if (growable_children == 0) first_growable_child = child;
growable_children += 1;
},
.vertical => if (layout.direction == .vertical) {
.vertical, .vertical_only => if (layout.direction == .vertical) {
if (growable_children == 0) first_growable_child = child;
growable_children += 1;
},
}
// non layout direction side growth
switch (layout.direction) {
.horizontal => if (child.properties.size.grow == .vertical or child.properties.size.grow == .both) {
.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 == .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;
},
}
@@ -825,8 +841,8 @@ pub fn Container(Model: type, Event: type) type {
if (child.properties.size.grow == .fixed) continue;
switch (layout.direction) {
.horizontal => if (child.properties.size.grow == .vertical) continue,
.vertical => if (child.properties.size.grow == .horizontal) continue,
.horizontal => if (child.properties.size.grow == .vertical or child.properties.size.grow == .vertical_only) continue,
.vertical => if (child.properties.size.grow == .horizontal or child.properties.size.grow == .horizontal_only) continue,
}
const size = switch (layout.direction) {
@@ -854,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;
@@ -881,28 +897,54 @@ pub fn Container(Model: type, Event: type) type {
}
}
this.element.resize(this.size);
for (this.elements.items) |*child| child.grow_resize(child.size);
this.element.resize(model, this.size);
for (this.elements.items) |*child| child.grow_resize(model, child.size);
}
pub fn resize(this: *@This(), size: Point) void {
pub fn resize(this: *@This(), model: *const Model, size: Point) void {
// NOTE assume that this function is only called for the root `Container`
this.size = size;
const fit_size = this.fit_resize();
// if (fit_size.y > size.y or fit_size.x > size.x) @panic("error: cannot render in available space");
switch (this.properties.size.grow) {
.both => this.size = Point.max(size, fit_size),
.fixed => {},
.horizontal => this.size = .{
const fit_size = this.fit_resize(model);
this.size = switch (this.properties.size.grow) {
.both => .max(size, fit_size),
.fixed => fit_size,
.horizontal_only => .{
.x = size.x,
.y = fit_size.y,
},
.vertical_only => .{
.x = fit_size.x,
.y = size.y,
},
.horizontal => .{
.x = @max(size.x, fit_size.x),
.y = size.y,
},
.vertical => this.size = .{
.vertical => .{
.x = size.x,
.y = @max(size.y, fit_size.y),
},
};
this.grow_resize(model, this.size);
}
this.grow_resize(this.size);
pub fn minSize(this: *const @This(), model: *const Model, size: Point) Point {
var min_size: Point = .{};
for (this.elements.items) |*child| {
const child_size = child.minSize(model, child.properties.size.dim);
min_size = switch (this.properties.layout.direction) {
.horizontal => .{
.x = child_size.x + min_size.x,
.y = @max(child_size.y, min_size.y),
},
.vertical => .{
.x = @max(child_size.x, min_size.x),
.y = child_size.y + min_size.y,
},
};
}
const element_size = this.element.minSize(model, size);
return .max(element_size, min_size);
}
pub fn handle(this: *const @This(), model: *Model, event: Event) !void {
@@ -915,6 +957,11 @@ pub fn Container(Model: type, Event: type) type {
relative_mouse.y -= this.origin.y;
try this.element.handle(model, .{ .mouse = relative_mouse });
},
.bell => {
// ring the terminal bell and do not propagate the event any further
_ = try terminal.ringBell();
return;
},
else => try this.element.handle(model, event),
}
for (this.elements.items) |*element| try element.handle(model, event);
@@ -961,6 +1008,7 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const build_options = @import("build_options");
const input = @import("input.zig");
const terminal = @import("terminal.zig");
const isTaggedUnion = @import("event.zig").isTaggedUnion;
const Cell = @import("cell.zig");
const Color = @import("color.zig").Color;

View File

@@ -50,6 +50,7 @@ pub const home = "\x1b[H";
pub const cup = "\x1b[{d};{d}H";
pub const hide_cursor = "\x1b[?25l";
pub const show_cursor = "\x1b[?25h";
pub const reset_cursor_shape = "\x1b[0 q";
pub const cursor_shape = "\x1b[{d} q";
pub const ri = "\x1bM";
pub const ind = "\n";
@@ -139,5 +140,6 @@ pub const osc10_reset = "\x1b]110\x1b\\"; // reset fg to terminal default
pub const osc11_query = "\x1b]11;?\x1b\\"; // bg
pub const osc11_set = "\x1b]11;rgb:{x:0>2}{x:0>2}/{x:0>2}{x:0>2}/{x:0>2}{x:0>2}\x1b\\"; // set default terminal bg
pub const osc11_reset = "\x1b]111\x1b\\"; // reset bg to terminal default
pub const osc12_set = "\x1b]12;{s}\x1b\\"; // set the cursor color through the name of the 8 base colors!
pub const osc12_query = "\x1b]12;?\x1b\\"; // cursor color
pub const osc12_reset = "\x1b]112\x1b\\"; // reset cursor to terminal default

View File

@@ -5,6 +5,7 @@
// -> currently you need to swap out the entire `Container` accordingly before rendering
// -> this might be necessary for size depending layout options, etc.
// -> maybe this can be implemented / supported similarly as to how the scrollable `Element`'s are implemented
// - `Form` using a struct that contains the fields that shall be generated for the user to submit (similar to `Input` it shall be build on-top of smaller pieces that are designed to be used in `Element` implementations)
// FIX known issues:
// - hold fewer instances of the `Allocator`
@@ -15,13 +16,28 @@ pub fn Element(Model: type, Event: type) type {
vtable: *const VTable = &.{},
pub const VTable = struct {
minSize: ?*const fn (ctx: *anyopaque, size: Point) Point = null,
resize: ?*const fn (ctx: *anyopaque, size: Point) void = null,
reposition: ?*const fn (ctx: *anyopaque, origin: Point) void = null,
deinit: ?*const fn (ctx: *anyopaque) void = null,
minSize: ?*const fn (ctx: *anyopaque, model: *const Model, size: Point) Point = null,
resize: ?*const fn (ctx: *anyopaque, model: *const Model, size: Point) void = null,
reposition: ?*const fn (ctx: *anyopaque, model: *const Model, origin: Point) void = null,
handle: ?*const fn (ctx: *anyopaque, model: *Model, event: Event) anyerror!void = null,
content: ?*const fn (ctx: *anyopaque, model: *const Model, cells: []Cell, size: Point) anyerror!void = null,
};
/// Deinitialize the `Element`. Each `Element` that requires an
/// `Allocator` shall keep one in their instance as no `Allocator`
/// will be passed through to the `deinit` call. The `deinit` function
/// is called from the root `Container` for every `Element` in the
/// `Container` tree to deinitialize the entire tree.
///
/// This function is only necessary to provide if the `Element`
/// implementation does allocate memory that should be cleaned up at
/// the end.
pub inline fn deinit(this: @This()) void {
if (this.vtable.deinit) |deinit_fn|
deinit_fn(this.ptr);
}
/// Request the minimal size required for this `Element` the provided
/// available *size* can be used as a fallback. This function ensures
/// that the minimal size returned has at least the dimensions of the
@@ -35,26 +51,23 @@ pub fn Element(Model: type, Event: type) type {
/// Currently only used for `Scrollable` elements, such that the
/// directly nested element in the `Scrollable`'s `Container` can
/// request a minimal size for its contents.
pub inline fn minSize(this: @This(), size: Point) Point {
pub inline fn minSize(this: @This(), model: *const Model, size: Point) Point {
if (this.vtable.minSize) |minSize_fn| {
const min_size = minSize_fn(this.ptr, size);
return .{
.x = @max(size.x, min_size.x),
.y = @max(size.y, min_size.y),
};
const min_size = minSize_fn(this.ptr, model, size);
return .max(size, min_size);
} else return size;
}
/// Resize the corresponding `Element` with the given *size*.
pub inline fn resize(this: @This(), size: Point) void {
pub inline fn resize(this: @This(), model: *const Model, size: Point) void {
if (this.vtable.resize) |resize_fn|
resize_fn(this.ptr, size);
resize_fn(this.ptr, model, size);
}
/// Reposition the corresponding `Element` with the given *origin*.
pub inline fn reposition(this: @This(), origin: Point) void {
pub inline fn reposition(this: @This(), model: *const Model, origin: Point) void {
if (this.vtable.reposition) |reposition_fn|
reposition_fn(this.ptr, origin);
reposition_fn(this.ptr, model, origin);
}
/// Handle the received event. The event is one of the user provided
@@ -149,10 +162,16 @@ pub fn Alignment(Model: type, Event: type) type {
};
}
fn deinit(ctx: *anyopaque) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.container.deinit();
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.deinit = deinit,
.resize = resize,
.reposition = reposition,
.handle = handle,
@@ -161,13 +180,13 @@ pub fn Alignment(Model: type, Event: type) type {
};
}
fn resize(ctx: *anyopaque, size: Point) void {
fn resize(ctx: *anyopaque, model: *const Model, size: Point) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.size = size;
this.container.resize(size);
this.container.resize(model, size);
}
fn reposition(ctx: *anyopaque, anchor: Point) void {
fn reposition(ctx: *anyopaque, model: *const Model, anchor: Point) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
var origin = anchor;
origin.x = switch (this.configuration.h) {
@@ -180,7 +199,7 @@ pub fn Alignment(Model: type, Event: type) type {
.center => origin.y + (this.size.y / 2) -| (this.container.size.y / 2),
.end => this.size.y -| this.container.size.y,
};
this.container.reposition(origin);
this.container.reposition(model, origin);
}
fn handle(ctx: *anyopaque, model: *Model, event: Event) !void {
@@ -200,8 +219,8 @@ pub fn Alignment(Model: type, Event: type) type {
outer: for (0..csize.y) |row| {
inner: for (0..csize.x) |col| {
// do not read/write out of bounce
if (this.size.x < row) break :outer;
if (this.size.y < col) break :inner;
if (this.size.y < row) break :outer;
if (this.size.x < col) break :inner;
cells[((row + origin.y) * size.x) + col + origin.x] = container_cells[(row * csize.x) + col];
}
@@ -228,16 +247,26 @@ pub fn Scrollable(Model: type, Event: type) type {
configuration: Configuration,
pub const Configuration = packed struct {
// TODO the scrollbar bool and the color should be in their own struct using `enabled` and `color` inside of the struct to be more consistent with other `Configuration` structs
scrollbar: bool,
/// Primary color to be used for the scrollbar (and background if enabled)
color: Color = .default,
/// With a background the `color` rendered with emphasis `.dim`
/// will be used to highlight the scrollbar from the background;
/// otherwise nothing is shown for the background
show_background: bool = false,
/// should the scrollbar be shown for the x-axis (horizontal)
x_axis: bool = false,
/// should the scrollbar be shown for the y-axis (vertical)
y_axis: bool = false,
pub const disabled: @This() = .{ .scrollbar = false };
pub fn enabled(color: Color) @This() {
return .{ .scrollbar = true, .color = color };
pub fn enabled(color: Color, show_background: bool) @This() {
return .{
.scrollbar = true,
.color = color,
.show_background = show_background,
};
}
};
@@ -248,10 +277,16 @@ pub fn Scrollable(Model: type, Event: type) type {
};
}
fn deinit(ctx: *anyopaque) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.container.deinit();
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.deinit = deinit,
.resize = resize,
.reposition = reposition,
.handle = handle,
@@ -260,57 +295,114 @@ pub fn Scrollable(Model: type, Event: type) type {
};
}
fn resize(ctx: *anyopaque, size: Point) void {
fn resize(ctx: *anyopaque, model: *const Model, size: Point) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
const last_max_anchor_x = this.container_size.x -| this.size.x;
const last_max_anchor_y = this.container_size.y -| this.size.y;
this.size = size;
// NOTE `container_size` denotes the `size` required for the container contents
var container_size = this.container.minSize(model, size);
if (this.configuration.scrollbar) {
if (this.container.properties.size.dim.x > this.size.x or this.container.size.x > this.size.x) this.configuration.y_axis = true;
if (this.container.properties.size.dim.y > this.size.y or this.container.size.y > this.size.y) this.configuration.x_axis = true;
this.configuration.y_axis = this.container.properties.size.dim.x > size.x or container_size.x > size.x;
this.configuration.x_axis = this.container.properties.size.dim.y > size.y or container_size.y > size.y;
// correct size dimensions due to space needed for the axis (if any)
container_size.x -= if (this.configuration.x_axis) 1 else 0;
container_size.y -= if (this.configuration.y_axis) 1 else 0;
}
// NOTE this call is at this point required as the container size
// may also be defined through the `Container`'s `Properties`, which
// may define a dimension (for static layouts)
this.container.resize(model, container_size); // notify the container about the minimal size it should have
// update the calculated size of the container
container_size = this.container.size;
const min_size = this.container.element.minSize(size);
this.container.resize(.{
.x = min_size.x - if (this.configuration.x_axis) @as(u16, 1) else @as(u16, 0),
.y = min_size.y - if (this.configuration.y_axis) @as(u16, 1) else @as(u16, 0),
});
} else this.container.resize(this.container.element.minSize(size)); // notify the container about the minimal size it should have (can be larger)
const new_max_anchor_x = this.container.size.x -| size.x;
const new_max_anchor_y = this.container.size.y -| size.y;
const new_max_anchor_x = container_size.x -| size.x;
const new_max_anchor_y = container_size.y -| size.y;
// correct anchor if necessary
if (new_max_anchor_x < last_max_anchor_x and this.anchor.x > new_max_anchor_x) this.anchor.x = new_max_anchor_x;
if (new_max_anchor_y < last_max_anchor_y and this.anchor.y > new_max_anchor_y) this.anchor.y = new_max_anchor_y;
this.container_size = this.container.size;
this.size = size;
this.container_size = container_size;
}
fn reposition(ctx: *anyopaque, _: Point) void {
fn reposition(ctx: *anyopaque, model: *const Model, _: Point) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.container.reposition(.{});
this.container.reposition(model, .{});
}
fn handle(ctx: *anyopaque, model: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
// TODO other means to scroll except with the mouse? (i.e. Ctrl-u/d, k/j, etc.?)
// TODO what about multiple scrollable `Element` usages? mouse
// can differ between them (due to the event being only send to
// the `Container` / `Element` one under the cursor!)
.key => |key| {
if (key.eql(.{ .cp = 'j' }) or key.eql(.{ .cp = input.Down })) {
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(this.anchor.y + 1, max_anchor_y);
}
if (key.eql(.{ .cp = 'k' }) or key.eql(.{ .cp = input.Up })) {
this.anchor.y -|= 1;
}
if (key.eql(.{ .cp = 'd', .mod = .{ .ctrl = true } })) {
// half page down
const half_page = this.size.y / 2;
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(this.anchor.y + half_page, max_anchor_y);
}
if (key.eql(.{ .cp = 'u', .mod = .{ .ctrl = true } })) {
// half page up
const half_page = this.size.y / 2;
this.anchor.y -|= half_page;
}
if (key.eql(.{ .cp = 'f', .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.PageDown })) {
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(this.anchor.y + this.size.y, max_anchor_y);
}
if (key.eql(.{ .cp = 'b', .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.PageDown })) {
this.anchor.y -|= this.size.y;
}
if (key.eql(.{ .cp = 'g' }) or key.eql(.{ .cp = input.Home })) {
this.anchor.y = 0;
}
if (key.eql(.{ .cp = 'G' }) or key.eql(.{ .cp = input.End })) {
this.anchor.y = this.container_size.y -| this.size.y;
}
},
.mouse => |mouse| switch (mouse.button) {
Mouse.Button.wheel_up => if (this.container_size.y > this.size.y) {
.wheel_up => if (this.container_size.y > this.size.y) {
this.anchor.y -|= 1;
},
Mouse.Button.wheel_down => if (this.container_size.y > this.size.y) {
.wheel_down => if (this.container_size.y > this.size.y) {
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(this.anchor.y + 1, max_anchor_y);
},
Mouse.Button.wheel_left => if (this.container_size.x > this.size.x) {
.wheel_left => if (this.container_size.x > this.size.x) {
this.anchor.x -|= 1;
},
Mouse.Button.wheel_right => if (this.container_size.x > this.size.x) {
.wheel_right => if (this.container_size.x > this.size.x) {
const max_anchor_x = this.container_size.x -| this.size.x;
this.anchor.x = @min(this.anchor.x + 1, max_anchor_x);
},
.left => {
// horizontal (-) scrollbar click
if (this.configuration.y_axis and mouse.y == this.size.y - 1) {
const ratio = @as(f32, @floatFromInt(mouse.x)) / @as(f32, @floatFromInt(this.size.x));
const value: u16 = @intFromFloat(ratio * @as(f32, @floatFromInt(this.container_size.x)));
const max_anchor_x = this.container_size.x -| this.size.x;
this.anchor.x = @min(value, max_anchor_x);
}
// vertical (|) scrollbar click
if (this.configuration.x_axis and mouse.x == this.size.x - 1) {
const ratio = @as(f32, @floatFromInt(mouse.y)) / @as(f32, @floatFromInt(this.size.y));
const value: u16 = @intFromFloat(ratio * @as(f32, @floatFromInt(this.container_size.y)));
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(value, max_anchor_y);
}
},
else => try this.container.handle(model, .{
.mouse = .{
.x = mouse.x + this.anchor.x,
@@ -376,8 +468,10 @@ pub fn Scrollable(Model: type, Event: type) type {
.fg = this.configuration.color,
.emphasis = if (row >= scrollbar_starting_pos and row <= scrollbar_starting_pos + scrollbar_size)
&.{} // scrollbar itself
else if (this.configuration.show_background)
&.{.dim} // background (around scrollbar)
else
&.{.dim}, // background (around scrollbar)
&.{.hidden}, // hide background
},
.cp = '🮋', // 7/8 block right
};
@@ -394,8 +488,10 @@ pub fn Scrollable(Model: type, Event: type) type {
.fg = this.configuration.color,
.emphasis = if (col >= scrollbar_starting_pos and col <= scrollbar_starting_pos + scrollbar_size)
&.{} // scrollbar itself
else if (this.configuration.show_background)
&.{.dim} // background (around scrollbar)
else
&.{.dim}, // background (around scrollbar)
&.{.hidden}, // hide background
},
.cp = '🮄', // 5/8 block top (to make it look more equivalent to the vertical scrollbar)
};
@@ -407,59 +503,34 @@ pub fn Scrollable(Model: type, Event: type) type {
};
} // Scrollable(Model: type, Event: type)
// TODO features
// - clear input (with and without retaining of capacity) through an public api
// - make handle / content functions public
pub fn Input(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) type {
// NOTE the struct is necessary, as otherwise I cannot point to the function I want to return
const input_struct = struct {
pub fn input_fn(accept_event: meta.FieldEnum(Event)) type {
const event_type: enum { ascii, utf8 } = blk: { // check for type correctness and the associated type to use for the passed `accept_event`
const err_msg = "Unexpected type for the associated input completion event to trigger. Only `[]u8` or `[]u21` are allowed.";
switch (@typeInfo(@FieldType(Event, @tagName(accept_event)))) {
.pointer => |pointer| {
if (pointer.size != .slice) @compileError(err_msg);
switch (@typeInfo(pointer.child)) {
.int => |num| {
if (num.signedness != .unsigned) @compileError(err_msg);
switch (num.bits) {
8 => break :blk .ascii,
21 => break :blk .utf8,
else => @compileError(err_msg),
}
},
else => @compileError(err_msg),
}
},
else => @compileError(err_msg),
}
};
pub fn TextField(Model: type, Event: type) type {
return struct {
allocator: std.mem.Allocator,
allocator: Allocator,
/// Offset from the end describing the current position of the cursor.
cursor_offset: usize = 0,
/// Configuration for the InputField.
configuration: Configuration,
/// Array holding the value of the input.
input: std.ArrayList(u21),
/// Reference to the app's queue to issue the associated event to trigger when completing the input.
queue: *Queue,
/// Configuration for InputField's.
pub const Configuration = packed struct {
color: Color,
cursor: Color,
pub fn init(color: Color) @This() {
return .{ .color = color };
pub fn init(color: Color, cursor: Color) @This() {
return .{
.color = color,
.cursor = cursor,
};
}
};
pub fn init(allocator: std.mem.Allocator, queue: *Queue, configuration: Configuration) @This() {
pub fn init(allocator: Allocator, configuration: Configuration) @This() {
return .{
.allocator = allocator,
.configuration = configuration,
.input = std.ArrayList(u21).initCapacity(allocator, 8) catch unreachable,
.queue = queue,
};
}
@@ -467,18 +538,29 @@ pub fn Input(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) t
this.input.deinit(this.allocator);
}
pub fn clear(this: *@This()) void {
this.input.clearRetainingCapacity();
this.cursor_offset = 0;
}
fn element_deinit(ctx: *anyopaque) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.deinit();
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.handle = handle,
.content = content,
.deinit = element_deinit,
.handle = element_handle,
.content = element_content,
},
};
}
fn handle(ctx: *anyopaque, _: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
pub fn handle(this: *@This(), model: *Model, event: Event) !void {
_ = model;
switch (event) {
.key => |key| {
assert(this.cursor_offset >= 0 and this.cursor_offset <= this.input.items.len);
@@ -547,8 +629,9 @@ pub fn Input(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) t
}
}
// TODO should this also accept `input.Enter` and insert `\n` into the value of the field?
// usual input keys
if (key.isAscii()) try this.input.insert(this.allocator, this.input.items.len - this.cursor_offset, key.cp);
if (key.isUnicode()) try this.input.insert(this.allocator, this.input.items.len - this.cursor_offset, key.cp);
if (key.eql(.{ .cp = input.Backspace })) {
if (this.cursor_offset < this.input.items.len) {
@@ -565,36 +648,50 @@ pub fn Input(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) t
// TODO enter to accept?
// - shift+enter is not recognized by the input reader of `zterm`, so currently it is not possible to add newlines into the text box?
if (key.eql(.{ .cp = input.Enter }) or key.eql(.{ .cp = input.KpEnter })) {
switch (event_type) {
.ascii => {
// NOTE convert unicode characters to ascii characters; if non ascii characters are found this is will fail!
var slice = try this.allocator.alloc(u8, this.input.items.len);
for (0.., this.input.items) |i, c| slice[i] = @intCast(c);
this.input.clearAndFree(this.allocator);
this.queue.push(@unionInit(
Event,
@tagName(accept_event),
slice,
));
},
.utf8 => this.queue.push(@unionInit(
Event,
@tagName(accept_event),
try this.input.toOwnedSlice(this.allocator),
)),
}
this.cursor_offset = 0;
}
if (key.eql(.{ .cp = input.Enter }) or key.eql(.{ .cp = input.KpEnter })) {}
},
else => {},
}
}
fn content(ctx: *anyopaque, _: *const Model, cells: []Cell, size: Point) !void {
fn element_handle(ctx: *anyopaque, model: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
try this.handle(model, event);
}
pub fn toOwnedSlice(this: *@This(), comptime t: enum { bytes, code_points }) !switch (t) {
.bytes => []u8,
.code_points => []u21,
} {
const value = switch (t) {
.bytes => blk: {
// NOTE convert unicode characters to ascii characters; if non ascii characters are found this is will fail!
const len: usize = len: {
var res: usize = 0;
for (this.input.items) |cp| res += try std.unicode.utf8CodepointSequenceLength(cp);
break :len res;
};
var slice = try this.allocator.alloc(u8, len);
errdefer this.allocator.free(slice);
var i: usize = 0;
for (this.input.items) |cp| {
const size = try std.unicode.utf8CodepointSequenceLength(cp);
_ = try std.unicode.utf8Encode(cp, slice[i .. i + size]);
i += size;
}
this.input.clearAndFree(this.allocator);
break :blk slice;
},
.code_points => try this.input.toOwnedSlice(this.allocator),
};
this.cursor_offset = 0;
return value;
}
pub fn content(this: *@This(), model: *const Model, cells: []Cell, size: Point) !void {
_ = model;
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
const offset = if (this.input.items.len - this.cursor_offset + 1 >= cells.len) this.input.items.len + 1 - cells.len else 0;
for (this.input.items[offset..], 0..) |cp, idx| {
cells[idx].style.fg = this.configuration.color;
@@ -609,10 +706,106 @@ pub fn Input(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) t
break;
}
}
if (this.input.items.len < cells.len)
cells[this.input.items.len - this.cursor_offset].style.cursor = true
else
if (this.input.items.len < cells.len) {
cells[this.input.items.len - this.cursor_offset].style.cursor = true;
cells[this.input.items.len - this.cursor_offset].style.cursor_color = this.configuration.cursor;
cells[this.input.items.len - this.cursor_offset].style.cursor_shape = .bar_blinking;
} else {
cells[this.input.items.len - offset - this.cursor_offset].style.cursor = true;
cells[this.input.items.len - offset - this.cursor_offset].style.cursor_color = this.configuration.cursor;
cells[this.input.items.len - offset - this.cursor_offset].style.cursor_shape = .bar_blinking;
}
}
fn element_content(ctx: *anyopaque, model: *const Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
try this.content(model, cells, size);
}
};
} // TextField(Model: type, Event: type)
// TODO features
// - clear input (with and without retaining of capacity) through an public api
// - make handle / content functions public
pub fn Input(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) type {
// NOTE the struct is necessary, as otherwise I cannot point to the function I want to return
const input_struct = struct {
pub fn input_fn(accept_event: meta.FieldEnum(Event)) type {
const event_type: enum { bytes, code_points } = blk: { // check for type correctness and the associated type to use for the passed `accept_event`
const err_msg = "Unexpected type for the associated input completion event to trigger. Only `[]u8` or `[]u21` are allowed.";
switch (@typeInfo(@FieldType(Event, @tagName(accept_event)))) {
.pointer => |pointer| {
if (pointer.size != .slice) @compileError(err_msg);
switch (@typeInfo(pointer.child)) {
.int => |num| {
if (num.signedness != .unsigned) @compileError(err_msg);
switch (num.bits) {
8 => break :blk .bytes,
21 => break :blk .code_points,
else => @compileError(err_msg),
}
},
else => @compileError(err_msg),
}
},
else => @compileError(err_msg),
}
};
return struct {
/// Inner `TextField` for capturing, displaying and handling user inputs.
text_field: TextField(Model, Event),
/// Reference to the app's queue to issue the associated event to trigger when completing the input.
queue: *Queue,
pub fn init(allocator: Allocator, queue: *Queue, configuration: TextField(Model, Event).Configuration) @This() {
return .{
.text_field = .init(allocator, configuration),
.queue = queue,
};
}
fn deinit(ctx: *anyopaque) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.text_field.deinit();
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.deinit = deinit,
.handle = handle,
.content = content,
},
};
}
fn handle(ctx: *anyopaque, model: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.key => |key| {
// NOTE handle order of keys such that the `input.Enter` might not be processed twice! (leading to unexpected values)
try this.text_field.handle(model, event);
// TODO enter to accept?
// - shift+enter is not recognized by the input reader of `zterm`, so currently it is not possible to add newlines into the text box?
if (key.eql(.{ .cp = input.Enter }) or key.eql(.{ .cp = input.KpEnter })) this.queue.push(@unionInit(
Event,
@tagName(accept_event),
switch (event_type) {
.bytes => try this.text_field.toOwnedSlice(.bytes),
.code_points => try this.text_field.toOwnedSlice(.code_points),
},
));
},
else => {},
}
}
fn content(ctx: *anyopaque, model: *const Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
// NOTE size assertion against cells happens in the `TextField.content` method
try this.text_field.content(model, cells, size);
}
};
}
@@ -626,8 +819,9 @@ pub fn Button(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event))
pub fn button_fn(accept_event: meta.FieldEnum(Event)) type {
{ // check for type correctness and the associated type to use for the passed `accept_event`
const err_msg = "Unexpected type for the associated input completion event to trigger. Only `void` is allowed.";
// TODO supported nested tagged unions to be also be used for triggering if the enum is then still of `void`type!
switch (@typeInfo(@FieldType(Event, @tagName(accept_event)))) {
.void => |_| {},
.void => {},
else => @compileError(err_msg),
}
}
@@ -1006,6 +1200,7 @@ pub fn Progress(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const meta = std.meta;
const build_options = @import("build_options");
const input = @import("input.zig");
@@ -1053,7 +1248,6 @@ test "scrollable vertical" {
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
defer box.deinit();
var scrollable: Scrollable(Model, event.SystemEvent) = .init(box, .disabled);
@@ -1068,13 +1262,13 @@ test "scrollable vertical" {
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
container.resize(&.{}, size);
container.reposition(&.{}, .{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.top.zon"), renderer.screen);
// scroll down 15 times (exactly to the end)
for (0..15) |_| try container.handle(&model, .{
// scroll down 15 times (exactly to the end) (with both mouse and key inputs)
for (0..7) |_| try container.handle(&model, .{
.mouse = .{
.button = .wheel_down,
.kind = .press,
@@ -1082,6 +1276,9 @@ test "scrollable vertical" {
.y = 5,
},
});
for (7..15) |_| try container.handle(&model, .{
.key = .{ .cp = 'j' },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
@@ -1096,6 +1293,40 @@ test "scrollable vertical" {
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
// scrolling up and down again
for (0..5) |_| try container.handle(&model, .{
.key = .{ .cp = 'k' },
});
for (0..5) |_| try container.handle(&model, .{
.key = .{ .cp = 'j' },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
// scrolling half page up and down again
try container.handle(&model, .{
.key = .{ .cp = 'u', .mod = .{ .ctrl = true } },
});
try container.handle(&model, .{
.key = .{ .cp = 'd', .mod = .{ .ctrl = true } },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
// scrolling page up
try container.handle(&model, .{
.key = .{ .cp = 'b', .mod = .{ .ctrl = true } },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.top.zon"), renderer.screen);
// and down again
try container.handle(&model, .{
.key = .{ .cp = 'f', .mod = .{ .ctrl = true } },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
}
test "scrollable vertical with scrollbar" {
@@ -1133,9 +1364,8 @@ test "scrollable vertical with scrollbar" {
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
defer box.deinit();
var scrollable: Scrollable(Model, event.SystemEvent) = .init(box, .enabled(.white));
var scrollable: Scrollable(Model, event.SystemEvent) = .init(box, .enabled(.white, true));
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{
.border = .{
@@ -1148,8 +1378,8 @@ test "scrollable vertical with scrollbar" {
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
container.resize(&.{}, size);
container.reposition(&.{}, .{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.scrollbar.top.zon"), renderer.screen);
@@ -1213,7 +1443,6 @@ test "scrollable horizontal" {
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
defer box.deinit();
var scrollable: Scrollable(Model, event.SystemEvent) = .init(box, .disabled);
@@ -1228,8 +1457,8 @@ test "scrollable horizontal" {
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
container.resize(&.{}, size);
container.reposition(&.{}, .{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.left.zon"), renderer.screen);
@@ -1293,9 +1522,8 @@ test "scrollable horizontal with scrollbar" {
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
defer box.deinit();
var scrollable: Scrollable(Model, event.SystemEvent) = .init(box, .enabled(.white));
var scrollable: Scrollable(Model, event.SystemEvent) = .init(box, .enabled(.white, true));
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{
.border = .{
@@ -1308,8 +1536,8 @@ test "scrollable horizontal with scrollbar" {
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
container.resize(&.{}, size);
container.reposition(&.{}, .{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.scrollbar.left.zon"), renderer.screen);
@@ -1347,16 +1575,13 @@ test "alignment center" {
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{}, .{});
defer container.deinit();
var aligned_container: Container(Model, event.SystemEvent) = try .init(allocator, .{
var alignment: Alignment(Model, event.SystemEvent) = .init(try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
.dim = .{ .x = 12, .y = 5 },
.grow = .fixed,
},
}, .{});
defer aligned_container.deinit();
var alignment: Alignment(Model, event.SystemEvent) = .init(aligned_container, .center);
}, .{}), .center);
try container.append(try .init(allocator, .{}, alignment.element()));
try testing.expectContainerScreen(.{
@@ -1374,16 +1599,13 @@ test "alignment left" {
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{}, .{});
defer container.deinit();
var aligned_container: Container(Model, event.SystemEvent) = try .init(allocator, .{
var alignment: Alignment(Model, event.SystemEvent) = .init(try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
.dim = .{ .x = 12, .y = 5 },
.grow = .fixed,
},
}, .{});
defer aligned_container.deinit();
var alignment: Alignment(Model, event.SystemEvent) = .init(aligned_container, .{
}, .{}), .{
.h = .start,
.v = .center,
});
@@ -1404,16 +1626,13 @@ test "alignment right" {
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{}, .{});
defer container.deinit();
var aligned_container: Container(Model, event.SystemEvent) = try .init(allocator, .{
var alignment: Alignment(Model, event.SystemEvent) = .init(try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
.dim = .{ .x = 12, .y = 5 },
.grow = .fixed,
},
}, .{});
defer aligned_container.deinit();
var alignment: Alignment(Model, event.SystemEvent) = .init(aligned_container, .{
}, .{}), .{
.h = .end,
.v = .center,
});
@@ -1434,16 +1653,13 @@ test "alignment top" {
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{}, .{});
defer container.deinit();
var aligned_container: Container(Model, event.SystemEvent) = try .init(allocator, .{
var alignment: Alignment(Model, event.SystemEvent) = .init(try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
.dim = .{ .x = 12, .y = 5 },
.grow = .fixed,
},
}, .{});
defer aligned_container.deinit();
var alignment: Alignment(Model, event.SystemEvent) = .init(aligned_container, .{
}, .{}), .{
.h = .center,
.v = .start,
});
@@ -1464,16 +1680,13 @@ test "alignment bottom" {
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{}, .{});
defer container.deinit();
var aligned_container: Container(Model, event.SystemEvent) = try .init(allocator, .{
var alignment: Alignment(Model, event.SystemEvent) = .init(try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
.dim = .{ .x = 12, .y = 5 },
.grow = .fixed,
},
}, .{});
defer aligned_container.deinit();
var alignment: Alignment(Model, event.SystemEvent) = .init(aligned_container, .{
}, .{}), .{
.h = .center,
.v = .end,
});
@@ -1506,9 +1719,7 @@ test "input element" {
};
var queue: Queue = .{};
var input_element: Input(Model, Event, Queue)(.accept) = .init(allocator, &queue, .init(.black));
defer input_element.deinit();
var input_element: Input(Model, Event, Queue)(.accept) = .init(allocator, &queue, .init(.black, .default));
const input_container: Container(Model, Event) = try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
@@ -1522,8 +1733,8 @@ test "input element" {
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
container.resize(&.{}, size);
container.reposition(&.{}, .{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/input.without.text.zon"), renderer.screen);
@@ -1588,8 +1799,8 @@ test "button" {
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
container.resize(&.{}, size);
container.reposition(&.{}, .{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/button.zon"), renderer.screen);
@@ -1646,8 +1857,8 @@ test "progress" {
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
container.resize(&.{}, size);
container.reposition(&.{}, .{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/progress_zero.zon"), renderer.screen);
@@ -1655,8 +1866,8 @@ test "progress" {
try container.handle(&model, .{
.progress = 25,
});
container.resize(size);
container.reposition(.{});
container.resize(&.{}, size);
container.reposition(&.{}, .{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/progress_one_quarter.zon"), renderer.screen);
@@ -1664,8 +1875,8 @@ test "progress" {
try container.handle(&model, .{
.progress = 50,
});
container.resize(size);
container.reposition(.{});
container.resize(&.{}, size);
container.reposition(&.{}, .{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/progress_half.zon"), renderer.screen);
@@ -1673,8 +1884,8 @@ test "progress" {
try container.handle(&model, .{
.progress = 75,
});
container.resize(size);
container.reposition(.{});
container.resize(&.{}, size);
container.reposition(&.{}, .{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/progress_three_quarter.zon"), renderer.screen);
@@ -1682,8 +1893,8 @@ test "progress" {
try container.handle(&model, .{
.progress = 100,
});
container.resize(size);
container.reposition(.{});
container.resize(&.{}, size);
container.reposition(&.{}, .{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/progress_one_hundred.zon"), renderer.screen);
}

View File

@@ -8,8 +8,19 @@ pub const SystemEvent = union(enum) {
init,
/// Quit event to signify the end of the event loop (rendering should stop afterwards)
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
///
/// Usually no `Container` nor `Element` should act on that event, as it
/// only serves for event based loops to force a re-draw with a new `Event`.
resize,
/// Ring the terminal bell to notify the user. This `Event` is handled by
/// every `Container` and will not be passed through the container tree.
bell,
/// Error event to notify other containers about a recoverable error
err: struct {
/// actual error
@@ -17,6 +28,11 @@ pub const SystemEvent = union(enum) {
/// associated error message
msg: []const u8,
},
/// 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,
/// Input key event received from the user
key: Key,
/// Mouse input event
@@ -110,7 +126,7 @@ pub fn isTaggedUnion(comptime T: type) bool {
/// Determine whether the provided type `T` is a `struct`.
pub fn isStruct(comptime T: type) bool {
return switch (@typeInfo(T)) {
.@"struct" => |_| true,
.@"struct" => true,
else => false,
};
}

View File

@@ -65,7 +65,30 @@ pub const Key = packed struct {
return meta.eql(this, other);
}
// TODO might be useful to use the std.ascii stuff!
/// Determine if the `Key` is an unicode 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.isUnicode()) try this.input.append(key.cp),
/// else => {},
/// }
/// ```
pub fn isUnicode(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) or // extended ascii codes
((this.cp >= 0x0080 and this.cp <= 0x07FF) or
(this.cp >= 0x0800 and this.cp <= 0xFFFF) or
(this.cp >= 0x100000 and this.cp <= 0x10FFFF)) and // allowed unicode character ranges (2 - 4 byte characters)
(this.cp < 57348 or this.cp > 57454); // no other predefined meanings (i.e. arrow keys, etc.)
}
/// 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

View File

@@ -1,7 +1,7 @@
// taken from https://github.com/rockorager/libvaxis/blob/main/src/queue.zig (MIT-License)
// with slight modifications
/// Queue implementation. Thread safe. Fixed size. Blocking push and pop. Polling through tryPop and tryPush.
/// Queue implementation. Thread safe. Fixed size. _Blocking_ `push` and `pop`. _Polling_ through `tryPop` and `tryPush`.
pub fn Queue(comptime T: type, comptime size: usize) type {
return struct {
buf: [size]T = undefined,

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;
@@ -104,6 +105,7 @@ pub const Buffered = struct {
.x = @truncate(col),
.y = @truncate(row),
};
try cvs.style.set_cursor_style(&writer);
}
if (cs.eql(cvs)) continue;
@@ -122,6 +124,95 @@ 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();
}
pub fn writeCtrlDWithNewline(this: *@This()) !void {
_ = this;
_ = try terminal.write("^D\n");
}
pub fn writeNewline(this: *@This()) !void {
_ = this;
_ = try terminal.write("\n");
}
/// 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

@@ -12,6 +12,8 @@ fg: Color = .default,
bg: Color = .default,
ul: Color = .default,
cursor: bool = false,
cursor_color: Color = .default,
cursor_shape: CursorShape = .default,
ul_style: Underline = .off,
emphasis: []const Emphasis,
@@ -36,11 +38,29 @@ pub const Emphasis = enum(u8) {
strikethrough,
};
pub const CursorShape = enum(u4) {
default = 0,
block_blinking = 1,
block_steady,
underline_blinking,
underline_steady,
bar_blinking,
bar_steady,
};
pub fn eql(this: Style, other: Style) bool {
// TODO should there be a compare for every field?
return meta.eql(this, other);
}
// TODO might be useful to use the std.ascii stuff!
pub fn set_cursor_style(this: Style, writer: *std.Io.Writer) !void {
if (!this.cursor) return;
switch (this.cursor_color) {
.default => try writer.print(ctlseqs.osc12_reset, .{}),
else => try writer.print(ctlseqs.osc12_set, .{@tagName(this.cursor_color)}),
}
try writer.print(ctlseqs.cursor_shape, .{@intFromEnum(this.cursor_shape)});
}
pub fn value(this: Style, writer: *std.Io.Writer, cp: u21) !void {
var buffer: [4]u8 = undefined;
@@ -74,4 +94,5 @@ const unicode = std.unicode;
const meta = std.meta;
const assert = std.debug.assert;
const Color = @import("color.zig").Color;
const ctlseqs = @import("ctlseqs.zig");
const Style = @This();

View File

@@ -42,6 +42,11 @@ pub fn showCursor() !void {
_ = try posix.write(posix.STDIN_FILENO, ctlseqs.show_cursor);
}
pub fn resetCursor() !void {
_ = try posix.write(posix.STDIN_FILENO, ctlseqs.reset_cursor_shape);
_ = try posix.write(posix.STDIN_FILENO, ctlseqs.osc12_reset);
}
pub fn setCursorPositionHome() !void {
_ = try posix.write(posix.STDIN_FILENO, ctlseqs.home);
}
@@ -54,6 +59,10 @@ pub fn disableMouseSupport() !void {
_ = try posix.write(posix.STDIN_FILENO, ctlseqs.mouse_reset);
}
pub fn ringBell() !void {
_ = try posix.write(posix.STDIN_FILENO, &.{7});
}
pub fn read(buf: []u8) !usize {
return try posix.read(posix.STDIN_FILENO, buf);
}
@@ -89,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");

View File

@@ -112,9 +112,11 @@ pub fn expectContainerScreen(size: Point, comptime T: type, container: *T, compt
var renderer: Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
try renderer.render(T, container, Model, &.{});
const model: Model = .{};
container.resize(&model, size);
container.reposition(&model, .{});
try renderer.render(T, container, Model, &model);
try expectEqualCells(.{}, renderer.size, expected, renderer.screen);
}
@@ -209,14 +211,16 @@ pub fn expectEqualCells(origin: Point, size: Point, expected: []const Cell, actu
if (!differ) return;
// test failed
debug.lockStdErr();
defer debug.unlockStdErr();
var buf: [1024]u8 = undefined;
std.debug.lockStdErr();
defer std.debug.unlockStdErr();
var buffer = std.fs.File.stderr().writer(&buf);
var error_writer = &buffer.interface;
try error_writer.writeAll(writer.buffer[0..writer.end]);
try error_writer.flush();
var stdout_buffer: [1024]u8 = undefined;
var stdout = std.fs.File.stdout().writer(&stdout_buffer);
const stdout_writer = &stdout.interface;
try stdout_writer.writeAll(writer.buffer[0..writer.end]);
try stdout_writer.flush();
return error.TestExpectEqualCells;
}