intermediate #1

Merged
yves-biener merged 31 commits from intermediate into main 2025-02-16 16:02:59 +01:00
44 changed files with 1292 additions and 3902 deletions

154
README.md
View File

@@ -3,7 +3,7 @@
`zterm` is a terminal user interface library to implement terminal (fullscreen or inline) applications. `zterm` is a terminal user interface library to implement terminal (fullscreen or inline) applications.
> [!NOTE] > [!NOTE]
> Only builds using the master version might will work. > Only builds using the master version are tested to work.
## Usage ## Usage
@@ -25,3 +25,155 @@ exe.root_module.addImport("zterm", zterm.module("zterm"));
``` ```
For an example you can take a look at [build.zig](build.zig) for an example. For an example you can take a look at [build.zig](build.zig) for an example.
---
## Design Goals
This project draws heavy inspiration from
[clay](https://github.com/nicbarker/clay) in the way the layout is declared by
the user. As terminal applications usually are rendered in intermediate mode,
the rendering is also part of the event loop. Such that every time an event
happens a render call will usually be done as well. However this is not strickly
necessary and can be separated to have a fixed rendering of every 16ms (i.e. for
60 fps), etc.
There is only one generic container which contains properties and elements (or
children) which can also be containers, such that each layout in the end is
a tree.
The library is designed to be very basic and not to provide any more complex
elements such as input fields, drop-down menu's, buttons, etc. Some of them are
either easy to implement yourself, specific for you needs or a too complex to
be provided by the library effectively. For these use-cases there may be other
libraries that build on top of this one to provide the complex elements as some
sort of pre-built elements for you to use in your application (or you create
them yourself).
There are only very few system events, that are used by the built-in containers
and properties accordingly. For you own widgets (i.e. a collection of elements)
you can extend the events to include your own events to communicate between
elements, effect the control flow and the corresponding generated layouts and
much more.
As this is a terminal based layout library it also provides a rendering pipeline
alongside the event loop implementation. Usually the event loop is waiting
blocking and will only cause a re-draw (**intermediate mode**) after each event.
Even though the each frame is regenerated from scratch each render loop, the
corresponding application is still pretty performant as the renderer uses a
double buffered intermediate mode implementation to only apply the changes from
each frame to the next to the terminal.
This library is also designed to work accordingly in ssh hosted environments,
such that an application created using this library can be accessed directly
via ssh. This provides security through the ssh protocol and can defer the
synchronization process, as users may access the same running instance. Which is
the primary use-case for myself to create this library in the first place.
---
## Roadmap
- [ ] Container rendering
- [x] Layout
- [x] direction
- [x] vertical
- [x] horizontal
- [x] padding
- [x] gap
- [x] sizing (removed - for now at least)
- width
- height
- options
- fit
- grow
- fixed
- percent
- [x] Border
- [x] sides
- [x] corners
- [x] separators
- [x] Rectangle
- [ ] User control
- [x] event handling
- [x] user content
- [ ] Default `Element` implementations
- [ ] Scrollable
- [ ] user input handling
- [ ] vertical
- [ ] horizontal
- [ ] scroll bar(s) rendering
- [ ] vertical
- [ ] horizontal
- [ ] Content alignment (i.e. standard calculations done with the provided `Size`)
- [ ] Text display
- [ ] User input
- [ ] single line
- [ ] multi line
- [ ] min size? (I don't have access to the `.resize` `Event`..)
Decorations should respect the layout and the viewport accordingly. This means
that scrollbars are always visible (except there is no need to have a scrollbar)
irrelevant depending on the size of the content. The rectangle apply to all
cells of the content (and may be overwritten by child elements contents).
The border of an element should be around independent of the scrolling of the
contents, just like padding.
### Scrollable contents
Contents that is scrollable should be done *virtually* through the contents of
the `Container`. This means each container contents implements scrolling for
itself if required.
This still has one issue: Layout of child elements that are already too large
(i.e. or become too small). The library could provide automatic rendering of a
scrollbar given the right parameters however. The scrolling input action would
then also be implemented by the user.
Open questions are regarding the sizing options (i.e. how is the size of a
`Container` actually controlled?, how should it be controlled?, etc.). There
should be support for the child elements to provide some kind of 'list'
functionality built-in.
**REMINDER**: (mostly for myself) The library should be and remain simple. This
means that some code for using the library may be duplicated, but this is not
the main goal. Others may provide more re-usable code snippets that build on top
of this library instead.
### User specific event handling and content rendering
For interactions controlled by the user each container can use an `Element`
interface which contains functions which are called by the `Container`
during event handling (i.e. `fn handle(..)`) and during rendering (i.e. `fn
content(..)`) to provide user specific content and user interaction. The
`Element` may be stateful, but may also be stateless and then be re-used in
multiple different `Container`s.
Composing multiple `Element`s currently requires the implementation of a wrapper
which contains the `Element`s that need to be handled (should work pretty well
for stateless `Element`s). Such *stateless* `Element`s may be provided by this
library.
### Input
How is the user input handled in the containers? Should there be active
containers? Some input may happen for a specific container (i.e. when using
mouse input). How would I handle scrolling for outer and inner elements of
a container?
### Archive
The alignment and sizing options only make sense if both are available. For
this the current implementation has the viewport size and the content size too
linked. Therefore they have both been removed (at least for now):
- *fit*: adjust virtual space of container by the size of its children (i.e. a
container needs to be able to get the necessary size of its children)
- *grow*: use as much space as available (what exactly would be the difference
between this option and *fit*?)
- *fixed*: use exactly as much cells (in the specified direction)
- *center*: elements should have their anchor be placed accordingly to their
size and the viewport size.
- *left*: the anchor remains at zero (relative to the location of the
container on the screen) -> similar to the current implementation!
- *right*: the anchor is fixed to the right side (i.e. size of the contents -
size of the viewport)

View File

@@ -1,110 +1,66 @@
const std = @import("std"); const std = @import("std");
// Although this function looks imperative, note that its job is to
// declaratively construct a build graph that will be executed by an external
// runner.
pub fn build(b: *std.Build) void { pub fn build(b: *std.Build) void {
// Standard target options allows the person running `zig build` to choose
// what target to build for. Here we do not override the defaults, which
// means any target is allowed, and the default is native. Other options
// for restricting supported target set are available.
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
// Standard optimization options allow the person running `zig build` to select
// between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not
// set a preferred release mode, allowing the user to decide how to optimize.
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});
const zg = b.dependency("zg", .{ const zg = b.dependency("zg", .{
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
}); });
const interface = b.dependency("interface", .{
.target = target,
.optimize = optimize,
});
// library
const lib = b.addModule("zterm", .{ const lib = b.addModule("zterm", .{
.root_source_file = b.path("src/zterm.zig"), .root_source_file = b.path("src/zterm.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
}); });
lib.addImport("interface", interface.module("interface"));
lib.addImport("code_point", zg.module("code_point")); lib.addImport("code_point", zg.module("code_point"));
// example executables // main executable (usually used for testing)
const stack_example = b.addExecutable(.{ const exe = b.addExecutable(.{
.name = "stack", .name = "zterm",
.root_source_file = b.path("examples/stack.zig"), .root_source_file = b.path("src/main.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
}); });
stack_example.root_module.addImport("zterm", lib); exe.root_module.addImport("zterm", lib);
const container_example = b.addExecutable(.{ // TODO: add example execution through optional argument to `zig run` to run
// an example application instead of the main executable
// example applications (usually used for documentation and demonstrations)
const container = b.addExecutable(.{
.name = "container", .name = "container",
.root_source_file = b.path("examples/container.zig"), .root_source_file = b.path("examples/container.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
}); });
container_example.root_module.addImport("zterm", lib); container.root_module.addImport("zterm", lib);
b.installArtifact(container);
const padding_example = b.addExecutable(.{ // zig build run
.name = "padding", const run_cmd = b.addRunArtifact(exe);
.root_source_file = b.path("examples/padding.zig"), run_cmd.step.dependOn(b.getInstallStep());
.target = target, // Allow additional arguments, like this: `zig build run -- arg1 arg2 etc`
.optimize = optimize, if (b.args) |args| run_cmd.addArgs(args);
});
padding_example.root_module.addImport("zterm", lib);
const exec_example = b.addExecutable(.{ // This creates a build step. It will be visible in the `zig build --help` menu,
.name = "exec", // and can be selected like this: `zig build run`
.root_source_file = b.path("examples/exec.zig"), // This will evaluate the `run` step rather than the default, which is "install".
.target = target, const run_step = b.step("run", "Run the app");
.optimize = optimize, run_step.dependOn(&run_cmd.step);
});
exec_example.root_module.addImport("zterm", lib);
const tui_example = b.addExecutable(.{ // zig build test
.name = "tui",
.root_source_file = b.path("examples/tui.zig"),
.target = target,
.optimize = optimize,
});
tui_example.root_module.addImport("zterm", lib);
const tabs_example = b.addExecutable(.{
.name = "tabs",
.root_source_file = b.path("examples/tabs.zig"),
.target = target,
.optimize = optimize,
});
tabs_example.root_module.addImport("zterm", lib);
// This declares intent for the executable to be installed into the
// standard location when the user invokes the "install" step (the default
// step when running `zig build`).
b.installArtifact(stack_example);
b.installArtifact(container_example);
b.installArtifact(padding_example);
b.installArtifact(exec_example);
b.installArtifact(tui_example);
b.installArtifact(tabs_example);
// Creates a step for unit testing. This only builds the test executable
// but does not run it.
const lib_unit_tests = b.addTest(.{ const lib_unit_tests = b.addTest(.{
.root_source_file = b.path("src/zterm.zig"), .root_source_file = b.path("src/zterm.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
}); });
lib_unit_tests.root_module.addImport("zg", zg.module("code_point")); lib_unit_tests.root_module.addImport("code_point", zg.module("code_point"));
const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests);
// Similar to creating the run step earlier, this exposes a `test` step to
// the `zig build --help` menu, providing a way for the user to request
// running the unit tests.
const test_step = b.step("test", "Run unit tests"); const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_lib_unit_tests.step); test_step.dependOn(&run_lib_unit_tests.step);
} }

View File

@@ -24,12 +24,8 @@
// internet connectivity. // internet connectivity.
.dependencies = .{ .dependencies = .{
.zg = .{ .zg = .{
.url = "git+https://codeberg.org/atman/zg#a363f507fc39b96fc48d693665a823a358345326", .url = "git+https://codeberg.org/atman/zg#4a002763419a34d61dcbb1f415821b83b9bf8ddc",
.hash = "1220fe42e39fd141c84fd7d5cf69945309bb47253033e68788f99bdfe5585fbc711a", .hash = "1220f3e29bc40856bfc06e0ee133f814b0011c76de987d8a6a458c2f34d82708899a",
},
.interface = .{
.url = "git+https://github.com/yves-biener/zig-interface#ef47e045df19e09250fff45c0702d014fb3d3c37",
.hash = "1220a442e8d9b813572bab7a55eef504c83b628f0b17fd283e776dbc1d1a3d98e842",
}, },
}, },
.paths = .{ .paths = .{

View File

@@ -1,96 +1,152 @@
const std = @import("std"); const std = @import("std");
const zterm = @import("zterm"); const zterm = @import("zterm");
const App = zterm.App( const App = zterm.App(union(enum) {});
union(enum) {},
zterm.Renderer.Direct,
true,
);
const Key = zterm.Key; const Key = zterm.Key;
const Layout = App.Layout;
const Widget = App.Widget;
const log = std.log.scoped(.container); const log = std.log.scoped(.example);
pub const ExampleElement = packed struct {
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
.vtable = &.{
.handle = handle,
.content = content,
},
};
}
// example function to render contents for a `Container`
fn content(ctx: *anyopaque, cells: []zterm.Cell, size: zterm.Size) !void {
_ = ctx;
std.debug.assert(cells.len == @as(usize, size.cols) * @as(usize, size.rows));
// NOTE: error should only be returned here in case an in-recoverable exception has occurred
const row = size.rows / 2;
const col = size.cols / 2 -| 3;
for (0..5) |c| {
cells[(row * size.cols) + col + c].style.fg = .black;
cells[(row * size.cols) + col + c].cp = '-';
}
}
// example function to handle events for a `Container`
fn handle(ctx: *anyopaque, event: App.Event) !void {
_ = ctx;
switch (event) {
.init => log.debug(".init event", .{}),
else => {},
}
}
};
pub fn main() !void { pub fn main() !void {
errdefer |err| log.err("Application Error: {any}", .{err}); errdefer |err| log.err("Application Error: {any}", .{err});
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{}; // TODO: maybe create own allocator as some sort of arena allocator to have consistent memory usage
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer { defer {
const deinit_status = gpa.deinit(); const deinit_status = gpa.deinit();
// fail test; can't try in defer as defer is executed after we return
if (deinit_status == .leak) { if (deinit_status == .leak) {
log.err("memory leak", .{}); log.err("memory leak", .{});
} }
} }
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var app: App = .{}; var app: App = .init;
var renderer: App.Renderer = .{}; var renderer = zterm.Renderer.Buffered.init(allocator);
// TODO: when not running fullscreen, the application needs to screen down accordingly to display the contents defer renderer.deinit();
// -> size hint how much should it use?
var layout = Layout.createFrom(Layout.HContainer.init(allocator, .{ var element_wrapper = ExampleElement{};
.{ const element = element_wrapper.element();
Widget.createFrom(Widget.Spacer.init(allocator)),
15,
},
.{
Layout.createFrom(Layout.VContainer.init(allocator, .{
.{
Widget.createFrom(Widget.Spacer.init(allocator)),
25,
},
.{
Widget.createFrom(blk: {
const file = try std.fs.cwd().openFile("./src/app.zig", .{});
defer file.close();
break :blk Widget.RawText.init(allocator, file);
}),
50,
},
.{
Widget.createFrom(Widget.Spacer.init(allocator)),
25,
},
})),
70,
},
.{
Widget.createFrom(Widget.Spacer.init(allocator)),
15,
},
}));
defer layout.deinit();
try app.start(null); var container = try App.Container.init(allocator, .{
defer app.stop() catch unreachable; .border = .{
.separator = .{
.enabled = true,
.line = .double,
},
},
.layout = .{
.gap = 2,
.padding = .all(5),
.direction = .vertical,
},
}, element);
var box = try App.Container.init(allocator, .{
.rectangle = .{ .fill = .blue },
.layout = .{
.gap = 1,
.direction = .vertical,
.padding = .vertical(1),
},
}, .{});
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
}, element));
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
}, .{}));
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
}, element));
try container.append(box);
try container.append(try App.Container.init(allocator, .{
.border = .{
.color = .light_blue,
.sides = .vertical,
},
}, .{}));
try container.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .blue },
}, .{}));
defer container.deinit(); // also de-initializes the children
// App.Event loop try app.start();
defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err});
// event loop
while (true) { while (true) {
const event = app.nextEvent(); const event = app.nextEvent();
log.debug("received event: {s}", .{@tagName(event)}); log.debug("received event: {s}", .{@tagName(event)});
switch (event) { switch (event) {
.quit => break, .init => {
.resize => |size| { try container.handle(event);
renderer.resize(size); continue; // do not render
}, },
.quit => break,
.resize => |size| try renderer.resize(size),
.key => |key| { .key => |key| {
// ctrl+c to quit if (key.matches(.{ .cp = 'q' })) app.quit();
if (Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) {
app.quit(); if (key.matches(.{ .cp = 'n', .mod = .{ .ctrl = true } })) {
try app.interrupt();
defer app.start() catch @panic("could not start app event loop");
var child = std.process.Child.init(&.{"hx"}, allocator);
_ = child.spawnAndWait() catch |err| app.postEvent(.{
.err = .{
.err = err,
.msg = "Spawning $EDITOR failed",
},
});
} }
}, },
.err => |err| { // NOTE: errors could be displayed in another container in case one was received, etc. to provide the user with feedback
log.err("Received {any} with message: {s}", .{ err.err, err.msg }); .err => |err| log.err("Received {s} with message: {s}", .{ @errorName(err.err), err.msg }),
},
else => {}, else => {},
} }
const events = try layout.handle(event);
for (events.items) |e| { // NOTE: returned errors should be propagated back to the application
app.postEvent(e); container.handle(event) catch |err| app.postEvent(.{
} .err = .{
try layout.render(&renderer); .err = err,
.msg = "Container Event handling failed",
},
});
try renderer.render(@TypeOf(container), &container);
try renderer.flush();
} }
} }

View File

@@ -1,105 +0,0 @@
const std = @import("std");
const zterm = @import("zterm");
const App = zterm.App(
union(enum) {},
zterm.Renderer.Direct,
true,
);
const Key = zterm.Key;
const Cell = zterm.Cell;
const Layout = App.Layout;
const Widget = App.Widget;
const log = std.log.scoped(.exec);
pub fn main() !void {
errdefer |err| log.err("Application Error: {any}", .{err});
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
defer {
const deinit_status = gpa.deinit();
// fail test; can't try in defer as defer is executed after we return
if (deinit_status == .leak) {
log.err("memory leak", .{});
}
}
const allocator = gpa.allocator();
var app: App = .{};
var renderer: App.Renderer = .{};
// TODO: when not running fullscreen, the application needs to screen down accordingly to display the contents
// -> size hint how much should it use?
var layout = Layout.createFrom(Layout.VContainer.init(allocator, .{
.{
Widget.createFrom(Widget.Spacer.init(allocator)),
45,
},
.{
Layout.createFrom(Layout.Framing.init(allocator, .{}, .{
.widget = Widget.createFrom(Widget.Text.init(allocator, .center, &[_]Cell{
.{ .content = "Press " },
.{ .content = "Ctrl+n", .style = .{ .fg = .{ .index = 6 } } },
.{ .content = " to launch $EDITOR" },
})),
})),
10,
},
.{
Widget.createFrom(Widget.Spacer.init(allocator)),
45,
},
}));
defer layout.deinit();
const min_size: zterm.Size = .{
.cols = 25,
.rows = 20,
};
try app.start(min_size);
defer app.stop() catch unreachable;
// App.Event loop
while (true) {
const event = app.nextEvent();
log.debug("received event: {s}", .{@tagName(event)});
switch (event) {
.quit => break,
.resize => |size| {
renderer.resize(size);
},
.key => |key| {
// ctrl+c to quit
if (Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) {
app.quit();
}
if (Key.matches(key, .{ .cp = 'n', .mod = .{ .ctrl = true } })) {
try app.interrupt();
defer app.start(min_size) catch @panic("could not start app event loop");
// TODO: parse environment variables to extract the value of $EDITOR and use it here instead
var child = std.process.Child.init(&.{"hx"}, allocator);
_ = child.spawnAndWait() catch |err| {
app.postEvent(.{
.err = .{
.err = err,
.msg = "Spawning $EDITOR failed",
},
});
};
}
},
.err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg });
},
else => {},
}
const events = try layout.handle(event);
for (events.items) |e| {
app.postEvent(e);
}
try layout.render(&renderer);
}
}

