diff --git a/README.md b/README.md index 6b8082d..611a19b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ `zterm` is a terminal user interface library to implement terminal (fullscreen or inline) applications. > [!NOTE] -> Only builds using the master version might will work. +> Only builds using the master version are tested to work. ## 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. + +--- +## 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) diff --git a/build.zig b/build.zig index 3a94af0..9a716c1 100644 --- a/build.zig +++ b/build.zig @@ -1,110 +1,66 @@ 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 { - // 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(.{}); - - // 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 zg = b.dependency("zg", .{ .target = target, .optimize = optimize, }); - const interface = b.dependency("interface", .{ - .target = target, - .optimize = optimize, - }); + // library const lib = b.addModule("zterm", .{ .root_source_file = b.path("src/zterm.zig"), .target = target, .optimize = optimize, }); - lib.addImport("interface", interface.module("interface")); lib.addImport("code_point", zg.module("code_point")); - // example executables - const stack_example = b.addExecutable(.{ - .name = "stack", - .root_source_file = b.path("examples/stack.zig"), + // main executable (usually used for testing) + const exe = b.addExecutable(.{ + .name = "zterm", + .root_source_file = b.path("src/main.zig"), .target = target, .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", .root_source_file = b.path("examples/container.zig"), .target = target, .optimize = optimize, }); - container_example.root_module.addImport("zterm", lib); + container.root_module.addImport("zterm", lib); + b.installArtifact(container); - const padding_example = b.addExecutable(.{ - .name = "padding", - .root_source_file = b.path("examples/padding.zig"), - .target = target, - .optimize = optimize, - }); - padding_example.root_module.addImport("zterm", lib); + // zig build run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + // Allow additional arguments, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| run_cmd.addArgs(args); - const exec_example = b.addExecutable(.{ - .name = "exec", - .root_source_file = b.path("examples/exec.zig"), - .target = target, - .optimize = optimize, - }); - exec_example.root_module.addImport("zterm", lib); + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); - const tui_example = b.addExecutable(.{ - .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. + // zig build test const lib_unit_tests = b.addTest(.{ .root_source_file = b.path("src/zterm.zig"), .target = target, .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); - // 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"); test_step.dependOn(&run_lib_unit_tests.step); } diff --git a/build.zig.zon b/build.zig.zon index 5998c03..b374a46 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -24,12 +24,8 @@ // internet connectivity. .dependencies = .{ .zg = .{ - .url = "git+https://codeberg.org/atman/zg#a363f507fc39b96fc48d693665a823a358345326", - .hash = "1220fe42e39fd141c84fd7d5cf69945309bb47253033e68788f99bdfe5585fbc711a", - }, - .interface = .{ - .url = "git+https://github.com/yves-biener/zig-interface#ef47e045df19e09250fff45c0702d014fb3d3c37", - .hash = "1220a442e8d9b813572bab7a55eef504c83b628f0b17fd283e776dbc1d1a3d98e842", + .url = "git+https://codeberg.org/atman/zg#4a002763419a34d61dcbb1f415821b83b9bf8ddc", + .hash = "1220f3e29bc40856bfc06e0ee133f814b0011c76de987d8a6a458c2f34d82708899a", }, }, .paths = .{ diff --git a/examples/container.zig b/examples/container.zig index 30c0a34..af98eac 100644 --- a/examples/container.zig +++ b/examples/container.zig @@ -1,96 +1,152 @@ const std = @import("std"); const zterm = @import("zterm"); -const App = zterm.App( - union(enum) {}, - zterm.Renderer.Direct, - true, -); +const App = zterm.App(union(enum) {}); 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 { 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 { 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 app: App = .init; + var renderer = zterm.Renderer.Buffered.init(allocator); + defer renderer.deinit(); - var layout = Layout.createFrom(Layout.HContainer.init(allocator, .{ - .{ - 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(); + var element_wrapper = ExampleElement{}; + const element = element_wrapper.element(); - try app.start(null); - defer app.stop() catch unreachable; + var container = try App.Container.init(allocator, .{ + .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) { const event = app.nextEvent(); log.debug("received event: {s}", .{@tagName(event)}); switch (event) { - .quit => break, - .resize => |size| { - renderer.resize(size); + .init => { + try container.handle(event); + continue; // do not render }, + .quit => break, + .resize => |size| try renderer.resize(size), .key => |key| { - // ctrl+c to quit - if (Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) { - app.quit(); + if (key.matches(.{ .cp = 'q' })) 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| { - log.err("Received {any} with message: {s}", .{ err.err, err.msg }); - }, + // 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 => {}, } - const events = try layout.handle(event); - for (events.items) |e| { - app.postEvent(e); - } - try layout.render(&renderer); + + // 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(); } } diff --git a/examples/exec.zig b/examples/exec.zig deleted file mode 100644 index 16996f2..0000000 --- a/examples/exec.zig +++ /dev/null @@ -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); - } -} diff --git a/examples/padding.zig b/examples/padding.zig deleted file mode 100644 index f992870..0000000 --- a/examples/padding.zig +++ /dev/null @@ -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); - } -} diff --git a/examples/stack.zig b/examples/stack.zig deleted file mode 100644 index 62077b9..0000000 --- a/examples/stack.zig +++ /dev/null @@ -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); - } -} diff --git a/examples/tabs.zig b/examples/tabs.zig deleted file mode 100644 index fe0624b..0000000 --- a/examples/tabs.zig +++ /dev/null @@ -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); - } -} diff --git a/examples/tui.zig b/examples/tui.zig deleted file mode 100644 index b3211d1..0000000 --- a/examples/tui.zig +++ /dev/null @@ -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); - } -} diff --git a/src/app.zig b/src/app.zig index 500ad48..a8e1dd3 100644 --- a/src/app.zig +++ b/src/app.zig @@ -6,7 +6,8 @@ const event = @import("event.zig"); const mergeTaggedUnions = event.mergeTaggedUnions; 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 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 /// 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 /// /// 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 App = zterm.App( /// union(enum) {}, -/// zterm.Renderer.Direct, -/// true, /// ); -/// // later on use -/// var app: App = .{}; -/// var renderer: App.Renderer = .{}; +/// // later on create an `App` instance and start the event loop +/// var app: App = .init; +/// 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)) { @compileError("Provided user event `E` for `App(comptime E: type)` is not of type `union(enum)`."); } return struct { pub const Event = mergeTaggedUnions(event.SystemEvent, E); - pub const Renderer = R(fullscreen); - pub const Layout = @import("layout.zig").Layout(Event, Renderer); - pub const Widget = @import("widget.zig").Widget(Event, Renderer); - pub const View = @import("view.zig").View(Event, Renderer); + pub const Container = @import("container.zig").Container(Event); + pub const Element = @import("element.zig").Element(Event); - queue: Queue(Event, 256) = .{}, - thread: ?std.Thread = null, - quit_event: std.Thread.ResetEvent = .{}, + queue: Queue(Event, 256), + thread: ?std.Thread, + quit_event: std.Thread.ResetEvent, termios: ?std.posix.termios = null, attached_handler: bool = false, - min_size: ?terminal.Size = null, - prev_size: terminal.Size = .{ .cols = 0, .rows = 0 }, + prev_size: Size, pub const SignalHandler = struct { context: *anyopaque, callback: *const fn (context: *anyopaque) void, }; - pub fn start(this: *@This(), min_size: ?terminal.Size) !void { - if (fullscreen) { // a minimal size only really makes sense if the application is rendered fullscreen - this.min_size = min_size; - } + pub const init: @This() = .{ + .queue = .{}, + .thread = null, + .quit_event = .{}, + .termios = null, + .attached_handler = false, + .prev_size = .{}, + }; + + pub fn start(this: *@This()) !void { if (this.thread) |_| return; 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, }); 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(); @@ -89,22 +87,22 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls var termios: std.posix.termios = undefined; try terminal.enableRawMode(&termios); - if (this.termios) |_| {} else { - this.termios = termios; - } - if (fullscreen) { - try terminal.saveScreen(); - try terminal.enterAltScreen(); - try terminal.hideCursor(); - } + if (this.termios) |_| {} else this.termios = termios; + + try terminal.saveScreen(); + try terminal.enterAltScreen(); + 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 { this.quit_event.set(); - if (fullscreen) { - try terminal.exitAltScreen(); - try terminal.restoreScreen(); - } + try terminal.exitAltScreen(); + try terminal.restoreScreen(); if (this.thread) |thread| { thread.join(); this.thread = null; @@ -115,11 +113,9 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls try this.interrupt(); if (this.termios) |*termios| { try terminal.disableRawMode(termios); - if (fullscreen) { - try terminal.showCursor(); - try terminal.exitAltScreen(); - try terminal.restoreScreen(); - } + try terminal.showCursor(); + try terminal.exitAltScreen(); + try terminal.restoreScreen(); } 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 { const this: *@This() = @ptrCast(@alignCast(ptr)); 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) { this.postEvent(.{ .resize = 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 // changes are handled by the winch signal handler // 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 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) { switch (buf[1]) { 0x4F => { // ss3 - if (read_bytes < 3) { - continue; - } + if (read_bytes < 3) continue; + const key: ?Key = switch (buf[2]) { 0x1B => null, '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 }, else => null, }; - if (key) |k| { - this.postEvent(.{ .key = k }); - } + if (key) |k| this.postEvent(.{ .key = k }); }, 0x5B => { // csi - if (read_bytes < 3) { - continue; - } + if (read_bytes < 3) continue; + // We start iterating at index 2 to get past the '[' const sequence = for (buf[2..], 2..) |b, i| { 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? // 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, .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) { this.postEvent(.{ .resize = 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 }, else => { var iter = terminal.code_point.Iterator{ .bytes = buf[0..read_bytes] }; - while (iter.next()) |cp| { - this.postEvent(.{ .key = .{ .cp = cp.code } }); - } + while (iter.next()) |cp| this.postEvent(.{ .key = .{ .cp = cp.code } }); continue; }, }; diff --git a/src/cell.zig b/src/cell.zig new file mode 100644 index 0000000..f0a4541 --- /dev/null +++ b/src/cell.zig @@ -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); +} diff --git a/src/color.zig b/src/color.zig new file mode 100644 index 0000000..fc9ae5e --- /dev/null +++ b/src/color.zig @@ -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)}), + } + } + } +}; diff --git a/src/container.zig b/src/container.zig new file mode 100644 index 0000000..20d988c --- /dev/null +++ b/src/container.zig @@ -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; + } + }; +} diff --git a/src/terminal/ctlseqs.zig b/src/ctlseqs.zig similarity index 100% rename from src/terminal/ctlseqs.zig rename to src/ctlseqs.zig diff --git a/src/element.zig b/src/element.zig new file mode 100644 index 0000000..9a2ce93 --- /dev/null +++ b/src/element.zig @@ -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)); + } + }; +} diff --git a/src/event.zig b/src/event.zig index 8d9f85c..7824afb 100644 --- a/src/event.zig +++ b/src/event.zig @@ -3,26 +3,32 @@ const std = @import("std"); const terminal = @import("terminal.zig"); -const Size = terminal.Size; -const Key = terminal.Key; +const Size = @import("size.zig").Size; +const Key = @import("key.zig"); -pub const Error = struct { - err: anyerror, - msg: []const u8, -}; - -// System events available to every application. -// TODO: should this also already include the .view enum option? +/// System events available to every `zterm.App` 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, - 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, + /// Input key event received from the user 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, }; 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)) { @compileError("Both types for merging tagged unions need to be of type `union(enum)`."); } diff --git a/src/terminal/Key.zig b/src/key.zig similarity index 99% rename from src/terminal/Key.zig rename to src/key.zig index eec5f8c..ec459c4 100644 --- a/src/terminal/Key.zig +++ b/src/key.zig @@ -1,6 +1,8 @@ //! Keybindings and Modifiers for user input detection and selection. const std = @import("std"); +pub const Key = @This(); + pub const Modifier = struct { shift: bool = false, alt: bool = false, diff --git a/src/layout.zig b/src/layout.zig deleted file mode 100644 index cc69d8e..0000000 --- a/src/layout.zig +++ /dev/null @@ -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; -} diff --git a/src/layout/Framing.zig b/src/layout/Framing.zig deleted file mode 100644 index cad9bf1..0000000 --- a/src/layout/Framing.zig +++ /dev/null @@ -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); - }, - } - } - }; -} diff --git a/src/layout/HContainer.zig b/src/layout/HContainer.zig deleted file mode 100644 index b28e179..0000000 --- a/src/layout/HContainer.zig +++ /dev/null @@ -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); - }, - } - } - } - }; -} diff --git a/src/layout/HStack.zig b/src/layout/HStack.zig deleted file mode 100644 index 8c01af3..0000000 --- a/src/layout/HStack.zig +++ /dev/null @@ -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); - }, - } - } - } - }; -} diff --git a/src/layout/Margin.zig b/src/layout/Margin.zig deleted file mode 100644 index b6177c5..0000000 --- a/src/layout/Margin.zig +++ /dev/null @@ -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); - }, - } - } - }; -} diff --git a/src/layout/Padding.zig b/src/layout/Padding.zig deleted file mode 100644 index 649cdc6..0000000 --- a/src/layout/Padding.zig +++ /dev/null @@ -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); - }, - } - } - }; -} diff --git a/src/layout/Tab.zig b/src/layout/Tab.zig deleted file mode 100644 index b5cea83..0000000 --- a/src/layout/Tab.zig +++ /dev/null @@ -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); - }, - } - } - }; -} diff --git a/src/layout/VContainer.zig b/src/layout/VContainer.zig deleted file mode 100644 index 9e7428c..0000000 --- a/src/layout/VContainer.zig +++ /dev/null @@ -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); - }, - } - } - } - }; -} diff --git a/src/layout/VStack.zig b/src/layout/VStack.zig deleted file mode 100644 index f7bdd82..0000000 --- a/src/layout/VStack.zig +++ /dev/null @@ -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); - }, - } - } - } - }; -} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..7e3fbc7 --- /dev/null +++ b/src/main.zig @@ -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(); + } +} diff --git a/src/properties.zig b/src/properties.zig new file mode 100644 index 0000000..e69de29 diff --git a/src/render.zig b/src/render.zig index efc6e92..f9875d0 100644 --- a/src/render.zig +++ b/src/render.zig @@ -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 terminal = @import("terminal.zig"); -const Cells = []const terminal.Cell; -const Position = terminal.Position; -const Size = terminal.Size; +const Cell = @import("cell.zig"); +const Position = @import("size.zig").Position; +const Size = @import("size.zig").Size; -pub fn Direct(comptime fullscreen: bool) type { - const log = std.log.scoped(.renderer_direct); - _ = log; - _ = fullscreen; - return struct { - size: Size = undefined, +/// Double-buffered intermediate rendering pipeline +pub const Buffered = struct { + const log = std.log.scoped(.renderer_buffered); + // _ = log; + allocator: std.mem.Allocator, + created: bool, + size: Size, + screen: []Cell, + virtual_screen: []Cell, - pub fn resize(this: *@This(), size: Size) void { - this.size = size; + pub fn init(allocator: std.mem.Allocator) @This() { + return .{ + .allocator = allocator, + .created = false, + .size = undefined, + .screen = undefined, + .virtual_screen = undefined, + }; + } + + pub fn deinit(this: *@This()) void { + if (this.created) { + this.allocator.free(this.screen); + this.allocator.free(this.virtual_screen); } + } - pub fn clear(this: *@This(), size: Size) !void { - _ = this; - // NOTE: clear on the entire screen may introduce too much overhead and could instead clear the entire screen instead. - // - it could also then try to optimize for further *clear* calls, that result in pretty much a nop? -> how to identify those clear calls? - // 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 resize(this: *@This(), size: Size) !void { + log.debug("renderer::resize", .{}); + defer this.size = size; + + if (!this.created) { + 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.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 vs = this.virtual_screen; + const anchor: usize = (@as(usize, size.anchor.row) * @as(usize, this.size.cols)) + @as(usize, size.anchor.col); + + blk: for (0..size.rows) |row| { + for (0..size.cols) |col| { + const cell = cells[idx]; + idx += 1; + + vs[anchor + (row * this.size.cols) + col].style = cell.style; + vs[anchor + (row * this.size.cols) + col].cp = cell.cp; + + if (cells.len == idx) break :blk; } } + // free immediately + container.allocator.free(cells); - pub fn render(this: *@This(), size: Size, cells: Cells) !void { - _ = this; - try terminal.setCursorPosition(size.anchor); - var row: u16 = 0; - var remaining_cols = size.cols; - const writer = terminal.writer(); - for (cells) |cell| { - var idx: usize = 0; - print_cell: while (true) { - const cell_len = cell.len(idx); - if (cell_len > remaining_cols) { - const result = try cell.writeUpToNewline(writer, idx, idx + remaining_cols); - 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; - 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) - } - } else { - // print rest of cell - const result = try cell.writeUpToNewline(writer, idx, idx + cell_len); - 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) { - break; // go to next cell - } - } + for (container.elements.items) |*element| { + try this.render(T, element); + } + } + + /// Write *virtual screen* to alternate screen (should be called once and last during each render loop iteration in the main loop). + 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]; } } - }; -} + } +}; diff --git a/src/size.zig b/src/size.zig new file mode 100644 index 0000000..0ada6dc --- /dev/null +++ b/src/size.zig @@ -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, +}; diff --git a/src/style.zig b/src/style.zig new file mode 100644 index 0000000..c173e80 --- /dev/null +++ b/src/style.zig @@ -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? diff --git a/src/terminal.zig b/src/terminal.zig index 9a14480..c7bfe7f 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -1,10 +1,11 @@ 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"); +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); // 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); } -pub fn getCursorPosition() !Position { +pub fn getCursorPosition() !Size.Position { // Needs Raw mode (no wait for \n) to work properly cause // control sequence will not be written without it. _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[6n"); @@ -126,8 +127,8 @@ pub fn getCursorPosition() !Position { } return .{ - .row = try std.fmt.parseInt(u16, row[0..ridx], 10), - .col = try std.fmt.parseInt(u16, col[0..cidx], 10), + .row = try std.fmt.parseInt(u16, row[0..ridx], 10) - 1, + .col = try std.fmt.parseInt(u16, col[0..cidx], 10) - 1, }; } @@ -227,7 +228,3 @@ fn getReportMode(ps: u8) ReportMode { else => ReportMode.not_recognized, }; } - -test { - _ = Cell; -} diff --git a/src/terminal/Cell.zig b/src/terminal/Cell.zig deleted file mode 100644 index 5bc726c..0000000 --- a/src/terminal/Cell.zig +++ /dev/null @@ -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; -} diff --git a/src/terminal/Position.zig b/src/terminal/Position.zig deleted file mode 100644 index 4a0f7b0..0000000 --- a/src/terminal/Position.zig +++ /dev/null @@ -1,2 +0,0 @@ -col: u16, -row: u16, diff --git a/src/terminal/Size.zig b/src/terminal/Size.zig deleted file mode 100644 index c492048..0000000 --- a/src/terminal/Size.zig +++ /dev/null @@ -1,5 +0,0 @@ -const Position = @import("Position.zig"); - -anchor: Position = .{ .col = 1, .row = 1 }, // top left corner by default -cols: u16, -rows: u16, diff --git a/src/terminal/Style.zig b/src/terminal/Style.zig deleted file mode 100644 index 7f739bb..0000000 --- a/src/terminal/Style.zig +++ /dev/null @@ -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; -} diff --git a/src/view.zig b/src/view.zig deleted file mode 100644 index f77470d..0000000 --- a/src/view.zig +++ /dev/null @@ -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, - }, - }; - } - }; -} diff --git a/src/widget.zig b/src/widget.zig deleted file mode 100644 index 6bdc8bd..0000000 --- a/src/widget.zig +++ /dev/null @@ -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; -} diff --git a/src/widget/Input.zig b/src/widget/Input.zig deleted file mode 100644 index d82230e..0000000 --- a/src/widget/Input.zig +++ /dev/null @@ -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)); //