View File

@@ -1,103 +0,0 @@
const std = @import("std");
const zterm = @import("zterm");
const App = zterm.App(
union(enum) {},
zterm.Renderer.Direct,
true,
);
const Key = zterm.Key;
const Layout = App.Layout;
const Widget = App.Widget;
const log = std.log.scoped(.padding);
pub fn main() !void {
errdefer |err| log.err("Application Error: {any}", .{err});
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
defer {
const deinit_status = gpa.deinit();
// fail test; can't try in defer as defer is executed after we return
if (deinit_status == .leak) {
log.err("memory leak", .{});
}
}
const allocator = gpa.allocator();
var app: App = .{};
var renderer: App.Renderer = .{};
// TODO: when not running fullscreen, the application needs to screen down accordingly to display the contents
// -> size hint how much should it use?
var layout = Layout.createFrom(Layout.Padding.init(allocator, .{
.padding = 15,
}, .{
.layout = Layout.createFrom(Layout.Framing.init(
allocator,
.{
.style = .{
.fg = .{
.index = 6,
},
},
.frame = .round,
.title = .{
.str = "Content in Margin",
.style = .{
.ul_style = .single,
.ul = .{ .index = 6 },
.bold = true,
},
},
},
.{
.layout = Layout.createFrom(Layout.Margin.init(
allocator,
.{
.margin = 10,
},
.{
.widget = Widget.createFrom(blk: {
const file = try std.fs.cwd().openFile("./examples/padding.zig", .{});
defer file.close();
break :blk Widget.RawText.init(allocator, file);
}),
},
)),
},
)),
}));
defer layout.deinit();
try app.start(null);
defer app.stop() catch unreachable;
// App.Event loop
while (true) {
const event = app.nextEvent();
log.debug("received event: {s}", .{@tagName(event)});
switch (event) {
.quit => break,
.resize => |size| {
renderer.resize(size);
},
.key => |key| {
// ctrl+c to quit
if (Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) {
app.quit();
}
},
.err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg });
},
else => {},
}
const events = try layout.handle(event);
for (events.items) |e| {
app.postEvent(e);
}
try layout.render(&renderer);
}
}

View File

@@ -1,130 +0,0 @@
const std = @import("std");
const zterm = @import("zterm");
const App = zterm.App(
union(enum) {},
zterm.Renderer.Direct,
true,
);
const Key = zterm.Key;
const Layout = App.Layout;
const Widget = App.Widget;
const log = std.log.scoped(.stack);
pub fn main() !void {
errdefer |err| log.err("Application Error: {any}", .{err});
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
defer {
const deinit_status = gpa.deinit();
// fail test; can't try in defer as defer is executed after we return
if (deinit_status == .leak) {
log.err("memory leak", .{});
}
}
const allocator = gpa.allocator();
var app: App = .{};
var renderer: App.Renderer = .{};
// TODO: when not running fullscreen, the application needs to screen down accordingly to display the contents
// -> size hint how much should it use?
var layout = Layout.createFrom(Layout.Framing.init(allocator, .{
.style = .{
.fg = .{
.index = 6,
},
},
.frame = .round,
.title = .{
.str = "HStack",
.style = .{
.ul_style = .single,
.ul = .{ .index = 6 },
.bold = true,
},
},
}, .{
.layout = Layout.createFrom(Layout.HStack.init(allocator, .{
Widget.createFrom(Widget.Spacer.init(allocator)),
Layout.createFrom(Layout.Framing.init(
allocator,
.{
.style = .{
.fg = .{
.index = 6,
},
},
.frame = .round,
.title = .{
.str = "VStack",
.style = .{
.ul_style = .single,
.ul = .{ .index = 6 },
.bold = true,
},
},
},
.{
.layout = Layout.createFrom(
Layout.Margin.init(
allocator,
.{
.margin = 10,
},
.{
.layout = Layout.createFrom(Layout.VStack.init(allocator, .{
Widget.createFrom(blk: {
const file = try std.fs.cwd().openFile("./examples/stack.zig", .{});
defer file.close();
break :blk Widget.RawText.init(allocator, file);
}),
Widget.createFrom(Widget.Spacer.init(allocator)),
Widget.createFrom(blk: {
const file = try std.fs.cwd().openFile("./examples/stack.zig", .{});
defer file.close();
break :blk Widget.RawText.init(allocator, file);
}),
})),
},
),
),
},
)),
Widget.createFrom(Widget.Spacer.init(allocator)),
})),
}));
defer layout.deinit();
try app.start(null);
defer app.stop() catch unreachable;
// App.Event loop
while (true) {
const event = app.nextEvent();
log.debug("received event: {s}", .{@tagName(event)});
switch (event) {
.quit => break,
.resize => |size| {
renderer.resize(size);
},
.key => |key| {
// ctrl+c to quit
if (Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) {
app.quit();
}
},
.err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg });
},
else => {},
}
const events = try layout.handle(event);
for (events.items) |e| {
app.postEvent(e);
}
try layout.render(&renderer);
}
}

View File

@@ -1,106 +0,0 @@
const std = @import("std");
const zterm = @import("zterm");
const App = zterm.App(
union(enum) {},
zterm.Renderer.Direct,
true,
);
const Key = zterm.Key;
const Cell = zterm.Cell;
const Layout = App.Layout;
const Widget = App.Widget;
const log = std.log.scoped(.tabs);
pub fn main() !void {
errdefer |err| log.err("Application Error: {any}", .{err});
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
defer {
const deinit_status = gpa.deinit();
// fail test; can't try in defer as defer is executed after we return
if (deinit_status == .leak) {
log.err("memory leak", .{});
}
}
const allocator = gpa.allocator();
var app: App = .{};
var renderer: App.Renderer = .{};
// TODO: when not running fullscreen, the application needs to screen down accordingly to display the contents
// -> size hint how much should it use?
var layout = Layout.createFrom(Layout.Tab.init(allocator, .{}, .{
.{
Layout.createFrom(Layout.Margin.init(allocator, .{ .margin = 10 }, .{
.widget = Widget.createFrom(blk: {
const file = try std.fs.cwd().openFile("./examples/tabs.zig", .{});
defer file.close();
break :blk Widget.RawText.init(allocator, file);
}),
})),
"Tab 2",
Cell.Style.Color{ .index = 6 },
},
.{
Layout.createFrom(Layout.Framing.init(
allocator,
.{
.frame = .round,
.title = .{
.str = "Content in Margin",
.style = .{
.ul_style = .single,
.ul = .{ .index = 4 },
.bold = true,
},
},
},
.{
.layout = Layout.createFrom(Layout.Margin.init(allocator, .{ .margin = 10 }, .{
.widget = Widget.createFrom(Widget.List.init(allocator, .ordered, .{
&[_]Cell{.{ .content = "First entry" }},
&[_]Cell{.{ .content = "Second entry" }},
&[_]Cell{.{ .content = "Third entry" }},
})),
})),
},
)),
"Tab 1",
Cell.Style.Color{ .index = 4 },
},
}));
defer layout.deinit();
try app.start(null);
defer app.stop() catch unreachable;
// App.Event loop
while (true) {
const event = app.nextEvent();
log.debug("received event: {s}", .{@tagName(event)});
switch (event) {
.quit => break,
.resize => |size| {
renderer.resize(size);
},
.key => |key| {
// ctrl+c to quit
if (Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) {
app.quit();
}
},
.err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg });
},
else => {},
}
const events = try layout.handle(event);
for (events.items) |e| {
app.postEvent(e);
}
try layout.render(&renderer);
}
}

View File

@@ -1,151 +0,0 @@
const std = @import("std");
const zterm = @import("zterm");
const App = zterm.App(
union(enum) {
view: union(enum) {
tui, // view instance to the corresponding view for 'tui'
},
},
zterm.Renderer.Direct,
true,
);
const Cell = zterm.Cell;
const Key = zterm.Key;
const Layout = App.Layout;
const Widget = App.Widget;
const View = App.View;
const Tui = struct {
const Events = std.ArrayList(App.Event);
allocator: std.mem.Allocator,
layout: Layout,
pub fn init(allocator: std.mem.Allocator) *Tui {
var tui = allocator.create(Tui) catch @panic("Out of memory: tui.zig");
tui.allocator = allocator;
// FIXME: the layout creates an 'incorrect alignment'?
tui.layout = Layout.createFrom(Layout.VContainer.init(allocator, .{
.{
Layout.createFrom(Layout.Framing.init(allocator, .{
.title = .{
.str = "Welcome to my terminal website",
.style = .{
.ul = .{ .index = 6 },
.ul_style = .single,
},
},
}, .{
.layout = Layout.createFrom(Layout.HContainer.init(allocator, .{
.{
Widget.createFrom(Widget.Text.init(allocator, .left, &[1]Cell{
.{ .content = "Yves Biener", .style = .{ .bold = true } },
})),
25,
},
.{
Widget.createFrom(Widget.Text.init(allocator, .left, &[1]Cell{
.{ .content = "File name", .style = .{ .bold = true } },
})),
50,
},
.{
Widget.createFrom(Widget.Text.init(allocator, .left, &[1]Cell{
.{ .content = "Contacts", .style = .{ .bold = true } },
})),
25,
},
})),
})),
10,
},
.{
Layout.createFrom(Layout.Margin.init(allocator, .{ .left = 15, .right = 15 }, .{
.widget = Widget.createFrom(Widget.Text.init(allocator, .default, &[1]Cell{
.{ .content = "Does this change anything", .style = .{ .ul = .default, .ul_style = .single } },
})),
})),
90,
},
}));
return tui;
}
pub fn deinit(this: *Tui) void {
this.layout.deinit();
this.allocator.destroy(this);
}
pub fn handle(this: *Tui, event: App.Event) !*Events {
return try this.layout.handle(event);
}
pub fn render(this: *Tui, renderer: *App.Renderer) !void {
try this.layout.render(renderer);
}
};
// TODO: create additional example with a bit more complex functionality for
// dynamic layouts, switching views, etc.
const log = std.log.scoped(.tui);
pub fn main() !void {
errdefer |err| log.err("Application Error: {any}", .{err});
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
var app: App = .{};
var renderer: App.Renderer = .{};
var view: View = undefined;
var tui_view = View.createFrom(Tui.init(allocator));
defer tui_view.deinit();
view = tui_view;
try app.start(null);
defer app.stop() catch unreachable;
// App.Event loop
while (true) {
const event = app.nextEvent();
switch (event) {
.quit => break,
.resize => |size| {
renderer.resize(size);
},
.key => |key| {
// ctrl+c to quit
if (Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) {
app.quit();
}
},
.err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg });
},
.view => |v| {
switch (v) {
.tui => {
view = tui_view;
// NOTE: report potentially new screen size
const events = try view.handle(.{ .resize = renderer.size });
for (events.items) |e| {
app.postEvent(e);
}
},
}
},
else => {},
}
const events = try view.handle(event);
for (events.items) |e| {
app.postEvent(e);
}
try view.render(&renderer);
}
}

View File

@@ -6,7 +6,8 @@ const event = @import("event.zig");
const mergeTaggedUnions = event.mergeTaggedUnions; const mergeTaggedUnions = event.mergeTaggedUnions;
const isTaggedUnion = event.isTaggedUnion; const isTaggedUnion = event.isTaggedUnion;
const Key = terminal.Key; const Key = @import("key.zig");
const Size = @import("size.zig").Size;
const Queue = @import("queue.zig").Queue; const Queue = @import("queue.zig").Queue;
const log = std.log.scoped(.app); const log = std.log.scoped(.app);
@@ -15,14 +16,6 @@ const log = std.log.scoped(.app);
/// an tagged union for all the user events that can be send through the /// an tagged union for all the user events that can be send through the
/// applications event loop. /// applications event loop.
/// ///
/// _R_ is the type function for the `Renderer` to use. The parameter boolean
/// will be set to the _fullscreen_ value at compile time. The corresponding
/// `Renderer` type is accessible through the generated type of this function.
///
/// _fullscreen_ will be used to configure the `App` and the `Renderer` to
/// respect the corresponding configuration whether to render a fullscreen tui
/// or an inline tui.
///
/// # Example /// # Example
/// ///
/// Create an `App` which renders using the `PlainRenderer` in fullscreen with /// Create an `App` which renders using the `PlainRenderer` in fullscreen with
@@ -32,41 +25,43 @@ const log = std.log.scoped(.app);
/// const zterm = @import("zterm"); /// const zterm = @import("zterm");
/// const App = zterm.App( /// const App = zterm.App(
/// union(enum) {}, /// union(enum) {},
/// zterm.Renderer.Direct,
/// true,
/// ); /// );
/// // later on use /// // later on create an `App` instance and start the event loop
/// var app: App = .{}; /// var app: App = .init;
/// var renderer: App.Renderer = .{}; /// try app.start();
/// defer app.stop() catch unreachable;
/// ``` /// ```
pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fullscreen: bool) type { pub fn App(comptime E: type) type {
if (!isTaggedUnion(E)) { if (!isTaggedUnion(E)) {
@compileError("Provided user event `E` for `App(comptime E: type)` is not of type `union(enum)`."); @compileError("Provided user event `E` for `App(comptime E: type)` is not of type `union(enum)`.");
} }
return struct { return struct {
pub const Event = mergeTaggedUnions(event.SystemEvent, E); pub const Event = mergeTaggedUnions(event.SystemEvent, E);
pub const Renderer = R(fullscreen); pub const Container = @import("container.zig").Container(Event);
pub const Layout = @import("layout.zig").Layout(Event, Renderer); pub const Element = @import("element.zig").Element(Event);
pub const Widget = @import("widget.zig").Widget(Event, Renderer);
pub const View = @import("view.zig").View(Event, Renderer);
queue: Queue(Event, 256) = .{}, queue: Queue(Event, 256),
thread: ?std.Thread = null, thread: ?std.Thread,
quit_event: std.Thread.ResetEvent = .{}, quit_event: std.Thread.ResetEvent,
termios: ?std.posix.termios = null, termios: ?std.posix.termios = null,
attached_handler: bool = false, attached_handler: bool = false,
min_size: ?terminal.Size = null, prev_size: Size,
prev_size: terminal.Size = .{ .cols = 0, .rows = 0 },
pub const SignalHandler = struct { pub const SignalHandler = struct {
context: *anyopaque, context: *anyopaque,
callback: *const fn (context: *anyopaque) void, callback: *const fn (context: *anyopaque) void,
}; };
pub fn start(this: *@This(), min_size: ?terminal.Size) !void { pub const init: @This() = .{
if (fullscreen) { // a minimal size only really makes sense if the application is rendered fullscreen .queue = .{},
this.min_size = min_size; .thread = null,
} .quit_event = .{},
.termios = null,
.attached_handler = false,
.prev_size = .{},
};
pub fn start(this: *@This()) !void {
if (this.thread) |_| return; if (this.thread) |_| return;
if (!this.attached_handler) { if (!this.attached_handler) {
@@ -82,6 +77,9 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
.callback = @This().winsizeCallback, .callback = @This().winsizeCallback,
}); });
this.attached_handler = true; this.attached_handler = true;
// post init event (as the very first element to be in the queue - event loop)
this.postEvent(.init);
} }
this.quit_event.reset(); this.quit_event.reset();
@@ -89,22 +87,22 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
var termios: std.posix.termios = undefined; var termios: std.posix.termios = undefined;
try terminal.enableRawMode(&termios); try terminal.enableRawMode(&termios);
if (this.termios) |_| {} else { if (this.termios) |_| {} else this.termios = termios;
this.termios = termios;
}
if (fullscreen) {
try terminal.saveScreen(); try terminal.saveScreen();
try terminal.enterAltScreen(); try terminal.enterAltScreen();
try terminal.hideCursor(); try terminal.hideCursor();
}
// send initial size afterwards
const size = terminal.getTerminalSize();
this.postEvent(.{ .resize = size });
this.prev_size = size;
} }
pub fn interrupt(this: *@This()) !void { pub fn interrupt(this: *@This()) !void {
this.quit_event.set(); this.quit_event.set();
if (fullscreen) {
try terminal.exitAltScreen(); try terminal.exitAltScreen();
try terminal.restoreScreen(); try terminal.restoreScreen();
}
if (this.thread) |thread| { if (this.thread) |thread| {
thread.join(); thread.join();
this.thread = null; this.thread = null;
@@ -115,12 +113,10 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
try this.interrupt(); try this.interrupt();
if (this.termios) |*termios| { if (this.termios) |*termios| {
try terminal.disableRawMode(termios); try terminal.disableRawMode(termios);
if (fullscreen) {
try terminal.showCursor(); try terminal.showCursor();
try terminal.exitAltScreen(); try terminal.exitAltScreen();
try terminal.restoreScreen(); try terminal.restoreScreen();
} }
}
this.termios = null; this.termios = null;
} }
@@ -144,15 +140,6 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
fn winsizeCallback(ptr: *anyopaque) void { fn winsizeCallback(ptr: *anyopaque) void {
const this: *@This() = @ptrCast(@alignCast(ptr)); const this: *@This() = @ptrCast(@alignCast(ptr));
const size = terminal.getTerminalSize(); const size = terminal.getTerminalSize();
// check for minimal size (if any was provided)
if (this.min_size) |min_size| {
if (size.cols < min_size.cols or size.rows < min_size.rows) {
this.postEvent(.{
.err = .{ .err = error.InsufficientSize, .msg = "Terminal size is too small for the requested minimal size" },
});
return;
}
}
if (size.cols != this.prev_size.cols or size.rows != this.prev_size.rows) { if (size.cols != this.prev_size.cols or size.rows != this.prev_size.rows) {
this.postEvent(.{ .resize = size }); this.postEvent(.{ .resize = size });
this.prev_size = size; this.prev_size = size;
@@ -178,19 +165,7 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
// send initial terminal size // send initial terminal size
// changes are handled by the winch signal handler // changes are handled by the winch signal handler
// see `App.start` and `App.registerWinch` for details // see `App.start` and `App.registerWinch` for details
{ {}
// TODO: what should happen if the initial window size is too small?
// -> currently the first render call will then crash the application (which happens anyway)
const size = terminal.getTerminalSize();
if (this.min_size) |min_size| {
if (size.cols < min_size.cols or size.rows < min_size.rows) {
this.postEvent(.{
.err = .{ .err = error.InsufficientSize, .msg = "Terminal size is too small for the requested minimal size" },
});
}
}
this.postEvent(.{ .resize = size });
}
// thread to read user inputs // thread to read user inputs
var buf: [256]u8 = undefined; var buf: [256]u8 = undefined;
@@ -202,9 +177,8 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
if (buf[0] == 0x1b and read_bytes > 1) { if (buf[0] == 0x1b and read_bytes > 1) {
switch (buf[1]) { switch (buf[1]) {
0x4F => { // ss3 0x4F => { // ss3
if (read_bytes < 3) { if (read_bytes < 3) continue;
continue;
}
const key: ?Key = switch (buf[2]) { const key: ?Key = switch (buf[2]) {
0x1B => null, 0x1B => null,
'A' => .{ .cp = Key.up }, 'A' => .{ .cp = Key.up },
@@ -220,14 +194,11 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
'S' => .{ .cp = Key.f4 }, 'S' => .{ .cp = Key.f4 },
else => null, else => null,
}; };
if (key) |k| { if (key) |k| this.postEvent(.{ .key = k });
this.postEvent(.{ .key = k });
}
}, },
0x5B => { // csi 0x5B => { // csi
if (read_bytes < 3) { if (read_bytes < 3) continue;
continue;
}
// We start iterating at index 2 to get past the '[' // We start iterating at index 2 to get past the '['
const sequence = for (buf[2..], 2..) |b, i| { const sequence = for (buf[2..], 2..) |b, i| {
switch (b) { switch (b) {
@@ -322,19 +293,10 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
// TODO: only post the event if the size has changed? // TODO: only post the event if the size has changed?
// because there might be too many resize events (which force a re-draw of the entire screen) // because there might be too many resize events (which force a re-draw of the entire screen)
const size: terminal.Size = .{ const size: Size = .{
.rows = std.fmt.parseUnsigned(u16, height_char, 10) catch break, .rows = std.fmt.parseUnsigned(u16, height_char, 10) catch break,
.cols = std.fmt.parseUnsigned(u16, width_char, 10) catch break, .cols = std.fmt.parseUnsigned(u16, width_char, 10) catch break,
}; };
// check for minimal size (if any was provided)
if (this.min_size) |min_size| {
if (size.cols < min_size.cols or size.rows < min_size.rows) {
this.postEvent(.{
.err = .{ .err = error.InsufficientSize, .msg = "Terminal size is too small for the requested minimal size" },
});
break;
}
}
if (size.cols != this.prev_size.cols or size.rows != this.prev_size.rows) { if (size.cols != this.prev_size.cols or size.rows != this.prev_size.rows) {
this.postEvent(.{ .resize = size }); this.postEvent(.{ .resize = size });
this.prev_size = size; this.prev_size = size;
@@ -372,9 +334,7 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
0x7f => .{ .cp = Key.backspace }, 0x7f => .{ .cp = Key.backspace },
else => { else => {
var iter = terminal.code_point.Iterator{ .bytes = buf[0..read_bytes] }; var iter = terminal.code_point.Iterator{ .bytes = buf[0..read_bytes] };
while (iter.next()) |cp| { while (iter.next()) |cp| this.postEvent(.{ .key = .{ .cp = cp.code } });
this.postEvent(.{ .key = .{ .cp = cp.code } });
}
continue; continue;
}, },
}; };

21
src/cell.zig Normal file
View File

@@ -0,0 +1,21 @@
const std = @import("std");
const Style = @import("style.zig");
pub const Cell = @This();
style: Style = .{ .attributes = &.{} },
// TODO: embrace `zg` dependency more due to utf-8 encoding
cp: u21 = ' ',
pub fn eql(this: Cell, other: Cell) bool {
return this.cp == other.cp and this.style.eql(other.style);
}
pub fn reset(this: *Cell) void {
this.style = .{ .attributes = &.{} };
this.cp = ' ';
}
pub fn value(this: Cell, writer: anytype) !void {
try this.style.value(writer, this.cp);
}

38
src/color.zig Normal file
View File

@@ -0,0 +1,38 @@
const std = @import("std");
pub const Color = enum(u8) {
default = 0,
black = 16,
light_red = 1,
light_green,
light_yellow,
light_blue,
light_magenta,
light_cyan,
light_grey,
grey,
red,
green,
yellow,
blue,
magenta,
cyan,
white,
// TODO: add further colors as described in https://gist.github.com/ConnerWill/d4b6c776b509add763e17f9f113fd25b # Color / Graphics Mode - 256 Colors
pub inline fn write(this: Color, writer: anytype, comptime coloring: enum { fg, bg, ul }) !void {
if (this == .default) {
switch (coloring) {
.fg => try std.fmt.format(writer, "39", .{}),
.bg => try std.fmt.format(writer, "49", .{}),
.ul => try std.fmt.format(writer, "59", .{}),
}
} else {
switch (coloring) {
.fg => try std.fmt.format(writer, "38;5;{d}", .{@intFromEnum(this)}),
.bg => try std.fmt.format(writer, "48;5;{d}", .{@intFromEnum(this)}),
.ul => try std.fmt.format(writer, "58;5;{d}", .{@intFromEnum(this)}),
}
}
}
};

406
src/container.zig Normal file
View File

@@ -0,0 +1,406 @@
const std = @import("std");
const isTaggedUnion = @import("event.zig").isTaggedUnion;
const Cell = @import("cell.zig");
const Color = @import("color.zig").Color;
const Size = @import("size.zig").Size;
const Style = @import("style.zig");
const log = std.log.scoped(.container);
/// Border configuration struct
pub const Border = packed struct {
pub const rounded_border: [6]u21 = .{ '╭', '─', '╮', '│', '╰', '╯' };
pub const squared_border: [6]u21 = .{ '┌', '─', '┐', '│', '└', '┘' };
pub const line: [2]u21 = .{ '│', '─' };
pub const dotted: [2]u21 = .{ '┆', '┄' };
pub const double: [2]u21 = .{ '║', '═' };
/// Color to use for the border
color: Color = .default,
/// Configure the corner type to be used for the border
corners: enum(u1) {
squared,
rounded,
} = .squared,
/// Configure the sides where the borders shall be rendered
sides: packed struct {
top: bool = false,
bottom: bool = false,
left: bool = false,
right: bool = false,
/// Enable border sides for all four sides
pub const all: @This() = .{ .top = true, .bottom = true, .left = true, .right = true };
/// Enable border sides for the left and right sides
pub const horizontal: @This() = .{ .left = true, .right = true };
/// Enable border sides for the top and bottom sides
pub const vertical: @This() = .{ .top = true, .bottom = true };
} = .{},
/// Configure separator borders between child element to added to the layout
separator: packed struct {
enabled: bool = false,
color: Color = .white,
line: enum(u2) {
line,
dotted,
double,
} = .line,
} = .{},
// NOTE: caller owns `cells` slice and ensures that `cells.len == size.cols * size.rows`
pub fn contents(this: @This(), cells: []Cell, size: Size, layout: Layout, len: u16) void {
std.debug.assert(cells.len == @as(usize, size.cols) * @as(usize, size.rows));
const frame = switch (this.corners) {
.rounded => Border.rounded_border,
.squared => Border.squared_border,
};
std.debug.assert(frame.len == 6);
// render top and bottom border
for (0..size.cols) |col| {
const last_row = @as(usize, size.rows - 1) * @as(usize, size.cols);
if (this.sides.left and col == 0) {
// top left corner
if (this.sides.top) cells[col].cp = frame[0];
// bottom left corner
if (this.sides.bottom) cells[last_row + col].cp = frame[4];
} else if (this.sides.right and col == size.cols - 1) {
// top right corner
if (this.sides.top) cells[col].cp = frame[2];
// bottom left corner
if (this.sides.bottom) cells[last_row + col].cp = frame[5];
} else {
// top side
if (this.sides.top) cells[col].cp = frame[1];
// bottom side
if (this.sides.bottom) cells[last_row + col].cp = frame[1];
}
if (this.sides.top) cells[col].style.fg = this.color;
if (this.sides.bottom) cells[last_row + col].style.fg = this.color;
}
// render left and right border
for (1..size.rows -| 1) |row| {
const idx = (row * size.cols);
if (this.sides.left) {
cells[idx].cp = frame[3]; // left
cells[idx].style.fg = this.color;
}
if (this.sides.right) {
cells[idx + size.cols - 1].cp = frame[3]; // right
cells[idx + size.cols - 1].style.fg = this.color;
}
}
if (this.separator.enabled) {
// calculate where the separator would need to be
const element_cols = blk: {
var cols = size.cols - layout.gap * (len - 1);
if (this.sides.left) cols -= 1;
if (this.sides.right) cols -= 1;
cols -= layout.padding.left + layout.padding.right;
break :blk @divTrunc(cols, len);
};
const element_rows = blk: {
var rows = size.rows - layout.gap * (len - 1);
if (this.sides.top) rows -= 1;
if (this.sides.bottom) rows -= 1;
rows -= layout.padding.top + layout.padding.bottom;
break :blk @divTrunc(rows, len);
};
var offset: u16 = switch (layout.direction) {
.horizontal => layout.padding.left,
.vertical => layout.padding.top,
};
var overflow = switch (layout.direction) {
.horizontal => blk: {
var cols = size.cols - layout.gap * (len - 1);
if (this.sides.left) cols -= 1;
if (this.sides.right) cols -= 1;
cols -= layout.padding.left + layout.padding.right;
break :blk cols - element_cols * len;
},
.vertical => blk: {
var rows = size.rows - layout.gap * (len - 1);
if (this.sides.top) rows -= 1;
if (this.sides.bottom) rows -= 1;
rows -= layout.padding.top + layout.padding.bottom;
break :blk rows - element_rows * len;
},
};
const line_cps: [2]u21 = switch (this.separator.line) {
.line => line,
.dotted => dotted,
.double => double,
};
switch (layout.direction) {
.horizontal => {
offset += layout.gap / 2;
for (0..len - 1) |_| {
var cols = element_cols;
if (overflow > 0) {
overflow -|= 1;
cols += 1;
}
offset += cols;
for (1..size.rows -| 1) |row| {
cells[row * size.cols + offset].cp = line_cps[0];
cells[row * size.cols + offset].style.fg = this.separator.color;
}
offset += layout.gap;
}
},
.vertical => {
offset += layout.gap / 2;
for (0..len - 1) |_| {
var rows = element_rows;
if (overflow > 0) {
overflow -|= 1;
rows += 1;
}
offset += rows;
for (1..size.cols -| 1) |col| {
cells[offset * size.cols + col].cp = line_cps[1];
cells[offset * size.cols + col].style.fg = this.separator.color;
}
offset += layout.gap;
}
},
}
}
}
};
/// Rectangle configuration struct
pub const Rectangle = packed struct {
/// `Color` to use to fill the `Rectangle` with
/// NOTE: used as background color when rendering! such that it renders the
/// children accordingly without removing the coloring of the `Rectangle`
fill: Color = .default,
// NOTE: caller owns `cells` slice and ensures that `cells.len == size.cols * size.rows`
pub fn contents(this: @This(), cells: []Cell, size: Size) void {
std.debug.assert(cells.len == @as(usize, size.cols) * @as(usize, size.rows));
for (0..size.rows) |row| {
for (0..size.cols) |col| {
cells[(row * size.cols) + col].style.bg = this.fill;
}
}
}
};
/// Layout configuration struct
pub const Layout = packed struct {
/// control the direction in which child elements are laid out
direction: enum(u1) { horizontal, vertical } = .horizontal,
/// Padding outside of the child elements
padding: packed struct {
top: u16 = 0,
bottom: u16 = 0,
left: u16 = 0,
right: u16 = 0,
/// Create a padding with equivalent padding in all four directions.
pub fn all(padding: u16) @This() {
return .{ .top = padding, .bottom = padding, .left = padding, .right = padding };
}
/// Create a padding with equivalent padding in the left and right directions; others directions remain the default value.
pub fn horizontal(padding: u16) @This() {
return .{ .left = padding, .right = padding };
}
/// Create a padding with equivalent padding in the top and bottom directions; others directions remain the default value.
pub fn vertical(padding: u16) @This() {
return .{ .top = padding, .bottom = padding };
}
} = .{},
/// Padding used in between child elements as gaps when laid out
gap: u16 = 0,
};
pub fn Container(comptime Event: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Container(comptime Event: type)`");
}
const Element = @import("element.zig").Element(Event);
return struct {
allocator: std.mem.Allocator,
size: Size,
properties: Properties,
element: Element,
elements: std.ArrayList(@This()),
/// Properties for each `Container` to configure their layout,
/// border, styling, etc. For details see the corresponding individual
/// documentation of the members of this struct accordingly.
pub const Properties = packed struct {
border: Border = .{},
rectangle: Rectangle = .{},
layout: Layout = .{},
};
pub fn init(
allocator: std.mem.Allocator,
properties: Properties,
element: Element,
) !@This() {
return .{
.allocator = allocator,
.size = .{},
.properties = properties,
.element = element,
.elements = std.ArrayList(@This()).init(allocator),
};
}
pub fn deinit(this: *@This()) void {
for (this.elements.items) |*element| {
element.deinit();
}
this.elements.deinit();
}
pub fn append(this: *@This(), element: @This()) !void {
try this.elements.append(element);
}
pub fn handle(this: *@This(), event: Event) !void {
switch (event) {
.resize => |size| resize: {
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
this.size = size;
if (this.elements.items.len == 0) break :resize;
if (this.properties.border.separator.enabled) this.properties.layout.gap += 1;
const layout = this.properties.layout;
const sides = this.properties.border.sides;
const padding = layout.padding;
const gap = layout.gap;
const len: u16 = @truncate(this.elements.items.len);
const element_cols = blk: {
var cols = size.cols - gap * (len - 1);
if (sides.left) cols -= 1;
if (sides.right) cols -= 1;
cols -= padding.left + padding.right;
break :blk @divTrunc(cols, len);
};
const element_rows = blk: {
var rows = size.rows - gap * (len - 1);
if (sides.top) rows -= 1;
if (sides.bottom) rows -= 1;
rows -= padding.top + padding.bottom;
break :blk @divTrunc(rows, len);
};
var offset: u16 = switch (layout.direction) {
.horizontal => padding.left,
.vertical => padding.top,
};
var overflow = switch (layout.direction) {
.horizontal => blk: {
var cols = size.cols - gap * (len - 1);
if (sides.left) cols -= 1;
if (sides.right) cols -= 1;
cols -= padding.left + padding.right;
break :blk cols - element_cols * len;
},
.vertical => blk: {
var rows = size.rows - gap * (len - 1);
if (sides.top) rows -= 1;
if (sides.bottom) rows -= 1;
rows -= padding.top + padding.bottom;
break :blk rows - element_rows * len;
},
};
for (this.elements.items) |*element| {
var element_size: Size = undefined;
switch (layout.direction) {
.horizontal => {
var cols = element_cols;
if (overflow > 0) {
overflow -|= 1;
cols += 1;
}
element_size = .{
.anchor = .{
.col = this.size.anchor.col + offset,
.row = this.size.anchor.row,
},
.cols = cols,
.rows = size.rows,
};
// border
if (sides.top) element_size.rows -= 1;
if (sides.bottom) element_size.rows -= 1;
// padding
element_size.anchor.row += padding.top;
element_size.rows -= padding.top + padding.bottom;
// gap
offset += gap;
offset += cols;
},
.vertical => {
var rows = element_rows;
if (overflow > 0) {
overflow -|= 1;
rows += 1;
}
element_size = .{
.anchor = .{
.col = this.size.anchor.col,
.row = this.size.anchor.row + offset,
},
.cols = size.cols,
.rows = rows,
};
// border
if (sides.left) element_size.cols -= 1;
if (sides.right) element_size.cols -= 1;
// padding
element_size.anchor.col += padding.left;
element_size.cols -= padding.left + padding.right;
// gap
offset += gap;
offset += rows;
},
}
// border resizing
if (sides.top) element_size.anchor.row += 1;
if (sides.left) element_size.anchor.col += 1;
try element.handle(.{ .resize = element_size });
}
},
else => {
try this.element.handle(event);
for (this.elements.items) |*element| try element.handle(event);
},
}
}
pub fn contents(this: *const @This()) ![]const Cell {
const cells = try this.allocator.alloc(Cell, @as(usize, this.size.cols) * @as(usize, this.size.rows));
@memset(cells, .{});
errdefer this.allocator.free(cells);
this.properties.border.contents(cells, this.size, this.properties.layout, @truncate(this.elements.items.len));
this.properties.rectangle.contents(cells, this.size);
try this.element.content(cells, this.size);
return cells;
}
};
}

70
src/element.zig Normal file
View File

@@ -0,0 +1,70 @@
//! Interface for Element's which describe the contents of a `Container`.
const std = @import("std");
const Cell = @import("cell.zig");
const Size = @import("size.zig").Size;
pub fn Element(Event: type) type {
return struct {
ptr: *anyopaque = undefined,
vtable: *const VTable = &.{},
pub const VTable = struct {
handle: ?*const fn (ctx: *anyopaque, event: Event) anyerror!void = null,
content: ?*const fn (ctx: *anyopaque, cells: []Cell, size: Size) anyerror!void = null,
};
/// Handle the received event. The event is one of the user provided
/// events or a system event, with the exception of the `.resize`
/// `Event` as every `Container` already handles that event.
///
/// In case of user errors this function should return an error. This
/// error may then be used by the application to display information
/// about the user error.
pub inline fn handle(this: @This(), event: Event) !void {
if (this.vtable.handle) |handle_fn|
try handle_fn(this.ptr, event);
}
/// Write content into the `cells` of the `Container`. The associated
/// `cells` slice has the size of (`size.cols * size.rows`). The
/// renderer will know where to place the contents on the screen.
///
/// This function should only fail with an error if the error is
/// non-recoverable (i.e. an allocation error, system error, etc.).
/// Otherwise user specific errors should be caught using the `handle`
/// function before the rendering of the `Container` happens.
pub inline fn content(this: @This(), cells: []Cell, size: Size) !void {
if (this.vtable.content) |content_fn|
try content_fn(this.ptr, cells, size);
}
};
}
/// This is an empty template implementation for an Element type which `zterm` may provide.
///
/// TODO: Should elements need to be composible with each other, such that they may build complexer outputs?
/// - the goal would rather be to have re-usable parts of handlers and/or content functions which serve similar functionalities.
pub fn Template(Event: type) type {
return packed struct {
pub fn element(this: *@This()) Element(Event) {
return .{
.ptr = this,
.vtable = &.{
.handle = handle,
.content = content,
},
};
}
fn handle(ctx: *anyopaque, event: Event) !void {
_ = ctx;
_ = event;
}
fn content(ctx: *anyopaque, cells: []Cell, size: Size) !void {
_ = ctx;
std.debug.assert(cells.len == @as(usize, size.cols) * @as(usize, size.rows));
}
};
}

View File

@@ -3,26 +3,32 @@
const std = @import("std"); const std = @import("std");
const terminal = @import("terminal.zig"); const terminal = @import("terminal.zig");
const Size = terminal.Size; const Size = @import("size.zig").Size;
const Key = terminal.Key; const Key = @import("key.zig");
pub const Error = struct { /// System events available to every `zterm.App`
err: anyerror,
msg: []const u8,
};
// System events available to every application.
// TODO: should this also already include the .view enum option?
pub const SystemEvent = union(enum) { pub const SystemEvent = union(enum) {
/// Initialize event, which is send once at the beginning of the event loop and before the first render loop
/// TODO: not sure if this is necessary or if there is an actual usecase for this - for now it will remain
init,
/// Quit event to signify the end of the event loop (rendering should stop afterwards)
quit, quit,
err: Error, /// Error event to notify other containers about a recoverable error
err: struct {
err: anyerror,
/// associated error message
msg: []const u8,
},
/// Resize event emitted by the terminal to derive the `Size` of the current terminal the application is rendered in
resize: Size, resize: Size,
/// Input key event received from the user
key: Key, key: Key,
/// Focus event for mouse interaction
/// TODO: this should instead be a union with a `Size` to derive which container / element the focus meant for
focus: bool, focus: bool,
}; };
pub fn mergeTaggedUnions(comptime A: type, comptime B: type) type { pub fn mergeTaggedUnions(comptime A: type, comptime B: type) type {
// TODO: should this expect one of the unions to contain the .view value option with its corresponding associated type?
if (!isTaggedUnion(A) or !isTaggedUnion(B)) { if (!isTaggedUnion(A) or !isTaggedUnion(B)) {
@compileError("Both types for merging tagged unions need to be of type `union(enum)`."); @compileError("Both types for merging tagged unions need to be of type `union(enum)`.");
} }

View File

@@ -1,6 +1,8 @@
//! Keybindings and Modifiers for user input detection and selection. //! Keybindings and Modifiers for user input detection and selection.
const std = @import("std"); const std = @import("std");
pub const Key = @This();
pub const Modifier = struct { pub const Modifier = struct {
shift: bool = false, shift: bool = false,
alt: bool = false, alt: bool = false,

View File

@@ -1,106 +0,0 @@
//! Dynamic dispatch for layout implementations. Each `Layout` has to implement
//! the `Layout.Interface`.
//!
//! Create a `Layout` using `createFrom(object: anytype)` and use them through
//! the defined `Layout.Interface`. The layout will take care of calling the
//! correct implementation of the corresponding underlying type.
//!
//! Each `Layout` is responsible for clearing the allocated memory of the used
//! `Element`s (union of `Layout` or `Widget`) when deallocated. This means
//! that `deinit()` will also deallocate every used `Element` too.
//!
//! When `Layout.render` is called the provided `Renderer` type is expected
//! which handles how contents are rendered for a given layout.
const std = @import("std");
const isTaggedUnion = @import("event.zig").isTaggedUnion;
pub fn Layout(comptime Event: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`.");
}
const Events = std.ArrayList(Event);
const Type = struct {
const LayoutType = @This();
const Element = union(enum) {
layout: LayoutType,
widget: @import("widget.zig").Widget(Event, Renderer),
};
pub const Interface = @import("interface").Interface(.{
.handle = fn (anytype, Event) anyerror!*Events,
.render = fn (anytype, *Renderer) anyerror!void,
.deinit = fn (anytype) void,
}, .{});
const VTable = struct {
handle: *const fn (this: *LayoutType, event: Event) anyerror!*Events,
render: *const fn (this: *LayoutType, renderer: *Renderer) anyerror!void,
deinit: *const fn (this: *LayoutType) void,
};
object: *anyopaque = undefined,
vtable: *const VTable = undefined,
// Handle the provided `Event` for this `Layout`.
pub fn handle(this: *LayoutType, event: Event) !*Events {
return try this.vtable.handle(this, event);
}
// Render this `Layout` completely. This will render contained sub-elements too.
pub fn render(this: *LayoutType, renderer: *Renderer) !void {
return try this.vtable.render(this, renderer);
}
pub fn deinit(this: *LayoutType) void {
this.vtable.deinit(this);
}
pub fn createFrom(object: anytype) LayoutType {
return LayoutType{
.object = @ptrCast(@alignCast(object)),
.vtable = &.{
.handle = struct {
// Handle the provided `Event` for this `Layout`.
fn handle(this: *LayoutType, event: Event) !*Events {
const layout: @TypeOf(object) = @ptrCast(@alignCast(this.object));
return try layout.handle(event);
}
}.handle,
.render = struct {
// Render the contents of this `Layout`.
fn render(this: *LayoutType, renderer: *Renderer) !void {
const layout: @TypeOf(object) = @ptrCast(@alignCast(this.object));
try layout.render(renderer);
}
}.render,
.deinit = struct {
fn deinit(this: *LayoutType) void {
const layout: @TypeOf(object) = @ptrCast(@alignCast(this.object));
layout.deinit();
}
}.deinit,
},
};
}
// import and export of `Layout` implementations
pub const HContainer = @import("layout/HContainer.zig").Layout(Event, Element, Renderer);
pub const HStack = @import("layout/HStack.zig").Layout(Event, Element, Renderer);
pub const VContainer = @import("layout/VContainer.zig").Layout(Event, Element, Renderer);
pub const VStack = @import("layout/VStack.zig").Layout(Event, Element, Renderer);
pub const Padding = @import("layout/Padding.zig").Layout(Event, Element, Renderer);
pub const Margin = @import("layout/Margin.zig").Layout(Event, Element, Renderer);
pub const Framing = @import("layout/Framing.zig").Layout(Event, Element, Renderer);
pub const Tab = @import("layout/Tab.zig").Layout(Event, Element, Renderer);
};
// test layout implementation satisfies the interface
comptime Type.Interface.satisfiedBy(Type);
comptime Type.Interface.satisfiedBy(Type.HContainer);
comptime Type.Interface.satisfiedBy(Type.HStack);
comptime Type.Interface.satisfiedBy(Type.VContainer);
comptime Type.Interface.satisfiedBy(Type.VStack);
comptime Type.Interface.satisfiedBy(Type.Padding);
comptime Type.Interface.satisfiedBy(Type.Margin);
comptime Type.Interface.satisfiedBy(Type.Framing);
comptime Type.Interface.satisfiedBy(Type.Tab);
return Type;
}

View File

@@ -1,198 +0,0 @@
//! Framing layout for a nested `Layout`s or `Widget`s.
//!
//! # Example
//! ...
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Key = terminal.Key;
const Style = terminal.Cell.Style;
const log = std.log.scoped(.layout_framing);
pub fn Layout(comptime Event: type, comptime Element: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!isTaggedUnion(Element)) {
@compileError("Provided type `Element` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[0].name, "layout")) {
@compileError("Expected `layout: Layout` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[0].name);
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[1].name, "widget")) {
@compileError("Expected `widget: Widget` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[1].name);
}
const Events = std.ArrayList(Event);
return struct {
allocator: std.mem.Allocator,
size: terminal.Size,
require_render: bool,
element: Element,
events: Events,
config: Config,
const Config = struct {
style: Style = .{ .fg = .default },
frame: Frame = .round,
title: Title = .{},
const Title = struct {
str: []const u8 = &.{},
style: Style = .{ .fg = .default },
};
const Frame = enum {
round,
square,
};
};
pub fn init(allocator: std.mem.Allocator, config: Config, element: Element) *@This() {
var this = allocator.create(@This()) catch @panic("Framing.zig: Failed to create.");
this.allocator = allocator;
this.require_render = true;
this.config = config;
this.element = element;
this.events = Events.init(allocator);
return this;
}
pub fn deinit(this: *@This()) void {
this.events.deinit();
switch ((&this.element).*) {
.layout => |*layout| {
layout.deinit();
},
.widget => |*widget| {
widget.deinit();
},
}
this.allocator.destroy(this);
}
pub fn handle(this: *@This(), event: Event) !*Events {
this.events.clearRetainingCapacity();
// order is important
switch (event) {
.resize => |size| {
this.size = size;
this.require_render = true;
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
// adjust size according to the containing elements
const sub_event: Event = .{
.resize = .{
.anchor = .{
.col = size.anchor.col + 1,
.row = size.anchor.row + 1,
},
.cols = size.cols -| 2,
.rows = size.rows -| 2,
},
};
switch ((&this.element).*) {
.layout => |*layout| {
const events = try layout.handle(sub_event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(sub_event)) |e| {
try this.events.append(e);
}
},
}
},
else => {
switch ((&this.element).*) {
.layout => |*layout| {
const events = try layout.handle(event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(event)) |e| {
try this.events.append(e);
}
},
}
},
}
return &this.events;
}
const round_frame = .{ "", "", "", "", "", "" };
const square_frame = .{ "", "", "", "", "", "" };
fn renderFrame(this: *@This(), renderer: *Renderer) !void {
// FIXME: use renderer instead!
_ = renderer;
const frame = switch (this.config.frame) {
.round => round_frame,
.square => square_frame,
};
std.debug.assert(frame.len == 6);
// render top: +---+
try terminal.setCursorPosition(this.size.anchor);
const writer = terminal.writer();
try this.config.style.value(writer, frame[0]);
if (this.config.title.str.len > 0) {
try this.config.title.style.value(writer, this.config.title.str);
}
for (0..this.size.cols -| 2 -| this.config.title.str.len) |_| {
try this.config.style.value(writer, frame[1]);
}
try this.config.style.value(writer, frame[2]);
// render left: |
for (1..this.size.rows -| 1) |r| {
const row: u16 = @truncate(r);
try terminal.setCursorPosition(.{
.col = this.size.anchor.col,
.row = this.size.anchor.row + row,
});
try this.config.style.value(writer, frame[3]);
}
// render right: |
for (1..this.size.rows -| 1) |r| {
const row: u16 = @truncate(r);
try terminal.setCursorPosition(.{
.col = this.size.anchor.col + this.size.cols -| 1,
.row = this.size.anchor.row + row,
});
try this.config.style.value(writer, frame[3]);
}
// render bottom: +---+
try terminal.setCursorPosition(.{
.col = this.size.anchor.col,
.row = this.size.anchor.row + this.size.rows - 1,
});
try this.config.style.value(writer, frame[4]);
for (0..this.size.cols -| 2) |_| {
try this.config.style.value(writer, frame[1]);
}
try this.config.style.value(writer, frame[5]);
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
if (this.require_render) {
try renderer.clear(this.size);
try this.renderFrame(renderer);
this.require_render = false;
}
switch ((&this.element).*) {
.layout => |*layout| {
try layout.render(renderer);
},
.widget => |*widget| {
try widget.render(renderer);
},
}
}
};
}

View File

@@ -1,187 +0,0 @@
//! Horizontal Container layout for nested `Layout`s and/or `Widget`s.
//! The contained elements are sized according to the provided configuration.
//! For an evenly spaced horizontal stacking see the `HStack` layout.
//!
//! # Example
//! ...
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Key = terminal.Key;
const log = std.log.scoped(.layout_hcontainer);
pub fn Layout(comptime Event: type, comptime Element: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!isTaggedUnion(Element)) {
@compileError("Provided type `Element` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[0].name, "layout")) {
@compileError("Expected `layout: Layout` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[0].name);
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[1].name, "widget")) {
@compileError("Expected `widget: Widget` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[1].name);
}
const Container = struct {
element: Element,
container_size: u8, // 0 - 100 %
};
const Containers = std.ArrayList(Container);
const LayoutType = @typeInfo(Element).@"union".fields[0].type;
const WidgetType = @typeInfo(Element).@"union".fields[1].type;
const Events = std.ArrayList(Event);
return struct {
// TODO: current focused `Element`?
allocator: std.mem.Allocator,
size: terminal.Size,
containers: Containers,
events: Events,
pub fn init(allocator: std.mem.Allocator, children: anytype) *@This() {
const ArgsType = @TypeOf(children);
const args_type_info = @typeInfo(ArgsType);
if (args_type_info != .@"struct") {
@compileError("expected tuple or struct argument, found " ++ @typeName(ArgsType));
}
comptime var total_size = 0;
const fields_info = args_type_info.@"struct".fields;
var containers = Containers.initCapacity(allocator, fields_info.len) catch @panic("HContainer.zig: out of memory");
inline for (comptime fields_info) |field| {
const child = @field(children, field.name);
const ChildType = @TypeOf(child);
const child_type_info = @typeInfo(ChildType);
if (child_type_info != .@"struct") {
@compileError("expected tuple or struct as child type, found " ++ @typeName(ChildType));
}
const child_fields = child_type_info.@"struct".fields;
if (child_fields.len != 2) {
@compileError("expected nested tuple or struct to have exactly 2 fields, but found " ++ child_fields.len);
}
const element = @field(child, child_fields[0].name);
const ElementType = @TypeOf(element);
const element_size = @field(child, child_fields[1].name);
const ElementSizeType = @TypeOf(element_size);
if (ElementSizeType != u8 and ElementSizeType != comptime_int) {
@compileError("expected an u8 or comptime_int as second argument of nested tuple or struct child, but found " ++ @typeName(ElementSizeType));
}
total_size += element_size;
if (total_size > 100) {
@compileError("cannot place element: " ++ child_fields[0].name ++ " as total size of used container elements would overflow");
}
if (ElementType == WidgetType) {
containers.append(.{
.element = .{ .widget = element },
.container_size = element_size,
}) catch {};
continue;
}
if (ElementType == LayoutType) {
containers.append(.{
.element = .{ .layout = element },
.container_size = element_size,
}) catch {};
continue;
}
@compileError("nested child: " ++ field.name ++ " is not of type " ++ @typeName(WidgetType) ++ " or " ++ @typeName(LayoutType) ++ " but " ++ @typeName(ChildType));
}
var this = allocator.create(@This()) catch @panic("HContainer.zig: Failed to create.");
this.allocator = allocator;
this.containers = containers;
this.events = Events.init(allocator);
return this;
}
pub fn deinit(this: *@This()) void {
this.events.deinit();
for (this.containers.items) |*container| {
switch (container.element) {
.layout => |*layout| {
layout.deinit();
},
.widget => |*widget| {
widget.deinit();
},
}
}
this.containers.deinit();
this.allocator.destroy(this);
}
pub fn handle(this: *@This(), event: Event) !*Events {
this.events.clearRetainingCapacity();
// order is important
switch (event) {
.resize => |size| {
this.size = size;
// adjust size according to the containing elements
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
// adjust size according to the container size
var offset: u16 = 0;
for (this.containers.items) |*container| {
const cols = @divTrunc(size.cols * container.container_size, 100);
const sub_event: Event = .{
.resize = .{
.anchor = .{
.col = size.anchor.col + offset,
.row = size.anchor.row,
},
.cols = cols,
.rows = size.rows,
},
};
offset += cols;
switch (container.element) {
.layout => |*layout| {
const events = try layout.handle(sub_event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(sub_event)) |e| {
try this.events.append(e);
}
},
}
}
},
else => {
for (this.containers.items) |*container| {
switch (container.element) {
.layout => |*layout| {
const events = try layout.handle(event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(event)) |e| {
try this.events.append(e);
}
},
}
}
},
}
return &this.events;
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
for (this.containers.items) |*container| {
switch (container.element) {
.layout => |*layout| {
try layout.render(renderer);
},
.widget => |*widget| {
try widget.render(renderer);
},
}
}
}
};
}

View File

@@ -1,162 +0,0 @@
//! Horizontal Stacking layout for nested `Layout`s and/or `Widget`s.
//!
//! # Example
//! ...
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Key = terminal.Key;
const log = std.log.scoped(.layout_hstack);
pub fn Layout(comptime Event: type, comptime Element: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!isTaggedUnion(Element)) {
@compileError("Provided type `Element` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[0].name, "layout")) {
@compileError("Expected `layout: Layout` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[0].name);
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[1].name, "widget")) {
@compileError("Expected `widget: Widget` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[1].name);
}
const Elements = std.ArrayList(Element);
const LayoutType = @typeInfo(Element).@"union".fields[0].type;
const WidgetType = @typeInfo(Element).@"union".fields[1].type;
const Events = std.ArrayList(Event);
return struct {
// TODO: current focused `Element`?
allocator: std.mem.Allocator,
size: terminal.Size,
elements: Elements,
events: Events,
pub fn init(allocator: std.mem.Allocator, children: anytype) *@This() {
const ArgsType = @TypeOf(children);
const args_type_info = @typeInfo(ArgsType);
if (args_type_info != .@"struct") {
@compileError("expected tuple or struct argument, found " ++ @typeName(ArgsType));
}
const fields_info = args_type_info.@"struct".fields;
var elements = Elements.initCapacity(allocator, fields_info.len) catch @panic("HStack.zig: out of memory");
inline for (comptime fields_info) |field| {
const child = @field(children, field.name);
const ChildType = @TypeOf(child);
if (ChildType == WidgetType) {
elements.append(.{ .widget = child }) catch {};
continue;
}
if (ChildType == LayoutType) {
elements.append(.{ .layout = child }) catch {};
continue;
}
@compileError("child: " ++ field.name ++ " is not of type " ++ @typeName(WidgetType) ++ " or " ++ @typeName(LayoutType) ++ " but " ++ @typeName(ChildType));
}
var this = allocator.create(@This()) catch @panic("HStack.zig: Failed to create.");
this.allocator = allocator;
this.elements = elements;
this.events = Events.init(allocator);
return this;
}
pub fn deinit(this: *@This()) void {
this.events.deinit();
for (this.elements.items) |*element| {
switch (element.*) {
.layout => |*layout| {
layout.deinit();
},
.widget => |*widget| {
widget.deinit();
},
}
}
this.elements.deinit();
this.allocator.destroy(this);
}
pub fn handle(this: *@This(), event: Event) !*Events {
this.events.clearRetainingCapacity();
// order is important
switch (event) {
.resize => |size| {
this.size = size;
// adjust size according to the containing elements
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
const len: u16 = @truncate(this.elements.items.len);
const element_cols = @divTrunc(size.cols, len);
var overflow = size.cols % len;
var offset: u16 = 0;
// adjust size according to the containing elements
for (this.elements.items) |*element| {
var cols = element_cols;
if (overflow > 0) {
overflow -|= 1;
cols += 1;
}
const sub_event: Event = .{
.resize = .{
.anchor = .{
.col = size.anchor.col + offset,
.row = size.anchor.row,
},
.cols = cols,
.rows = size.rows,
},
};
offset += cols;
switch (element.*) {
.layout => |*layout| {
const events = try layout.handle(sub_event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(sub_event)) |e| {
try this.events.append(e);
}
},
}
}
},
else => {
for (this.elements.items) |*element| {
switch (element.*) {
.layout => |*layout| {
const events = try layout.handle(event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(event)) |e| {
try this.events.append(e);
}
},
}
}
},
}
return &this.events;
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
for (this.elements.items) |*element| {
switch (element.*) {
.layout => |*layout| {
try layout.render(renderer);
},
.widget => |*widget| {
try widget.render(renderer);
},
}
}
}
};
}

View File

@@ -1,164 +0,0 @@
//! Margin layout for a nested `Layout`s or `Widget`s.
//!
//! # Example
//! ...
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Key = terminal.Key;
const log = std.log.scoped(.layout_margin);
pub fn Layout(comptime Event: type, comptime Element: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!isTaggedUnion(Element)) {
@compileError("Provided type `Element` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[0].name, "layout")) {
@compileError("Expected `layout: Layout` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[0].name);
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[1].name, "widget")) {
@compileError("Expected `widget: Widget` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[1].name);
}
const Events = std.ArrayList(Event);
return struct {
allocator: std.mem.Allocator,
size: terminal.Size,
require_render: bool,
element: Element,
events: Events,
config: Config,
const Config = struct {
margin: ?u8 = null,
left: u8 = 0,
right: u8 = 0,
top: u8 = 0,
bottom: u8 = 0,
};
pub fn init(allocator: std.mem.Allocator, config: Config, element: Element) *@This() {
if (config.margin) |margin| {
std.debug.assert(margin <= 50);
} else {
std.debug.assert(config.left + config.right < 100);
std.debug.assert(config.top + config.bottom < 100);
}
var this = allocator.create(@This()) catch @panic("Margin.zig: Failed to create.");
this.allocator = allocator;
this.require_render = true;
this.config = config;
this.element = element;
this.events = Events.init(allocator);
return this;
}
pub fn deinit(this: *@This()) void {
this.events.deinit();
switch ((&this.element).*) {
.layout => |*layout| {
layout.deinit();
},
.widget => |*widget| {
widget.deinit();
},
}
this.allocator.destroy(this);
}
pub fn handle(this: *@This(), event: Event) !*Events {
this.events.clearRetainingCapacity();
// order is important
switch (event) {
.resize => |size| {
this.size = size;
this.require_render = true;
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
var sub_event: Event = undefined;
if (this.config.margin) |margin| {
// used overall margin
const h_margin: u16 = @divTrunc(margin * size.cols, 100);
const v_margin: u16 = @divFloor(margin * size.rows, 100);
sub_event = .{
.resize = .{
.anchor = .{
.col = size.anchor.col + h_margin,
.row = size.anchor.row + v_margin,
},
.cols = size.cols -| (h_margin * 2),
.rows = size.rows -| (v_margin * 2),
},
};
} else {
// use all for directions individually
const left_margin: u16 = @divFloor(this.config.left * size.cols, 100);
const right_margin: u16 = @divFloor(this.config.right * size.cols, 100);
const top_margin: u16 = @divFloor(this.config.top * size.rows, 100);
const bottom_margin: u16 = @divFloor(this.config.bottom * size.rows, 100);
sub_event = .{
.resize = .{
.anchor = .{
.col = size.anchor.col + left_margin,
.row = size.anchor.row + top_margin,
},
.cols = size.cols -| left_margin -| right_margin,
.rows = size.rows -| top_margin -| bottom_margin,
},
};
}
// adjust size according to the containing elements
switch ((&this.element).*) {
.layout => |*layout| {
const events = try layout.handle(sub_event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(sub_event)) |e| {
try this.events.append(e);
}
},
}
},
else => {
switch ((&this.element).*) {
.layout => |*layout| {
const events = try layout.handle(event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(event)) |e| {
try this.events.append(e);
}
},
}
},
}
return &this.events;
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
if (this.require_render) {
try renderer.clear(this.size);
this.require_render = false;
}
switch ((&this.element).*) {
.layout => |*layout| {
try layout.render(renderer);
},
.widget => |*widget| {
try widget.render(renderer);
},
}
}
};
}

View File

@@ -1,152 +0,0 @@
//! Padding layout for a nested `Layout`s or `Widget`s.
//!
//! # Example
//! ...
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Key = terminal.Key;
const log = std.log.scoped(.layout_padding);
pub fn Layout(comptime Event: type, comptime Element: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!isTaggedUnion(Element)) {
@compileError("Provided type `Element` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[0].name, "layout")) {
@compileError("Expected `layout: Layout` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[0].name);
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[1].name, "widget")) {
@compileError("Expected `widget: Widget` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[1].name);
}
const Events = std.ArrayList(Event);
return struct {
allocator: std.mem.Allocator,
size: terminal.Size,
require_render: bool,
element: Element,
events: Events,
config: Config,
const Config = struct {
padding: ?u16 = null,
left: u16 = 0,
right: u16 = 0,
top: u16 = 0,
bottom: u16 = 0,
};
pub fn init(allocator: std.mem.Allocator, config: Config, element: Element) *@This() {
var this = allocator.create(@This()) catch @panic("Padding.zig: Failed to create.");
this.allocator = allocator;
this.require_render = true;
this.config = config;
this.element = element;
this.events = Events.init(allocator);
return this;
}
pub fn deinit(this: *@This()) void {
this.events.deinit();
switch ((&this.element).*) {
.layout => |*layout| {
layout.deinit();
},
.widget => |*widget| {
widget.deinit();
},
}
this.allocator.destroy(this);
}
pub fn handle(this: *@This(), event: Event) !*Events {
this.events.clearRetainingCapacity();
// order is important
switch (event) {
.resize => |size| {
this.size = size;
this.require_render = true;
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
var sub_event: Event = undefined;
if (this.config.padding) |padding| {
// used overall padding
sub_event = .{
.resize = .{
.anchor = .{
.col = size.anchor.col + padding,
.row = size.anchor.row + padding,
},
.cols = size.cols -| (padding * 2),
.rows = size.rows -| (padding * 2),
},
};
} else {
// use all for directions individually
sub_event = .{
.resize = .{
.anchor = .{
.col = size.anchor.col + this.config.left,
.row = size.anchor.row + this.config.top,
},
.cols = size.cols -| this.config.left -| this.config.right,
.rows = size.rows -| this.config.top -| this.config.bottom,
},
};
}
// adjust size according to the containing elements
switch ((&this.element).*) {
.layout => |*layout| {
const events = try layout.handle(sub_event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(sub_event)) |e| {
try this.events.append(e);
}
},
}
},
else => {
switch ((&this.element).*) {
.layout => |*layout| {
const events = try layout.handle(event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(event)) |e| {
try this.events.append(e);
}
},
}
},
}
return &this.events;
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
if (this.require_render) {
try renderer.clear(this.size);
this.require_render = false;
}
switch ((&this.element).*) {
.layout => |*layout| {
try layout.render(renderer);
},
.widget => |*widget| {
try widget.render(renderer);
},
}
}
};
}

View File

@@ -1,314 +0,0 @@
//! Tab layout for a nested `Layout`s or `Widget`s.
//!
//! # Example
//! ...
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Key = terminal.Key;
const Cell = terminal.Cell;
const Style = Cell.Style;
const Color = Style.Color;
const log = std.log.scoped(.layout_tab);
pub fn Layout(comptime Event: type, comptime Element: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!isTaggedUnion(Element)) {
@compileError("Provided type `Element` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[0].name, "layout")) {
@compileError("Expected `layout: Layout` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[0].name);
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[1].name, "widget")) {
@compileError("Expected `widget: Widget` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[1].name);
}
const Events = std.ArrayList(Event);
const LayoutType = @typeInfo(Element).@"union".fields[0].type;
const WidgetType = @typeInfo(Element).@"union".fields[1].type;
const Tab = struct {
element: Element,
title: []const u8,
color: Color,
};
const Tabs = std.ArrayList(Tab);
return struct {
allocator: std.mem.Allocator,
size: terminal.Size,
require_render: bool,
tabs: Tabs,
active_tab: usize,
events: Events,
config: Config,
const Config = struct {
frame: Frame = .round,
const Frame = enum {
round,
square,
};
};
pub fn init(allocator: std.mem.Allocator, config: Config, children: anytype) *@This() {
const ArgsType = @TypeOf(children);
const args_type_info = @typeInfo(ArgsType);
if (args_type_info != .@"struct") {
@compileError("expected tuple or struct argument, found " ++ @typeName(ArgsType));
}
const fields_info = args_type_info.@"struct".fields;
var tabs = Tabs.initCapacity(allocator, fields_info.len) catch @panic("Tab.zig: out of memory");
inline for (comptime fields_info) |field| {
const child = @field(children, field.name);
const ChildType = @TypeOf(child);
const child_type_info = @typeInfo(ChildType);
if (child_type_info != .@"struct") {
@compileError("expected tuple or struct as child type, found " ++ @typeName(ChildType));
}
const child_fields = child_type_info.@"struct".fields;
if (child_fields.len != 3) {
@compileError("expected nested tuple or struct to have exactly 3 fields, but found " ++ child_fields.len);
}
const element = @field(child, child_fields[0].name);
const ElementType = @TypeOf(element);
const tab_title = @field(child, child_fields[1].name);
const TabTitleType = @TypeOf(tab_title);
const tab_title_type_info = @typeInfo(TabTitleType);
const tab_color = @field(child, child_fields[2].name);
const TabColorType = @TypeOf(tab_color);
if (tab_title_type_info != .array and tab_title_type_info != .pointer) {
// TODO: check for inner type of the title to be u8
@compileError("expected an u8 array second argument of nested tuple or struct child, but found " ++ @tagName(tab_title_type_info));
}
if (TabColorType != Color) {
@compileError("expected an Color typed third argument of nested tuple or struct child, but found " ++ @typeName(TabColorType));
}
if (ElementType == WidgetType) {
tabs.append(.{
.element = .{ .widget = element },
.title = tab_title,
.color = tab_color,
}) catch {};
continue;
}
if (ElementType == LayoutType) {
tabs.append(.{
.element = .{ .layout = element },
.title = tab_title,
.color = tab_color,
}) catch {};
continue;
}
@compileError("nested child: " ++ field.name ++ " is not of type " ++ @typeName(WidgetType) ++ " or " ++ @typeName(LayoutType) ++ " but " ++ @typeName(ChildType));
}
var this = allocator.create(@This()) catch @panic("Tab.zig: Failed to create.");
this.allocator = allocator;
this.active_tab = 0;
this.require_render = true;
this.config = config;
this.tabs = tabs;
this.events = Events.init(allocator);
return this;
}
pub fn deinit(this: *@This()) void {
this.events.deinit();
for (this.tabs.items) |*tab| {
switch (tab.element) {
.layout => |*layout| {
layout.deinit();
},
.widget => |*widget| {
widget.deinit();
},
}
}
this.tabs.deinit();
this.allocator.destroy(this);
}
fn resize_active_tab(this: *@This()) !void {
const sub_event: Event = .{
.resize = .{
.anchor = .{
.col = this.size.anchor.col + 1,
.row = this.size.anchor.row + 1,
},
.cols = this.size.cols -| 2,
.rows = this.size.rows -| 2,
},
};
// resize active tab to re-render the widget in the following render loop
var tab = this.tabs.items[this.active_tab];
switch ((&tab.element).*) {
.layout => |*layout| {
const events = try layout.handle(sub_event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(sub_event)) |e| {
try this.events.append(e);
}
},
}
}
pub fn handle(this: *@This(), event: Event) !*Events {
this.events.clearRetainingCapacity();
if (this.tabs.items.len == 0) {
return &this.events;
}
// order is important
switch (event) {
.resize => |size| {
this.size = size;
this.require_render = true;
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
try this.resize_active_tab();
},
.key => |key| {
// tab -> cycle forward
// back-tab -> cycle backward
if (key.matches(.{ .cp = Key.tab })) {
this.active_tab += 1;
this.active_tab %= this.tabs.items.len;
this.require_render = true;
try this.resize_active_tab();
} else if (key.matches(.{ .cp = Key.tab, .mod = .{ .shift = true } })) { // backtab / shift + tab
if (this.active_tab > 0) {
this.active_tab -|= 1;
} else {
this.active_tab = this.tabs.items.len - 1;
}
this.require_render = true;
try this.resize_active_tab();
} else {
// TODO: absorb tab key or send key down too?
var tab = this.tabs.items[this.active_tab];
switch ((&tab.element).*) {
.layout => |*layout| {
const events = try layout.handle(event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(event)) |e| {
try this.events.append(e);
}
},
}
}
},
else => {
// NOTE: should this only send the event to the 'active_tab'
var tab = this.tabs.items[this.active_tab];
switch ((&tab.element).*) {
.layout => |*layout| {
const events = try layout.handle(event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(event)) |e| {
try this.events.append(e);
}
},
}
},
}
return &this.events;
}
const round_frame = .{ "", "", "", "", "", "" };
const square_frame = .{ "", "", "", "", "", "" };
fn renderFrame(this: *@This(), renderer: *Renderer) !void {
// FIXME: use renderer instead!
_ = renderer;
const frame = switch (this.config.frame) {
.round => round_frame,
.square => square_frame,
};
std.debug.assert(frame.len == 6);
// render top: +---+
try terminal.setCursorPosition(this.size.anchor);
const writer = terminal.writer();
var style: Style = .{ .fg = this.tabs.items[this.active_tab].color };
try style.value(writer, frame[0]);
var tab_title_len: usize = 0;
for (this.tabs.items, 0..) |tab, idx| {
var tab_style: Cell.Style = .{
.fg = tab.color,
.bg = .default,
};
if (idx == this.active_tab) {
tab_style.fg = .default;
tab_style.bg = tab.color;
}
const cell: Cell = .{
.content = tab.title,
.style = tab_style,
};
try cell.value(writer, 0, tab.title.len);
tab_title_len += tab.title.len;
}
for (0..this.size.cols -| 2 -| tab_title_len) |_| {
try style.value(writer, frame[1]);
}
try style.value(writer, frame[2]);
// render left: |
for (1..this.size.rows -| 1) |r| {
const row: u16 = @truncate(r);
try terminal.setCursorPosition(.{
.col = this.size.anchor.col,
.row = this.size.anchor.row + row,
});
try style.value(writer, frame[3]);
}
// render right: |
for (1..this.size.rows -| 1) |r| {
const row: u16 = @truncate(r);
try terminal.setCursorPosition(.{
.col = this.size.anchor.col + this.size.cols -| 1,
.row = this.size.anchor.row + row,
});
try style.value(writer, frame[3]);
}
// render bottom: +---+
try terminal.setCursorPosition(.{
.col = this.size.anchor.col,
.row = this.size.anchor.row + this.size.rows - 1,
});
try style.value(writer, frame[4]);
for (0..this.size.cols -| 2) |_| {
try style.value(writer, frame[1]);
}
try style.value(writer, frame[5]);
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
if (this.require_render) {
try renderer.clear(this.size);
try this.renderFrame(renderer);
this.require_render = false;
}
var tab = this.tabs.items[this.active_tab];
switch ((&tab.element).*) {
.layout => |*layout| {
try layout.render(renderer);
},
.widget => |*widget| {
try widget.render(renderer);
},
}
}
};
}

View File

@@ -1,187 +0,0 @@
//! Vertical Container layout for nested `Layout`s and/or `Widget`s.
//! The contained elements are sized according to the provided configuration.
//! For an evenly spaced vertical stacking see the `VStack` layout.
//!
//! # Example
//! ...
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Key = terminal.Key;
const log = std.log.scoped(.layout_vcontainer);
pub fn Layout(comptime Event: type, comptime Element: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!isTaggedUnion(Element)) {
@compileError("Provided type `Element` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[0].name, "layout")) {
@compileError("Expected `layout: Layout` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[0].name);
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[1].name, "widget")) {
@compileError("Expected `widget: Widget` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[1].name);
}
const Container = struct {
element: Element,
container_size: u8, // 0 - 100 %
};
const Containers = std.ArrayList(Container);
const LayoutType = @typeInfo(Element).@"union".fields[0].type;
const WidgetType = @typeInfo(Element).@"union".fields[1].type;
const Events = std.ArrayList(Event);
return struct {
// TODO: current focused `Element`?
allocator: std.mem.Allocator,
size: terminal.Size,
containers: Containers,
events: Events,
pub fn init(allocator: std.mem.Allocator, children: anytype) *@This() {
const ArgsType = @TypeOf(children);
const args_type_info = @typeInfo(ArgsType);
if (args_type_info != .@"struct") {
@compileError("expected tuple or struct argument, found " ++ @typeName(ArgsType));
}
comptime var total_size = 0;
const fields_info = args_type_info.@"struct".fields;
var containers = Containers.initCapacity(allocator, fields_info.len) catch @panic("VContainer.zig: out of memory");
inline for (comptime fields_info) |field| {
const child = @field(children, field.name);
const ChildType = @TypeOf(child);
const child_type_info = @typeInfo(ChildType);
if (child_type_info != .@"struct") {
@compileError("expected tuple or struct as child type, found " ++ @typeName(ChildType));
}
const child_fields = child_type_info.@"struct".fields;
if (child_fields.len != 2) {
@compileError("expected nested tuple or struct to have exactly 2 fields, but found " ++ child_fields.len);
}
const element = @field(child, child_fields[0].name);
const ElementType = @TypeOf(element);
const element_size = @field(child, child_fields[1].name);
const ElementSizeType = @TypeOf(element_size);
if (ElementSizeType != u8 and ElementSizeType != comptime_int) {
@compileError("expected an u8 or comptime_int as second argument of nested tuple or struct child, but found " ++ @typeName(ElementSizeType));
}
total_size += element_size;
if (total_size > 100) {
@compileError("cannot place element: " ++ child_fields[0].name ++ " as total size of used container elements would overflow");
}
if (ElementType == WidgetType) {
containers.append(.{
.element = .{ .widget = element },
.container_size = element_size,
}) catch {};
continue;
}
if (ElementType == LayoutType) {
containers.append(.{
.element = .{ .layout = element },
.container_size = element_size,
}) catch {};
continue;
}
@compileError("nested child: " ++ field.name ++ " is not of type " ++ @typeName(WidgetType) ++ " or " ++ @typeName(LayoutType) ++ " but " ++ @typeName(ChildType));
}
var this = allocator.create(@This()) catch @panic("VContainer.zig: Failed to create.");
this.allocator = allocator;
this.containers = containers;
this.events = Events.init(allocator);
return this;
}
pub fn deinit(this: *@This()) void {
this.events.deinit();
for (this.containers.items) |*container| {
switch (container.element) {
.layout => |*layout| {
layout.deinit();
},
.widget => |*widget| {
widget.deinit();
},
}
}
this.containers.deinit();
this.allocator.destroy(this);
}
pub fn handle(this: *@This(), event: Event) !*Events {
this.events.clearRetainingCapacity();
// order is important
switch (event) {
.resize => |size| {
this.size = size;
// adjust size according to the containing elements
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
// adjust size according to the container size
var offset: u16 = 0;
for (this.containers.items) |*container| {
const rows = @divTrunc(size.rows * container.container_size, 100);
const sub_event: Event = .{
.resize = .{
.anchor = .{
.col = size.anchor.col,
.row = size.anchor.row + offset,
},
.cols = size.cols,
.rows = rows,
},
};
offset += rows;
switch (container.element) {
.layout => |*layout| {
const events = try layout.handle(sub_event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(sub_event)) |e| {
try this.events.append(e);
}
},
}
}
},
else => {
for (this.containers.items) |*container| {
switch (container.element) {
.layout => |*layout| {
const events = try layout.handle(event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(event)) |e| {
try this.events.append(e);
}
},
}
}
},
}
return &this.events;
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
for (this.containers.items) |*container| {
switch (container.element) {
.layout => |*layout| {
try layout.render(renderer);
},
.widget => |*widget| {
try widget.render(renderer);
},
}
}
}
};
}

View File

@@ -1,161 +0,0 @@
//! Vertical Stacking layout for nested `Layout`s and/or `Widget`s.
//!
//! # Example
//! ...
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Key = terminal.Key;
const log = std.log.scoped(.layout_vstack);
pub fn Layout(comptime Event: type, comptime Element: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!isTaggedUnion(Element)) {
@compileError("Provided type `Element` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[0].name, "layout")) {
@compileError("Expected `layout: Layout` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[0].name);
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[1].name, "widget")) {
@compileError("Expected `widget: Widget` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[1].name);
}
const Elements = std.ArrayList(Element);
const LayoutType = @typeInfo(Element).@"union".fields[0].type;
const WidgetType = @typeInfo(Element).@"union".fields[1].type;
const Events = std.ArrayList(Event);
return struct {
// TODO: current focused `Element`?
allocator: std.mem.Allocator,
size: terminal.Size,
elements: Elements,
events: Events,
pub fn init(allocator: std.mem.Allocator, children: anytype) *@This() {
const ArgsType = @TypeOf(children);
const args_type_info = @typeInfo(ArgsType);
if (args_type_info != .@"struct") {
@compileError("expected tuple or struct argument, found " ++ @typeName(ArgsType));
}
const fields_info = args_type_info.@"struct".fields;
var elements = Elements.initCapacity(allocator, fields_info.len) catch @panic("VStack.zig out of memory");
inline for (comptime fields_info) |field| {
const child = @field(children, field.name);
const ChildType = @TypeOf(child);
if (ChildType == WidgetType) {
elements.append(.{ .widget = child }) catch {};
continue;
}
if (ChildType == LayoutType) {
elements.append(.{ .layout = child }) catch {};
continue;
}
@compileError("child: " ++ field.name ++ " is not of type " ++ @typeName(WidgetType) ++ " or " ++ @typeName(LayoutType) ++ " but " ++ @typeName(ChildType));
}
var this = allocator.create(@This()) catch @panic("VStack.zig: Failed to create.");
this.allocator = allocator;
this.elements = elements;
this.events = Events.init(allocator);
return this;
}
pub fn deinit(this: *@This()) void {
this.events.deinit();
for (this.elements.items) |*element| {
switch (element.*) {
.layout => |*layout| {
layout.deinit();
},
.widget => |*widget| {
widget.deinit();
},
}
}
this.elements.deinit();
this.allocator.destroy(this);
}
pub fn handle(this: *@This(), event: Event) !*Events {
this.events.clearRetainingCapacity();
// order is important
switch (event) {
.resize => |size| {
this.size = size;
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
const len: u16 = @truncate(this.elements.items.len);
const element_rows = @divTrunc(size.rows, len);
var overflow = size.rows % len;
var offset: u16 = 0;
// adjust size according to the containing elements
for (this.elements.items) |*element| {
var rows = element_rows;
if (overflow > 0) {
overflow -|= 1;
rows += 1;
}
const sub_event: Event = .{
.resize = .{
.anchor = .{
.col = size.anchor.col,
.row = size.anchor.row + offset,
},
.cols = size.cols,
.rows = rows,
},
};
offset += rows;
switch (element.*) {
.layout => |*layout| {
const events = try layout.handle(sub_event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(sub_event)) |e| {
try this.events.append(e);
}
},
}
}
},
else => {
for (this.elements.items) |*element| {
switch (element.*) {
.layout => |*layout| {
const events = try layout.handle(event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(event)) |e| {
try this.events.append(e);
}
},
}
}
},
}
return &this.events;
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
for (this.elements.items) |*element| {
switch (element.*) {
.layout => |*layout| {
try layout.render(renderer);
},
.widget => |*widget| {
try widget.render(renderer);
},
}
}
}
};
}

171
src/main.zig Normal file
View File

@@ -0,0 +1,171 @@
const std = @import("std");
const zterm = @import("zterm");
const App = zterm.App(union(enum) {});
const Key = zterm.Key;
const log = std.log.scoped(.default);
pub const HelloWorldText = packed struct {
const text = "Hello World";
text_color: zterm.Color = .black,
// example function to create the interface instance for this `Element` implementation
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
.vtable = &.{
.handle = handle,
.content = content,
},
};
}
// example function to render contents for a `Container`
fn content(ctx: *anyopaque, cells: []zterm.Cell, size: zterm.Size) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
std.debug.assert(cells.len == @as(usize, size.cols) * @as(usize, size.rows));
// NOTE: error should only be returned here in case an in-recoverable exception has occurred
const row = size.rows / 2;
const col = size.cols / 2 -| (text.len / 2);
for (0.., text) |idx, char| {
cells[(row * size.cols) + col + idx].style.fg = this.text_color;
cells[(row * size.cols) + col + idx].cp = char;
}
}
// example function to handle events for a `Container`
fn handle(ctx: *anyopaque, event: App.Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.init => log.debug(".init event", .{}),
.key => |key| {
if (key.matches(.{ .cp = zterm.Key.space })) {
var next_color_idx = @intFromEnum(this.text_color);
next_color_idx += 1;
next_color_idx %= 17; // iterate over the first 16 colors (but exclude `.default` == 0)
if (next_color_idx == 0) next_color_idx += 1;
this.text_color = @enumFromInt(next_color_idx);
log.debug("Next color: {s}", .{@tagName(this.text_color)});
}
},
else => {},
}
}
};
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.GeneralPurposeAllocator(.{}) = .init;
defer {
const deinit_status = gpa.deinit();
if (deinit_status == .leak) {
log.err("memory leak", .{});
}
}
const allocator = gpa.allocator();
var app: App = .init;
var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit();
var element_wrapper: HelloWorldText = .{};
const element = element_wrapper.element();
var container = try App.Container.init(allocator, .{
.border = .{
.separator = .{
.enabled = true,
.line = .double,
},
},
.layout = .{
.gap = 2,
.padding = .all(5),
.direction = .vertical,
},
}, .{});
var box = try App.Container.init(allocator, .{
.rectangle = .{ .fill = .blue },
.layout = .{
.gap = 1,
.direction = .vertical,
.padding = .vertical(1),
},
}, .{});
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
}, .{}));
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
}, element));
try box.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .light_green },
}, .{}));
try container.append(box);
try container.append(try App.Container.init(allocator, .{
.border = .{
.color = .light_blue,
.sides = .vertical,
},
}, .{}));
try container.append(try App.Container.init(allocator, .{
.rectangle = .{ .fill = .blue },
}, .{}));
defer container.deinit(); // also de-initializes the children
try app.start();
defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err});
// event loop
while (true) {
const event = app.nextEvent();
log.debug("received event: {s}", .{@tagName(event)});
switch (event) {
.init => {
try container.handle(event);
continue; // do not render
},
.quit => break,
.resize => |size| try renderer.resize(size),
.key => |key| {
if (key.matches(.{ .cp = 'q' })) app.quit();
// corresponding element could even be changed from the 'outside' (not forced through the event system)
// event system however allows for cross element communication (i.e. broadcasting messages, etc.)
if (key.matches(.{ .cp = zterm.Key.escape })) element_wrapper.text_color = .black;
if (key.matches(.{ .cp = 'n', .mod = .{ .ctrl = true } })) {
try app.interrupt();
defer app.start() catch @panic("could not start app event loop");
var child = std.process.Child.init(&.{"hx"}, allocator);
_ = child.spawnAndWait() catch |err| app.postEvent(.{
.err = .{
.err = err,
.msg = "Spawning $EDITOR failed",
},
});
}
},
// 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(event) catch |err| app.postEvent(.{
.err = .{
.err = err,
.msg = "Container Event handling failed",
},
});
try renderer.render(@TypeOf(container), &container);
try renderer.flush();
}
}

0
src/properties.zig Normal file
View File

View File

@@ -1,108 +1,115 @@
//! Renderer which holds the screen to compare with the previous screen for efficient rendering.
//! Each renderer should at least implement these functions:
//! - resize(this: *@This(), size: Size) void {}
//! - clear(this: *@This(), size: Size) !void {}
//! - render(this: *@This(), size: Size, contents: []u8) !void {}
//!
//! Each `Renderer` should be able to be used interchangeable without having to
//! change any code of any `Layout` or `Widget`. The only change should be the
//! passed type to `zterm.App` _R_ parameter.
const std = @import("std"); const std = @import("std");
const terminal = @import("terminal.zig"); const terminal = @import("terminal.zig");
const Cells = []const terminal.Cell; const Cell = @import("cell.zig");
const Position = terminal.Position; const Position = @import("size.zig").Position;
const Size = terminal.Size; const Size = @import("size.zig").Size;
pub fn Direct(comptime fullscreen: bool) type { /// Double-buffered intermediate rendering pipeline
const log = std.log.scoped(.renderer_direct); pub const Buffered = struct {
_ = log; const log = std.log.scoped(.renderer_buffered);
_ = fullscreen; // _ = log;
return struct { allocator: std.mem.Allocator,
size: Size = undefined, created: bool,
size: Size,
screen: []Cell,
virtual_screen: []Cell,
pub fn resize(this: *@This(), size: Size) void { pub fn init(allocator: std.mem.Allocator) @This() {
this.size = size; return .{
.allocator = allocator,
.created = false,
.size = undefined,
.screen = undefined,
.virtual_screen = undefined,
};
} }
pub fn clear(this: *@This(), size: Size) !void { pub fn deinit(this: *@This()) void {
_ = this; if (this.created) {
// NOTE: clear on the entire screen may introduce too much overhead and could instead clear the entire screen instead. this.allocator.free(this.screen);
// - it could also then try to optimize for further *clear* calls, that result in pretty much a nop? -> how to identify those clear calls? this.allocator.free(this.virtual_screen);
// TODO: this should instead by dynamic and correct of size (terminal could be too large currently)
std.debug.assert(1028 > size.cols);
var buf: [1028]u8 = undefined;
@memset(buf[0..], ' ');
for (0..size.rows) |r| {
const row: u16 = @truncate(r);
try terminal.setCursorPosition(.{
.col = size.anchor.col,
.row = size.anchor.row + row,
});
_ = try terminal.write(buf[0..size.cols]);
} }
} }
pub fn render(this: *@This(), size: Size, cells: Cells) !void { pub fn resize(this: *@This(), size: Size) !void {
_ = this; log.debug("renderer::resize", .{});
try terminal.setCursorPosition(size.anchor); defer this.size = size;
var row: u16 = 0;
var remaining_cols = size.cols; if (!this.created) {
const writer = terminal.writer(); this.screen = this.allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows)) catch @panic("render.zig: Out of memory.");
for (cells) |cell| { @memset(this.screen, .{});
this.virtual_screen = this.allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows)) catch @panic("render.zig: Out of memory.");
@memset(this.virtual_screen, .{});
this.created = true;
} else {
this.allocator.free(this.screen);
this.screen = this.allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows)) catch @panic("render.zig: Out of memory.");
@memset(this.screen, .{});
this.allocator.free(this.virtual_screen);
this.virtual_screen = this.allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows)) catch @panic("render.zig: Out of memory.");
@memset(this.virtual_screen, .{});
}
try this.clear();
}
/// Clear the entire screen and reset the screen buffer, to force a re-draw with the next `flush` call.
pub fn clear(this: *@This()) !void {
log.debug("renderer::clear", .{});
try terminal.clearScreen();
@memset(this.screen, .{});
}
/// Render provided cells at size (anchor and dimension) into the *virtual screen*.
pub fn render(this: *@This(), comptime T: type, container: *T) !void {
const size: Size = container.size;
const cells: []const Cell = try container.contents();
if (cells.len == 0) return;
var idx: usize = 0; var idx: usize = 0;
print_cell: while (true) { var vs = this.virtual_screen;
const cell_len = cell.len(idx); const anchor: usize = (@as(usize, size.anchor.row) * @as(usize, this.size.cols)) + @as(usize, size.anchor.col);
if (cell_len > remaining_cols) {
const result = try cell.writeUpToNewline(writer, idx, idx + remaining_cols); blk: for (0..size.rows) |row| {
row += 1; for (0..size.cols) |col| {
if (row >= size.rows) { const cell = cells[idx];
return; // we are done idx += 1;
}
try terminal.setCursorPosition(.{ vs[anchor + (row * this.size.cols) + col].style = cell.style;
.col = size.anchor.col, vs[anchor + (row * this.size.cols) + col].cp = cell.cp;
.row = size.anchor.row + row,
}); if (cells.len == idx) break :blk;
remaining_cols = size.cols;
idx = result.idx;
if (result.newline) {
idx += 1; // skip over newline
} else {
// there is still content to the newline (which will not be printed)
for (idx..cell.content.len) |i| {
if (cell.content[i] == '\n') {
idx = i + 1;
continue :print_cell;
} }
} }
break; // go to next cell (as we went to the end of the cell and do not print on the next line) // free immediately
} container.allocator.free(cells);
} else {
// print rest of cell for (container.elements.items) |*element| {
const result = try cell.writeUpToNewline(writer, idx, idx + cell_len); try this.render(T, element);
if (result.newline) {
row += 1;
if (row >= size.rows) {
return; // we are done
}
try terminal.setCursorPosition(.{
.col = size.anchor.col,
.row = size.anchor.row + row,
});
remaining_cols = size.cols;
idx = result.idx + 1; // skip over newline
} else {
remaining_cols -= @truncate(cell_len -| idx);
idx = 0;
break; // go to next cell
} }
} }
// written all cell contents
if (idx >= cell.content.len) { /// Write *virtual screen* to alternate screen (should be called once and last during each render loop iteration in the main loop).
break; // go to next cell pub fn flush(this: *@This()) !void {
} // TODO: measure timings of rendered frames?
log.debug("renderer::flush", .{});
const writer = terminal.writer();
const s = this.screen;
const vs = this.virtual_screen;
for (0..this.size.rows) |row| {
for (0..this.size.cols) |col| {
const idx = (row * this.size.cols) + col;
const cs = s[idx];
const cvs = vs[idx];
if (cs.eql(cvs)) continue;
// render differences found in virtual screen
try terminal.setCursorPosition(.{ .row = @truncate(row + 1), .col = @truncate(col + 1) });
try cvs.value(writer);
// update screen to be the virtual screen for the next frame
s[idx] = vs[idx];
} }
} }
} }
}; };
}

10
src/size.zig Normal file
View File

@@ -0,0 +1,10 @@
pub const Size = packed struct {
anchor: Position = .{},
cols: u16 = 0,
rows: u16 = 0,
};
pub const Position = packed struct {
col: u16 = 0,
row: u16 = 0,
};

73
src/style.zig Normal file
View File

@@ -0,0 +1,73 @@
//! Helper function collection to provide ascii encodings for styling outputs.
//! Stylings are implemented such that they can be nested in anyway to support
//! multiple styles (i.e. bold and italic).
//!
//! Stylings however also include highlighting for specific terminal capabilities.
//! For example url highlighting.
// taken from https://github.com/rockorager/libvaxis/blob/main/src/Cell.zig (MIT-License)
// with slight modifications
const std = @import("std");
const Color = @import("color.zig").Color;
pub const Style = @This();
pub const Underline = enum {
off,
single,
double,
curly,
dotted,
dashed,
};
pub const Attribute = enum(u8) {
reset = 0,
bold = 1,
dim,
italic,
underline,
blink,
invert = 7,
hidden,
strikethrough,
};
fg: Color = .white,
bg: Color = .default,
ul: Color = .default,
ul_style: Underline = .off,
attributes: []const Attribute,
pub fn eql(this: Style, other: Style) bool {
return std.meta.eql(this, other);
}
pub fn value(this: Style, writer: anytype, cp: u21) !void {
var buffer: [4]u8 = undefined;
const bytes = try std.unicode.utf8Encode(cp, &buffer);
std.debug.assert(bytes > 0);
// build ansi sequence for 256 colors ...
// foreground
try std.fmt.format(writer, "\x1b[", .{});
try this.fg.write(writer, .fg);
// background
try std.fmt.format(writer, ";", .{});
try this.bg.write(writer, .bg);
// underline
// FIX: assert that if the underline property is set that the ul style and the attribute for underlining is available
try std.fmt.format(writer, ";", .{});
try this.ul.write(writer, .ul);
// append styles (aka attributes like bold, italic, strikethrough, etc.)
for (this.attributes) |attribute| {
try std.fmt.format(writer, ";{d}", .{@intFromEnum(attribute)});
}
try std.fmt.format(writer, "m", .{});
// content
try std.fmt.format(writer, "{s}", .{buffer});
}
// TODO: implement helper functions for terminal capabilities:
// - links / url display (osc 8)
// - show / hide cursor?

View File

@@ -1,10 +1,11 @@
const std = @import("std"); const std = @import("std");
pub const Key = @import("terminal/Key.zig");
pub const Size = @import("terminal/Size.zig");
pub const Position = @import("terminal/Position.zig");
pub const Cell = @import("terminal/Cell.zig");
pub const code_point = @import("code_point"); pub const code_point = @import("code_point");
const Key = @import("key.zig");
const Position = @import("size.zig").Position;
const Size = @import("size.zig").Size;
const Cell = @import("cell.zig");
const log = std.log.scoped(.terminal); const log = std.log.scoped(.terminal);
// Ref: https://vt100.net/docs/vt510-rm/DECRPM.html // Ref: https://vt100.net/docs/vt510-rm/DECRPM.html
@@ -84,7 +85,7 @@ pub fn setCursorPosition(pos: Position) !void {
_ = try std.posix.write(std.posix.STDIN_FILENO, value); _ = try std.posix.write(std.posix.STDIN_FILENO, value);
} }
pub fn getCursorPosition() !Position { pub fn getCursorPosition() !Size.Position {
// Needs Raw mode (no wait for \n) to work properly cause // Needs Raw mode (no wait for \n) to work properly cause
// control sequence will not be written without it. // control sequence will not be written without it.
_ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[6n"); _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[6n");
@@ -126,8 +127,8 @@ pub fn getCursorPosition() !Position {
} }
return .{ return .{
.row = try std.fmt.parseInt(u16, row[0..ridx], 10), .row = try std.fmt.parseInt(u16, row[0..ridx], 10) - 1,
.col = try std.fmt.parseInt(u16, col[0..cidx], 10), .col = try std.fmt.parseInt(u16, col[0..cidx], 10) - 1,
}; };
} }
@@ -227,7 +228,3 @@ fn getReportMode(ps: u8) ReportMode {
else => ReportMode.not_recognized, else => ReportMode.not_recognized,
}; };
} }
test {
_ = Cell;
}

View File

@@ -1,63 +0,0 @@
const std = @import("std");
pub const Style = @import("Style.zig");
style: Style = .{},
content: []const u8 = undefined,
pub const Result = struct {
idx: usize,
newline: bool,
};
pub fn len(this: @This(), start: usize) usize {
std.debug.assert(this.content.len > start);
return this.content[start..].len;
}
pub fn write(this: @This(), writer: anytype, start: usize, end: usize) !void {
std.debug.assert(this.content.len > start);
std.debug.assert(this.content.len >= end);
std.debug.assert(start < end);
try this.style.value(writer, this.content[start..end]);
}
pub fn writeUpToNewline(this: @This(), writer: anytype, start: usize, end: usize) !Result {
std.debug.assert(this.content.len > start);
std.debug.assert(this.content.len >= end);
std.debug.assert(start < end);
for (start..end) |i| {
if (this.content[i] == '\n') {
if (start < i) {
// this is just an empty line with a newline
try this.value(writer, start, i);
}
return .{
.idx = i,
.newline = true,
};
}
}
try this.write(writer, start, end);
return .{
.idx = end,
.newline = false,
};
}
pub fn value(this: @This(), writer: anytype, start: usize, end: usize) !void {
std.debug.assert(start < this.content.len);
std.debug.assert(this.content.len >= end);
std.debug.assert(start < end);
try this.style.value(writer, this.content[start..end]);
}
// not really supported
pub fn format(this: @This(), writer: anytype, comptime fmt: []const u8, args: anytype) !void {
try this.style.format(writer, fmt, args); // NOTE: args should contain this.content[start..end] or this.content
}
test {
_ = Style;
}

View File

@@ -1,2 +0,0 @@
col: u16,
row: u16,

View File

@@ -1,5 +0,0 @@
const Position = @import("Position.zig");
anchor: Position = .{ .col = 1, .row = 1 }, // top left corner by default
cols: u16,
rows: u16,

View File

@@ -1,305 +0,0 @@
//! Helper function collection to provide ascii encodings for styling outputs.
//! Stylings are implemented such that they can be nested in anyway to support
//! multiple styles (i.e. bold and italic).
//!
//! Stylings however also include highlighting for specific terminal capabilities.
//! For example url highlighting.
// taken from https://github.com/rockorager/libvaxis/blob/main/src/Cell.zig (MIT-License)
// with slight modifications
const std = @import("std");
const ctlseqs = @import("ctlseqs.zig");
pub const Underline = enum {
off,
single,
double,
curly,
dotted,
dashed,
};
pub const Color = union(enum) {
default,
index: u8,
rgb: [3]u8,
pub fn eql(a: @This(), b: @This()) bool {
switch (a) {
.default => return b == .default,
.index => |a_idx| {
switch (b) {
.index => |b_idx| return a_idx == b_idx,
else => return false,
}
},
.rgb => |a_rgb| {
switch (b) {
.rgb => |b_rgb| return a_rgb[0] == b_rgb[0] and
a_rgb[1] == b_rgb[1] and
a_rgb[2] == b_rgb[2],
else => return false,
}
},
}
}
pub fn rgbFromUint(val: u24) Color {
const r_bits = val & 0b11111111_00000000_00000000;
const g_bits = val & 0b00000000_11111111_00000000;
const b_bits = val & 0b00000000_00000000_11111111;
const rgb = [_]u8{
@truncate(r_bits >> 16),
@truncate(g_bits >> 8),
@truncate(b_bits),
};
return .{ .rgb = rgb };
}
/// parse an XParseColor-style rgb specification into an rgb Color. The spec
/// is of the form: rgb:rrrr/gggg/bbbb. Generally, the high two bits will always
/// be the same as the low two bits.
pub fn rgbFromSpec(spec: []const u8) !Color {
var iter = std.mem.splitScalar(u8, spec, ':');
const prefix = iter.next() orelse return error.InvalidColorSpec;
if (!std.mem.eql(u8, "rgb", prefix)) return error.InvalidColorSpec;
const spec_str = iter.next() orelse return error.InvalidColorSpec;
var spec_iter = std.mem.splitScalar(u8, spec_str, '/');
const r_raw = spec_iter.next() orelse return error.InvalidColorSpec;
if (r_raw.len != 4) return error.InvalidColorSpec;
const g_raw = spec_iter.next() orelse return error.InvalidColorSpec;
if (g_raw.len != 4) return error.InvalidColorSpec;
const b_raw = spec_iter.next() orelse return error.InvalidColorSpec;
if (b_raw.len != 4) return error.InvalidColorSpec;
const r = try std.fmt.parseUnsigned(u8, r_raw[2..], 16);
const g = try std.fmt.parseUnsigned(u8, g_raw[2..], 16);
const b = try std.fmt.parseUnsigned(u8, b_raw[2..], 16);
return .{
.rgb = [_]u8{ r, g, b },
};
}
test "rgbFromSpec" {
const spec = "rgb:aaaa/bbbb/cccc";
const actual = try rgbFromSpec(spec);
switch (actual) {
.rgb => |rgb| {
try std.testing.expectEqual(0xAA, rgb[0]);
try std.testing.expectEqual(0xBB, rgb[1]);
try std.testing.expectEqual(0xCC, rgb[2]);
},
else => try std.testing.expect(false),
}
}
};
fg: Color = .default,
bg: Color = .default,
ul: Color = .default,
ul_style: Underline = .off,
bold: bool = false,
dim: bool = false,
italic: bool = false,
blink: bool = false,
reverse: bool = false,
invisible: bool = false,
strikethrough: bool = false,
/// Merge _other_ `Style` to _this_ style and overwrite _this_ `Style`'s value
/// if the _other_ value differs from the default value.
pub fn merge(this: *@This(), other: @This()) void {
if (other.fg != .default) this.fg = other.fg;
if (other.bg != .default) this.bg = other.bg;
if (other.ul != .default) this.ul = other.ul;
if (other.ul_style != .off) this.ul_style = other.ul_style;
if (other.bold != false) this.bold = other.bold;
if (other.dim != false) this.dim = other.dim;
if (other.italic != false) this.italic = other.italic;
if (other.blink != false) this.blink = other.blink;
if (other.reverse != false) this.reverse = other.reverse;
if (other.invisible != false) this.invisible = other.invisible;
if (other.strikethrough != false) this.strikethrough = other.strikethrough;
}
fn start(this: @This(), writer: anytype) !void {
// foreground
switch (this.fg) {
.default => try std.fmt.format(writer, ctlseqs.fg_reset, .{}),
.index => |idx| {
switch (idx) {
0...7 => {
try std.fmt.format(writer, ctlseqs.fg_base, .{idx});
},
8...15 => {
try std.fmt.format(writer, ctlseqs.fg_bright, .{idx - 8});
},
else => {
try std.fmt.format(writer, ctlseqs.fg_indexed, .{idx});
},
}
},
.rgb => |rgb| {
try std.fmt.format(writer, ctlseqs.fg_rgb, .{ rgb[0], rgb[1], rgb[2] });
},
}
// background
switch (this.bg) {
.default => try std.fmt.format(writer, ctlseqs.bg_reset, .{}),
.index => |idx| {
switch (idx) {
0...7 => {
try std.fmt.format(writer, ctlseqs.bg_base, .{idx});
},
8...15 => {
try std.fmt.format(writer, ctlseqs.bg_bright, .{idx});
},
else => {
try std.fmt.format(writer, ctlseqs.bg_indexed, .{idx});
},
}
},
.rgb => |rgb| {
try std.fmt.format(writer, ctlseqs.bg_rgb, .{ rgb[0], rgb[1], rgb[2] });
},
}
// underline color
switch (this.ul) {
.default => try std.fmt.format(writer, ctlseqs.ul_reset, .{}),
.index => |idx| {
try std.fmt.format(writer, ctlseqs.ul_indexed, .{idx});
},
.rgb => |rgb| {
try std.fmt.format(writer, ctlseqs.ul_rgb, .{ rgb[0], rgb[1], rgb[2] });
},
}
// underline style
switch (this.ul_style) {
.off => try std.fmt.format(writer, ctlseqs.ul_off, .{}),
.single => try std.fmt.format(writer, ctlseqs.ul_single, .{}),
.double => try std.fmt.format(writer, ctlseqs.ul_double, .{}),
.curly => try std.fmt.format(writer, ctlseqs.ul_curly, .{}),
.dotted => try std.fmt.format(writer, ctlseqs.ul_dotted, .{}),
.dashed => try std.fmt.format(writer, ctlseqs.ul_dashed, .{}),
}
// bold
switch (this.bold) {
true => try std.fmt.format(writer, ctlseqs.bold_set, .{}),
false => try std.fmt.format(writer, ctlseqs.bold_dim_reset, .{}),
}
// dim
switch (this.dim) {
true => try std.fmt.format(writer, ctlseqs.dim_set, .{}),
false => try std.fmt.format(writer, ctlseqs.bold_dim_reset, .{}),
}
// italic
switch (this.italic) {
true => try std.fmt.format(writer, ctlseqs.italic_set, .{}),
false => try std.fmt.format(writer, ctlseqs.italic_reset, .{}),
}
// blink
switch (this.blink) {
true => try std.fmt.format(writer, ctlseqs.blink_set, .{}),
false => try std.fmt.format(writer, ctlseqs.blink_reset, .{}),
}
// reverse
switch (this.reverse) {
true => try std.fmt.format(writer, ctlseqs.reverse_set, .{}),
false => try std.fmt.format(writer, ctlseqs.reverse_reset, .{}),
}
// invisible
switch (this.invisible) {
true => try std.fmt.format(writer, ctlseqs.invisible_set, .{}),
false => try std.fmt.format(writer, ctlseqs.invisible_reset, .{}),
}
// strikethrough
switch (this.strikethrough) {
true => try std.fmt.format(writer, ctlseqs.strikethrough_set, .{}),
false => try std.fmt.format(writer, ctlseqs.strikethrough_reset, .{}),
}
}
fn end(this: @This(), writer: anytype) !void {
// foreground
switch (this.fg) {
.default => {},
else => try std.fmt.format(writer, ctlseqs.fg_reset, .{}),
}
// background
switch (this.bg) {
.default => {},
else => try std.fmt.format(writer, ctlseqs.bg_reset, .{}),
}
// underline color
switch (this.ul) {
.default => {},
else => try std.fmt.format(writer, ctlseqs.ul_reset, .{}),
}
// underline style
switch (this.ul_style) {
.off => {},
else => try std.fmt.format(writer, ctlseqs.ul_off, .{}),
}
// bold
switch (this.bold) {
true => try std.fmt.format(writer, ctlseqs.bold_dim_reset, .{}),
false => {},
}
// dim
switch (this.dim) {
true => try std.fmt.format(writer, ctlseqs.bold_dim_reset, .{}),
false => {},
}
// italic
switch (this.italic) {
true => try std.fmt.format(writer, ctlseqs.italic_reset, .{}),
false => {},
}
// blink
switch (this.blink) {
true => try std.fmt.format(writer, ctlseqs.blink_reset, .{}),
false => {},
}
// reverse
switch (this.reverse) {
true => try std.fmt.format(writer, ctlseqs.reverse_reset, .{}),
false => {},
}
// invisible
switch (this.invisible) {
true => try std.fmt.format(writer, ctlseqs.invisible_reset, .{}),
false => {},
}
// strikethrough
switch (this.strikethrough) {
true => try std.fmt.format(writer, ctlseqs.strikethrough_reset, .{}),
false => {},
}
}
pub fn format(this: @This(), writer: anytype, comptime content: []const u8, args: anytype) !void {
try this.start(writer);
try std.fmt.format(writer, content, args);
try this.end(writer);
}
pub fn value(this: @This(), writer: anytype, content: []const u8) !void {
try this.start(writer);
_ = try writer.write(content);
try this.end(writer);
}
// TODO: implement helper functions for terminal capabilities:
// - links / url display (osc 8)
// - show / hide cursor?
test {
_ = Color;
}

View File

@@ -1,116 +0,0 @@
//! Dynamic dispatch for view implementations. Each `View` has to implement the `View.Interface`
//!
//! Create a `View` using `createFrom(object: anytype)` and use them through
//! the defined `View.Interface`. The view will take care of calling the
//! correct implementation of the corresponding underlying type.
//!
//! A `View` holds the necessary `Layout`'s for different screen sizes as well
//! as the corresponding used `Widget`'s alongside holding the corresponding memory
//! for the data shown through the `View`.
const std = @import("std");
const isTaggedUnion = @import("event.zig").isTaggedUnion;
const log = std.log.scoped(.view);
pub fn View(comptime Event: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `View(comptime Event: type)` is not of type `union(enum)`.");
}
const Events = std.ArrayList(Event);
return struct {
const ViewType = @This();
pub const Interface = @import("interface").Interface(.{
.handle = fn (anytype, Event) anyerror!*Events,
.render = fn (anytype, *Renderer) anyerror!void,
.enable = fn (anytype) void,
.disable = fn (anytype) void,
.deinit = fn (anytype) void,
}, .{});
// TODO: this VTable creation and abstraction could maybe even be done through a comptime implementation -> another library?
const VTable = struct {
handle: *const fn (this: *ViewType, event: Event) anyerror!*Events,
render: *const fn (this: *ViewType, renderer: *Renderer) anyerror!void,
enable: *const fn (this: *ViewType) void,
disable: *const fn (this: *ViewType) void,
deinit: *const fn (this: *ViewType) void,
};
object: *anyopaque = undefined,
vtable: *const VTable = undefined,
/// Handle the provided `Event` for this `View`.
pub fn handle(this: *ViewType, event: Event) !*Events {
switch (event) {
.resize => |size| {
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
},
else => {},
}
return this.vtable.handle(this, event);
}
/// Render the content of this `View` given the `Size` of the available terminal (.resize System`Event`).
pub fn render(this: *ViewType, renderer: *Renderer) !void {
try this.vtable.render(this, renderer);
}
/// Function to call when this `View` will be handled and rendered as the 'active' `View`.
pub fn enable(this: *ViewType) void {
this.vtable.enable(this);
}
/// Function to call when this `View` will no longer be 'active'.
pub fn disable(this: *ViewType) void {
this.vtable.disable(this);
}
pub fn deinit(this: *ViewType) void {
this.vtable.deinit(this);
}
pub fn createFrom(object: anytype) ViewType {
return ViewType{
.object = @ptrCast(@alignCast(object)),
.vtable = &.{
.handle = struct {
fn handle(this: *ViewType, event: Event) !*Events {
const view: @TypeOf(object) = @ptrCast(@alignCast(this.object));
return view.handle(event);
}
}.handle,
.render = struct {
fn render(this: *ViewType, renderer: *Renderer) !void {
const view: @TypeOf(object) = @ptrCast(@alignCast(this.object));
try view.render(renderer);
}
}.render,
.enable = struct {
fn enable(this: *ViewType) void {
const view: @TypeOf(object) = @ptrCast(@alignCast(this.object));
view.enable();
}
}.enable,
.disable = struct {
fn disable(this: *ViewType) void {
const view: @TypeOf(object) = @ptrCast(@alignCast(this.object));
view.disable();
}
}.disable,
.deinit = struct {
fn deinit(this: *ViewType) void {
const view: @TypeOf(object) = @ptrCast(@alignCast(this.object));
view.deinit();
}
}.deinit,
},
};
}
};
}

View File

@@ -1,107 +0,0 @@
//! Dynamic dispatch for widget implementations. Each `Widget` has to implement
//! the `Widget.Interface`.
//!
//! Create a `Widget` using `createFrom(object: anytype)` and use them through
//! the defined `Widget.Interface`. The widget will take care of calling the
//! correct implementation of the corresponding underlying type.
//!
//! Each `Widget` may cache its content and should if the contents will not
//! change for a long time.
//!
//! When `Widget.render` is called the provided `Renderer` type is expected
//! which handles how contents are rendered for a given widget.
const isTaggedUnion = @import("event.zig").isTaggedUnion;
const log = @import("std").log.scoped(.widget);
pub fn Widget(comptime Event: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`.");
}
const Type = struct {
const WidgetType = @This();
pub const Interface = @import("interface").Interface(.{
.handle = fn (anytype, Event) ?Event,
.render = fn (anytype, *Renderer) anyerror!void,
.deinit = fn (anytype) void,
}, .{});
const VTable = struct {
handle: *const fn (this: *WidgetType, event: Event) ?Event,
render: *const fn (this: *WidgetType, renderer: *Renderer) anyerror!void,
deinit: *const fn (this: *WidgetType) void,
};
object: *anyopaque = undefined,
vtable: *const VTable = undefined,
// Handle the provided `Event` for this `Widget`.
pub fn handle(this: *WidgetType, event: Event) ?Event {
switch (event) {
.resize => |size| {
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
},
else => {},
}
return this.vtable.handle(this, event);
}
// Render the content of this `Widget` given the `Size` of the widget (.resize System`Event`).
pub fn render(this: *WidgetType, renderer: *Renderer) !void {
try this.vtable.render(this, renderer);
}
pub fn deinit(this: *WidgetType) void {
this.vtable.deinit(this);
}
pub fn createFrom(object: anytype) WidgetType {
return WidgetType{
.object = @ptrCast(@alignCast(object)),
.vtable = &.{
.handle = struct {
// Handle the provided `Event` for this `Widget`.
fn handle(this: *WidgetType, event: Event) ?Event {
const widget: @TypeOf(object) = @ptrCast(@alignCast(this.object));
return widget.handle(event);
}
}.handle,
.render = struct {
// Return the entire content of this `Widget`.
fn render(this: *WidgetType, renderer: *Renderer) !void {
const widget: @TypeOf(object) = @ptrCast(@alignCast(this.object));
try widget.render(renderer);
}
}.render,
.deinit = struct {
fn deinit(this: *WidgetType) void {
const widget: @TypeOf(object) = @ptrCast(@alignCast(this.object));
widget.deinit();
}
}.deinit,
},
};
}
// TODO: implement a minimal size requirement for Widgets to render correctly?
// import and export of `Widget` implementations
pub const Input = @import("widget/Input.zig").Widget(Event, Renderer);
pub const Text = @import("widget/Text.zig").Widget(Event, Renderer);
pub const RawText = @import("widget/RawText.zig").Widget(Event, Renderer);
pub const Spacer = @import("widget/Spacer.zig").Widget(Event, Renderer);
pub const List = @import("widget/List.zig").Widget(Event, Renderer);
};
// test widget implementation satisfies the interface
comptime Type.Interface.satisfiedBy(Type);
comptime Type.Interface.satisfiedBy(Type.Input);
comptime Type.Interface.satisfiedBy(Type.Text);
comptime Type.Interface.satisfiedBy(Type.RawText);
comptime Type.Interface.satisfiedBy(Type.Spacer);
comptime Type.Interface.satisfiedBy(Type.List);
return Type;
}

View File

@@ -1,250 +0,0 @@
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Cell = terminal.Cell;
const Key = terminal.Key;
const Size = terminal.Size;
const log = std.log.scoped(.widget_input);
pub fn Widget(comptime Event: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`.");
}
return struct {
active: bool,
allocator: std.mem.Allocator,
label: ?[]const u8,
placeholder: ?[]const u8,
size: Size,
require_render: bool,
value: std.ArrayList(u8),
/// value content length
value_len: usize,
/// current cursor position
cursor_idx: usize,
pub fn init(allocator: std.mem.Allocator, label: ?[]const u8, placeholder: ?[]const u8) *@This() {
var value = std.ArrayList(u8).init(allocator);
value.resize(32) catch @panic("Input.zig: out of memory");
var this = allocator.create(@This()) catch @panic("Input.zig: Failed to create.");
this.allocator = allocator;
this.active = false;
this.require_render = true;
this.label = null;
this.placeholder = null;
this.value_len = 0;
this.cursor_idx = 0;
this.value = value;
this.label = label;
this.placeholder = placeholder;
return this;
}
pub fn deinit(this: *@This()) void {
this.value.deinit();
this.allocator.destroy(this);
}
pub fn getValue(this: *const @This()) []const u8 {
return this.value.items[0..this.value_len];
}
pub fn handle(this: *@This(), event: Event) ?Event {
switch (event) {
.resize => |size| {
this.size = size;
this.require_render = true;
var required_cols: u16 = 4; // '...c'
if (this.label) |label| {
required_cols += @as(u16, @truncate(label.len)); // <label>
required_cols += 2; // ': '
}
if (this.size.cols < required_cols) {
return .{ .err = .{
.err = error.InsufficientSize,
.msg = "Received Size is too small to render App.Widget.Input correctly",
} };
}
},
.key => |key| {
if (!this.active) {
return null;
}
if (key.matches(.{ .cp = Key.tab }) or key.matches(.{ .cp = Key.enter })) {
// ignored keys
} else if (key.mod.alt or key.mod.ctrl or key.matches(.{ .cp = Key.escape })) {
// TODO: what about ctrl-v, ctrl-w, alt-b, alt-f?
// ignored keys
} else if (key.matches(.{ .cp = Key.backspace })) {
// remove one character
_ = this.value.orderedRemove(this.cursor_idx);
this.cursor_idx -|= 1;
this.value_len -|= 1;
this.require_render = true;
} else if (key.matches(.{ .cp = Key.left }) or key.matches(.{ .cp = 'b', .mod = .{ .ctrl = true } })) {
// left
this.cursor_idx -|= 1;
this.require_render = true;
} else if (key.matches(.{ .cp = Key.right }) or key.matches(.{ .cp = 'f', .mod = .{ .ctrl = true } })) {
// right
if (this.cursor_idx < this.value_len) {
this.cursor_idx += 1;
this.require_render = true;
}
} else {
if (this.value.items.len <= this.value_len) {
// double capacity in case we need more space
this.value.resize(this.value.capacity * 2) catch |err| {
return .{
.err = .{
.err = err,
.msg = "Could not resize input value buffer",
},
};
};
}
this.value.insert(this.cursor_idx, @as(u8, @truncate(key.cp))) catch @panic("Input.zig: out of memory");
this.cursor_idx += 1;
this.value_len += 1;
this.require_render = true;
}
// TODO: handle key input that should be used for the input field value
// - move cursor using arrow keys
// - allow word-wise navigation?
// - add / remove characters
// - allow removal of words?
// - do not support pasting, as that can be done by the terminal emulator (not sure if this would even work correctly over ssh)
},
else => {},
}
return null;
}
// Overview of the rendered contents:
//
// With both label and placeholder:
// <label>: <placeholder>
// Without any label:
// <placeholder>
// Without any placeholder, but a label:
// <label>: ____________
// With neither label nor placeholder:
// ____________
// When value is not an empty string, the corresponding placeholder
// (if any) will be replaced with the current value. The current
// cursor position is show when this input field is `active`.
pub fn render(this: *@This(), renderer: *Renderer) !void {
if (!this.require_render) {
return;
}
try renderer.clear(this.size);
var size = this.size;
this.require_render = false;
if (this.label) |label| {
const label_style: Cell.Style = .{
.fg = .default,
.italic = true,
};
try renderer.render(size, &[_]Cell{
.{
.content = label,
.style = label_style,
},
});
size.anchor.col += @as(u16, @truncate(label.len));
size.cols -= @as(u16, @truncate(label.len));
try renderer.render(size, &[_]Cell{
.{
.content = ":",
},
});
size.anchor.col += 2;
size.cols -= 2;
}
if (this.value_len > 0) {
var start: usize = 0;
// TODO: moving the cursor position will change position of the '..' placement (i.e. at the beginning, at the end or both)
// truncate representation according to the available space
if (this.value_len >= size.cols - 1) {
start = this.value_len -| (size.cols - 3);
try renderer.render(size, &[_]Cell{
.{
.content = "..",
.style = .{ .dim = true },
},
});
size.anchor.col += 2;
size.cols -|= 2;
}
// print current value representation (and cursor position if active)
if (this.cursor_idx == 0 and this.value_len > 1) {
try renderer.render(size, &[_]Cell{
.{
.content = this.value.items[0..1],
.style = .{ .reverse = true },
},
.{
.content = this.value.items[1..this.value_len],
},
});
} else if (this.cursor_idx == 0 and this.value_len == 1) {
try renderer.render(size, &[_]Cell{
.{
.content = this.value.items[0..1],
.style = .{ .reverse = true },
},
});
} else if (this.cursor_idx == this.value_len) {
try renderer.render(size, &[_]Cell{
.{
.content = this.value.items[start..this.cursor_idx],
},
.{
.content = " ",
.style = .{ .reverse = true },
},
});
} else {
try renderer.render(size, &[_]Cell{
.{
.content = this.value.items[start..this.cursor_idx],
},
.{
.content = this.value.items[this.cursor_idx .. this.cursor_idx + 1],
.style = .{ .reverse = true, .blink = true },
},
});
size.anchor.col += @as(u16, @truncate(this.cursor_idx)) + 1;
size.cols -= @as(u16, @truncate(this.cursor_idx)) + 1;
if (this.value_len > this.cursor_idx + 1) {
try renderer.render(size, &[_]Cell{
.{
.content = this.value.items[this.cursor_idx + 1 .. this.value_len],
},
});
}
}
} else {
if (this.placeholder) |placeholder| {
var placeholder_style: Cell.Style = .{
.fg = .default,
.dim = true,
};
if (this.active) {
placeholder_style.blink = true;
}
try renderer.render(size, &[_]Cell{
.{
.content = placeholder,
.style = placeholder_style,
},
});
}
}
}
};
}

View File

@@ -1,147 +0,0 @@
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Cell = terminal.Cell;
const Size = terminal.Size;
const log = std.log.scoped(.widget_list);
pub fn Widget(comptime Event: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`.");
}
const ListItems = std.ArrayList([]const Cell);
return struct {
allocator: std.mem.Allocator,
idx: usize,
config: ListType,
contents: ListItems,
size: terminal.Size,
require_render: bool,
const ListType = enum {
unordered,
ordered,
};
pub fn init(allocator: std.mem.Allocator, config: ListType, children: anytype) *@This() {
const ArgsType = @TypeOf(children);
const args_type_info = @typeInfo(ArgsType);
if (args_type_info != .@"struct") {
@compileError("expected tuple or struct argument, found " ++ @typeName(ArgsType));
}
const fields_info = args_type_info.@"struct".fields;
var contents = ListItems.initCapacity(allocator, fields_info.len) catch @panic("List.zig: out of memory");
inline for (comptime fields_info) |field| {
const child = @field(children, field.name);
const ChildType = @TypeOf(child);
const child_type_info = @typeInfo(ChildType);
if (child_type_info != .array and child_type_info != .pointer) {
@compileError("child: " ++ field.name ++ " is not an array of const Cell but " ++ @typeName(ChildType));
}
contents.append(child) catch {};
}
var this = allocator.create(@This()) catch @panic("List.zig: Failed to create.");
this.allocator = allocator;
this.require_render = true;
this.idx = 0;
this.config = config;
this.contents = contents;
return this;
}
pub fn deinit(this: *@This()) void {
this.contents.deinit();
this.allocator.destroy(this);
}
pub fn handle(this: *@This(), event: Event) ?Event {
switch (event) {
// store the received size
.resize => |size| {
this.size = size;
this.require_render = true;
},
.key => |key| {
var require_render = true;
if (key.matches(.{ .cp = 'g' }) or key.matches(.{ .cp = terminal.Key.home })) {
// top
if (this.idx != 0) {
this.idx = 0;
} else {
require_render = false;
}
} else if (key.matches(.{ .cp = 'G' }) or key.matches(.{ .cp = terminal.Key.end })) {
// bottom
if (this.idx < this.contents.items.len -| 1) {
this.idx = this.contents.items.len -| 1;
} else {
require_render = false;
}
} else if (key.matches(.{ .cp = 'j' }) or key.matches(.{ .cp = terminal.Key.down })) {
// down
if (this.idx < this.contents.items.len -| 1) {
this.idx += 1;
} else {
require_render = false;
}
} else if (key.matches(.{ .cp = 'k' }) or key.matches(.{ .cp = terminal.Key.up })) {
// up
if (this.idx > 0) {
this.idx -= 1;
} else {
require_render = false;
}
} else {
require_render = false;
}
this.require_render = require_render;
},
else => {},
}
return null;
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
if (!this.require_render) {
return;
}
try renderer.clear(this.size);
var row: u16 = 0;
for (this.contents.items[this.idx..], this.idx + 1..) |content, num| {
var size: Size = .{
.anchor = .{
.col = this.size.anchor.col,
.row = this.size.anchor.row + row,
},
.rows = this.size.rows -| row,
.cols = this.size.cols,
};
switch (this.config) {
.unordered => {
try renderer.render(size, &[_]Cell{
.{ .content = "" },
});
size.anchor.col += 2;
size.cols -|= 2;
},
.ordered => {
var buf: [32]u8 = undefined;
const val = try std.fmt.bufPrint(&buf, "{d}.", .{num});
try renderer.render(size, &[_]Cell{
.{ .content = val },
});
const cols: u16 = @truncate(val.len + 1);
size.anchor.col += cols;
size.cols -|= cols;
},
}
try renderer.render(size, content);
row += 1; // NOTE: as there are no line breaks currently there will always exactly one line be written
}
this.require_render = false;
}
};
}

View File

@@ -1,127 +0,0 @@
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Key = terminal.Key;
const Style = terminal.Style;
const log = std.log.scoped(.widget_rawtext);
pub fn Widget(comptime Event: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`.");
}
const Contents = std.ArrayList(u8);
return struct {
allocator: std.mem.Allocator,
contents: Contents,
line_index: std.ArrayList(usize),
line: usize,
size: terminal.Size,
require_render: bool,
pub fn init(allocator: std.mem.Allocator, file: std.fs.File) *@This() {
var contents = Contents.init(allocator);
var line_index = std.ArrayList(usize).init(allocator);
file.reader().readAllArrayList(&contents, std.math.maxInt(usize)) catch {};
line_index.append(0) catch {};
for (contents.items, 0..) |item, i| {
if (item == '\n') {
line_index.append(i + 1) catch {};
}
}
var this = allocator.create(@This()) catch @panic("RawText.zig: Failed to create.");
this.allocator = allocator;
this.line = 0;
this.require_render = true;
this.contents = contents;
this.line_index = line_index;
return this;
}
pub fn deinit(this: *@This()) void {
this.contents.deinit();
this.line_index.deinit();
this.allocator.destroy(this);
}
pub fn handle(this: *@This(), event: Event) ?Event {
var require_render = true;
switch (event) {
// store the received size
.resize => |size| {
this.size = size;
if (this.line > this.line_index.items.len -| 1 -| size.rows) {
this.line = this.line_index.items.len -| 1 -| size.rows;
}
},
.key => |key| {
if (key.matches(.{ .cp = 'g' }) or key.matches(.{ .cp = Key.home })) {
// top
if (this.line != 0) {
this.line = 0;
} else {
require_render = false;
}
} else if (key.matches(.{ .cp = 'G' }) or key.matches(.{ .cp = Key.end })) {
// bottom
if (this.line < this.line_index.items.len -| 1 -| this.size.rows) {
this.line = this.line_index.items.len -| 1 -| this.size.rows;
} else {
require_render = false;
}
} else if (key.matches(.{ .cp = 'j' }) or key.matches(.{ .cp = Key.down })) {
// down
if (this.line < this.line_index.items.len -| 1 -| this.size.rows) {
this.line += 1;
} else {
require_render = false;
}
} else if (key.matches(.{ .cp = 'k' }) or key.matches(.{ .cp = Key.up })) {
// up
if (this.line > 0) {
this.line -= 1;
} else {
require_render = false;
}
} else {
require_render = false;
}
},
else => {
require_render = false;
},
}
this.require_render = require_render;
return null;
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
if (!this.require_render) {
return;
}
try renderer.clear(this.size);
if (this.size.rows >= this.line_index.items.len) {
try renderer.render(this.size, &[_]terminal.Cell{
.{ .content = this.contents.items, .style = .{ .dim = true, .fg = .{ .index = 8 } } },
});
} else {
// more rows than we can display
const i = this.line_index.items[this.line];
const e = this.size.rows + this.line;
if (e > this.line_index.items.len) {
try renderer.render(this.size, &[_]terminal.Cell{
.{ .content = this.contents.items[i..], .style = .{ .dim = true, .fg = .{ .index = 7 } } },
});
return;
}
const x = this.line_index.items[e];
try renderer.render(this.size, &[_]terminal.Cell{
.{ .content = this.contents.items[i..x], .style = .{ .dim = true, .fg = .{ .index = 9 } } },
});
}
this.require_render = false;
}
};
}

View File

@@ -1,47 +0,0 @@
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const log = std.log.scoped(.widget_spacer);
pub fn Widget(comptime Event: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`.");
}
return struct {
allocator: std.mem.Allocator,
size: terminal.Size,
size_changed: bool,
pub fn init(allocator: std.mem.Allocator) *@This() {
var this = allocator.create(@This()) catch @panic("Space.zig: Failed to create.");
this.allocator = allocator;
this.size_changed = true;
return this;
}
pub fn deinit(this: *@This()) void {
this.allocator.destroy(this);
}
pub fn handle(this: *@This(), event: Event) ?Event {
switch (event) {
.resize => |size| {
this.size = size;
this.size_changed = true;
},
else => {},
}
return null;
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
if (this.size_changed) {
try renderer.clear(this.size);
this.size_changed = false;
}
}
};
}

View File

@@ -1,155 +0,0 @@
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Cell = terminal.Cell;
const log = std.log.scoped(.widget_text);
pub fn Widget(comptime Event: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`.");
}
return struct {
allocator: std.mem.Allocator,
alignment: Alignment,
contents: []const Cell,
size: terminal.Size,
require_render: bool,
const Alignment = enum {
default,
center,
top,
bottom,
left,
right,
};
pub fn init(allocator: std.mem.Allocator, alignment: Alignment, contents: []const Cell) *@This() {
var this = allocator.create(@This()) catch @panic("Text.zig: Failed to create");
this.allocator = allocator;
this.require_render = true;
this.alignment = alignment;
this.contents = contents;
return this;
}
pub fn deinit(this: *@This()) void {
this.allocator.destroy(this);
}
pub fn handle(this: *@This(), event: Event) ?Event {
switch (event) {
// store the received size
.resize => |size| {
this.size = size;
this.require_render = true;
},
else => {},
}
return null;
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
if (!this.require_render) {
return;
}
try renderer.clear(this.size);
// update size for aligned contents, default will not change size
const size: terminal.Size = blk: {
switch (this.alignment) {
.default => break :blk this.size,
.center => {
var length_usize: usize = 0;
for (this.contents) |content| {
length_usize += content.content.len;
}
const length: u16 = @truncate(length_usize);
const cols = @min(length, this.size.cols);
const rows = cols / length;
break :blk .{
.anchor = .{
.col = this.size.anchor.col + @divTrunc(this.size.cols, 2) - @divTrunc(cols, 2),
.row = this.size.anchor.row + @divTrunc(this.size.rows, 2),
},
.rows = rows,
.cols = cols,
};
},
.top => {
var length_usize: usize = 0;
for (this.contents) |content| {
length_usize += content.content.len;
}
const length: u16 = @truncate(length_usize);
const cols = @min(length, this.size.cols);
const rows = cols / length;
break :blk .{
.anchor = .{
.col = this.size.anchor.col + @divTrunc(this.size.cols, 2) - @divTrunc(cols, 2),
.row = this.size.anchor.row,
},
.rows = rows,
.cols = cols,
};
},
.bottom => {
var length_usize: usize = 0;
for (this.contents) |content| {
length_usize += content.content.len;
}
const length: u16 = @truncate(length_usize);
const cols = @min(length, this.size.cols);
const rows = cols / length;
break :blk .{
.anchor = .{
.col = this.size.anchor.col + @divTrunc(this.size.cols, 2) - @divTrunc(cols, 2),
.row = this.size.anchor.row + this.size.rows - rows,
},
.rows = rows,
.cols = cols,
};
},
.left => {
var length_usize: usize = 0;
for (this.contents) |content| {
length_usize += content.content.len;
}
const length: u16 = @truncate(length_usize);
const cols = @min(length, this.size.cols);
const rows = cols / length;
break :blk .{
.anchor = .{
.col = this.size.anchor.col,
.row = this.size.anchor.row + @divTrunc(this.size.rows, 2) - @divTrunc(rows, 2),
},
.rows = rows,
.cols = cols,
};
},
.right => {
var length_usize: usize = 0;
for (this.contents) |content| {
length_usize += content.content.len;
}
const length: u16 = @truncate(length_usize);
const cols = @min(length, this.size.cols);
const rows = cols / length;
break :blk .{
.anchor = .{
.col = this.size.anchor.col + this.size.cols - cols,
.row = this.size.anchor.row + @divTrunc(this.size.rows, 2) - @divTrunc(rows, 2),
},
.rows = rows,
.cols = cols,
};
},
}
};
try renderer.render(size, this.contents);
this.require_render = false;
}
};
}

View File

@@ -1,16 +1,35 @@
// private imports // private imports
const container = @import("container.zig");
const color = @import("color.zig");
const size = @import("size.zig");
// public import / exports // public exports
pub const terminal = @import("terminal.zig");
pub const App = @import("app.zig").App; pub const App = @import("app.zig").App;
// App also exports further types once initialized with the user events at compile time:
// `App.Container`
// `App.Element`
pub const Renderer = @import("render.zig"); pub const Renderer = @import("render.zig");
pub const Key = terminal.Key; // Container Configurations
pub const Position = terminal.Position; pub const Border = container.Border;
pub const Size = terminal.Size; pub const Rectangle = container.Rectangle;
pub const Cell = terminal.Cell; pub const Scroll = container.Scroll;
pub const Layout = container.Layout;
pub const Cell = @import("cell.zig");
pub const Color = color.Color;
pub const Key = @import("key.zig");
pub const Size = size.Size;
pub const Style = @import("style.zig");
test { test {
_ = @import("terminal.zig"); _ = @import("terminal.zig");
_ = @import("queue.zig"); _ = @import("queue.zig");
_ = color;
_ = size;
_ = Cell;
_ = Key;
_ = Style;
} }