From 0330b3a2f54583fca864bada3c8373be8bd7eb11 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Sat, 2 Nov 2024 17:52:44 +0100 Subject: [PATCH 01/20] add: layout and widget dynamic dispatch with interface definitions --- README.md | 7 ++ src/event.zig | 86 +++++++++++++++++++++ src/layout.zig | 81 ++++++++++++++++++++ src/layout/Pane.zig | 39 ++++++++++ src/main.zig | 145 ++++++----------------------------- src/terminal.zig | 159 +++++++++++++++++++++++++++++++++++++++ src/widget.zig | 118 +++++++++++++++++------------ src/widget/Header.zig | 127 ------------------------------- src/widget/PopupMenu.zig | 80 -------------------- src/widget/RawText.zig | 37 +++++++++ src/widget/ViewPort.zig | 103 ------------------------- 11 files changed, 502 insertions(+), 480 deletions(-) create mode 100644 src/event.zig create mode 100644 src/layout.zig create mode 100644 src/layout/Pane.zig create mode 100644 src/terminal.zig delete mode 100644 src/widget/Header.zig delete mode 100644 src/widget/PopupMenu.zig create mode 100644 src/widget/RawText.zig delete mode 100644 src/widget/ViewPort.zig diff --git a/README.md b/README.md index 859e9a7..24733a9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ It contains information about me and my projects as well as blog entries about s - they are instead showed locally, which might cause issues with the docker container running in the background - very likely it is `tui-website` which causes this issue - not entirely as inputs are not passed through correctly to the below running application (i.e. `diffnav` via `serve git diff`) + - fex however works as expected - [ ] Improve navigation - [ ] Have clickable/navigatable links inside of the tui application - [ ] Launch simple http server alongside tui application + +--- + +## Branch: `own-tty-visuals` + +- [ ] How can I support to run a sub-process inside of a given pane / layout? diff --git a/src/event.zig b/src/event.zig new file mode 100644 index 0000000..8c49dd4 --- /dev/null +++ b/src/event.zig @@ -0,0 +1,86 @@ +//! Events which are defined by the library. They might be extended by user +//! events. +const std = @import("std"); +const terminal = @import("terminal.zig"); + +// Application events which contain information about default application +// parameter. Either `none` or `err`, where `none` represents no event with no +// message, while `err` represents an error which is propagated. `Widget`s or +// `Layout`s may react to the event but should continue throwing the message up +// to the application event loop. +pub const ApplicationEvent = union(enum) { + none, + err: []const u8, +}; + +// System events which contain information about events triggered from outside +// of the application which impact the application. E.g. the terminal window +// size has changed, etc. +pub const SystemEvent = union(enum) { + resize: terminal.Size, +}; + +pub const BuiltinEvent = MergeTaggedUnions(SystemEvent, ApplicationEvent); + +pub fn MergeTaggedUnions(comptime A: type, comptime B: type) type { + if (!isTaggedUnion(A) or !isTaggedUnion(B)) { + @compileError("Both types for merging tagged unions need to be of type `union(enum)`."); + } + const a_fields = @typeInfo(A).Union.fields; + const a_fields_tag = @typeInfo(A).Union.tag_type.?; + const a_enum_fields = @typeInfo(a_fields_tag).Enum.fields; + const b_fields = @typeInfo(B).Union.fields; + const b_fields_tag = @typeInfo(B).Union.tag_type.?; + const b_enum_fields = @typeInfo(b_fields_tag).Enum.fields; + var fields: [a_fields.len + b_fields.len]std.builtin.Type.UnionField = undefined; + var enum_fields: [a_fields.len + b_fields.len]std.builtin.Type.EnumField = undefined; + var i: usize = 0; + for (a_fields, a_enum_fields) |field, enum_field| { + fields[i] = field; + var enum_f = enum_field; + enum_f.value = i; + enum_fields[i] = enum_f; + i += 1; + } + for (b_fields, b_enum_fields) |field, enum_field| { + fields[i] = field; + var enum_f = enum_field; + enum_f.value = i; + enum_fields[i] = enum_f; + i += 1; + } + + const log2_i = @bitSizeOf(@TypeOf(i)) - @clz(i); + + const EventType = @Type(.{ .Int = .{ + .signedness = .unsigned, + .bits = log2_i, + } }); + + const Event = @Type(.{ .Enum = .{ + .tag_type = EventType, + .fields = enum_fields[0..], + .decls = &.{}, + .is_exhaustive = true, + } }); + + return @Type(.{ .Union = .{ + .layout = .auto, + .tag_type = Event, + .fields = fields[0..], + .decls = &.{}, + } }); +} + +// Determine at `comptime` wether the provided type `E` is an `union(enum)`. +pub fn isTaggedUnion(comptime E: type) bool { + switch (@typeInfo(E)) { + .Union => |u| { + if (u.tag_type) |_| {} else { + return false; + } + }, + else => return false, + } + return true; +} diff --git a/src/layout.zig b/src/layout.zig new file mode 100644 index 0000000..57a1be1 --- /dev/null +++ b/src/layout.zig @@ -0,0 +1,81 @@ +//! Dynamic dispatch for layout implementations. +//! Each layout should at last implement these functions: +//! - handle(this: *@This(), event: Event) Event {} +//! - content(this: *@This()) *std.ArrayList(u8) {} +//! - deinit(this: *@This()) void {} +//! +//! Create a `Layout` using `createFrom(object: anytype)` and use them through +//! the defined 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 +//! widgets when deallocated. This means that `deinit()` will also deallocate +//! every used widget too. +const std = @import("std"); +const lib_event = @import("event.zig"); + +pub fn Layout(comptime E: type) type { + if (!lib_event.isTaggedUnion(E)) { + @compileError("Provided user event `E` for `Layout(comptime E: type)` is not of type `union(enum)`."); + } + return struct { + pub const Event = lib_event.MergeTaggedUnions(lib_event.BuiltinEvent, E); + const LayoutType = @This(); + const Ptr = usize; + + const VTable = struct { + handle: *const fn (this: *LayoutType, event: Event) Event, + content: *const fn (this: *LayoutType) *std.ArrayList(u8), + deinit: *const fn (this: *LayoutType) void, + }; + + object: Ptr = undefined, + vtable: *const VTable = undefined, + + // Handle the provided `Event` for this `Widget`. + pub fn handle(this: *LayoutType, event: Event) Event { + return this.vtable.handle(this, event); + } + + // Return the entire content of this `Widget`. + pub fn content(this: *LayoutType) *std.ArrayList(u8) { + return this.vtable.content(this); + } + + pub fn deinit(this: *LayoutType) void { + this.vtable.deinit(this); + this.* = undefined; + } + + pub fn createFrom(object: anytype) LayoutType { + return LayoutType{ + .object = @intFromPtr(object), + .vtable = &.{ + .handle = struct { + // Handle the provided `Event` for this `Widget`. + fn handle(this: *LayoutType, event: Event) Event { + const layout: @TypeOf(object) = @ptrFromInt(this.object); + return layout.handle(event); + } + }.handle, + .content = struct { + // Return the entire content of this `Widget`. + fn content(this: *LayoutType) *std.ArrayList(u8) { + const layout: @TypeOf(object) = @ptrFromInt(this.object); + return layout.content(); + } + }.content, + .deinit = struct { + fn deinit(this: *LayoutType) void { + const layout: @TypeOf(object) = @ptrFromInt(this.object); + layout.deinit(); + } + }.deinit, + }, + }; + } + + // import and export of `Layout` implementations + pub const Pane = @import("layout/Pane.zig").Layout(E); + }; +} diff --git a/src/layout/Pane.zig b/src/layout/Pane.zig new file mode 100644 index 0000000..629dd59 --- /dev/null +++ b/src/layout/Pane.zig @@ -0,0 +1,39 @@ +const std = @import("std"); +const lib_event = @import("../event.zig"); +const widget = @import("../widget.zig"); + +pub fn Layout(comptime E: type) type { + if (!lib_event.isTaggedUnion(E)) { + @compileError("Provided user event `E` for `Layout(comptime E: type)` is not of type `union(enum)`."); + } + return struct { + pub const Event = lib_event.MergeTaggedUnions(lib_event.BuiltinEvent, E); + + w: widget.Widget(E) = undefined, + c: std.ArrayList(u8) = undefined, + + pub fn init(allocator: std.mem.Allocator, w: widget.Widget(E)) @This() { + return .{ + .w = w, + .c = std.ArrayList(u8).init(allocator), + }; + } + + pub fn deinit(this: *@This()) void { + this.w.deinit(); + this.c.deinit(); + this.* = undefined; + } + + pub fn handle(this: *@This(), event: Event) Event { + return this.w.handle(event); + } + + pub fn content(this: *@This()) *std.ArrayList(u8) { + const widget_content = this.w.content(); + this.c.clearRetainingCapacity(); + this.c.appendSlice(widget_content.items) catch @panic("OOM"); + return &this.c; + } + }; +} diff --git a/src/main.zig b/src/main.zig index 0fdd2ce..d0d6dc8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,142 +1,45 @@ const std = @import("std"); -const vaxis = @import("vaxis"); const zlog = @import("zlog"); -const widget = @import("widget.zig"); +const terminal = @import("terminal.zig"); -const TextInput = vaxis.widgets.TextInput; -const Event = widget.Event; +const UserEvent = union(enum) {}; + +const Widget = @import("widget.zig").Widget(UserEvent); +const Layout = @import("layout.zig").Layout(UserEvent); pub const std_options = zlog.std_options; +const log = std.log.scoped(.main); pub fn main() !void { 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 + // fail test; can't try in defer as defer is executed after we return if (deinit_status == .leak) { std.log.err("memory leak", .{}); } } - const alloc = gpa.allocator(); + const allocator = gpa.allocator(); - // Initialize a tty - var tty = try vaxis.Tty.init(); - defer tty.deinit(); + const size = terminal.getTerminalSize(); - // Initialize Vaxis - var vx = try vaxis.init(alloc, .{}); - // deinit takes an optional allocator. If your program is exiting, you can - // choose to pass a null allocator to save some exit time. - defer vx.deinit(alloc, tty.anyWriter()); + log.debug("Size := [x: {d}, y: {d}]", .{ size.cols, size.rows }); + var rawText = Widget.RawText.init(allocator); + const widget = Widget.createFrom(&rawText); + var layout = Layout.Pane.init(allocator, widget); + defer layout.deinit(); - // The event loop requires an intrusive init. We create an instance with - // stable pointers to Vaxis and our TTY, then init the instance. Doing so - // installs a signal handler for SIGWINCH on posix TTYs - // - // This event loop is thread safe. It reads the tty in a separate thread - var loop: vaxis.Loop(Event) = .{ - .tty = &tty, - .vaxis = &vx, - }; - try loop.init(); + // single 'draw' loop + _ = layout.handle(.none); + log.debug("Layout result: {s}", .{layout.content().items}); - // Start the read loop. This puts the terminal in raw mode and begins - // reading user input - try loop.start(); - defer loop.stop(); - - // Optionally enter the alternate screen - try vx.enterAltScreen(tty.anyWriter()); - - var header = try widget.Header.init(alloc, &vx.unicode); - defer header.deinit(); - - var view_port = widget.ViewPort.init(alloc, &vx.unicode); - defer view_port.deinit(); - - var active_menu = false; - var menu = widget.PopupMenu.init(alloc, &vx.unicode); - defer menu.deinit(); - - // Sends queries to terminal to detect certain features. This should always - // be called after entering the alt screen, if you are using the alt screen - try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); - - loop.postEvent(.{ .path = "./doc/home.md" }); - - while (true) { - const event = loop.nextEvent(); - // update widgets - header.update(event); - view_port.update(event); - if (active_menu) { - if (menu.update(event)) |e| { - _ = loop.tryPostEvent(e); - active_menu = false; - } - } - - switch (event) { - .key_press => |key| { - if (active_menu) { - if (key.matches(vaxis.Key.escape, .{})) { - active_menu = false; - } - } - - if (key.matches('c', .{ .ctrl = true })) { - break; - } else if (key.matches(vaxis.Key.space, .{})) { - active_menu = true; - } else if (key.matches('l', .{ .ctrl = true })) { - vx.queueRefresh(); - } - }, - .winsize => |ws| try vx.resize(alloc, tty.anyWriter(), ws), - else => {}, - } - - var root_window = vx.window(); - root_window.clear(); - // FIXME: this should not be necessary to clear the contents - try vx.render(tty.anyWriter()); // re-draw after clear! - - header.draw(root_window.child(.{ - .x_off = 0, - .y_off = 0, - .height = .{ .limit = 3 }, - .border = .{ .where = .all }, - })); - - // should be 120 characters wide and centered horizontally - var view_port_x_off: usize = undefined; - var limit: usize = 120; - if (root_window.width / 2 -| 60 > 0) { - view_port_x_off = root_window.width / 2 -| 60; - } else { - view_port_x_off = 1; - limit = root_window.width - 1; - } - view_port.draw(root_window.child(.{ - .x_off = view_port_x_off, - .y_off = 3, - .width = .{ .limit = limit }, - })); - - if (active_menu) { - menu.draw(root_window.child(.{ - .x_off = root_window.width / 2 -| 25, - .y_off = root_window.height / 2 -| 10, - .width = .{ .limit = 50 }, - .height = .{ .limit = 20 }, - .border = .{ .where = .all }, - })); - } - - // Render the screen. Using a buffered writer will offer much better - // performance, but is not required - try vx.render(tty.anyWriter()); - } + // how would I draw? + // use array for screen contents? <-> support partial re-draws + // support widget type drawing similar to the already existing widgets + // determine the corresponding capabilities of the terminal? + // support layouts + // - contents of corresponding locations + // resize event } diff --git a/src/terminal.zig b/src/terminal.zig new file mode 100644 index 0000000..f49c94b --- /dev/null +++ b/src/terminal.zig @@ -0,0 +1,159 @@ +const std = @import("std"); + +const posix = std.posix; +const fmt = std.fmt; + +const log = std.log.scoped(.terminal); + +pub const Size = struct { + cols: u16, + rows: u16, +}; + +pub const Position = struct { + col: u16, + row: u16, +}; + +// Ref: https://vt100.net/docs/vt510-rm/DECRPM.html +pub const ReportMode = enum { + not_recognized, + set, + reset, + permanently_set, + permanently_reset, +}; + +/// Gets number of rows and columns in the terminal +pub fn getTerminalSize() Size { + var ws: posix.winsize = undefined; + _ = posix.system.ioctl(posix.STDERR_FILENO, posix.T.IOCGWINSZ, &ws); + return .{ .cols = ws.ws_col, .rows = ws.ws_row }; +} + +pub fn getCursorPosition() !Position { + // Needs Raw mode (no wait for \n) to work properly cause + // control sequence will not be written without it. + _ = try posix.write(posix.STDERR_FILENO, "\x1b[6n"); + + var buf: [64]u8 = undefined; + + // format: \x1b, "[", R1,..., Rn, ";", C1, ..., Cn, "R" + const len = try posix.read(posix.STDIN_FILENO, &buf); + + if (!isCursorPosition(buf[0..len])) { + return error.InvalidValueReturned; + } + + var row: [8]u8 = undefined; + var col: [8]u8 = undefined; + + var ridx: u3 = 0; + var cidx: u3 = 0; + + var is_parsing_cols = false; + for (2..(len - 1)) |i| { + const b = buf[i]; + if (b == ';') { + is_parsing_cols = true; + continue; + } + + if (b == 'R') { + break; + } + + if (is_parsing_cols) { + col[cidx] = buf[i]; + cidx += 1; + } else { + row[ridx] = buf[i]; + ridx += 1; + } + } + + return .{ + .row = try fmt.parseInt(u16, row[0..ridx], 10), + .col = try fmt.parseInt(u16, col[0..cidx], 10), + }; +} + +/// Function is more of a heuristic as opposed +/// to an exact check. +pub fn isCursorPosition(buf: []u8) bool { + if (buf.len < 6) { + return false; + } + + if (buf[0] != 27 or buf[1] != '[') { + return false; + } + + return true; +} + +/// Sets the following +/// - IXON: disables start/stop output flow (reads CTRL-S, CTRL-Q) +/// - ICRNL: disables CR to NL translation (reads CTRL-M) +/// - IEXTEN: disable implementation defined functions (reads CTRL-V, CTRL-O) +/// - ECHO: user input is not printed to terminal +/// - ICANON: read runs for every input (no waiting for `\n`) +/// - ISIG: disable QUIT, ISIG, SUSP. +/// +/// `bak`: pointer to store termios struct backup before +/// altering, this is used to disable raw mode. +pub fn enableRawMode(bak: *posix.termios) !void { + var termios = try posix.tcgetattr(posix.STDIN_FILENO); + bak.* = termios; + + termios.iflag.IXON = false; + termios.iflag.ICRNL = false; + + termios.lflag.ECHO = false; + termios.lflag.ICANON = false; + termios.lflag.IEXTEN = false; + termios.lflag.ISIG = false; + + try posix.tcsetattr( + posix.STDIN_FILENO, + posix.TCSA.FLUSH, + termios, + ); +} + +/// Reverts `enableRawMode` to restore initial functionality. +pub fn disableRawMode(bak: *posix.termios) !void { + try posix.tcsetattr( + posix.STDIN_FILENO, + posix.TCSA.FLUSH, + bak.*, + ); +} + +// Ref +pub fn canSynchornizeOutput() !bool { + // Needs Raw mode (no wait for \n) to work properly cause + // control sequence will not be written without it. + _ = try posix.write(posix.STDERR_FILENO, "\x1b[?2026$p"); + + var buf: [64]u8 = undefined; + + // format: \x1b, "[", "?", "2", "0", "2", "6", ";", n, "$", "y" + const len = try posix.read(posix.STDIN_FILENO, &buf); + if (!std.mem.eql(u8, buf[0..len], "\x1b[?2026;") or len < 9) { + return false; + } + + // Check value of n + return getReportMode(buf[8]) == .reset; +} + +fn getReportMode(ps: u8) ReportMode { + return switch (ps) { + '1' => ReportMode.set, + '2' => ReportMode.reset, + '3' => ReportMode.permanently_set, + '4' => ReportMode.permanently_reset, + else => ReportMode.not_recognized, + }; +} diff --git a/src/widget.zig b/src/widget.zig index 5c8b8a7..e4fc387 100644 --- a/src/widget.zig +++ b/src/widget.zig @@ -1,63 +1,83 @@ -//! Dynamic dispatch for widget implementations -//! Each widget should at last implement these two methods: -//! - update(this: *@This(), event: Event) void {} -//! - draw(this: *@This(), win: vaxis.Window) void {} +//! Dynamic dispatch for widget implementations. +//! Each widget should at last implement these functions: +//! - handle(this: *@This(), event: Event) Event {} +//! - content(this: *@This()) *std.ArrayList(u8) {} +//! - deinit(this: *@This()) void {} //! //! Create a `Widget` using `createFrom(object: anytype)` and use them through //! the defined 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. +const std = @import("std"); +const lib_event = @import("event.zig"); -const vaxis = @import("vaxis"); +pub fn Widget(comptime E: type) type { + if (!lib_event.isTaggedUnion(E)) { + @compileError("Provided user event `E` for `Layout(comptime E: type)` is not of type `union(enum)`."); + } + return struct { + pub const Event = lib_event.MergeTaggedUnions(lib_event.BuiltinEvent, E); + const WidgetType = @This(); + const Ptr = usize; -pub const Event = union(enum) { - key_press: vaxis.Key, - winsize: vaxis.Winsize, - path: []const u8, -}; + const VTable = struct { + handle: *const fn (this: *WidgetType, event: Event) Event, + content: *const fn (this: *WidgetType) *std.ArrayList(u8), + deinit: *const fn (this: *WidgetType) void, + }; -const Ptr = usize; + object: Ptr = undefined, + vtable: *const VTable = undefined, -object: Ptr = undefined, -vtable: *const VTable = undefined, + // Handle the provided `Event` for this `Widget`. + pub fn handle(this: *WidgetType, event: Event) Event { + return this.vtable.handle(this, event); + } -const VTable = struct { - update: *const fn (this: *@This(), event: Event) void, - draw: *const fn (this: *@This(), win: vaxis.Window) void, -}; + // Return the entire content of this `Widget`. + pub fn content(this: *WidgetType) *std.ArrayList(u8) { + return this.vtable.content(this); + } -/// Update loop for a given widget to react to the provided `Event`. It may -/// change its internal state, update variables, react to user input, etc. -pub fn update(this: *@This(), event: Event) void { - this.vtable.update(this, event); -} + pub fn deinit(this: *WidgetType) void { + this.vtable.deinit(this); + this.* = undefined; + } -/// Draw a given widget using the provided `vaxis.Window`. The window controls -/// the dimension one widget may take on the screen. The widget itself has no -/// control over this. -pub fn draw(this: *@This(), win: vaxis.Window) void { - this.vtable.draw(this, win); -} + pub fn createFrom(object: anytype) WidgetType { + return WidgetType{ + .object = @intFromPtr(object), + .vtable = &.{ + .handle = struct { + // Handle the provided `Event` for this `Widget`. + fn handle(this: *WidgetType, event: Event) Event { + const widget: @TypeOf(object) = @ptrFromInt(this.object); + return widget.handle(event); + } + }.handle, + .content = struct { + // Return the entire content of this `Widget`. + fn content(this: *WidgetType) *std.ArrayList(u8) { + const widget: @TypeOf(object) = @ptrFromInt(this.object); + return widget.content(); + } + }.content, + .deinit = struct { + fn deinit(this: *WidgetType) void { + const widget: @TypeOf(object) = @ptrFromInt(this.object); + widget.deinit(); + } + }.deinit, + }, + }; + } -pub fn createFrom(object: anytype) @This() { - return @This(){ - .object = @intFromPtr(object), - .vtable = &.{ - .update = struct { - fn update(this: *@This(), event: Event) void { - const widget: @TypeOf(object) = @ptrFromInt(this.object); - widget.update(event); - } - }.update, - .draw = struct { - fn draw(this: *@This(), win: vaxis.Window) void { - const widget: @TypeOf(object) = @ptrFromInt(this.object); - widget.draw(win); - } - }.draw, - }, + // TODO: import and export of `Widget` implementations (with corresponding intialization using `Event`) + pub const RawText = @import("widget/RawText.zig").Widget(E); + // pub const Header = @import("widget/Header.zig"); + // pub const ViewPort = @import("widget/ViewPort.zig"); + // pub const PopupMenu = @import("widget/PopupMenu.zig"); }; } - -pub const Header = @import("widget/Header.zig"); -pub const ViewPort = @import("widget/ViewPort.zig"); -pub const PopupMenu = @import("widget/PopupMenu.zig"); diff --git a/src/widget/Header.zig b/src/widget/Header.zig deleted file mode 100644 index c8b1732..0000000 --- a/src/widget/Header.zig +++ /dev/null @@ -1,127 +0,0 @@ -//! Header widget, which shows the name of the website and the main navigation entries -const std = @import("std"); -const vaxis = @import("vaxis"); - -const widget = @import("../widget.zig"); - -const Event = widget.Event; - -allocator: std.mem.Allocator = undefined, -unicode: *const vaxis.Unicode = undefined, -path: ?[]const u8 = undefined, -view: ?vaxis.widgets.View = undefined, - -pub fn init(allocator: std.mem.Allocator, unicode: *const vaxis.Unicode) !@This() { - return .{ - .allocator = allocator, - .unicode = unicode, - .path = null, - .view = null, - }; -} - -pub fn deinit(this: *@This()) void { - if (this.view) |*view| { - view.*.deinit(); - } - if (this.path) |*path| { - this.allocator.free(path.*); - } - this.* = undefined; -} - -fn fillView(this: *@This()) void { - this.view.?.clear(); - - const msg = "Yves Biener"; - for (msg, 0..) |_, i| { - const cell: vaxis.Cell = .{ - // each cell takes a _grapheme_ as opposed to a single - // codepoint. This allows Vaxis to handle emoji properly, - // particularly with terminals that the Unicode Core extension - // (IE Mode 2027) - .char = .{ .grapheme = msg[i .. i + 1] }, - .style = .{ - .fg = .{ .index = 6 }, - .bold = true, - }, - }; - this.view.?.writeCell(i + 1, 0, cell); - } - - if (this.path) |path| { - for (0..path.len, this.view.?.screen.width / 2 - path.len / 2..) |i, col| { - const cell: vaxis.Cell = .{ - .char = .{ .grapheme = path[i .. i + 1] }, - .style = .{ - .ul_style = .single, - }, - }; - this.view.?.writeCell(col, 0, cell); - } - } - - // github - { - const cell: vaxis.Cell = .{ - .link = .{ .uri = "https://github.com/yves-biener" }, - .char = .{ .grapheme = "github" }, - .style = .{ - .fg = .{ .index = 3 }, - .ul_style = .single, - }, - }; - this.view.?.writeCell(this.view.?.screen.width - 9, 0, cell); - } - // mail - { - const cell: vaxis.Cell = .{ - .link = .{ .uri = "mailto:yves.biener@gmx.de" }, - .char = .{ .grapheme = "mail" }, - .style = .{ - .fg = .{ .index = 3 }, - .ul_style = .single, - }, - }; - this.view.?.writeCell(this.view.?.screen.width - 16, 0, cell); - } -} - -/// Update loop for a given widget to react to the provided `Event`. It may -/// change its internal state, update variables, react to user input, etc. -pub fn update(this: *@This(), event: Event) void { - switch (event) { - .winsize => |ws| { - if (this.view) |*view| { - if (ws.cols != view.screen.width) { - view.*.deinit(); - this.view = vaxis.widgets.View.init(this.allocator, this.unicode, .{ .width = ws.cols, .height = ws.rows }) catch @panic("OOM"); - this.fillView(); - } - } else { - this.view = vaxis.widgets.View.init(this.allocator, this.unicode, .{ .width = ws.cols, .height = ws.rows }) catch @panic("OOM"); - this.fillView(); - } - }, - .path => |path| { - // TODO: try to remove the necessary amount of allocations - if (this.path) |*p| { - this.allocator.free(p.*); - } - const p = this.allocator.alloc(u8, path.len) catch @panic("OOM"); - @memcpy(p, path); - this.path = p; - this.fillView(); - }, - else => {}, - } -} - -/// Draw a given widget using the provided `vaxis.Window`. The window controls -/// the dimension one widget may take on the screen. The widget itself has no -/// control over this. -pub fn draw(this: *@This(), win: vaxis.Window) void { - if (this.view) |*view| { - view.*.draw(win, .{}); - } -} diff --git a/src/widget/PopupMenu.zig b/src/widget/PopupMenu.zig deleted file mode 100644 index 3643033..0000000 --- a/src/widget/PopupMenu.zig +++ /dev/null @@ -1,80 +0,0 @@ -//! Pop-up Menu widget to show the available keybindings - -const std = @import("std"); -const vaxis = @import("vaxis"); - -const converter = @import("node2buffer.zig").Markdown; -const widget = @import("../widget.zig"); - -const Event = widget.Event; - -allocator: std.mem.Allocator = undefined, -unicode: *const vaxis.Unicode = undefined, - -pub fn init(allocator: std.mem.Allocator, unicode: *const vaxis.Unicode) @This() { - return .{ - .allocator = allocator, - .unicode = unicode, - }; -} - -pub fn deinit(this: *@This()) void { - this.* = undefined; -} - -/// Update loop for a given widget to react to the provided `Event`. It may -/// change its internal state, update variables, react to user input, etc. -pub fn update(this: *@This(), event: Event) ?Event { - _ = this; - switch (event) { - .key_press => |key| { - if (key.matches('a', .{})) { - // About - return .{ .path = "./doc/about.md" }; - } - if (key.matches('h', .{})) { - // Home - return .{ .path = "./doc/home.md" }; - } - if (key.matches('t', .{})) { - // test - return .{ .path = "./doc/test.md" }; - } - }, - else => {}, - } - return null; -} - -/// Draw a given widget using the provided `vaxis.Window`. The window controls -/// the dimension one widget may take on the screen. The widget itself has no -/// control over this. -pub fn draw(this: *@This(), win: vaxis.Window) void { - var view = vaxis.widgets.View.init(this.allocator, this.unicode, .{ .width = win.width, .height = win.height }) catch @panic("OOM"); - defer view.deinit(); - - const msg = - \\# Goto - \\ - \\**a** about - \\**h** home - \\**t** test - ; - var cells = std.ArrayList(vaxis.Cell).init(this.allocator); - defer cells.deinit(); - - converter.toBuffer(msg, this.allocator, &cells); - - var col: usize = 0; - var row: usize = 0; - for (cells.items) |cell| { - view.writeCell(col, row, cell); - if (std.mem.eql(u8, cell.char.grapheme, "\n")) { - col = 0; - row += 1; - } else { - col += 1; - } - } - view.draw(win, .{}); -} diff --git a/src/widget/RawText.zig b/src/widget/RawText.zig new file mode 100644 index 0000000..42836fd --- /dev/null +++ b/src/widget/RawText.zig @@ -0,0 +1,37 @@ +const std = @import("std"); +const lib_event = @import("../event.zig"); + +pub fn Widget(comptime E: type) type { + if (!lib_event.isTaggedUnion(E)) { + @compileError("Provided user event `E` for `Layout(comptime E: type)` is not of type `union(enum)`."); + } + return struct { + pub const Event = lib_event.MergeTaggedUnions(lib_event.BuiltinEvent, E); + + c: std.ArrayList(u8) = undefined, + + pub fn init(allocator: std.mem.Allocator) @This() { + var c = std.ArrayList(u8).init(allocator); + c.appendSlice("This is a simple test") catch @panic("OOM"); + return .{ + .c = c, + }; + } + + pub fn deinit(this: *@This()) void { + this.c.deinit(); + this.* = undefined; + } + + pub fn handle(this: *@This(), event: Event) Event { + // ignore the event for now + _ = this; + _ = event; + return .none; + } + + pub fn content(this: *@This()) *std.ArrayList(u8) { + return &this.c; + } + }; +} diff --git a/src/widget/ViewPort.zig b/src/widget/ViewPort.zig deleted file mode 100644 index 05e7f54..0000000 --- a/src/widget/ViewPort.zig +++ /dev/null @@ -1,103 +0,0 @@ -//! ViewPort widget, which show the content of a file as a pager with corresponding navigation of the view port - -const std = @import("std"); -const vaxis = @import("vaxis"); -const Zmd = @import("zmd").Zmd; - -const converter = @import("node2buffer.zig").Markdown; -const widget = @import("../widget.zig"); - -const Event = widget.Event; - -allocator: std.mem.Allocator = undefined, -unicode: *const vaxis.Unicode = undefined, -contents: ?[]const u8 = null, -buffer: std.ArrayList(vaxis.Cell) = undefined, -view: vaxis.widgets.ScrollView = undefined, - -pub fn init(allocator: std.mem.Allocator, unicode: *const vaxis.Unicode) @This() { - return .{ - .allocator = allocator, - .unicode = unicode, - .contents = null, - .buffer = std.ArrayList(vaxis.Cell).init(allocator), - .view = .{ .vertical_scrollbar = null }, - }; -} - -pub fn deinit(this: *@This()) void { - if (this.contents) |*content| { - this.allocator.free(content.*); - } - this.buffer.deinit(); - this.* = undefined; -} - -/// Update loop for a given widget to react to the provided `Event`. It may -/// change its internal state, update variables, react to user input, etc. -pub fn update(this: *@This(), event: Event) void { - // FIXME: limit scroll-able area to corresponding contents! - switch (event) { - .key_press => |key| { - if (key.matches(vaxis.Key.up, .{}) or key.matches('k', .{})) { - this.view.scroll.y -|= 1; - } else if (key.matches(vaxis.Key.page_up, .{}) or key.matches('u', .{ .ctrl = true })) { - this.view.scroll.y -|= 32; - } else if (key.matches(vaxis.Key.down, .{}) or key.matches('j', .{})) { - this.view.scroll.y +|= 1; - } else if (key.matches(vaxis.Key.page_down, .{}) or key.matches('d', .{ .ctrl = true })) { - this.view.scroll.y +|= 32; - } else if (key.matches(vaxis.Key.end, .{}) or key.matches('G', .{})) { - this.view.scroll.y = std.math.maxInt(usize); - } else if (key.matches(vaxis.Key.home, .{}) or key.matches('g', .{})) { - this.view.scroll.y = 0; - } - }, - .path => |path| { - const file = std.fs.cwd().openFile(path, .{ .mode = .read_only }) catch |err| { - // TODO: in case of an error show an error-page or an error notification? - std.log.err("could not open file: {s} due to {any}", .{ path, err }); - return; - }; - defer file.close(); - if (this.contents) |*content| { - this.allocator.free(content.*); - } - this.contents = file.readToEndAlloc(this.allocator, 4096) catch @panic("could not read to end"); - - // TODO: support typst files as parser and display driver -> as I'll be using this file format anyway with my personal note system - // - I should leverage the typst compiler! (i.e. maybe use the html export, once that is available?) - - this.buffer.clearRetainingCapacity(); - converter.toBuffer(this.contents.?, this.allocator, &this.buffer); - }, - else => {}, - } -} - -/// Draw a given widget using the provided `vaxis.Window`. The window controls -/// the dimension one widget may take on the screen. The widget itself has no -/// control over this. -pub fn draw(this: *@This(), win: vaxis.Window) void { - // TODO: this is not very performant and should be improved - var rows: usize = 1; - for (this.buffer.items) |cell| { - if (std.mem.eql(u8, cell.char.grapheme, "\n")) { - rows += 1; - } - } - this.view.draw(win, .{ .cols = win.width, .rows = rows }); // needed for scroll bar scaling and display - - const Pos = struct { x: usize = 0, y: usize = 0 }; - var pos: Pos = .{}; - for (this.buffer.items) |cell| { - // NOTE: do not print newline characters, but instead go to the next row - if (std.mem.eql(u8, cell.char.grapheme, "\n")) { - pos.x = 0; - pos.y += 1; - } else { - this.view.writeCell(win, pos.x, pos.y, cell); - pos.x += 1; - } - } -} -- 2.49.1 From 14aab9ef50c73be353249d9af962d11073c04f56 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Mon, 4 Nov 2024 22:27:45 +0100 Subject: [PATCH 02/20] mod(tui): create own terminal interface framework --- src/app.zig | 90 ++++++++++++ src/event.zig | 12 +- src/layout.zig | 33 ++--- src/layout/Pane.zig | 29 ++-- src/main.zig | 59 ++++++-- src/queue.zig | 322 +++++++++++++++++++++++++++++++++++++++++ src/terminal.zig | 61 +++++--- src/widget.zig | 27 ++-- src/widget/RawText.zig | 22 ++- 9 files changed, 559 insertions(+), 96 deletions(-) create mode 100644 src/app.zig create mode 100644 src/queue.zig diff --git a/src/app.zig b/src/app.zig new file mode 100644 index 0000000..8c986e0 --- /dev/null +++ b/src/app.zig @@ -0,0 +1,90 @@ +//! Application type for TUI-applications +const std = @import("std"); +const terminal = @import("terminal.zig"); +const mergeTaggedUnions = @import("event.zig").mergeTaggedUnions; +const isTaggedUnion = @import("event.zig").isTaggedUnion; + +const BuiltinEvent = @import("event.zig").BuiltinEvent; +const Queue = @import("queue.zig").Queue; + +const log = std.log.scoped(.app); + +// Create the App Type with the associated user events `E` which describes +// an tagged union for all the user events that can be send through the +// applications event loop. +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(BuiltinEvent, E); + pub const Layout = @import("layout.zig").Layout(Event); + pub const Widget = @import("widget.zig").Widget(Event); + + queue: Queue(Event, 256) = .{}, + thread: ?std.Thread = null, + quit: bool = false, + termios: ?std.posix.termios = null, + + // TODO: event loop function? + // layout handling? + + pub fn init() @This() { + return .{}; + } + + pub fn start(this: *@This()) !void { + if (this.thread) |_| return; + + this.thread = try std.Thread.spawn(.{}, @This().run, .{this}); + var termios: std.posix.termios = undefined; + try terminal.enableRawMode(&termios); + this.termios = termios; + try terminal.saveScreen(); + } + + pub fn stop(this: *@This()) !void { + if (this.termios) |*termios| { + try terminal.disableRawMode(termios); + try terminal.restoreScreen(); + } + this.quit = true; + if (this.thread) |thread| { + thread.join(); + this.thread = null; + } + } + + /// Returns the next available event, blocking until one is available. + pub fn nextEvent(this: *@This()) Event { + return this.queue.pop(); + } + + /// Post an event into the queue. Blocks if there is no capacity for the event. + pub fn postEvent(this: *@This(), event: Event) void { + this.queue.push(event); + } + + fn run(this: *@This()) !void { + // thread to read user inputs + const size = terminal.getTerminalSize(); + this.postEvent(.{ .resize = size }); + // read input in loop + const buf: [256]u8 = undefined; + _ = buf; + while (!this.quit) { + std.time.sleep(5 * std.time.ns_per_s); + break; + // try terminal.read(buf[0..]); + // TODO: send corresponding events with key_presses + // -> create corresponding event + // -> handle key inputs (modifier, op codes, etc.) + // -> I could take inspiration from `libvaxis` for this + } + // FIXME: here is a race-condition -> i.e. there could be events in + // the queue, but they will not be executed because the main loop + // will close! + this.postEvent(.quit); + } + }; +} diff --git a/src/event.zig b/src/event.zig index 8c49dd4..c6a5068 100644 --- a/src/event.zig +++ b/src/event.zig @@ -1,5 +1,5 @@ //! Events which are defined by the library. They might be extended by user -//! events. +//! events. See `App` for more details about user defined events. const std = @import("std"); const terminal = @import("terminal.zig"); @@ -8,21 +8,23 @@ const terminal = @import("terminal.zig"); // message, while `err` represents an error which is propagated. `Widget`s or // `Layout`s may react to the event but should continue throwing the message up // to the application event loop. -pub const ApplicationEvent = union(enum) { +const ApplicationEvent = union(enum) { none, + quit, err: []const u8, }; // System events which contain information about events triggered from outside // of the application which impact the application. E.g. the terminal window // size has changed, etc. -pub const SystemEvent = union(enum) { +const SystemEvent = union(enum) { resize: terminal.Size, + // key_press: terminal.Key, }; -pub const BuiltinEvent = MergeTaggedUnions(SystemEvent, ApplicationEvent); +pub const BuiltinEvent = mergeTaggedUnions(SystemEvent, ApplicationEvent); -pub fn MergeTaggedUnions(comptime A: type, comptime B: type) type { +pub fn mergeTaggedUnions(comptime A: type, comptime B: type) 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/layout.zig b/src/layout.zig index 57a1be1..3cdc01f 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -1,7 +1,7 @@ //! Dynamic dispatch for layout implementations. //! Each layout should at last implement these functions: -//! - handle(this: *@This(), event: Event) Event {} -//! - content(this: *@This()) *std.ArrayList(u8) {} +//! - handle(this: *@This(), event: Event) anyerror!*std.ArrayList(Event) {} +//! - content(this: *@This()) anyerror!*std.ArrayList(u8) {} //! - deinit(this: *@This()) void {} //! //! Create a `Layout` using `createFrom(object: anytype)` and use them through @@ -14,18 +14,17 @@ const std = @import("std"); const lib_event = @import("event.zig"); -pub fn Layout(comptime E: type) type { - if (!lib_event.isTaggedUnion(E)) { - @compileError("Provided user event `E` for `Layout(comptime E: type)` is not of type `union(enum)`."); +pub fn Layout(comptime Event: type) type { + if (!lib_event.isTaggedUnion(Event)) { + @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } return struct { - pub const Event = lib_event.MergeTaggedUnions(lib_event.BuiltinEvent, E); const LayoutType = @This(); const Ptr = usize; const VTable = struct { - handle: *const fn (this: *LayoutType, event: Event) Event, - content: *const fn (this: *LayoutType) *std.ArrayList(u8), + handle: *const fn (this: *LayoutType, event: Event) anyerror!*std.ArrayList(Event), + content: *const fn (this: *LayoutType) anyerror!*std.ArrayList(u8), deinit: *const fn (this: *LayoutType) void, }; @@ -33,13 +32,13 @@ pub fn Layout(comptime E: type) type { vtable: *const VTable = undefined, // Handle the provided `Event` for this `Widget`. - pub fn handle(this: *LayoutType, event: Event) Event { - return this.vtable.handle(this, event); + pub fn handle(this: *LayoutType, event: Event) !*std.ArrayList(Event) { + return try this.vtable.handle(this, event); } // Return the entire content of this `Widget`. - pub fn content(this: *LayoutType) *std.ArrayList(u8) { - return this.vtable.content(this); + pub fn content(this: *LayoutType) !*std.ArrayList(u8) { + return try this.vtable.content(this); } pub fn deinit(this: *LayoutType) void { @@ -53,16 +52,16 @@ pub fn Layout(comptime E: type) type { .vtable = &.{ .handle = struct { // Handle the provided `Event` for this `Widget`. - fn handle(this: *LayoutType, event: Event) Event { + fn handle(this: *LayoutType, event: Event) !*std.ArrayList(Event) { const layout: @TypeOf(object) = @ptrFromInt(this.object); - return layout.handle(event); + return try layout.handle(event); } }.handle, .content = struct { // Return the entire content of this `Widget`. - fn content(this: *LayoutType) *std.ArrayList(u8) { + fn content(this: *LayoutType) !*std.ArrayList(u8) { const layout: @TypeOf(object) = @ptrFromInt(this.object); - return layout.content(); + return try layout.content(); } }.content, .deinit = struct { @@ -76,6 +75,6 @@ pub fn Layout(comptime E: type) type { } // import and export of `Layout` implementations - pub const Pane = @import("layout/Pane.zig").Layout(E); + pub const Pane = @import("layout/Pane.zig").Layout(Event); }; } diff --git a/src/layout/Pane.zig b/src/layout/Pane.zig index 629dd59..c7c0998 100644 --- a/src/layout/Pane.zig +++ b/src/layout/Pane.zig @@ -2,37 +2,42 @@ const std = @import("std"); const lib_event = @import("../event.zig"); const widget = @import("../widget.zig"); -pub fn Layout(comptime E: type) type { - if (!lib_event.isTaggedUnion(E)) { - @compileError("Provided user event `E` for `Layout(comptime E: type)` is not of type `union(enum)`."); +pub fn Layout(comptime Event: type) type { + if (!lib_event.isTaggedUnion(Event)) { + @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } return struct { - pub const Event = lib_event.MergeTaggedUnions(lib_event.BuiltinEvent, E); - - w: widget.Widget(E) = undefined, + w: widget.Widget(Event) = undefined, + events: std.ArrayList(Event) = undefined, c: std.ArrayList(u8) = undefined, - pub fn init(allocator: std.mem.Allocator, w: widget.Widget(E)) @This() { + pub fn init(allocator: std.mem.Allocator, w: widget.Widget(Event)) @This() { return .{ .w = w, + .events = std.ArrayList(Event).init(allocator), .c = std.ArrayList(u8).init(allocator), }; } pub fn deinit(this: *@This()) void { this.w.deinit(); + this.events.deinit(); this.c.deinit(); this.* = undefined; } - pub fn handle(this: *@This(), event: Event) Event { - return this.w.handle(event); + pub fn handle(this: *@This(), event: Event) !*std.ArrayList(Event) { + this.events.clearRetainingCapacity(); + if (this.w.handle(event)) |e| { + try this.events.append(e); + } + return &this.events; } - pub fn content(this: *@This()) *std.ArrayList(u8) { - const widget_content = this.w.content(); + pub fn content(this: *@This()) !*std.ArrayList(u8) { + const widget_content = try this.w.content(); this.c.clearRetainingCapacity(); - this.c.appendSlice(widget_content.items) catch @panic("OOM"); + try this.c.appendSlice(widget_content.items); return &this.c; } }; diff --git a/src/main.zig b/src/main.zig index d0d6dc8..b0baf99 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,18 +1,15 @@ const std = @import("std"); - +const terminal = @import("terminal.zig"); const zlog = @import("zlog"); -const terminal = @import("terminal.zig"); - -const UserEvent = union(enum) {}; - -const Widget = @import("widget.zig").Widget(UserEvent); -const Layout = @import("layout.zig").Layout(UserEvent); +const App = @import("app.zig").App(union(enum) {}); pub const std_options = zlog.std_options; const log = std.log.scoped(.main); pub fn main() !void { + errdefer |err| log.err("Application Error: {any}", .{err}); + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer { const deinit_status = gpa.deinit(); @@ -23,18 +20,46 @@ pub fn main() !void { } const allocator = gpa.allocator(); - const size = terminal.getTerminalSize(); + var app = App.init(); - log.debug("Size := [x: {d}, y: {d}]", .{ size.cols, size.rows }); - var rawText = Widget.RawText.init(allocator); - const widget = Widget.createFrom(&rawText); - var layout = Layout.Pane.init(allocator, widget); + var rawText = App.Widget.RawText.init(allocator); + const widget = App.Widget.createFrom(&rawText); + var layout = App.Layout.Pane.init(allocator, widget); defer layout.deinit(); - // single 'draw' loop - _ = layout.handle(.none); - log.debug("Layout result: {s}", .{layout.content().items}); + try app.start(); + defer app.stop() catch unreachable; + // NOTE: necessary for fullscreen tui applications + try terminal.enterAltScreen(); + defer terminal.existAltScreen() catch unreachable; + + // App.Event loop + while (true) { + const event = app.nextEvent(); + + switch (event) { + .none => continue, + .quit => break, + .resize => |size| { + // NOTE: draw actions should not happen here (still here for testing) + // NOTE: clearing the screen and positioning the cursor is only necessary for full screen applications + // - in-line applications should use relative movements instead and should only clear lines (which they draw) + // - in-line applications should not enter the alt screen + try terminal.clearScreen(); + try terminal.setCursorPositionHome(); + log.debug("Size := [x: {d}, y: {d}]", .{ size.cols, size.rows }); + }, + else => {}, + } + const events = try layout.handle(event); + for (events.items) |e| { + app.postEvent(e); + } + log.debug("Layout result: {s}", .{(try layout.content()).items}); + } + // TODO: I could use the ascii codes in vaxis + // - see https://gist.github.com/ConnerWill/d4b6c776b509add763e17f9f113fd25b // how would I draw? // use array for screen contents? <-> support partial re-draws // support widget type drawing similar to the already existing widgets @@ -43,3 +68,7 @@ pub fn main() !void { // - contents of corresponding locations // resize event } + +test { + _ = @import("queue.zig"); +} diff --git a/src/queue.zig b/src/queue.zig new file mode 100644 index 0000000..a364e57 --- /dev/null +++ b/src/queue.zig @@ -0,0 +1,322 @@ +// taken from https://github.com/rockorager/libvaxis/blob/main/src/queue.zig (MIT-License) +// with slight modifications +const std = @import("std"); +const assert = std.debug.assert; + +/// Thread safe. Fixed size. Blocking push and pop. +pub fn Queue(comptime T: type, comptime size: usize) type { + return struct { + buf: [size]T = undefined, + + read_index: usize = 0, + write_index: usize = 0, + + mutex: std.Thread.Mutex = .{}, + // blocks when the buffer is full + not_full: std.Thread.Condition = .{}, + // ...or empty + not_empty: std.Thread.Condition = .{}, + + const QueueType = @This(); + + /// Pop an item from the queue. Blocks until an item is available. + pub fn pop(this: *QueueType) T { + this.mutex.lock(); + defer this.mutex.unlock(); + while (this.isEmptyLH()) { + this.not_empty.wait(&this.mutex); + } + assert(!this.isEmptyLH()); + if (this.isFullLH()) { + // If we are full, wake up a push that might be + // waiting here. + this.not_full.signal(); + } + + const result = this.buf[this.mask(this.read_index)]; + this.read_index = this.mask2(this.read_index + 1); + return result; + } + + /// Push an item into the queue. Blocks until an item has been + /// put in the queue. + pub fn push(this: *QueueType, item: T) void { + this.mutex.lock(); + defer this.mutex.unlock(); + while (this.isFullLH()) { + this.not_full.wait(&this.mutex); + } + if (this.isEmptyLH()) { + // If we were empty, wake up a pop if it was waiting. + this.not_empty.signal(); + } + assert(!this.isFullLH()); + + this.buf[this.mask(this.write_index)] = item; + this.write_index = this.mask2(this.write_index + 1); + } + + /// Push an item into the queue. Returns true when the item + /// was successfully placed in the queue, false if the queue + /// was full. + pub fn tryPush(this: *QueueType, item: T) bool { + this.mutex.lock(); + if (this.isFullLH()) { + this.mutex.unlock(); + return false; + } + this.mutex.unlock(); + this.push(item); + return true; + } + + /// Pop an item from the queue. Returns null when no item is + /// available. + pub fn tryPop(this: *QueueType) ?T { + this.mutex.lock(); + if (this.isEmptyLH()) { + this.mutex.unlock(); + return null; + } + this.mutex.unlock(); + return this.pop(); + } + + /// Poll the queue. This call blocks until events are in the queue + pub fn poll(this: *QueueType) void { + this.mutex.lock(); + defer this.mutex.unlock(); + while (this.isEmptyLH()) { + this.not_empty.wait(&this.mutex); + } + assert(!this.isEmptyLH()); + } + + fn isEmptyLH(this: QueueType) bool { + return this.write_index == this.read_index; + } + + fn isFullLH(this: QueueType) bool { + return this.mask2(this.write_index + this.buf.len) == + this.read_index; + } + + /// Returns `true` if the queue is empty and `false` otherwise. + pub fn isEmpty(this: *QueueType) bool { + this.mutex.lock(); + defer this.mutex.unlock(); + return this.isEmptyLH(); + } + + /// Returns `true` if the queue is full and `false` otherwise. + pub fn isFull(this: *QueueType) bool { + this.mutex.lock(); + defer this.mutex.unlock(); + return this.isFullLH(); + } + + /// Returns the length + fn len(this: QueueType) usize { + const wrap_offset = 2 * this.buf.len * + @intFromBool(this.write_index < this.read_index); + const adjusted_write_index = this.write_index + wrap_offset; + return adjusted_write_index - this.read_index; + } + + /// Returns `index` modulo the length of the backing slice. + fn mask(this: QueueType, index: usize) usize { + return index % this.buf.len; + } + + /// Returns `index` modulo twice the length of the backing slice. + fn mask2(this: QueueType, index: usize) usize { + return index % (2 * this.buf.len); + } + }; +} + +const testing = std.testing; +const cfg = Thread.SpawnConfig{ .allocator = testing.allocator }; +test "Queue: simple push / pop" { + var queue: Queue(u8, 16) = .{}; + queue.push(1); + queue.push(2); + const pop = queue.pop(); + try testing.expectEqual(1, pop); + try testing.expectEqual(2, queue.pop()); +} + +const Thread = std.Thread; +fn testPushPop(q: *Queue(u8, 2)) !void { + q.push(3); + try testing.expectEqual(2, q.pop()); +} + +test "Fill, wait to push, pop once in another thread" { + var queue: Queue(u8, 2) = .{}; + queue.push(1); + queue.push(2); + const t = try Thread.spawn(cfg, testPushPop, .{&queue}); + try testing.expectEqual(false, queue.tryPush(3)); + try testing.expectEqual(1, queue.pop()); + t.join(); + try testing.expectEqual(3, queue.pop()); + try testing.expectEqual(null, queue.tryPop()); +} + +fn testPush(q: *Queue(u8, 2)) void { + q.push(0); + q.push(1); + q.push(2); + q.push(3); + q.push(4); +} + +test "Try to pop, fill from another thread" { + var queue: Queue(u8, 2) = .{}; + const thread = try Thread.spawn(cfg, testPush, .{&queue}); + for (0..5) |idx| { + try testing.expectEqual(@as(u8, @intCast(idx)), queue.pop()); + } + thread.join(); +} + +fn sleepyPop(q: *Queue(u8, 2)) !void { + // First we wait for the queue to be full. + while (!q.isFull()) + try Thread.yield(); + + // Then we spuriously wake it up, because that's a thing that can + // happen. + q.not_full.signal(); + q.not_empty.signal(); + + // Then give the other thread a good chance of waking up. It's not + // clear that yield guarantees the other thread will be scheduled, + // so we'll throw a sleep in here just to be sure. The queue is + // still full and the push in the other thread is still blocked + // waiting for space. + try Thread.yield(); + std.time.sleep(std.time.ns_per_s); + // Finally, let that other thread go. + try std.testing.expectEqual(1, q.pop()); + + // This won't continue until the other thread has had a chance to + // put at least one item in the queue. + while (!q.isFull()) + try Thread.yield(); + // But we want to ensure that there's a second push waiting, so + // here's another sleep. + std.time.sleep(std.time.ns_per_s / 2); + + // Another spurious wake... + q.not_full.signal(); + q.not_empty.signal(); + // And another chance for the other thread to see that it's + // spurious and go back to sleep. + try Thread.yield(); + std.time.sleep(std.time.ns_per_s / 2); + + // Pop that thing and we're done. + try std.testing.expectEqual(2, q.pop()); +} + +test "Fill, block, fill, block" { + // Fill the queue, block while trying to write another item, have + // a background thread unblock us, then block while trying to + // write yet another thing. Have the background thread unblock + // that too (after some time) then drain the queue. This test + // fails if the while loop in `push` is turned into an `if`. + + var queue: Queue(u8, 2) = .{}; + const thread = try Thread.spawn(cfg, sleepyPop, .{&queue}); + queue.push(1); + queue.push(2); + const now = std.time.milliTimestamp(); + queue.push(3); // This one should block. + const then = std.time.milliTimestamp(); + + // Just to make sure the sleeps are yielding to this thread, make + // sure it took at least 900ms to do the push. + try std.testing.expect(then - now > 900); + + // This should block again, waiting for the other thread. + queue.push(4); + + // And once that push has gone through, the other thread's done. + thread.join(); + try std.testing.expectEqual(3, queue.pop()); + try std.testing.expectEqual(4, queue.pop()); +} + +fn sleepyPush(q: *Queue(u8, 1)) !void { + // Try to ensure the other thread has already started trying to pop. + try Thread.yield(); + std.time.sleep(std.time.ns_per_s / 2); + + // Spurious wake + q.not_full.signal(); + q.not_empty.signal(); + + try Thread.yield(); + std.time.sleep(std.time.ns_per_s / 2); + + // Stick something in the queue so it can be popped. + q.push(1); + // Ensure it's been popped. + while (!q.isEmpty()) + try Thread.yield(); + // Give the other thread time to block again. + try Thread.yield(); + std.time.sleep(std.time.ns_per_s / 2); + + // Spurious wake + q.not_full.signal(); + q.not_empty.signal(); + + q.push(2); +} + +test "Drain, block, drain, block" { + // This is like fill/block/fill/block, but on the pop end. This + // test should fail if the `while` loop in `pop` is turned into an + // `if`. + + var queue: Queue(u8, 1) = .{}; + const thread = try Thread.spawn(cfg, sleepyPush, .{&queue}); + try std.testing.expectEqual(1, queue.pop()); + try std.testing.expectEqual(2, queue.pop()); + thread.join(); +} + +fn readerThread(q: *Queue(u8, 1)) !void { + try testing.expectEqual(1, q.pop()); +} + +test "2 readers" { + // 2 threads read, one thread writes + var queue: Queue(u8, 1) = .{}; + const t1 = try Thread.spawn(cfg, readerThread, .{&queue}); + const t2 = try Thread.spawn(cfg, readerThread, .{&queue}); + try Thread.yield(); + std.time.sleep(std.time.ns_per_s / 2); + queue.push(1); + queue.push(1); + t1.join(); + t2.join(); +} + +fn writerThread(q: *Queue(u8, 1)) !void { + q.push(1); +} + +test "2 writers" { + var queue: Queue(u8, 1) = .{}; + const t1 = try Thread.spawn(cfg, writerThread, .{&queue}); + const t2 = try Thread.spawn(cfg, writerThread, .{&queue}); + + try testing.expectEqual(1, queue.pop()); + try testing.expectEqual(1, queue.pop()); + t1.join(); + t2.join(); +} diff --git a/src/terminal.zig b/src/terminal.zig index f49c94b..1a71f0d 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -1,8 +1,5 @@ const std = @import("std"); -const posix = std.posix; -const fmt = std.fmt; - const log = std.log.scoped(.terminal); pub const Size = struct { @@ -26,20 +23,44 @@ pub const ReportMode = enum { /// Gets number of rows and columns in the terminal pub fn getTerminalSize() Size { - var ws: posix.winsize = undefined; - _ = posix.system.ioctl(posix.STDERR_FILENO, posix.T.IOCGWINSZ, &ws); + var ws: std.posix.winsize = undefined; + _ = std.posix.system.ioctl(std.posix.STDERR_FILENO, std.posix.T.IOCGWINSZ, &ws); return .{ .cols = ws.ws_col, .rows = ws.ws_row }; } +pub fn saveScreen() !void { + _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[?47h"); +} + +pub fn restoreScreen() !void { + _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[?47l"); +} + +pub fn enterAltScreen() !void { + _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[?1049h"); +} + +pub fn existAltScreen() !void { + _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[?1049l"); +} + +pub fn clearScreen() !void { + _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[2J"); +} + +pub fn setCursorPositionHome() !void { + _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[H"); +} + pub fn getCursorPosition() !Position { // Needs Raw mode (no wait for \n) to work properly cause // control sequence will not be written without it. - _ = try posix.write(posix.STDERR_FILENO, "\x1b[6n"); + _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[6n"); var buf: [64]u8 = undefined; // format: \x1b, "[", R1,..., Rn, ";", C1, ..., Cn, "R" - const len = try posix.read(posix.STDIN_FILENO, &buf); + const len = try std.posix.read(std.posix.STDIN_FILENO, &buf); if (!isCursorPosition(buf[0..len])) { return error.InvalidValueReturned; @@ -73,8 +94,8 @@ pub fn getCursorPosition() !Position { } return .{ - .row = try fmt.parseInt(u16, row[0..ridx], 10), - .col = try fmt.parseInt(u16, col[0..cidx], 10), + .row = try std.fmt.parseInt(u16, row[0..ridx], 10), + .col = try std.fmt.parseInt(u16, col[0..cidx], 10), }; } @@ -102,8 +123,8 @@ pub fn isCursorPosition(buf: []u8) bool { /// /// `bak`: pointer to store termios struct backup before /// altering, this is used to disable raw mode. -pub fn enableRawMode(bak: *posix.termios) !void { - var termios = try posix.tcgetattr(posix.STDIN_FILENO); +pub fn enableRawMode(bak: *std.posix.termios) !void { + var termios = try std.posix.tcgetattr(std.posix.STDIN_FILENO); bak.* = termios; termios.iflag.IXON = false; @@ -114,18 +135,18 @@ pub fn enableRawMode(bak: *posix.termios) !void { termios.lflag.IEXTEN = false; termios.lflag.ISIG = false; - try posix.tcsetattr( - posix.STDIN_FILENO, - posix.TCSA.FLUSH, + try std.posix.tcsetattr( + std.posix.STDIN_FILENO, + .FLUSH, termios, ); } /// Reverts `enableRawMode` to restore initial functionality. -pub fn disableRawMode(bak: *posix.termios) !void { - try posix.tcsetattr( - posix.STDIN_FILENO, - posix.TCSA.FLUSH, +pub fn disableRawMode(bak: *std.posix.termios) !void { + try std.posix.tcsetattr( + std.posix.STDIN_FILENO, + .FLUSH, bak.*, ); } @@ -134,12 +155,12 @@ pub fn disableRawMode(bak: *posix.termios) !void { pub fn canSynchornizeOutput() !bool { // Needs Raw mode (no wait for \n) to work properly cause // control sequence will not be written without it. - _ = try posix.write(posix.STDERR_FILENO, "\x1b[?2026$p"); + _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[?2026$p"); var buf: [64]u8 = undefined; // format: \x1b, "[", "?", "2", "0", "2", "6", ";", n, "$", "y" - const len = try posix.read(posix.STDIN_FILENO, &buf); + const len = try std.posix.read(std.posix.STDIN_FILENO, &buf); if (!std.mem.eql(u8, buf[0..len], "\x1b[?2026;") or len < 9) { return false; } diff --git a/src/widget.zig b/src/widget.zig index e4fc387..a9e8a65 100644 --- a/src/widget.zig +++ b/src/widget.zig @@ -1,6 +1,6 @@ //! Dynamic dispatch for widget implementations. //! Each widget should at last implement these functions: -//! - handle(this: *@This(), event: Event) Event {} +//! - handle(this: *@This(), event: Event) ?Event {} //! - content(this: *@This()) *std.ArrayList(u8) {} //! - deinit(this: *@This()) void {} //! @@ -13,18 +13,17 @@ const std = @import("std"); const lib_event = @import("event.zig"); -pub fn Widget(comptime E: type) type { - if (!lib_event.isTaggedUnion(E)) { - @compileError("Provided user event `E` for `Layout(comptime E: type)` is not of type `union(enum)`."); +pub fn Widget(comptime Event: type) type { + if (!lib_event.isTaggedUnion(Event)) { + @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } return struct { - pub const Event = lib_event.MergeTaggedUnions(lib_event.BuiltinEvent, E); const WidgetType = @This(); const Ptr = usize; const VTable = struct { - handle: *const fn (this: *WidgetType, event: Event) Event, - content: *const fn (this: *WidgetType) *std.ArrayList(u8), + handle: *const fn (this: *WidgetType, event: Event) ?Event, + content: *const fn (this: *WidgetType) anyerror!*std.ArrayList(u8), deinit: *const fn (this: *WidgetType) void, }; @@ -32,13 +31,13 @@ pub fn Widget(comptime E: type) type { vtable: *const VTable = undefined, // Handle the provided `Event` for this `Widget`. - pub fn handle(this: *WidgetType, event: Event) Event { + pub fn handle(this: *WidgetType, event: Event) ?Event { return this.vtable.handle(this, event); } // Return the entire content of this `Widget`. - pub fn content(this: *WidgetType) *std.ArrayList(u8) { - return this.vtable.content(this); + pub fn content(this: *WidgetType) !*std.ArrayList(u8) { + return try this.vtable.content(this); } pub fn deinit(this: *WidgetType) void { @@ -52,16 +51,16 @@ pub fn Widget(comptime E: type) type { .vtable = &.{ .handle = struct { // Handle the provided `Event` for this `Widget`. - fn handle(this: *WidgetType, event: Event) Event { + fn handle(this: *WidgetType, event: Event) ?Event { const widget: @TypeOf(object) = @ptrFromInt(this.object); return widget.handle(event); } }.handle, .content = struct { // Return the entire content of this `Widget`. - fn content(this: *WidgetType) *std.ArrayList(u8) { + fn content(this: *WidgetType) !*std.ArrayList(u8) { const widget: @TypeOf(object) = @ptrFromInt(this.object); - return widget.content(); + return try widget.content(); } }.content, .deinit = struct { @@ -75,7 +74,7 @@ pub fn Widget(comptime E: type) type { } // TODO: import and export of `Widget` implementations (with corresponding intialization using `Event`) - pub const RawText = @import("widget/RawText.zig").Widget(E); + pub const RawText = @import("widget/RawText.zig").Widget(Event); // pub const Header = @import("widget/Header.zig"); // pub const ViewPort = @import("widget/ViewPort.zig"); // pub const PopupMenu = @import("widget/PopupMenu.zig"); diff --git a/src/widget/RawText.zig b/src/widget/RawText.zig index 42836fd..5aba79c 100644 --- a/src/widget/RawText.zig +++ b/src/widget/RawText.zig @@ -1,21 +1,15 @@ const std = @import("std"); const lib_event = @import("../event.zig"); -pub fn Widget(comptime E: type) type { - if (!lib_event.isTaggedUnion(E)) { - @compileError("Provided user event `E` for `Layout(comptime E: type)` is not of type `union(enum)`."); +pub fn Widget(comptime Event: type) type { + if (!lib_event.isTaggedUnion(Event)) { + @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } return struct { - pub const Event = lib_event.MergeTaggedUnions(lib_event.BuiltinEvent, E); - c: std.ArrayList(u8) = undefined, pub fn init(allocator: std.mem.Allocator) @This() { - var c = std.ArrayList(u8).init(allocator); - c.appendSlice("This is a simple test") catch @panic("OOM"); - return .{ - .c = c, - }; + return .{ .c = std.ArrayList(u8).init(allocator) }; } pub fn deinit(this: *@This()) void { @@ -23,14 +17,16 @@ pub fn Widget(comptime E: type) type { this.* = undefined; } - pub fn handle(this: *@This(), event: Event) Event { + pub fn handle(this: *@This(), event: Event) ?Event { // ignore the event for now _ = this; _ = event; - return .none; + return null; } - pub fn content(this: *@This()) *std.ArrayList(u8) { + pub fn content(this: *@This()) !*std.ArrayList(u8) { + this.c.clearRetainingCapacity(); + try this.c.appendSlice("This is a simple test"); return &this.c; } }; -- 2.49.1 From 9ef1081903726183ca07de979f191f38c3fb0d4f Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Mon, 4 Nov 2024 22:35:22 +0100 Subject: [PATCH 03/20] mod(build): update zlog dependency --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 192c11c..673f7a6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -24,8 +24,8 @@ // internet connectivity. .dependencies = .{ .zlog = .{ - .url = "git+https://gitea.yves-biener.de/yves-biener/zlog#73991389f6f4156e5ccbc6afd611bc34fe738a72", - .hash = "1220d86aec0bf263dd4df028e017f094f4c530cc2367a3cd031989bbe94f466da1e0", + .url = "git+https://gitea.yves-biener.de/yves-biener/zlog#87cd904c708bf47c0ecde51631328e7411563dcf", + .hash = "122055b153ca7493f2c5b34bcaa9dc40f7bfc2ead3d4880be023fbb99e3ec13d2827", }, .vaxis = .{ .url = "git+https://github.com/rockorager/libvaxis#e3062d62b60bf8a351f267a77e1a94e2614394aa", -- 2.49.1 From b0b262ae0be91ad2aa318fee4daa7799a2dec70a Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Tue, 5 Nov 2024 19:59:55 +0100 Subject: [PATCH 04/20] mod(read_input): read user input from tty --- build.zig | 2 + build.zig.zon | 4 ++ src/app.zig | 47 +++++++++++----- src/event.zig | 2 +- src/main.zig | 6 +++ src/terminal.zig | 136 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 13 deletions(-) diff --git a/build.zig b/build.zig index 2534bdd..6fac23a 100644 --- a/build.zig +++ b/build.zig @@ -29,6 +29,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .target = target, }); + const zg_dep = b.dependency("zg", .{}); const exe = b.addExecutable(.{ .name = "tui-website", @@ -39,6 +40,7 @@ pub fn build(b: *std.Build) void { exe.root_module.addImport("vaxis", vaxis_dep.module("vaxis")); exe.root_module.addImport("zlog", zlog_dep.module("zlog")); exe.root_module.addImport("zmd", zmd_dep.module("zmd")); + exe.root_module.addImport("code_point", zg_dep.module("code_point")); // This declares intent for the executable to be installed into the // standard location when the user invokes the "install" step (the default diff --git a/build.zig.zon b/build.zig.zon index 673f7a6..92c3069 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -35,6 +35,10 @@ .url = "git+https://github.com/jetzig-framework/zmd#90e52429cacd4fdc90fd615596fe584ae40ec8e9", .hash = "12202a4edefedd52478223a44cdc9a3b41d4bc5cf3670497a48377f45ff16a5e3363", }, + .zg = .{ + .url = "https://codeberg.org/dude_the_builder/zg/archive/v0.13.2.tar.gz", + .hash = "122055beff332830a391e9895c044d33b15ea21063779557024b46169fb1984c6e40", + }, }, .paths = .{ "build.zig", diff --git a/src/app.zig b/src/app.zig index 8c986e0..f93e2df 100644 --- a/src/app.zig +++ b/src/app.zig @@ -1,6 +1,7 @@ //! Application type for TUI-applications const std = @import("std"); const terminal = @import("terminal.zig"); +const code_point = terminal.code_point; const mergeTaggedUnions = @import("event.zig").mergeTaggedUnions; const isTaggedUnion = @import("event.zig").isTaggedUnion; @@ -70,20 +71,42 @@ pub fn App(comptime E: type) type { const size = terminal.getTerminalSize(); this.postEvent(.{ .resize = size }); // read input in loop - const buf: [256]u8 = undefined; - _ = buf; + var buf: [256]u8 = undefined; while (!this.quit) { - std.time.sleep(5 * std.time.ns_per_s); - break; - // try terminal.read(buf[0..]); - // TODO: send corresponding events with key_presses - // -> create corresponding event - // -> handle key inputs (modifier, op codes, etc.) - // -> I could take inspiration from `libvaxis` for this + // FIXME: here is a race-condition -> i.e. there could be events + // in the queue, but they will not be executed because the main + // loop will close! + const read_bytes = try terminal.read(buf[0..]); + // escape key presses + if (buf[0] == 0x1b and read_bytes > 1) { + switch (buf[1]) { + // TODO: parse corresponding codes + else => {}, + } + } else { + const b = buf[0]; + const key: terminal.Key = switch (b) { + 0x00 => .{ .cp = '@', .mod = .{ .ctrl = true } }, + 0x08 => .{ .cp = terminal.Key.backspace }, + 0x09 => .{ .cp = terminal.Key.tab }, + 0x0a, 0x0d => .{ .cp = terminal.Key.enter }, + 0x01...0x07, 0x0b...0x0c, 0x0e...0x1a => .{ .cp = b + 0x60, .mod = .{ .ctrl = true } }, + 0x1b => escape: { + std.debug.assert(read_bytes == 1); + break :escape .{ .cp = terminal.Key.escape }; + }, + 0x7f => .{ .cp = terminal.Key.backspace }, + else => { + var iter = code_point.Iterator{ .bytes = buf[0..read_bytes] }; + while (iter.next()) |cp| { + this.postEvent(.{ .key = .{ .cp = cp.code } }); + } + continue; + }, + }; + this.postEvent(.{ .key = key }); + } } - // FIXME: here is a race-condition -> i.e. there could be events in - // the queue, but they will not be executed because the main loop - // will close! this.postEvent(.quit); } }; diff --git a/src/event.zig b/src/event.zig index c6a5068..29b2b03 100644 --- a/src/event.zig +++ b/src/event.zig @@ -19,7 +19,7 @@ const ApplicationEvent = union(enum) { // size has changed, etc. const SystemEvent = union(enum) { resize: terminal.Size, - // key_press: terminal.Key, + key: terminal.Key, }; pub const BuiltinEvent = mergeTaggedUnions(SystemEvent, ApplicationEvent); diff --git a/src/main.zig b/src/main.zig index b0baf99..74c90dd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -50,6 +50,12 @@ pub fn main() !void { try terminal.setCursorPositionHome(); log.debug("Size := [x: {d}, y: {d}]", .{ size.cols, size.rows }); }, + .key => |key| { + log.debug("received key: {any}", .{key}); + if (terminal.Key.matches(key, .{ .cp = 'q' })) { + app.quit = true; // TODO: who should emit the .quit event? + } + }, else => {}, } const events = try layout.handle(event); diff --git a/src/terminal.zig b/src/terminal.zig index 1a71f0d..5ef1d3f 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -1,4 +1,5 @@ const std = @import("std"); +pub const code_point = @import("code_point"); const log = std.log.scoped(.terminal); @@ -12,6 +13,137 @@ pub const Position = struct { row: u16, }; +pub const Key = struct { + cp: u21, + mod: Modifier = .{}, + + pub fn matches(self: Key, other: Key) bool { + return std.meta.eql(self, other); + } + + // codepoints for keys + pub const tab: u21 = 0x09; + pub const enter: u21 = 0x0D; + pub const escape: u21 = 0x1B; + pub const space: u21 = 0x20; + pub const backspace: u21 = 0x7F; + + // kitty key encodings (re-used here) + pub const insert: u21 = 57348; + pub const delete: u21 = 57349; + pub const left: u21 = 57350; + pub const right: u21 = 57351; + pub const up: u21 = 57352; + pub const down: u21 = 57353; + pub const page_up: u21 = 57354; + pub const page_down: u21 = 57355; + pub const home: u21 = 57356; + pub const end: u21 = 57357; + pub const caps_lock: u21 = 57358; + pub const scroll_lock: u21 = 57359; + pub const num_lock: u21 = 57360; + pub const print_screen: u21 = 57361; + pub const pause: u21 = 57362; + pub const menu: u21 = 57363; + pub const f1: u21 = 57364; + pub const f2: u21 = 57365; + pub const f3: u21 = 57366; + pub const f4: u21 = 57367; + pub const f5: u21 = 57368; + pub const f6: u21 = 57369; + pub const f7: u21 = 57370; + pub const f8: u21 = 57371; + pub const f9: u21 = 57372; + pub const f10: u21 = 57373; + pub const f11: u21 = 57374; + pub const f12: u21 = 57375; + pub const f13: u21 = 57376; + pub const f14: u21 = 57377; + pub const f15: u21 = 57378; + pub const @"f16": u21 = 57379; + pub const f17: u21 = 57380; + pub const f18: u21 = 57381; + pub const f19: u21 = 57382; + pub const f20: u21 = 57383; + pub const f21: u21 = 57384; + pub const f22: u21 = 57385; + pub const f23: u21 = 57386; + pub const f24: u21 = 57387; + pub const f25: u21 = 57388; + pub const f26: u21 = 57389; + pub const f27: u21 = 57390; + pub const f28: u21 = 57391; + pub const f29: u21 = 57392; + pub const f30: u21 = 57393; + pub const f31: u21 = 57394; + pub const @"f32": u21 = 57395; + pub const f33: u21 = 57396; + pub const f34: u21 = 57397; + pub const f35: u21 = 57398; + pub const kp_0: u21 = 57399; + pub const kp_1: u21 = 57400; + pub const kp_2: u21 = 57401; + pub const kp_3: u21 = 57402; + pub const kp_4: u21 = 57403; + pub const kp_5: u21 = 57404; + pub const kp_6: u21 = 57405; + pub const kp_7: u21 = 57406; + pub const kp_8: u21 = 57407; + pub const kp_9: u21 = 57408; + pub const kp_decimal: u21 = 57409; + pub const kp_divide: u21 = 57410; + pub const kp_multiply: u21 = 57411; + pub const kp_subtract: u21 = 57412; + pub const kp_add: u21 = 57413; + pub const kp_enter: u21 = 57414; + pub const kp_equal: u21 = 57415; + pub const kp_separator: u21 = 57416; + pub const kp_left: u21 = 57417; + pub const kp_right: u21 = 57418; + pub const kp_up: u21 = 57419; + pub const kp_down: u21 = 57420; + pub const kp_page_up: u21 = 57421; + pub const kp_page_down: u21 = 57422; + pub const kp_home: u21 = 57423; + pub const kp_end: u21 = 57424; + pub const kp_insert: u21 = 57425; + pub const kp_delete: u21 = 57426; + pub const kp_begin: u21 = 57427; + pub const media_play: u21 = 57428; + pub const media_pause: u21 = 57429; + pub const media_play_pause: u21 = 57430; + pub const media_reverse: u21 = 57431; + pub const media_stop: u21 = 57432; + pub const media_fast_forward: u21 = 57433; + pub const media_rewind: u21 = 57434; + pub const media_track_next: u21 = 57435; + pub const media_track_previous: u21 = 57436; + pub const media_record: u21 = 57437; + pub const lower_volume: u21 = 57438; + pub const raise_volume: u21 = 57439; + pub const mute_volume: u21 = 57440; + pub const left_shift: u21 = 57441; + pub const left_control: u21 = 57442; + pub const left_alt: u21 = 57443; + pub const left_super: u21 = 57444; + pub const left_hyper: u21 = 57445; + pub const left_meta: u21 = 57446; + pub const right_shift: u21 = 57447; + pub const right_control: u21 = 57448; + pub const right_alt: u21 = 57449; + pub const right_super: u21 = 57450; + pub const right_hyper: u21 = 57451; + pub const right_meta: u21 = 57452; + pub const iso_level_3_shift: u21 = 57453; + pub const iso_level_5_shift: u21 = 57454; +}; + +pub const Modifier = struct { + shift: bool = false, + alt: bool = false, + ctrl: bool = false, +}; + // Ref: https://vt100.net/docs/vt510-rm/DECRPM.html pub const ReportMode = enum { not_recognized, @@ -52,6 +184,10 @@ pub fn setCursorPositionHome() !void { _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[H"); } +pub fn read(buf: []u8) !usize { + return try std.posix.read(std.posix.STDIN_FILENO, buf); +} + pub fn getCursorPosition() !Position { // Needs Raw mode (no wait for \n) to work properly cause // control sequence will not be written without it. -- 2.49.1 From 9ddbb193364c42a7a6d3a01084c8fe434647f5e2 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Wed, 6 Nov 2024 01:38:55 +0100 Subject: [PATCH 05/20] add: signal handling WINCH, user input thread with waiting blocking --- src/app.zig | 123 +++++++++++++++++++++++++++-------------- src/event.zig | 22 +++----- src/layout.zig | 12 ++-- src/layout/Pane.zig | 19 ++++--- src/main.zig | 8 +-- src/widget.zig | 4 +- src/widget/RawText.zig | 5 +- 7 files changed, 113 insertions(+), 80 deletions(-) diff --git a/src/app.zig b/src/app.zig index f93e2df..9bf0eb3 100644 --- a/src/app.zig +++ b/src/app.zig @@ -1,11 +1,11 @@ //! Application type for TUI-applications const std = @import("std"); const terminal = @import("terminal.zig"); -const code_point = terminal.code_point; + const mergeTaggedUnions = @import("event.zig").mergeTaggedUnions; const isTaggedUnion = @import("event.zig").isTaggedUnion; -const BuiltinEvent = @import("event.zig").BuiltinEvent; +const SystemEvent = @import("event.zig").SystemEvent; const Queue = @import("queue.zig").Queue; const log = std.log.scoped(.app); @@ -18,25 +18,40 @@ pub fn App(comptime E: type) type { @compileError("Provided user event `E` for `App(comptime E: type)` is not of type `union(enum)`."); } return struct { - pub const Event = mergeTaggedUnions(BuiltinEvent, E); + pub const Event = mergeTaggedUnions(SystemEvent, E); pub const Layout = @import("layout.zig").Layout(Event); pub const Widget = @import("widget.zig").Widget(Event); queue: Queue(Event, 256) = .{}, thread: ?std.Thread = null, - quit: bool = false, + quit: std.Thread.ResetEvent = .{}, termios: ?std.posix.termios = null, - // TODO: event loop function? - // layout handling? + pub const SignalHandler = struct { + context: *anyopaque, + callback: *const fn (context: *anyopaque) void, + }; pub fn init() @This() { + var winch_act = std.posix.Sigaction{ + .handler = .{ .handler = @This().handleWinch }, + .mask = std.posix.empty_sigset, + .flags = 0, + }; + std.posix.sigaction(std.posix.SIG.WINCH, &winch_act, null) catch @panic("could not attach signal WINCH"); + return .{}; } pub fn start(this: *@This()) !void { if (this.thread) |_| return; + try registerWinch(.{ + .context = this, + .callback = @This().winsizeCallback, + }); + + this.quit.reset(); this.thread = try std.Thread.spawn(.{}, @This().run, .{this}); var termios: std.posix.termios = undefined; try terminal.enableRawMode(&termios); @@ -49,7 +64,7 @@ pub fn App(comptime E: type) type { try terminal.disableRawMode(termios); try terminal.restoreScreen(); } - this.quit = true; + this.quit.set(); if (this.thread) |thread| { thread.join(); this.thread = null; @@ -66,46 +81,70 @@ pub fn App(comptime E: type) type { this.queue.push(event); } + fn winsizeCallback(ptr: *anyopaque) void { + const this: *@This() = @ptrCast(@alignCast(ptr)); + this.postEvent(.{ .resize = terminal.getTerminalSize() }); + } + + var winch_handler: ?SignalHandler = null; + + fn registerWinch(handler: SignalHandler) !void { + if (winch_handler) |_| { + @panic("Cannot register another WINCH handler."); + } + winch_handler = handler; + } + + fn handleWinch(_: c_int) callconv(.C) void { + if (winch_handler) |handler| { + handler.callback(handler.context); + } + } + fn run(this: *@This()) !void { - // thread to read user inputs + // send initial terminal size + // changes are handled by the winch signal handler (see `init` and `registerWinch`) const size = terminal.getTerminalSize(); this.postEvent(.{ .resize = size }); - // read input in loop + + // thread to read user inputs var buf: [256]u8 = undefined; - while (!this.quit) { - // FIXME: here is a race-condition -> i.e. there could be events - // in the queue, but they will not be executed because the main - // loop will close! - const read_bytes = try terminal.read(buf[0..]); - // escape key presses - if (buf[0] == 0x1b and read_bytes > 1) { - switch (buf[1]) { - // TODO: parse corresponding codes - else => {}, + while (true) { + // FIX: I still think that there is a race condition (I'm just waiting 'long' enough) + this.quit.timedWait(20 * std.time.ns_per_ms) catch { + const read_bytes = try terminal.read(buf[0..]); + // escape key presses + if (buf[0] == 0x1b and read_bytes > 1) { + switch (buf[1]) { + // TODO: parse corresponding codes + else => {}, + } + } else { + const b = buf[0]; + const key: terminal.Key = switch (b) { + 0x00 => .{ .cp = '@', .mod = .{ .ctrl = true } }, + 0x08 => .{ .cp = terminal.Key.backspace }, + 0x09 => .{ .cp = terminal.Key.tab }, + 0x0a, 0x0d => .{ .cp = terminal.Key.enter }, + 0x01...0x07, 0x0b...0x0c, 0x0e...0x1a => .{ .cp = b + 0x60, .mod = .{ .ctrl = true } }, + 0x1b => escape: { + std.debug.assert(read_bytes == 1); + break :escape .{ .cp = terminal.Key.escape }; + }, + 0x7f => .{ .cp = terminal.Key.backspace }, + else => { + var iter = terminal.code_point.Iterator{ .bytes = buf[0..read_bytes] }; + while (iter.next()) |cp| { + this.postEvent(.{ .key = .{ .cp = cp.code } }); + } + continue; + }, + }; + this.postEvent(.{ .key = key }); } - } else { - const b = buf[0]; - const key: terminal.Key = switch (b) { - 0x00 => .{ .cp = '@', .mod = .{ .ctrl = true } }, - 0x08 => .{ .cp = terminal.Key.backspace }, - 0x09 => .{ .cp = terminal.Key.tab }, - 0x0a, 0x0d => .{ .cp = terminal.Key.enter }, - 0x01...0x07, 0x0b...0x0c, 0x0e...0x1a => .{ .cp = b + 0x60, .mod = .{ .ctrl = true } }, - 0x1b => escape: { - std.debug.assert(read_bytes == 1); - break :escape .{ .cp = terminal.Key.escape }; - }, - 0x7f => .{ .cp = terminal.Key.backspace }, - else => { - var iter = code_point.Iterator{ .bytes = buf[0..read_bytes] }; - while (iter.next()) |cp| { - this.postEvent(.{ .key = .{ .cp = cp.code } }); - } - continue; - }, - }; - this.postEvent(.{ .key = key }); - } + continue; + }; + break; } this.postEvent(.quit); } diff --git a/src/event.zig b/src/event.zig index 29b2b03..1f43013 100644 --- a/src/event.zig +++ b/src/event.zig @@ -3,27 +3,19 @@ const std = @import("std"); const terminal = @import("terminal.zig"); -// Application events which contain information about default application -// parameter. Either `none` or `err`, where `none` represents no event with no -// message, while `err` represents an error which is propagated. `Widget`s or -// `Layout`s may react to the event but should continue throwing the message up -// to the application event loop. -const ApplicationEvent = union(enum) { - none, - quit, - err: []const u8, +pub const Error = struct { + err: anyerror, + msg: []const u8, }; -// System events which contain information about events triggered from outside -// of the application which impact the application. E.g. the terminal window -// size has changed, etc. -const SystemEvent = union(enum) { +// System events available to every application. +pub const SystemEvent = union(enum) { + quit, + err: Error, resize: terminal.Size, key: terminal.Key, }; -pub const BuiltinEvent = mergeTaggedUnions(SystemEvent, ApplicationEvent); - pub fn mergeTaggedUnions(comptime A: type, comptime B: type) 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/layout.zig b/src/layout.zig index 3cdc01f..eeb79f9 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -12,10 +12,10 @@ //! widgets when deallocated. This means that `deinit()` will also deallocate //! every used widget too. const std = @import("std"); -const lib_event = @import("event.zig"); +const isTaggedUnion = @import("event.zig").isTaggedUnion; pub fn Layout(comptime Event: type) type { - if (!lib_event.isTaggedUnion(Event)) { + if (!isTaggedUnion(Event)) { @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } return struct { @@ -31,12 +31,12 @@ pub fn Layout(comptime Event: type) type { object: Ptr = undefined, vtable: *const VTable = undefined, - // Handle the provided `Event` for this `Widget`. + // Handle the provided `Event` for this `Layout`. pub fn handle(this: *LayoutType, event: Event) !*std.ArrayList(Event) { return try this.vtable.handle(this, event); } - // Return the entire content of this `Widget`. + // Return the entire content of this `Layout`. pub fn content(this: *LayoutType) !*std.ArrayList(u8) { return try this.vtable.content(this); } @@ -51,14 +51,14 @@ pub fn Layout(comptime Event: type) type { .object = @intFromPtr(object), .vtable = &.{ .handle = struct { - // Handle the provided `Event` for this `Widget`. + // Handle the provided `Event` for this `Layout`. fn handle(this: *LayoutType, event: Event) !*std.ArrayList(Event) { const layout: @TypeOf(object) = @ptrFromInt(this.object); return try layout.handle(event); } }.handle, .content = struct { - // Return the entire content of this `Widget`. + // Return the entire content of this `Layout`. fn content(this: *LayoutType) !*std.ArrayList(u8) { const layout: @TypeOf(object) = @ptrFromInt(this.object); return try layout.content(); diff --git a/src/layout/Pane.zig b/src/layout/Pane.zig index c7c0998..d2a2950 100644 --- a/src/layout/Pane.zig +++ b/src/layout/Pane.zig @@ -1,26 +1,27 @@ const std = @import("std"); -const lib_event = @import("../event.zig"); -const widget = @import("../widget.zig"); +const isTaggedUnion = @import("../event.zig").isTaggedUnion; +const Error = @import("../event.zig").Error; pub fn Layout(comptime Event: type) type { - if (!lib_event.isTaggedUnion(Event)) { + if (!isTaggedUnion(Event)) { @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } + const Widget = @import("../widget.zig").Widget(Event); return struct { - w: widget.Widget(Event) = undefined, + widget: Widget = undefined, events: std.ArrayList(Event) = undefined, c: std.ArrayList(u8) = undefined, - pub fn init(allocator: std.mem.Allocator, w: widget.Widget(Event)) @This() { + pub fn init(allocator: std.mem.Allocator, widget: Widget) @This() { return .{ - .w = w, + .widget = widget, .events = std.ArrayList(Event).init(allocator), .c = std.ArrayList(u8).init(allocator), }; } pub fn deinit(this: *@This()) void { - this.w.deinit(); + this.widget.deinit(); this.events.deinit(); this.c.deinit(); this.* = undefined; @@ -28,14 +29,14 @@ pub fn Layout(comptime Event: type) type { pub fn handle(this: *@This(), event: Event) !*std.ArrayList(Event) { this.events.clearRetainingCapacity(); - if (this.w.handle(event)) |e| { + if (this.widget.handle(event)) |e| { try this.events.append(e); } return &this.events; } pub fn content(this: *@This()) !*std.ArrayList(u8) { - const widget_content = try this.w.content(); + const widget_content = try this.widget.content(); this.c.clearRetainingCapacity(); try this.c.appendSlice(widget_content.items); return &this.c; diff --git a/src/main.zig b/src/main.zig index 74c90dd..5595e28 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,7 +5,7 @@ const zlog = @import("zlog"); const App = @import("app.zig").App(union(enum) {}); pub const std_options = zlog.std_options; -const log = std.log.scoped(.main); +const log = std.log.scoped(.default); pub fn main() !void { errdefer |err| log.err("Application Error: {any}", .{err}); @@ -39,7 +39,6 @@ pub fn main() !void { const event = app.nextEvent(); switch (event) { - .none => continue, .quit => break, .resize => |size| { // NOTE: draw actions should not happen here (still here for testing) @@ -52,8 +51,9 @@ pub fn main() !void { }, .key => |key| { log.debug("received key: {any}", .{key}); - if (terminal.Key.matches(key, .{ .cp = 'q' })) { - app.quit = true; // TODO: who should emit the .quit event? + // ctrl+c to quit + if (terminal.Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) { + app.quit.set(); } }, else => {}, diff --git a/src/widget.zig b/src/widget.zig index a9e8a65..7d6f29f 100644 --- a/src/widget.zig +++ b/src/widget.zig @@ -11,10 +11,10 @@ //! Each `Widget` may cache its content and should if the contents will not //! change for a long time. const std = @import("std"); -const lib_event = @import("event.zig"); +const isTaggedUnion = @import("event.zig").isTaggedUnion; pub fn Widget(comptime Event: type) type { - if (!lib_event.isTaggedUnion(Event)) { + if (!isTaggedUnion(Event)) { @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } return struct { diff --git a/src/widget/RawText.zig b/src/widget/RawText.zig index 5aba79c..da7fe89 100644 --- a/src/widget/RawText.zig +++ b/src/widget/RawText.zig @@ -1,8 +1,9 @@ const std = @import("std"); -const lib_event = @import("../event.zig"); +const isTaggedUnion = @import("../event.zig").isTaggedUnion; +const Error = @import("../event.zig").Error; pub fn Widget(comptime Event: type) type { - if (!lib_event.isTaggedUnion(Event)) { + if (!isTaggedUnion(Event)) { @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } return struct { -- 2.49.1 From 9b165e8f81a836c145581bce3658368d348995f5 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Wed, 6 Nov 2024 15:20:34 +0100 Subject: [PATCH 06/20] add/mod the following features - split structure for better inclusions - create PlainRenderer to render contents to the terminal - simplify events - clearify what structs are created on the heap and which are on the stack - quit event is now emitted from the main event loop and not the input loop (see helper function `App.quit`) - rename several variables and/or functions for easier understanding - introduce `App.interrupt` to stop the input thread and start a new sub TUI which takes over the entire screen (i.e. 'hx', 'nvim', etc.) --- README.md | 6 +- src/app.zig | 73 +++++++++++--------- src/event.zig | 3 +- src/key.zig | 149 +++++++++++++++++++++++++++++++++++++++++ src/layout/Pane.zig | 8 +++ src/main.zig | 38 +++++++---- src/render.zig | 48 +++++++++++++ src/style.zig | 23 +++++++ src/terminal.zig | 136 ++----------------------------------- src/widget/RawText.zig | 22 ++++-- 10 files changed, 320 insertions(+), 186 deletions(-) create mode 100644 src/key.zig create mode 100644 src/render.zig create mode 100644 src/style.zig diff --git a/README.md b/README.md index 24733a9..23d82ef 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,6 @@ It contains information about me and my projects as well as blog entries about s ## Open tasks -- [ ] BUG: when served via `wish-serve` the corresponding outputs are not pushed through the ssh connection - - they are instead showed locally, which might cause issues with the docker container running in the background - - very likely it is `tui-website` which causes this issue - - not entirely as inputs are not passed through correctly to the below running application (i.e. `diffnav` via `serve git diff`) - - fex however works as expected - [ ] Improve navigation - [ ] Have clickable/navigatable links inside of the tui application - [ ] Launch simple http server alongside tui application @@ -20,3 +15,4 @@ It contains information about me and my projects as well as blog entries about s ## Branch: `own-tty-visuals` - [ ] How can I support to run a sub-process inside of a given pane / layout? +- [ ] Create demo gifs using [vhs](https://github.com/charmbracelet/vhs) diff --git a/src/app.zig b/src/app.zig index 9bf0eb3..a1dc6a7 100644 --- a/src/app.zig +++ b/src/app.zig @@ -5,6 +5,7 @@ const terminal = @import("terminal.zig"); const mergeTaggedUnions = @import("event.zig").mergeTaggedUnions; const isTaggedUnion = @import("event.zig").isTaggedUnion; +const Key = @import("key.zig"); const SystemEvent = @import("event.zig").SystemEvent; const Queue = @import("queue.zig").Queue; @@ -24,53 +25,64 @@ pub fn App(comptime E: type) type { queue: Queue(Event, 256) = .{}, thread: ?std.Thread = null, - quit: std.Thread.ResetEvent = .{}, + quit_event: std.Thread.ResetEvent = .{}, termios: ?std.posix.termios = null, + attached_handler: bool = false, pub const SignalHandler = struct { context: *anyopaque, callback: *const fn (context: *anyopaque) void, }; - pub fn init() @This() { - var winch_act = std.posix.Sigaction{ - .handler = .{ .handler = @This().handleWinch }, - .mask = std.posix.empty_sigset, - .flags = 0, - }; - std.posix.sigaction(std.posix.SIG.WINCH, &winch_act, null) catch @panic("could not attach signal WINCH"); - - return .{}; - } - pub fn start(this: *@This()) !void { if (this.thread) |_| return; - try registerWinch(.{ - .context = this, - .callback = @This().winsizeCallback, - }); + if (!this.attached_handler) { + var winch_act = std.posix.Sigaction{ + .handler = .{ .handler = @This().handleWinch }, + .mask = std.posix.empty_sigset, + .flags = 0, + }; + std.posix.sigaction(std.posix.SIG.WINCH, &winch_act, null) catch @panic("could not attach signal WINCH"); - this.quit.reset(); + try registerWinch(.{ + .context = this, + .callback = @This().winsizeCallback, + }); + this.attached_handler = true; + } + + this.quit_event.reset(); this.thread = try std.Thread.spawn(.{}, @This().run, .{this}); + if (this.termios) |_| return; + var termios: std.posix.termios = undefined; try terminal.enableRawMode(&termios); this.termios = termios; try terminal.saveScreen(); } - pub fn stop(this: *@This()) !void { - if (this.termios) |*termios| { - try terminal.disableRawMode(termios); - try terminal.restoreScreen(); - } - this.quit.set(); + pub fn interrupt(this: *@This()) !void { + this.quit_event.set(); if (this.thread) |thread| { thread.join(); this.thread = null; } } + pub fn stop(this: *@This()) !void { + try this.interrupt(); + if (this.termios) |*termios| { + try terminal.disableRawMode(termios); + try terminal.restoreScreen(); + } + } + + pub fn quit(this: *@This()) void { + this.quit_event.set(); + this.postEvent(.quit); + } + /// Returns the next available event, blocking until one is available. pub fn nextEvent(this: *@This()) Event { return this.queue.pop(); @@ -111,7 +123,7 @@ pub fn App(comptime E: type) type { var buf: [256]u8 = undefined; while (true) { // FIX: I still think that there is a race condition (I'm just waiting 'long' enough) - this.quit.timedWait(20 * std.time.ns_per_ms) catch { + this.quit_event.timedWait(20 * std.time.ns_per_ms) catch { const read_bytes = try terminal.read(buf[0..]); // escape key presses if (buf[0] == 0x1b and read_bytes > 1) { @@ -121,17 +133,17 @@ pub fn App(comptime E: type) type { } } else { const b = buf[0]; - const key: terminal.Key = switch (b) { + const key: Key = switch (b) { 0x00 => .{ .cp = '@', .mod = .{ .ctrl = true } }, - 0x08 => .{ .cp = terminal.Key.backspace }, - 0x09 => .{ .cp = terminal.Key.tab }, - 0x0a, 0x0d => .{ .cp = terminal.Key.enter }, + 0x08 => .{ .cp = Key.backspace }, + 0x09 => .{ .cp = Key.tab }, + 0x0a, 0x0d => .{ .cp = Key.enter }, 0x01...0x07, 0x0b...0x0c, 0x0e...0x1a => .{ .cp = b + 0x60, .mod = .{ .ctrl = true } }, 0x1b => escape: { std.debug.assert(read_bytes == 1); - break :escape .{ .cp = terminal.Key.escape }; + break :escape .{ .cp = Key.escape }; }, - 0x7f => .{ .cp = terminal.Key.backspace }, + 0x7f => .{ .cp = Key.backspace }, else => { var iter = terminal.code_point.Iterator{ .bytes = buf[0..read_bytes] }; while (iter.next()) |cp| { @@ -146,7 +158,6 @@ pub fn App(comptime E: type) type { }; break; } - this.postEvent(.quit); } }; } diff --git a/src/event.zig b/src/event.zig index 1f43013..60f76a9 100644 --- a/src/event.zig +++ b/src/event.zig @@ -2,6 +2,7 @@ //! events. See `App` for more details about user defined events. const std = @import("std"); const terminal = @import("terminal.zig"); +const Key = @import("key.zig"); pub const Error = struct { err: anyerror, @@ -13,7 +14,7 @@ pub const SystemEvent = union(enum) { quit, err: Error, resize: terminal.Size, - key: terminal.Key, + key: Key, }; pub fn mergeTaggedUnions(comptime A: type, comptime B: type) type { diff --git a/src/key.zig b/src/key.zig new file mode 100644 index 0000000..eec5f8c --- /dev/null +++ b/src/key.zig @@ -0,0 +1,149 @@ +//! Keybindings and Modifiers for user input detection and selection. +const std = @import("std"); + +pub const Modifier = struct { + shift: bool = false, + alt: bool = false, + ctrl: bool = false, +}; + +cp: u21, +mod: Modifier = .{}, + +/// Compare _this_ `Key` with an _other_ `Key`. +/// +/// # Example +/// +/// Configure `ctrl+c` to quit the application (done in main event loop of the application): +/// +/// ```zig +/// switch (event) { +/// .quit => break, +/// .key => |key| { +/// // ctrl+c to quit +/// if (terminal.Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) { +/// app.quit.set(); +/// } +/// }, +/// else => {}, +/// } +/// ``` +pub fn matches(this: @This(), other: @This()) bool { + return std.meta.eql(this, other); +} + +// codepoints for keys +pub const tab: u21 = 0x09; +pub const enter: u21 = 0x0D; +pub const escape: u21 = 0x1B; +pub const space: u21 = 0x20; +pub const backspace: u21 = 0x7F; + +// kitty key encodings (re-used here) +pub const insert: u21 = 57348; +pub const delete: u21 = 57349; +pub const left: u21 = 57350; +pub const right: u21 = 57351; +pub const up: u21 = 57352; +pub const down: u21 = 57353; +pub const page_up: u21 = 57354; +pub const page_down: u21 = 57355; +pub const home: u21 = 57356; +pub const end: u21 = 57357; +pub const caps_lock: u21 = 57358; +pub const scroll_lock: u21 = 57359; +pub const num_lock: u21 = 57360; +pub const print_screen: u21 = 57361; +pub const pause: u21 = 57362; +pub const menu: u21 = 57363; +pub const f1: u21 = 57364; +pub const f2: u21 = 57365; +pub const f3: u21 = 57366; +pub const f4: u21 = 57367; +pub const f5: u21 = 57368; +pub const f6: u21 = 57369; +pub const f7: u21 = 57370; +pub const f8: u21 = 57371; +pub const f9: u21 = 57372; +pub const f10: u21 = 57373; +pub const f11: u21 = 57374; +pub const f12: u21 = 57375; +pub const f13: u21 = 57376; +pub const f14: u21 = 57377; +pub const f15: u21 = 57378; +pub const @"f16": u21 = 57379; +pub const f17: u21 = 57380; +pub const f18: u21 = 57381; +pub const f19: u21 = 57382; +pub const f20: u21 = 57383; +pub const f21: u21 = 57384; +pub const f22: u21 = 57385; +pub const f23: u21 = 57386; +pub const f24: u21 = 57387; +pub const f25: u21 = 57388; +pub const f26: u21 = 57389; +pub const f27: u21 = 57390; +pub const f28: u21 = 57391; +pub const f29: u21 = 57392; +pub const f30: u21 = 57393; +pub const f31: u21 = 57394; +pub const @"f32": u21 = 57395; +pub const f33: u21 = 57396; +pub const f34: u21 = 57397; +pub const f35: u21 = 57398; +pub const kp_0: u21 = 57399; +pub const kp_1: u21 = 57400; +pub const kp_2: u21 = 57401; +pub const kp_3: u21 = 57402; +pub const kp_4: u21 = 57403; +pub const kp_5: u21 = 57404; +pub const kp_6: u21 = 57405; +pub const kp_7: u21 = 57406; +pub const kp_8: u21 = 57407; +pub const kp_9: u21 = 57408; +pub const kp_decimal: u21 = 57409; +pub const kp_divide: u21 = 57410; +pub const kp_multiply: u21 = 57411; +pub const kp_subtract: u21 = 57412; +pub const kp_add: u21 = 57413; +pub const kp_enter: u21 = 57414; +pub const kp_equal: u21 = 57415; +pub const kp_separator: u21 = 57416; +pub const kp_left: u21 = 57417; +pub const kp_right: u21 = 57418; +pub const kp_up: u21 = 57419; +pub const kp_down: u21 = 57420; +pub const kp_page_up: u21 = 57421; +pub const kp_page_down: u21 = 57422; +pub const kp_home: u21 = 57423; +pub const kp_end: u21 = 57424; +pub const kp_insert: u21 = 57425; +pub const kp_delete: u21 = 57426; +pub const kp_begin: u21 = 57427; +pub const media_play: u21 = 57428; +pub const media_pause: u21 = 57429; +pub const media_play_pause: u21 = 57430; +pub const media_reverse: u21 = 57431; +pub const media_stop: u21 = 57432; +pub const media_fast_forward: u21 = 57433; +pub const media_rewind: u21 = 57434; +pub const media_track_next: u21 = 57435; +pub const media_track_previous: u21 = 57436; +pub const media_record: u21 = 57437; +pub const lower_volume: u21 = 57438; +pub const raise_volume: u21 = 57439; +pub const mute_volume: u21 = 57440; +pub const left_shift: u21 = 57441; +pub const left_control: u21 = 57442; +pub const left_alt: u21 = 57443; +pub const left_super: u21 = 57444; +pub const left_hyper: u21 = 57445; +pub const left_meta: u21 = 57446; +pub const right_shift: u21 = 57447; +pub const right_control: u21 = 57448; +pub const right_alt: u21 = 57449; +pub const right_super: u21 = 57450; +pub const right_hyper: u21 = 57451; +pub const right_meta: u21 = 57452; +pub const iso_level_3_shift: u21 = 57453; +pub const iso_level_5_shift: u21 = 57454; diff --git a/src/layout/Pane.zig b/src/layout/Pane.zig index d2a2950..aaed7fa 100644 --- a/src/layout/Pane.zig +++ b/src/layout/Pane.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const terminal = @import("../terminal.zig"); const isTaggedUnion = @import("../event.zig").isTaggedUnion; const Error = @import("../event.zig").Error; @@ -11,6 +12,7 @@ pub fn Layout(comptime Event: type) type { widget: Widget = undefined, events: std.ArrayList(Event) = undefined, c: std.ArrayList(u8) = undefined, + size: terminal.Size = undefined, pub fn init(allocator: std.mem.Allocator, widget: Widget) @This() { return .{ @@ -28,6 +30,12 @@ pub fn Layout(comptime Event: type) type { } pub fn handle(this: *@This(), event: Event) !*std.ArrayList(Event) { + switch (event) { + .resize => |size| { + this.size = size; + }, + else => {}, + } this.events.clearRetainingCapacity(); if (this.widget.handle(event)) |e| { try this.events.append(e); diff --git a/src/main.zig b/src/main.zig index 5595e28..33c7821 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,6 +3,8 @@ const terminal = @import("terminal.zig"); const zlog = @import("zlog"); const App = @import("app.zig").App(union(enum) {}); +const Renderer = @import("render.zig").PlainRenderer(); +const Key = @import("key.zig"); pub const std_options = zlog.std_options; const log = std.log.scoped(.default); @@ -20,7 +22,8 @@ pub fn main() !void { } const allocator = gpa.allocator(); - var app = App.init(); + var app: App = .{}; + var renderer: Renderer = .{}; var rawText = App.Widget.RawText.init(allocator); const widget = App.Widget.createFrom(&rawText); @@ -40,29 +43,36 @@ pub fn main() !void { switch (event) { .quit => break, - .resize => |size| { - // NOTE: draw actions should not happen here (still here for testing) - // NOTE: clearing the screen and positioning the cursor is only necessary for full screen applications - // - in-line applications should use relative movements instead and should only clear lines (which they draw) - // - in-line applications should not enter the alt screen - try terminal.clearScreen(); - try terminal.setCursorPositionHome(); - log.debug("Size := [x: {d}, y: {d}]", .{ size.cols, size.rows }); - }, .key => |key| { - log.debug("received key: {any}", .{key}); // ctrl+c to quit - if (terminal.Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) { - app.quit.set(); + 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() 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 Helix failed", + }, + }); + }; + } + }, + .err => |err| { + log.err("Received {any} with message: {s}", .{ err.err, err.msg }); }, else => {}, } + // NOTE: this currently re-renders the screen for every key-press -> which might be a bit of an overkill const events = try layout.handle(event); for (events.items) |e| { app.postEvent(e); } - log.debug("Layout result: {s}", .{(try layout.content()).items}); + try renderer.render(try layout.content()); } // TODO: I could use the ascii codes in vaxis // - see https://gist.github.com/ConnerWill/d4b6c776b509add763e17f9f113fd25b diff --git a/src/render.zig b/src/render.zig new file mode 100644 index 0000000..b71dba3 --- /dev/null +++ b/src/render.zig @@ -0,0 +1,48 @@ +//! Renderer which holds the screen to compare with the previous screen for efficient rendering. +const std = @import("std"); +const terminal = @import("terminal.zig"); + +pub fn BufferedRenderer() type { + return struct { + refresh: bool = false, + size: terminal.Size = undefined, + screen: std.ArrayList(u8) = undefined, + + pub fn init(allocator: std.mem.Allocator) @This() { + return .{ + .screen = std.ArrayList(u8).init(allocator), + }; + } + + pub fn deinit(this: *@This()) void { + this.screen.deinit(); + this.* = undefined; + } + + pub fn resize(this: *@This(), size: terminal.Size) void { + // TODO: are there size changes which impact the corresponding rendered content? + // -> can I even be sure nothing needs to be re-rendered? + this.size = size; + this.refresh = true; + } + + pub fn render(this: *@This(), content: *std.ArrayList(u8)) !void { + // TODO: put the corresponding screen to the terminal + // -> determine diff between screen and new content and only update the corresponding characters of the terminal + _ = this; + _ = content; + @panic("Not yet implemented."); + } + }; +} + +pub fn PlainRenderer() type { + return struct { + pub fn render(this: *@This(), content: *std.ArrayList(u8)) !void { + _ = this; + try terminal.clearScreen(); + try terminal.setCursorPositionHome(); + _ = try terminal.write(content.items); + } + }; +} diff --git a/src/style.zig b/src/style.zig new file mode 100644 index 0000000..a6d5e09 --- /dev/null +++ b/src/style.zig @@ -0,0 +1,23 @@ +//! 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. +const std = @import("std"); + +// TODO: implement helper functions for the following stylings: +// - bold +// - italic +// - underline +// - curly line +// - strike through +// - reverse +// - blink +// - color: +// - foreground +// - background + +// 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 5ef1d3f..28af695 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Key = @import("key.zig"); pub const code_point = @import("code_point"); const log = std.log.scoped(.terminal); @@ -13,137 +14,6 @@ pub const Position = struct { row: u16, }; -pub const Key = struct { - cp: u21, - mod: Modifier = .{}, - - pub fn matches(self: Key, other: Key) bool { - return std.meta.eql(self, other); - } - - // codepoints for keys - pub const tab: u21 = 0x09; - pub const enter: u21 = 0x0D; - pub const escape: u21 = 0x1B; - pub const space: u21 = 0x20; - pub const backspace: u21 = 0x7F; - - // kitty key encodings (re-used here) - pub const insert: u21 = 57348; - pub const delete: u21 = 57349; - pub const left: u21 = 57350; - pub const right: u21 = 57351; - pub const up: u21 = 57352; - pub const down: u21 = 57353; - pub const page_up: u21 = 57354; - pub const page_down: u21 = 57355; - pub const home: u21 = 57356; - pub const end: u21 = 57357; - pub const caps_lock: u21 = 57358; - pub const scroll_lock: u21 = 57359; - pub const num_lock: u21 = 57360; - pub const print_screen: u21 = 57361; - pub const pause: u21 = 57362; - pub const menu: u21 = 57363; - pub const f1: u21 = 57364; - pub const f2: u21 = 57365; - pub const f3: u21 = 57366; - pub const f4: u21 = 57367; - pub const f5: u21 = 57368; - pub const f6: u21 = 57369; - pub const f7: u21 = 57370; - pub const f8: u21 = 57371; - pub const f9: u21 = 57372; - pub const f10: u21 = 57373; - pub const f11: u21 = 57374; - pub const f12: u21 = 57375; - pub const f13: u21 = 57376; - pub const f14: u21 = 57377; - pub const f15: u21 = 57378; - pub const @"f16": u21 = 57379; - pub const f17: u21 = 57380; - pub const f18: u21 = 57381; - pub const f19: u21 = 57382; - pub const f20: u21 = 57383; - pub const f21: u21 = 57384; - pub const f22: u21 = 57385; - pub const f23: u21 = 57386; - pub const f24: u21 = 57387; - pub const f25: u21 = 57388; - pub const f26: u21 = 57389; - pub const f27: u21 = 57390; - pub const f28: u21 = 57391; - pub const f29: u21 = 57392; - pub const f30: u21 = 57393; - pub const f31: u21 = 57394; - pub const @"f32": u21 = 57395; - pub const f33: u21 = 57396; - pub const f34: u21 = 57397; - pub const f35: u21 = 57398; - pub const kp_0: u21 = 57399; - pub const kp_1: u21 = 57400; - pub const kp_2: u21 = 57401; - pub const kp_3: u21 = 57402; - pub const kp_4: u21 = 57403; - pub const kp_5: u21 = 57404; - pub const kp_6: u21 = 57405; - pub const kp_7: u21 = 57406; - pub const kp_8: u21 = 57407; - pub const kp_9: u21 = 57408; - pub const kp_decimal: u21 = 57409; - pub const kp_divide: u21 = 57410; - pub const kp_multiply: u21 = 57411; - pub const kp_subtract: u21 = 57412; - pub const kp_add: u21 = 57413; - pub const kp_enter: u21 = 57414; - pub const kp_equal: u21 = 57415; - pub const kp_separator: u21 = 57416; - pub const kp_left: u21 = 57417; - pub const kp_right: u21 = 57418; - pub const kp_up: u21 = 57419; - pub const kp_down: u21 = 57420; - pub const kp_page_up: u21 = 57421; - pub const kp_page_down: u21 = 57422; - pub const kp_home: u21 = 57423; - pub const kp_end: u21 = 57424; - pub const kp_insert: u21 = 57425; - pub const kp_delete: u21 = 57426; - pub const kp_begin: u21 = 57427; - pub const media_play: u21 = 57428; - pub const media_pause: u21 = 57429; - pub const media_play_pause: u21 = 57430; - pub const media_reverse: u21 = 57431; - pub const media_stop: u21 = 57432; - pub const media_fast_forward: u21 = 57433; - pub const media_rewind: u21 = 57434; - pub const media_track_next: u21 = 57435; - pub const media_track_previous: u21 = 57436; - pub const media_record: u21 = 57437; - pub const lower_volume: u21 = 57438; - pub const raise_volume: u21 = 57439; - pub const mute_volume: u21 = 57440; - pub const left_shift: u21 = 57441; - pub const left_control: u21 = 57442; - pub const left_alt: u21 = 57443; - pub const left_super: u21 = 57444; - pub const left_hyper: u21 = 57445; - pub const left_meta: u21 = 57446; - pub const right_shift: u21 = 57447; - pub const right_control: u21 = 57448; - pub const right_alt: u21 = 57449; - pub const right_super: u21 = 57450; - pub const right_hyper: u21 = 57451; - pub const right_meta: u21 = 57452; - pub const iso_level_3_shift: u21 = 57453; - pub const iso_level_5_shift: u21 = 57454; -}; - -pub const Modifier = struct { - shift: bool = false, - alt: bool = false, - ctrl: bool = false, -}; - // Ref: https://vt100.net/docs/vt510-rm/DECRPM.html pub const ReportMode = enum { not_recognized, @@ -188,6 +58,10 @@ pub fn read(buf: []u8) !usize { return try std.posix.read(std.posix.STDIN_FILENO, buf); } +pub fn write(buf: []const u8) !usize { + return try std.posix.write(std.posix.STDERR_FILENO, buf); +} + pub fn getCursorPosition() !Position { // Needs Raw mode (no wait for \n) to work properly cause // control sequence will not be written without it. diff --git a/src/widget/RawText.zig b/src/widget/RawText.zig index da7fe89..7db46c2 100644 --- a/src/widget/RawText.zig +++ b/src/widget/RawText.zig @@ -1,6 +1,9 @@ const std = @import("std"); +const terminal = @import("../terminal.zig"); + const isTaggedUnion = @import("../event.zig").isTaggedUnion; const Error = @import("../event.zig").Error; +const Key = @import("../key.zig"); pub fn Widget(comptime Event: type) type { if (!isTaggedUnion(Event)) { @@ -8,6 +11,8 @@ pub fn Widget(comptime Event: type) type { } return struct { c: std.ArrayList(u8) = undefined, + key: Key = undefined, + size: terminal.Size = undefined, pub fn init(allocator: std.mem.Allocator) @This() { return .{ .c = std.ArrayList(u8).init(allocator) }; @@ -19,15 +24,24 @@ pub fn Widget(comptime Event: type) type { } pub fn handle(this: *@This(), event: Event) ?Event { - // ignore the event for now - _ = this; - _ = event; + switch (event) { + // store the received size + .resize => |size| { + this.size = size; + }, + .key => |key| { + this.key = key; + }, + else => {}, + } return null; } pub fn content(this: *@This()) !*std.ArrayList(u8) { this.c.clearRetainingCapacity(); - try this.c.appendSlice("This is a simple test"); + const writer = this.c.writer(); + try std.fmt.format(writer, "The terminal has a reported size of [cols: {d}, rows: {d}]\n", .{ this.size.cols, this.size.rows }); + try std.fmt.format(writer, "User entered key: {any}\n", .{this.key}); return &this.c; } }; -- 2.49.1 From 4ded0210ee5b0c6cbfceb8dffb73b34902fb8548 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Wed, 6 Nov 2024 17:06:45 +0100 Subject: [PATCH 07/20] mod(Render): fullscreen option as comptime configuration through App(..) --- src/app.zig | 51 +++++++++++++++++++++++++++++++++++++++++--------- src/main.zig | 15 +++++++-------- src/render.zig | 12 ++++++++---- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/app.zig b/src/app.zig index a1dc6a7..e651ea5 100644 --- a/src/app.zig +++ b/src/app.zig @@ -11,10 +11,34 @@ const Queue = @import("queue.zig").Queue; const log = std.log.scoped(.app); -// Create the App Type with the associated user events `E` which describes -// an tagged union for all the user events that can be send through the -// applications event loop. -pub fn App(comptime E: type) type { +/// Create the App Type with the associated user events _E_ which describes +/// 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 accessable 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 +/// an empty user Event: +/// +/// ```zig +/// const App = @import("app.zig").App( +/// union(enum) {}, +/// @import("render.zig").PlainRenderer, +/// true, +/// ); +/// // later on use +/// var app: App = .{}; +/// var renderer: App.Renderer = .{}; +/// ``` +pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fullscreen: bool) type { if (!isTaggedUnion(E)) { @compileError("Provided user event `E` for `App(comptime E: type)` is not of type `union(enum)`."); } @@ -22,6 +46,7 @@ pub fn App(comptime E: type) type { pub const Event = mergeTaggedUnions(SystemEvent, E); pub const Layout = @import("layout.zig").Layout(Event); pub const Widget = @import("widget.zig").Widget(Event); + pub const Renderer = R(fullscreen); queue: Queue(Event, 256) = .{}, thread: ?std.Thread = null, @@ -59,7 +84,10 @@ pub fn App(comptime E: type) type { var termios: std.posix.termios = undefined; try terminal.enableRawMode(&termios); this.termios = termios; - try terminal.saveScreen(); + if (fullscreen) { + try terminal.saveScreen(); + try terminal.enterAltScreen(); + } } pub fn interrupt(this: *@This()) !void { @@ -74,10 +102,15 @@ pub fn App(comptime E: type) type { try this.interrupt(); if (this.termios) |*termios| { try terminal.disableRawMode(termios); - try terminal.restoreScreen(); + if (fullscreen) { + try terminal.existAltScreen(); + try terminal.restoreScreen(); + } } } + /// Quit the application loop. + /// This will stop the internal input thread and post a **.quit** `Event`. pub fn quit(this: *@This()) void { this.quit_event.set(); this.postEvent(.quit); @@ -115,9 +148,9 @@ pub fn App(comptime E: type) type { fn run(this: *@This()) !void { // send initial terminal size - // changes are handled by the winch signal handler (see `init` and `registerWinch`) - const size = terminal.getTerminalSize(); - this.postEvent(.{ .resize = size }); + // changes are handled by the winch signal handler + // see `App.start` and `App.registerWinch` for details + this.postEvent(.{ .resize = terminal.getTerminalSize() }); // thread to read user inputs var buf: [256]u8 = undefined; diff --git a/src/main.zig b/src/main.zig index 33c7821..b2c024a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,8 +2,11 @@ const std = @import("std"); const terminal = @import("terminal.zig"); const zlog = @import("zlog"); -const App = @import("app.zig").App(union(enum) {}); -const Renderer = @import("render.zig").PlainRenderer(); +const App = @import("app.zig").App( + union(enum) {}, + @import("render.zig").PlainRenderer, + true, +); const Key = @import("key.zig"); pub const std_options = zlog.std_options; @@ -23,20 +26,16 @@ pub fn main() !void { const allocator = gpa.allocator(); var app: App = .{}; - var renderer: Renderer = .{}; + var renderer: App.Renderer = .{}; var rawText = App.Widget.RawText.init(allocator); const widget = App.Widget.createFrom(&rawText); var layout = App.Layout.Pane.init(allocator, widget); - defer layout.deinit(); + defer layout.deinit(); // deinitializes the contained widget try app.start(); defer app.stop() catch unreachable; - // NOTE: necessary for fullscreen tui applications - try terminal.enterAltScreen(); - defer terminal.existAltScreen() catch unreachable; - // App.Event loop while (true) { const event = app.nextEvent(); diff --git a/src/render.zig b/src/render.zig index b71dba3..58022ea 100644 --- a/src/render.zig +++ b/src/render.zig @@ -2,7 +2,7 @@ const std = @import("std"); const terminal = @import("terminal.zig"); -pub fn BufferedRenderer() type { +pub fn BufferedRenderer(comptime fullscreen: bool) type { return struct { refresh: bool = false, size: terminal.Size = undefined, @@ -10,6 +10,7 @@ pub fn BufferedRenderer() type { pub fn init(allocator: std.mem.Allocator) @This() { return .{ + .fullscreen = fullscreen, .screen = std.ArrayList(u8).init(allocator), }; } @@ -36,12 +37,15 @@ pub fn BufferedRenderer() type { }; } -pub fn PlainRenderer() type { +pub fn PlainRenderer(comptime fullscreen: bool) type { return struct { pub fn render(this: *@This(), content: *std.ArrayList(u8)) !void { _ = this; - try terminal.clearScreen(); - try terminal.setCursorPositionHome(); + if (fullscreen) { + try terminal.clearScreen(); + try terminal.setCursorPositionHome(); + } + // TODO: how would I clear the screen in case of a non fullscreen application (i.e. to clear to the start of the command) _ = try terminal.write(content.items); } }; -- 2.49.1 From 7cfe632c33e17d36c82df648816facd571a0ef81 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Wed, 6 Nov 2024 17:10:24 +0100 Subject: [PATCH 08/20] mod(terminal): write tui to stdout; log messages to stderr --- src/terminal.zig | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/terminal.zig b/src/terminal.zig index 28af695..3774da9 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -26,32 +26,32 @@ pub const ReportMode = enum { /// Gets number of rows and columns in the terminal pub fn getTerminalSize() Size { var ws: std.posix.winsize = undefined; - _ = std.posix.system.ioctl(std.posix.STDERR_FILENO, std.posix.T.IOCGWINSZ, &ws); + _ = std.posix.system.ioctl(std.posix.STDIN_FILENO, std.posix.T.IOCGWINSZ, &ws); return .{ .cols = ws.ws_col, .rows = ws.ws_row }; } pub fn saveScreen() !void { - _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[?47h"); + _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[?47h"); } pub fn restoreScreen() !void { - _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[?47l"); + _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[?47l"); } pub fn enterAltScreen() !void { - _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[?1049h"); + _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[?1049h"); } pub fn existAltScreen() !void { - _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[?1049l"); + _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[?1049l"); } pub fn clearScreen() !void { - _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[2J"); + _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[2J"); } pub fn setCursorPositionHome() !void { - _ = try std.posix.write(std.posix.STDERR_FILENO, "\x1b[H"); + _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[H"); } pub fn read(buf: []u8) !usize { @@ -59,13 +59,13 @@ pub fn read(buf: []u8) !usize { } pub fn write(buf: []const u8) !usize { - return try std.posix.write(std.posix.STDERR_FILENO, buf); + return try std.posix.write(std.posix.STDIN_FILENO, buf); } pub fn getCursorPosition() !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.STDERR_FILENO, "\x1b[6n"); + _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[6n"); var buf: [64]u8 = undefined; @@ -165,7 +165,7 @@ pub fn disableRawMode(bak: *std.posix.termios) !void { pub fn canSynchornizeOutput() !bool { // 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.STDERR_FILENO, "\x1b[?2026$p"); + _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[?2026$p"); var buf: [64]u8 = undefined; -- 2.49.1 From 2e93218b447dc658db52b9398d6bb4d93a9aba21 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Wed, 6 Nov 2024 17:14:27 +0100 Subject: [PATCH 09/20] mod(main): use scoped log messages when reporting memory leaks --- src/main.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.zig b/src/main.zig index b2c024a..845d0c6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -15,12 +15,12 @@ const log = std.log.scoped(.default); pub fn main() !void { errdefer |err| log.err("Application Error: {any}", .{err}); - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + 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) { - std.log.err("memory leak", .{}); + log.err("memory leak", .{}); } } const allocator = gpa.allocator(); -- 2.49.1 From d9bcbcec7e28869f0ff1193c6f313876af5d96a0 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Thu, 7 Nov 2024 21:28:52 +0100 Subject: [PATCH 10/20] feat(RawText): simple pager for a static file name Layout is a simple pane without any restrictions, which should be implemented next to see if the interfaces are stable and usable enough. As for example the interface for `Widget.content()` has changed to return a `[]u8` and not a `*std.ArrayList`. --- src/ctlseqs.zig | 140 ++++++++++++++++++++ src/layout.zig | 7 +- src/layout/Pane.zig | 14 +- src/main.zig | 11 +- src/style.zig | 286 +++++++++++++++++++++++++++++++++++++++-- src/widget.zig | 12 +- src/widget/RawText.zig | 55 ++++++-- 7 files changed, 476 insertions(+), 49 deletions(-) create mode 100644 src/ctlseqs.zig diff --git a/src/ctlseqs.zig b/src/ctlseqs.zig new file mode 100644 index 0000000..4e55448 --- /dev/null +++ b/src/ctlseqs.zig @@ -0,0 +1,140 @@ +// Queries +pub const primary_device_attrs = "\x1b[c"; +pub const tertiary_device_attrs = "\x1b[=c"; +pub const device_status_report = "\x1b[5n"; +pub const xtversion = "\x1b[>0q"; +pub const decrqm_focus = "\x1b[?1004$p"; +pub const decrqm_sgr_pixels = "\x1b[?1016$p"; +pub const decrqm_sync = "\x1b[?2026$p"; +pub const decrqm_unicode = "\x1b[?2027$p"; +pub const decrqm_color_scheme = "\x1b[?2031$p"; +pub const csi_u_query = "\x1b[?u"; +pub const kitty_graphics_query = "\x1b_Gi=1,a=q\x1b\\"; +pub const sixel_geometry_query = "\x1b[?2;1;0S"; + +// mouse. We try for button motion and any motion. terminals will enable the +// last one we tried (any motion). This was added because zellij doesn't +// support any motion currently +// See: https://github.com/zellij-org/zellij/issues/1679 +pub const mouse_set = "\x1b[?1002;1003;1004;1006h"; +pub const mouse_set_pixels = "\x1b[?1002;1003;1004;1016h"; +pub const mouse_reset = "\x1b[?1002;1003;1004;1006;1016l"; + +// in-band window size reports +pub const in_band_resize_set = "\x1b[?2048h"; +pub const in_band_resize_reset = "\x1b[?2048l"; + +// sync +pub const sync_set = "\x1b[?2026h"; +pub const sync_reset = "\x1b[?2026l"; + +// unicode +pub const unicode_set = "\x1b[?2027h"; +pub const unicode_reset = "\x1b[?2027l"; + +// bracketed paste +pub const bp_set = "\x1b[?2004h"; +pub const bp_reset = "\x1b[?2004l"; + +// color scheme updates +pub const color_scheme_request = "\x1b[?996n"; +pub const color_scheme_set = "\x1b[?2031h"; +pub const color_scheme_reset = "\x1b[?2031l"; + +// Key encoding +pub const csi_u_push = "\x1b[>{d}u"; +pub const csi_u_pop = "\x1b[ |size| { - this.size = size; - }, else => {}, } this.events.clearRetainingCapacity(); @@ -46,7 +44,7 @@ pub fn Layout(comptime Event: type) type { pub fn content(this: *@This()) !*std.ArrayList(u8) { const widget_content = try this.widget.content(); this.c.clearRetainingCapacity(); - try this.c.appendSlice(widget_content.items); + try this.c.appendSlice(widget_content); return &this.c; } }; diff --git a/src/main.zig b/src/main.zig index 845d0c6..f950a39 100644 --- a/src/main.zig +++ b/src/main.zig @@ -28,10 +28,12 @@ pub fn main() !void { var app: App = .{}; var renderer: App.Renderer = .{}; - var rawText = App.Widget.RawText.init(allocator); + const file = try std.fs.cwd().openFile("./src/main.zig", .{}); + var rawText = App.Widget.RawText.init(allocator, file); const widget = App.Widget.createFrom(&rawText); var layout = App.Layout.Pane.init(allocator, widget); defer layout.deinit(); // deinitializes the contained widget + file.close(); try app.start(); defer app.stop() catch unreachable; @@ -75,13 +77,6 @@ pub fn main() !void { } // TODO: I could use the ascii codes in vaxis // - see https://gist.github.com/ConnerWill/d4b6c776b509add763e17f9f113fd25b - // how would I draw? - // use array for screen contents? <-> support partial re-draws - // support widget type drawing similar to the already existing widgets - // determine the corresponding capabilities of the terminal? - // support layouts - // - contents of corresponding locations - // resize event } test { diff --git a/src/style.zig b/src/style.zig index a6d5e09..fb46c9d 100644 --- a/src/style.zig +++ b/src/style.zig @@ -4,19 +4,281 @@ //! //! Stylings however also include highlighting for specific terminal capabilities. //! For example url highlighting. -const std = @import("std"); -// TODO: implement helper functions for the following stylings: -// - bold -// - italic -// - underline -// - curly line -// - strike through -// - reverse -// - blink -// - color: -// - foreground -// - background +// 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, + +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) diff --git a/src/widget.zig b/src/widget.zig index 7d6f29f..d99a1f3 100644 --- a/src/widget.zig +++ b/src/widget.zig @@ -1,7 +1,7 @@ //! Dynamic dispatch for widget implementations. //! Each widget should at last implement these functions: //! - handle(this: *@This(), event: Event) ?Event {} -//! - content(this: *@This()) *std.ArrayList(u8) {} +//! - content(this: *@This()) ![]u8 {} //! - deinit(this: *@This()) void {} //! //! Create a `Widget` using `createFrom(object: anytype)` and use them through @@ -10,7 +10,6 @@ //! //! Each `Widget` may cache its content and should if the contents will not //! change for a long time. -const std = @import("std"); const isTaggedUnion = @import("event.zig").isTaggedUnion; pub fn Widget(comptime Event: type) type { @@ -23,7 +22,7 @@ pub fn Widget(comptime Event: type) type { const VTable = struct { handle: *const fn (this: *WidgetType, event: Event) ?Event, - content: *const fn (this: *WidgetType) anyerror!*std.ArrayList(u8), + content: *const fn (this: *WidgetType) anyerror![]u8, deinit: *const fn (this: *WidgetType) void, }; @@ -36,7 +35,7 @@ pub fn Widget(comptime Event: type) type { } // Return the entire content of this `Widget`. - pub fn content(this: *WidgetType) !*std.ArrayList(u8) { + pub fn content(this: *WidgetType) ![]u8 { return try this.vtable.content(this); } @@ -58,7 +57,7 @@ pub fn Widget(comptime Event: type) type { }.handle, .content = struct { // Return the entire content of this `Widget`. - fn content(this: *WidgetType) !*std.ArrayList(u8) { + fn content(this: *WidgetType) ![]u8 { const widget: @TypeOf(object) = @ptrFromInt(this.object); return try widget.content(); } @@ -75,8 +74,5 @@ pub fn Widget(comptime Event: type) type { // TODO: import and export of `Widget` implementations (with corresponding intialization using `Event`) pub const RawText = @import("widget/RawText.zig").Widget(Event); - // pub const Header = @import("widget/Header.zig"); - // pub const ViewPort = @import("widget/ViewPort.zig"); - // pub const PopupMenu = @import("widget/PopupMenu.zig"); }; } diff --git a/src/widget/RawText.zig b/src/widget/RawText.zig index 7db46c2..7814b46 100644 --- a/src/widget/RawText.zig +++ b/src/widget/RawText.zig @@ -1,5 +1,6 @@ const std = @import("std"); const terminal = @import("../terminal.zig"); +const Style = @import("../style.zig"); const isTaggedUnion = @import("../event.zig").isTaggedUnion; const Error = @import("../event.zig").Error; @@ -11,15 +12,25 @@ pub fn Widget(comptime Event: type) type { } return struct { c: std.ArrayList(u8) = undefined, - key: Key = undefined, + line_index: std.ArrayList(usize) = undefined, + line: usize = 0, size: terminal.Size = undefined, - pub fn init(allocator: std.mem.Allocator) @This() { - return .{ .c = std.ArrayList(u8).init(allocator) }; + pub fn init(allocator: std.mem.Allocator, file: std.fs.File) @This() { + var c = std.ArrayList(u8).init(allocator); + var line_index = std.ArrayList(usize).init(allocator); + file.reader().readAllArrayList(&c, 4192) catch {}; + for (c.items, 0..) |item, i| { + if (item == '\n') { + line_index.append(i) catch {}; + } + } + return .{ .c = c, .line_index = line_index }; } pub fn deinit(this: *@This()) void { this.c.deinit(); + this.line_index.deinit(); this.* = undefined; } @@ -30,19 +41,43 @@ pub fn Widget(comptime Event: type) type { this.size = size; }, .key => |key| { - this.key = key; + if (key.matches(.{ .cp = 'g' })) { + // top + this.line = 0; + } + if (key.matches(.{ .cp = 'G' })) { + // bottom + this.line = this.line_index.items.len - 1; + } + if (key.matches(.{ .cp = 'j' })) { + // down + if (this.line < this.line_index.items.len - 1) { + this.line +|= 1; + } + } + if (key.matches(.{ .cp = 'k' })) { + // up + this.line -|= 1; + } }, else => {}, } return null; } - pub fn content(this: *@This()) !*std.ArrayList(u8) { - this.c.clearRetainingCapacity(); - const writer = this.c.writer(); - try std.fmt.format(writer, "The terminal has a reported size of [cols: {d}, rows: {d}]\n", .{ this.size.cols, this.size.rows }); - try std.fmt.format(writer, "User entered key: {any}\n", .{this.key}); - return &this.c; + pub fn content(this: *@This()) ![]u8 { + if (this.size.rows >= this.line_index.items.len) { + return this.c.items; + } else { + // more rows than we can display + const i = this.line_index.items[this.line]; + const e = this.size.rows + this.line; + if (e >= this.line_index.items.len) { + return this.c.items[i..]; + } + const x = this.line_index.items[e]; + return this.c.items[i..x]; + } } }; } -- 2.49.1 From 4be3451fbfaabe45073b98b2e57fa144c82f53ad Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Thu, 7 Nov 2024 21:55:19 +0100 Subject: [PATCH 11/20] mod(app): interrupt application correctly --- src/app.zig | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app.zig b/src/app.zig index e651ea5..baf65e1 100644 --- a/src/app.zig +++ b/src/app.zig @@ -79,11 +79,12 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls this.quit_event.reset(); this.thread = try std.Thread.spawn(.{}, @This().run, .{this}); - if (this.termios) |_| return; var termios: std.posix.termios = undefined; try terminal.enableRawMode(&termios); - this.termios = termios; + if (this.termios) |_| {} else { + this.termios = termios; + } if (fullscreen) { try terminal.saveScreen(); try terminal.enterAltScreen(); @@ -92,6 +93,10 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls pub fn interrupt(this: *@This()) !void { this.quit_event.set(); + if (fullscreen) { + try terminal.existAltScreen(); + try terminal.restoreScreen(); + } if (this.thread) |thread| { thread.join(); this.thread = null; @@ -107,6 +112,7 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls try terminal.restoreScreen(); } } + this.termios = null; } /// Quit the application loop. -- 2.49.1 From 2d2e63ac6354154f16b1190d2af5a9e837720958 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Thu, 7 Nov 2024 23:30:40 +0100 Subject: [PATCH 12/20] mod(terminal): enable termios flags corresponding to termios(3) man page --- README.md | 8 ++++++++ src/terminal.zig | 21 +++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 23d82ef..a0ae101 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,11 @@ It contains information about me and my projects as well as blog entries about s - [ ] How can I support to run a sub-process inside of a given pane / layout? - [ ] Create demo gifs using [vhs](https://github.com/charmbracelet/vhs) +- [ ] How would I measure my FPS? +- [ ] Could I simulate a corresponding event loop? + - empty user event + - emmit as many as possible through another thread (until the event queue is full?) + - see how fast the application can render each frame and measure the necessary time for each _frame_? + -> determine statistics like, min, max, median, mean, etc. + -> Or buffered writer to the `std.posix.STDOUT_FILENO`? + -> I could use this to see if it makes sense to implement a buffered version using a screen buffer (to only render the differences?) diff --git a/src/terminal.zig b/src/terminal.zig index 3774da9..5ac995e 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -137,13 +137,30 @@ pub fn enableRawMode(bak: *std.posix.termios) !void { var termios = try std.posix.tcgetattr(std.posix.STDIN_FILENO); bak.* = termios; - termios.iflag.IXON = false; + // termios flags used by termios(3) + termios.iflag.IGNBRK = false; + termios.iflag.BRKINT = false; + termios.iflag.PARMRK = false; + termios.iflag.ISTRIP = false; + termios.iflag.INLCR = false; + termios.iflag.IGNCR = false; termios.iflag.ICRNL = false; + termios.iflag.IXON = false; + + // messes with output -> not used + // termios.oflag.OPOST = false; termios.lflag.ECHO = false; + termios.lflag.ECHONL = false; termios.lflag.ICANON = false; - termios.lflag.IEXTEN = false; termios.lflag.ISIG = false; + termios.lflag.IEXTEN = false; + + termios.cflag.CSIZE = .CS8; + termios.cflag.PARENB = false; + + termios.cc[@intFromEnum(std.posix.V.MIN)] = 1; + termios.cc[@intFromEnum(std.posix.V.TIME)] = 0; try std.posix.tcsetattr( std.posix.STDIN_FILENO, -- 2.49.1 From 817d818d4c807cd1848872f762f315b1dd0d6edd Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Sat, 9 Nov 2024 01:17:09 +0100 Subject: [PATCH 13/20] feat(benchmark): add benchmark build (with build option) to test frame rate --- README.md | 8 ++++---- build.zig | 9 +++++++++ src/main.zig | 42 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a0ae101..3e13cdf 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ It contains information about me and my projects as well as blog entries about s - [ ] How can I support to run a sub-process inside of a given pane / layout? - [ ] Create demo gifs using [vhs](https://github.com/charmbracelet/vhs) -- [ ] How would I measure my FPS? -- [ ] Could I simulate a corresponding event loop? - - empty user event - - emmit as many as possible through another thread (until the event queue is full?) + +- [x] Could I simulate a corresponding event loop? + - emmit as many as possible through another thread (until the event queue is full?) [1023] - see how fast the application can render each frame and measure the necessary time for each _frame_? -> determine statistics like, min, max, median, mean, etc. -> Or buffered writer to the `std.posix.STDOUT_FILENO`? -> I could use this to see if it makes sense to implement a buffered version using a screen buffer (to only render the differences?) + - seems pretty good (with some exceptions) diff --git a/build.zig b/build.zig index 6fac23a..f05cf3a 100644 --- a/build.zig +++ b/build.zig @@ -15,6 +15,14 @@ pub fn build(b: *std.Build) void { // set a preferred release mode, allowing the user to decide how to optimize. const optimize = b.standardOptimizeOption(.{}); + // build options to customize the log message formatting + const benchmark = b.option(bool, "benchmark", "Create a benchmark build (default: false)") orelse false; + + const options = b.addOptions(); + options.addOption(bool, "benchmark", benchmark); + + const options_module = options.createModule(); + // Dependencies const vaxis_dep = b.dependency("vaxis", .{ .optimize = optimize, @@ -37,6 +45,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + exe.root_module.addImport("build_options", options_module); exe.root_module.addImport("vaxis", vaxis_dep.module("vaxis")); exe.root_module.addImport("zlog", zlog_dep.module("zlog")); exe.root_module.addImport("zmd", zmd_dep.module("zmd")); diff --git a/src/main.zig b/src/main.zig index f950a39..6a5f95d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,3 +1,4 @@ +const build_options = @import("build_options"); const std = @import("std"); const terminal = @import("terminal.zig"); const zlog = @import("zlog"); @@ -38,6 +39,14 @@ pub fn main() !void { try app.start(); defer app.stop() catch unreachable; + // Benchmarking + var instants: std.ArrayList(u64) = undefined; + var benchmark_thread: std.Thread = undefined; + if (comptime build_options.benchmark) { + instants = try std.ArrayList(u64).initCapacity(allocator, 512); + benchmark_thread = try std.Thread.spawn(.{}, benchmark, .{&app}); + } + // App.Event loop while (true) { const event = app.nextEvent(); @@ -68,15 +77,44 @@ pub fn main() !void { }, else => {}, } + + var start_instant: std.time.Instant = undefined; + if (comptime build_options.benchmark) { + start_instant = try std.time.Instant.now(); + } + // NOTE: this currently re-renders the screen for every key-press -> which might be a bit of an overkill const events = try layout.handle(event); for (events.items) |e| { app.postEvent(e); } try renderer.render(try layout.content()); + + if (comptime build_options.benchmark) { + const end_instant = try std.time.Instant.now(); + try instants.append(end_instant.since(start_instant)); + } } - // TODO: I could use the ascii codes in vaxis - // - see https://gist.github.com/ConnerWill/d4b6c776b509add763e17f9f113fd25b + + if (comptime build_options.benchmark) { + benchmark_thread.join(); + // print benchmark results + for (instants.items, 1..) |epoch, i| { + _ = i; + const epoch_float: f64 = @floatFromInt(epoch); + std.log.info("{d:.3}", .{epoch_float / std.time.ns_per_ms}); + } + instants.deinit(); + } +} + +fn benchmark(app: *App) void { + std.time.sleep(1 * std.time.ns_per_s); + for (0..511) |_| { + app.postEvent(.{ .key = .{ .cp = 'j' } }); + app.postEvent(.{ .key = .{ .cp = 'k' } }); + } + app.quit(); } test { -- 2.49.1 From 9d711ea047f8c2d4919d5758d2e25f3d457597d0 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Sat, 9 Nov 2024 01:33:06 +0100 Subject: [PATCH 14/20] mod(benchmark): correct size and provide documentation --- README.md | 11 +++++++++++ src/layout/Pane.zig | 24 +++++++++++++++++++----- src/main.zig | 4 ++-- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3e13cdf..8b1baf4 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,17 @@ This is my terminal based website. It is served as a tui application via ssh and It contains information about me and my projects as well as blog entries about something I feel like writing something about. +## Terminal User Interface + +### Benchmark + +```sh +zig build -Dbenchmark run 2> log +``` + +_Press any button_ at the end of the benchmark (when you are back to the original screen). `log` now contains the frame +delay for each frame in each line of the output. + ## Open tasks - [ ] Improve navigation diff --git a/src/layout/Pane.zig b/src/layout/Pane.zig index 30e75b0..8f15b59 100644 --- a/src/layout/Pane.zig +++ b/src/layout/Pane.zig @@ -31,12 +31,24 @@ pub fn Layout(comptime Event: type) type { } pub fn handle(this: *@This(), event: Event) !*Events { - switch (event) { - else => {}, - } this.events.clearRetainingCapacity(); - if (this.widget.handle(event)) |e| { - try this.events.append(e); + switch (event) { + .resize => |size| { + const widget_event: Event = .{ + .resize = .{ + .cols = size.cols, + .rows = size.rows -| 2, // remove top and bottom rows + }, + }; + if (this.widget.handle(widget_event)) |e| { + try this.events.append(e); + } + }, + else => { + if (this.widget.handle(event)) |e| { + try this.events.append(e); + } + }, } return &this.events; } @@ -44,7 +56,9 @@ pub fn Layout(comptime Event: type) type { pub fn content(this: *@This()) !*std.ArrayList(u8) { const widget_content = try this.widget.content(); this.c.clearRetainingCapacity(); + try this.c.appendSlice("\n"); try this.c.appendSlice(widget_content); + try this.c.appendSlice("\n"); return &this.c; } }; diff --git a/src/main.zig b/src/main.zig index 6a5f95d..d3ba4db 100644 --- a/src/main.zig +++ b/src/main.zig @@ -43,7 +43,7 @@ pub fn main() !void { var instants: std.ArrayList(u64) = undefined; var benchmark_thread: std.Thread = undefined; if (comptime build_options.benchmark) { - instants = try std.ArrayList(u64).initCapacity(allocator, 512); + instants = try std.ArrayList(u64).initCapacity(allocator, 1024); benchmark_thread = try std.Thread.spawn(.{}, benchmark, .{&app}); } @@ -110,7 +110,7 @@ pub fn main() !void { fn benchmark(app: *App) void { std.time.sleep(1 * std.time.ns_per_s); - for (0..511) |_| { + for (0..512) |_| { app.postEvent(.{ .key = .{ .cp = 'j' } }); app.postEvent(.{ .key = .{ .cp = 'k' } }); } -- 2.49.1 From ba01bf00bbef6addeab441f873af2e69018ae2cd Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Sat, 9 Nov 2024 01:52:37 +0100 Subject: [PATCH 15/20] mod(pane) example layout pane has top and bottom empty row Fix corresponding RawText line control (i.e. last line and changes to the window size in regards to the current line). --- src/layout/Pane.zig | 3 +-- src/widget/RawText.zig | 7 +++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/layout/Pane.zig b/src/layout/Pane.zig index 8f15b59..ceaf0ef 100644 --- a/src/layout/Pane.zig +++ b/src/layout/Pane.zig @@ -54,10 +54,9 @@ pub fn Layout(comptime Event: type) type { } pub fn content(this: *@This()) !*std.ArrayList(u8) { - const widget_content = try this.widget.content(); this.c.clearRetainingCapacity(); try this.c.appendSlice("\n"); - try this.c.appendSlice(widget_content); + try this.c.appendSlice(try this.widget.content()); try this.c.appendSlice("\n"); return &this.c; } diff --git a/src/widget/RawText.zig b/src/widget/RawText.zig index 7814b46..90f07c3 100644 --- a/src/widget/RawText.zig +++ b/src/widget/RawText.zig @@ -39,6 +39,9 @@ pub fn Widget(comptime Event: type) type { // store the received size .resize => |size| { this.size = size; + if (this.line > this.line_index.items.len - 1 - size.rows) { + this.line = this.line_index.items.len - 1 - size.rows; + } }, .key => |key| { if (key.matches(.{ .cp = 'g' })) { @@ -47,11 +50,11 @@ pub fn Widget(comptime Event: type) type { } if (key.matches(.{ .cp = 'G' })) { // bottom - this.line = this.line_index.items.len - 1; + this.line = this.line_index.items.len - 1 - this.size.rows; } if (key.matches(.{ .cp = 'j' })) { // down - if (this.line < this.line_index.items.len - 1) { + if (this.line < this.line_index.items.len - 1 - this.size.rows) { this.line +|= 1; } } -- 2.49.1 From 225368280a10cc010c63898c56408394156aa20b Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Sat, 9 Nov 2024 04:47:08 +0100 Subject: [PATCH 16/20] feat(layouts): add WIP layout implementations --- README.md | 8 +++ src/layout.zig | 4 ++ src/layout/Framing.zig | 92 ++++++++++++++++++++++++++++++++++ src/layout/HStack.zig | 109 +++++++++++++++++++++++++++++++++++++++++ src/layout/Padding.zig | 92 ++++++++++++++++++++++++++++++++++ src/layout/Pane.zig | 16 +++--- src/layout/VStack.zig | 109 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 src/layout/Framing.zig create mode 100644 src/layout/HStack.zig create mode 100644 src/layout/Padding.zig create mode 100644 src/layout/VStack.zig diff --git a/README.md b/README.md index 8b1baf4..558a8a3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,14 @@ delay for each frame in each line of the output. - [ ] Have clickable/navigatable links inside of the tui application - [ ] Launch simple http server alongside tui application +- [ ] Create other layouts + - [ ] horizontal stack + - [ ] vertical stack + - [ ] `Layout` in `Layout`? -> interfaces are very similar anyway + - [ ] Building Block `Layout`s + - [ ] Framing `Layout` + - [ ] Padding `Layout` + --- ## Branch: `own-tty-visuals` diff --git a/src/layout.zig b/src/layout.zig index f45c07a..478b856 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -77,5 +77,9 @@ pub fn Layout(comptime Event: type) type { // import and export of `Layout` implementations pub const Pane = @import("layout/Pane.zig").Layout(Event); + pub const HStack = @import("layout/HStack.zig").Layout(Event); + pub const VStack = @import("layout/VStack.zig").Layout(Event); + pub const Padding = @import("layout/Padding.zig").Layout(Event); + pub const Framing = @import("layout/Framing.zig").Layout(Event); }; } diff --git a/src/layout/Framing.zig b/src/layout/Framing.zig new file mode 100644 index 0000000..7397337 --- /dev/null +++ b/src/layout/Framing.zig @@ -0,0 +1,92 @@ +//! 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 = @import("../key.zig"); + +pub fn Layout(comptime Event: type) type { + if (!isTaggedUnion(Event)) { + @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); + } + const Element = union { + layout: @import("../layout.zig").Layout(Event), + widget: @import("../widget.zig").Widget(Event), + }; + const Events = std.ArrayList(Event); + const Contents = std.ArrayList(u8); + return struct { + size: terminal.Size = undefined, + contents: Contents = undefined, + element: Element = undefined, + events: Events = undefined, + + pub fn init(allocator: std.mem.Allocator, element: Element) @This() { + return .{ + .contents = Contents.init(allocator), + .element = element, + .events = Events.init(allocator), + }; + } + + pub fn deinit(this: *@This()) void { + this.contents.deinit(); + this.element.deinit(); + this.events.deinit(); + } + + pub fn handle(this: *@This(), event: Event) !*Event { + this.events.clearRetainingCapacity(); + // order is important + switch (event) { + .resize => |size| { + this.size = size; + // adjust size according to the containing elements + const sub_event = event; + switch (this.element) { + .layout => |layout| { + this.events.appendSlice(layout.handle(sub_event).items); + }, + .widget => |widget| { + if (widget.handle(sub_event)) |e| { + this.events.append(e); + } + }, + } + }, + else => { + for (this.elements.items) |element| { + switch (element) { + .layout => |layout| { + this.events.appendSlice(layout.handle(event).items); + }, + .widget => |widget| { + if (widget.handle(event)) |e| { + this.events.append(e); + } + }, + } + } + }, + } + } + + pub fn content(this: *@This()) !*Contents { + this.contents.clearRetainingCapacity(); + // TODO: frame contents + switch (this.element) { + .layout => |layout| { + const layout_content = try layout.content(); + try this.contents.appendSlice(layout_content.items); + }, + .widget => |widget| { + try this.contents.appendSlice(try widget.content()); + }, + } + return &this.c; + } + }; +} diff --git a/src/layout/HStack.zig b/src/layout/HStack.zig new file mode 100644 index 0000000..6fdfc26 --- /dev/null +++ b/src/layout/HStack.zig @@ -0,0 +1,109 @@ +//! 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 = @import("../key.zig"); + +pub fn Layout(comptime Event: type) type { + if (!isTaggedUnion(Event)) { + @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); + } + const Element = union { + layout: @import("../layout.zig").Layout(Event), + widget: @import("../widget.zig").Widget(Event), + }; + const Elements = std.ArrayList(Element); + const Events = std.ArrayList(Event); + const Contents = std.ArrayList(u8); + return struct { + // TODO: current focused `Element`? + size: terminal.Size = undefined, + contents: Contents = undefined, + elements: Elements = undefined, + events: Events = undefined, + + // TODO: optional verdic argument for `Element`? + pub fn init(allocator: std.mem.Allocator) @This() { + return .{ + .contents = Contents.init(allocator), + .elements = Elements.init(allocator), + .events = Events.init(allocator), + }; + } + + pub fn deinit(this: *@This()) void { + this.events.deinit(); + this.contents.deinit(); + if (this.elements.items) |element| { + switch (element) { + .layout => |l| { + l.deinit(); + }, + .widget => |w| { + w.deinit(); + }, + } + } + this.elements.deinit(); + } + + pub fn handle(this: *@This(), event: Event) !*Event { + this.events.clearRetainingCapacity(); + // order is important + switch (event) { + .resize => |size| { + this.size = size; + // adjust size according to the containing elements + for (this.elements.items) |element| { + const sub_event = event; + switch (element) { + .layout => |l| { + this.events.appendSlice(l.handle(sub_event).items); + }, + .widget => |w| { + if (w.handle(sub_event)) |e| { + this.events.append(e); + } + }, + } + } + }, + else => { + for (this.elements.items) |element| { + switch (element) { + .layout => |layout| { + this.events.appendSlice(layout.handle(event).items); + }, + .widget => |widget| { + if (widget.handle(event)) |e| { + this.events.append(e); + } + }, + } + } + }, + } + } + + pub fn content(this: *@This()) !*Contents { + this.contents.clearRetainingCapacity(); + // TODO: concat contents accordingly to create a vertical stack + for (this.elements.items) |element| { + switch (element) { + .layout => |layout| { + const layout_content = try layout.content(); + try this.contents.appendSlice(layout_content.items); + }, + .widget => |widget| { + try this.contents.appendSlice(try widget.content()); + }, + } + } + return &this.c; + } + }; +} diff --git a/src/layout/Padding.zig b/src/layout/Padding.zig new file mode 100644 index 0000000..c8aec73 --- /dev/null +++ b/src/layout/Padding.zig @@ -0,0 +1,92 @@ +//! 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 = @import("../key.zig"); + +pub fn Layout(comptime Event: type) type { + if (!isTaggedUnion(Event)) { + @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); + } + const Element = union { + layout: @import("../layout.zig").Layout(Event), + widget: @import("../widget.zig").Widget(Event), + }; + const Events = std.ArrayList(Event); + const Contents = std.ArrayList(u8); + return struct { + size: terminal.Size = undefined, + contents: Contents = undefined, + element: Element = undefined, + events: Events = undefined, + + pub fn init(allocator: std.mem.Allocator, element: Element) @This() { + return .{ + .contents = Contents.init(allocator), + .element = element, + .events = Events.init(allocator), + }; + } + + pub fn deinit(this: *@This()) void { + this.contents.deinit(); + this.element.deinit(); + this.events.deinit(); + } + + pub fn handle(this: *@This(), event: Event) !*Event { + this.events.clearRetainingCapacity(); + // order is important + switch (event) { + .resize => |size| { + this.size = size; + // adjust size according to the containing elements + const sub_event = event; + switch (this.element) { + .layout => |layout| { + this.events.appendSlice(layout.handle(sub_event).items); + }, + .widget => |widget| { + if (widget.handle(sub_event)) |e| { + this.events.append(e); + } + }, + } + }, + else => { + for (this.elements.items) |element| { + switch (element) { + .layout => |layout| { + this.events.appendSlice(layout.handle(event).items); + }, + .widget => |widget| { + if (widget.handle(event)) |e| { + this.events.append(e); + } + }, + } + } + }, + } + } + + pub fn content(this: *@This()) !*Contents { + this.contents.clearRetainingCapacity(); + // TODO: padding contents accordingly + switch (this.element) { + .layout => |layout| { + const layout_content = try layout.content(); + try this.contents.appendSlice(layout_content.items); + }, + .widget => |widget| { + try this.contents.appendSlice(try widget.content()); + }, + } + return &this.c; + } + }; +} diff --git a/src/layout/Pane.zig b/src/layout/Pane.zig index ceaf0ef..1d3678c 100644 --- a/src/layout/Pane.zig +++ b/src/layout/Pane.zig @@ -1,3 +1,4 @@ +// TODO: remove this `Layout` const std = @import("std"); const terminal = @import("../terminal.zig"); const isTaggedUnion = @import("../event.zig").isTaggedUnion; @@ -10,23 +11,24 @@ pub fn Layout(comptime Event: type) type { } const Widget = @import("../widget.zig").Widget(Event); const Events = std.ArrayList(Event); + const Contents = std.ArrayList(u8); return struct { widget: Widget = undefined, events: Events = undefined, - c: std.ArrayList(u8) = undefined, + contents: Contents = undefined, pub fn init(allocator: std.mem.Allocator, widget: Widget) @This() { return .{ .widget = widget, .events = Events.init(allocator), - .c = std.ArrayList(u8).init(allocator), + .contents = Contents.init(allocator), }; } pub fn deinit(this: *@This()) void { this.widget.deinit(); this.events.deinit(); - this.c.deinit(); + this.contents.deinit(); this.* = undefined; } @@ -54,10 +56,10 @@ pub fn Layout(comptime Event: type) type { } pub fn content(this: *@This()) !*std.ArrayList(u8) { - this.c.clearRetainingCapacity(); - try this.c.appendSlice("\n"); - try this.c.appendSlice(try this.widget.content()); - try this.c.appendSlice("\n"); + this.contents.clearRetainingCapacity(); + try this.contents.appendSlice("\n"); + try this.contents.appendSlice(try this.widget.content()); + try this.contents.appendSlice("\n"); return &this.c; } }; diff --git a/src/layout/VStack.zig b/src/layout/VStack.zig new file mode 100644 index 0000000..25f481c --- /dev/null +++ b/src/layout/VStack.zig @@ -0,0 +1,109 @@ +//! 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 = @import("../key.zig"); + +pub fn Layout(comptime Event: type) type { + if (!isTaggedUnion(Event)) { + @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); + } + const Element = union { + layout: @import("../layout.zig").Layout(Event), + widget: @import("../widget.zig").Widget(Event), + }; + const Elements = std.ArrayList(Element); + const Events = std.ArrayList(Event); + const Contents = std.ArrayList(u8); + return struct { + // TODO: current focused `Element`? + size: terminal.Size = undefined, + contents: Contents = undefined, + elements: Elements = undefined, + events: Events = undefined, + + // TODO: optional verdic argument for `Element`? + pub fn init(allocator: std.mem.Allocator) @This() { + return .{ + .contents = Contents.init(allocator), + .elements = Elements.init(allocator), + .events = Events.init(allocator), + }; + } + + pub fn deinit(this: *@This()) void { + this.events.deinit(); + this.contents.deinit(); + if (this.elements.items) |element| { + switch (element) { + .layout => |l| { + l.deinit(); + }, + .widget => |w| { + w.deinit(); + }, + } + } + this.elements.deinit(); + } + + pub fn handle(this: *@This(), event: Event) !*Event { + this.events.clearRetainingCapacity(); + // order is important + switch (event) { + .resize => |size| { + this.size = size; + // adjust size according to the containing elements + for (this.elements.items) |element| { + const sub_event = event; + switch (element) { + .layout => |l| { + this.events.appendSlice(l.handle(sub_event).items); + }, + .widget => |w| { + if (w.handle(sub_event)) |e| { + this.events.append(e); + } + }, + } + } + }, + else => { + for (this.elements.items) |element| { + switch (element) { + .layout => |layout| { + this.events.appendSlice(layout.handle(event).items); + }, + .widget => |widget| { + if (widget.handle(event)) |e| { + this.events.append(e); + } + }, + } + } + }, + } + } + + pub fn content(this: *@This()) !*Contents { + this.contents.clearRetainingCapacity(); + // TODO: concat contents accordingly to create a vertical stack + for (this.elements.items) |element| { + switch (element) { + .layout => |layout| { + const layout_content = try layout.content(); + try this.contents.appendSlice(layout_content.items); + }, + .widget => |widget| { + try this.contents.appendSlice(try widget.content()); + }, + } + } + return &this.c; + } + }; +} -- 2.49.1 From b5c5f4e3e2a77d5ef5efcf1cc4662d14b17a64d9 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Sat, 9 Nov 2024 14:29:55 +0100 Subject: [PATCH 17/20] feat(layout): fix compile errors for newly introduced layouts --- src/layout/Framing.zig | 56 +++++++++++++++------------ src/layout/HStack.zig | 86 +++++++++++++++++++++++++++--------------- src/layout/Padding.zig | 54 +++++++++++++++----------- src/layout/VStack.zig | 86 +++++++++++++++++++++++++++--------------- src/main.zig | 16 ++++++-- 5 files changed, 186 insertions(+), 112 deletions(-) diff --git a/src/layout/Framing.zig b/src/layout/Framing.zig index 7397337..9ea7abb 100644 --- a/src/layout/Framing.zig +++ b/src/layout/Framing.zig @@ -12,7 +12,7 @@ pub fn Layout(comptime Event: type) type { if (!isTaggedUnion(Event)) { @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } - const Element = union { + const Element = union(enum) { layout: @import("../layout.zig").Layout(Event), widget: @import("../widget.zig").Widget(Event), }; @@ -34,11 +34,18 @@ pub fn Layout(comptime Event: type) type { pub fn deinit(this: *@This()) void { this.contents.deinit(); - this.element.deinit(); this.events.deinit(); + switch ((&this.element).*) { + .layout => |*layout| { + layout.deinit(); + }, + .widget => |*widget| { + widget.deinit(); + }, + } } - pub fn handle(this: *@This(), event: Event) !*Event { + pub fn handle(this: *@This(), event: Event) !*Events { this.events.clearRetainingCapacity(); // order is important switch (event) { @@ -46,47 +53,48 @@ pub fn Layout(comptime Event: type) type { this.size = size; // adjust size according to the containing elements const sub_event = event; - switch (this.element) { - .layout => |layout| { - this.events.appendSlice(layout.handle(sub_event).items); + switch ((&this.element).*) { + .layout => |*layout| { + const events = try layout.handle(sub_event); + try this.events.appendSlice(events.items); }, - .widget => |widget| { + .widget => |*widget| { if (widget.handle(sub_event)) |e| { - this.events.append(e); + try this.events.append(e); } }, } }, else => { - for (this.elements.items) |element| { - switch (element) { - .layout => |layout| { - this.events.appendSlice(layout.handle(event).items); - }, - .widget => |widget| { - if (widget.handle(event)) |e| { - this.events.append(e); - } - }, - } + 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 content(this: *@This()) !*Contents { this.contents.clearRetainingCapacity(); - // TODO: frame contents - switch (this.element) { - .layout => |layout| { + // TODO: padding contents accordingly + switch ((&this.element).*) { + .layout => |*layout| { const layout_content = try layout.content(); try this.contents.appendSlice(layout_content.items); }, - .widget => |widget| { + .widget => |*widget| { try this.contents.appendSlice(try widget.content()); }, } - return &this.c; + return &this.contents; } }; } diff --git a/src/layout/HStack.zig b/src/layout/HStack.zig index 6fdfc26..87aeea6 100644 --- a/src/layout/HStack.zig +++ b/src/layout/HStack.zig @@ -12,9 +12,11 @@ pub fn Layout(comptime Event: type) type { if (!isTaggedUnion(Event)) { @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } - const Element = union { - layout: @import("../layout.zig").Layout(Event), - widget: @import("../widget.zig").Widget(Event), + const Widget = @import("../widget.zig").Widget(Event); + const Lay = @import("../layout.zig").Layout(Event); + const Element = union(enum) { + layout: Lay, + widget: Widget, }; const Elements = std.ArrayList(Element); const Events = std.ArrayList(Event); @@ -26,11 +28,30 @@ pub fn Layout(comptime Event: type) type { elements: Elements = undefined, events: Events = undefined, - // TODO: optional verdic argument for `Element`? - pub fn init(allocator: std.mem.Allocator) @This() { + 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("OOM"); + inline for (comptime fields_info) |field| { + const child = @field(children, field.name); + const ChildType = @TypeOf(child); + if (ChildType == Widget) { + elements.append(.{ .widget = child }) catch {}; + continue; + } + if (ChildType == Lay) { + elements.append(.{ .layout = child }) catch {}; + continue; + } + @compileError("child: " ++ field.name ++ " is not of type " ++ @typeName(Lay) ++ " or " ++ @typeName(Widget) ++ " but " ++ @typeName(ChildType)); + } return .{ .contents = Contents.init(allocator), - .elements = Elements.init(allocator), + .elements = elements, .events = Events.init(allocator), }; } @@ -38,72 +59,75 @@ pub fn Layout(comptime Event: type) type { pub fn deinit(this: *@This()) void { this.events.deinit(); this.contents.deinit(); - if (this.elements.items) |element| { - switch (element) { - .layout => |l| { - l.deinit(); + for (this.elements.items) |*element| { + switch (element.*) { + .layout => |*layout| { + layout.deinit(); }, - .widget => |w| { - w.deinit(); + .widget => |*widget| { + widget.deinit(); }, } } this.elements.deinit(); } - pub fn handle(this: *@This(), event: Event) !*Event { + 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 - for (this.elements.items) |element| { + for (this.elements.items) |*element| { const sub_event = event; - switch (element) { - .layout => |l| { - this.events.appendSlice(l.handle(sub_event).items); + switch (element.*) { + .layout => |*layout| { + const events = try layout.handle(sub_event); + try this.events.appendSlice(events.items); }, - .widget => |w| { - if (w.handle(sub_event)) |e| { - this.events.append(e); + .widget => |*widget| { + if (widget.handle(sub_event)) |e| { + try this.events.append(e); } }, } } }, else => { - for (this.elements.items) |element| { - switch (element) { - .layout => |layout| { - this.events.appendSlice(layout.handle(event).items); + for (this.elements.items) |*element| { + switch (element.*) { + .layout => |*layout| { + const events = try layout.handle(event); + try this.events.appendSlice(events.items); }, - .widget => |widget| { + .widget => |*widget| { if (widget.handle(event)) |e| { - this.events.append(e); + try this.events.append(e); } }, } } }, } + return &this.events; } pub fn content(this: *@This()) !*Contents { this.contents.clearRetainingCapacity(); // TODO: concat contents accordingly to create a vertical stack - for (this.elements.items) |element| { - switch (element) { - .layout => |layout| { + for (this.elements.items) |*element| { + switch (element.*) { + .layout => |*layout| { const layout_content = try layout.content(); try this.contents.appendSlice(layout_content.items); }, - .widget => |widget| { + .widget => |*widget| { try this.contents.appendSlice(try widget.content()); }, } } - return &this.c; + return &this.contents; } }; } diff --git a/src/layout/Padding.zig b/src/layout/Padding.zig index c8aec73..ea32fa3 100644 --- a/src/layout/Padding.zig +++ b/src/layout/Padding.zig @@ -12,7 +12,7 @@ pub fn Layout(comptime Event: type) type { if (!isTaggedUnion(Event)) { @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } - const Element = union { + const Element = union(enum) { layout: @import("../layout.zig").Layout(Event), widget: @import("../widget.zig").Widget(Event), }; @@ -34,11 +34,18 @@ pub fn Layout(comptime Event: type) type { pub fn deinit(this: *@This()) void { this.contents.deinit(); - this.element.deinit(); this.events.deinit(); + switch ((&this.element).*) { + .layout => |*layout| { + layout.deinit(); + }, + .widget => |*widget| { + widget.deinit(); + }, + } } - pub fn handle(this: *@This(), event: Event) !*Event { + pub fn handle(this: *@This(), event: Event) !*Events { this.events.clearRetainingCapacity(); // order is important switch (event) { @@ -46,47 +53,48 @@ pub fn Layout(comptime Event: type) type { this.size = size; // adjust size according to the containing elements const sub_event = event; - switch (this.element) { - .layout => |layout| { - this.events.appendSlice(layout.handle(sub_event).items); + switch ((&this.element).*) { + .layout => |*layout| { + const events = try layout.handle(sub_event); + try this.events.appendSlice(events.items); }, - .widget => |widget| { + .widget => |*widget| { if (widget.handle(sub_event)) |e| { - this.events.append(e); + try this.events.append(e); } }, } }, else => { - for (this.elements.items) |element| { - switch (element) { - .layout => |layout| { - this.events.appendSlice(layout.handle(event).items); - }, - .widget => |widget| { - if (widget.handle(event)) |e| { - this.events.append(e); - } - }, - } + 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 content(this: *@This()) !*Contents { this.contents.clearRetainingCapacity(); // TODO: padding contents accordingly - switch (this.element) { - .layout => |layout| { + switch ((&this.element).*) { + .layout => |*layout| { const layout_content = try layout.content(); try this.contents.appendSlice(layout_content.items); }, - .widget => |widget| { + .widget => |*widget| { try this.contents.appendSlice(try widget.content()); }, } - return &this.c; + return &this.contents; } }; } diff --git a/src/layout/VStack.zig b/src/layout/VStack.zig index 25f481c..135dfb4 100644 --- a/src/layout/VStack.zig +++ b/src/layout/VStack.zig @@ -12,9 +12,11 @@ pub fn Layout(comptime Event: type) type { if (!isTaggedUnion(Event)) { @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } - const Element = union { - layout: @import("../layout.zig").Layout(Event), - widget: @import("../widget.zig").Widget(Event), + const Widget = @import("../widget.zig").Widget(Event); + const Lay = @import("../layout.zig").Layout(Event); + const Element = union(enum) { + layout: Lay, + widget: Widget, }; const Elements = std.ArrayList(Element); const Events = std.ArrayList(Event); @@ -26,11 +28,30 @@ pub fn Layout(comptime Event: type) type { elements: Elements = undefined, events: Events = undefined, - // TODO: optional verdic argument for `Element`? - pub fn init(allocator: std.mem.Allocator) @This() { + 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("OOM"); + inline for (comptime fields_info) |field| { + const child = @field(children, field.name); + const ChildType = @TypeOf(child); + if (ChildType == Widget) { + elements.append(.{ .widget = child }) catch {}; + continue; + } + if (ChildType == Lay) { + elements.append(.{ .layout = child }) catch {}; + continue; + } + @compileError("child: " ++ field.name ++ " is not of type " ++ @typeName(Lay) ++ " or " ++ @typeName(Widget) ++ " but " ++ @typeName(ChildType)); + } return .{ .contents = Contents.init(allocator), - .elements = Elements.init(allocator), + .elements = elements, .events = Events.init(allocator), }; } @@ -38,72 +59,75 @@ pub fn Layout(comptime Event: type) type { pub fn deinit(this: *@This()) void { this.events.deinit(); this.contents.deinit(); - if (this.elements.items) |element| { - switch (element) { - .layout => |l| { - l.deinit(); + for (this.elements.items) |*element| { + switch (element.*) { + .layout => |*layout| { + layout.deinit(); }, - .widget => |w| { - w.deinit(); + .widget => |*widget| { + widget.deinit(); }, } } this.elements.deinit(); } - pub fn handle(this: *@This(), event: Event) !*Event { + 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 - for (this.elements.items) |element| { + for (this.elements.items) |*element| { const sub_event = event; - switch (element) { - .layout => |l| { - this.events.appendSlice(l.handle(sub_event).items); + switch (element.*) { + .layout => |*layout| { + const events = try layout.handle(sub_event); + try this.events.appendSlice(events.items); }, - .widget => |w| { - if (w.handle(sub_event)) |e| { - this.events.append(e); + .widget => |*widget| { + if (widget.handle(sub_event)) |e| { + try this.events.append(e); } }, } } }, else => { - for (this.elements.items) |element| { - switch (element) { - .layout => |layout| { - this.events.appendSlice(layout.handle(event).items); + for (this.elements.items) |*element| { + switch (element.*) { + .layout => |*layout| { + const events = try layout.handle(event); + try this.events.appendSlice(events.items); }, - .widget => |widget| { + .widget => |*widget| { if (widget.handle(event)) |e| { - this.events.append(e); + try this.events.append(e); } }, } } }, } + return &this.events; } pub fn content(this: *@This()) !*Contents { this.contents.clearRetainingCapacity(); // TODO: concat contents accordingly to create a vertical stack - for (this.elements.items) |element| { - switch (element) { - .layout => |layout| { + for (this.elements.items) |*element| { + switch (element.*) { + .layout => |*layout| { const layout_content = try layout.content(); try this.contents.appendSlice(layout_content.items); }, - .widget => |widget| { + .widget => |*widget| { try this.contents.appendSlice(try widget.content()); }, } } - return &this.c; + return &this.contents; } }; } diff --git a/src/main.zig b/src/main.zig index d3ba4db..71197b3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -31,11 +31,21 @@ pub fn main() !void { const file = try std.fs.cwd().openFile("./src/main.zig", .{}); var rawText = App.Widget.RawText.init(allocator, file); - const widget = App.Widget.createFrom(&rawText); - var layout = App.Layout.Pane.init(allocator, widget); - defer layout.deinit(); // deinitializes the contained widget file.close(); + var framing = App.Layout.Framing.init(allocator, .{ + .widget = App.Widget.createFrom(&rawText), + }); + var vstack = App.Layout.VStack.init(allocator, .{ + App.Layout.createFrom(&framing), + }); + var hstack = App.Layout.HStack.init(allocator, .{ + App.Layout.createFrom(&vstack), + }); + + var layout = App.Layout.createFrom(&hstack); + defer layout.deinit(); + try app.start(); defer app.stop() catch unreachable; -- 2.49.1 From b418e4d3a7a668a682a8c52736ab2c902683f00f Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Sat, 9 Nov 2024 16:57:05 +0100 Subject: [PATCH 18/20] add(layout/vstack): intial implementation of vstack layout --- README.md | 23 +++++++++++++++-------- src/layout/VStack.zig | 23 +++++++++++++++++++---- src/main.zig | 14 +++++++++----- src/widget/RawText.zig | 39 ++++++++++++++++++++++++--------------- 4 files changed, 67 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 558a8a3..562854c 100644 --- a/README.md +++ b/README.md @@ -21,20 +21,25 @@ delay for each frame in each line of the output. - [ ] Have clickable/navigatable links inside of the tui application - [ ] Launch simple http server alongside tui application -- [ ] Create other layouts - - [ ] horizontal stack - - [ ] vertical stack - - [ ] `Layout` in `Layout`? -> interfaces are very similar anyway - - [ ] Building Block `Layout`s - - [ ] Framing `Layout` - - [ ] Padding `Layout` +--- + +- [ ] Split into own repository + - [ ] Create other layouts + - [ ] horizontal stack + - [ ] vertical stack + - [ ] `Layout` in `Layout`? -> interfaces are very similar anyway + - [ ] Building Block `Layout`s + - [ ] Framing `Layout` + - [ ] Padding `Layout` + - [ ] Create demo gifs using [vhs](https://github.com/charmbracelet/vhs) + - [ ] Move documentation into new repository +- [ ] add dependency to new repository into this project --- ## Branch: `own-tty-visuals` - [ ] How can I support to run a sub-process inside of a given pane / layout? -- [ ] Create demo gifs using [vhs](https://github.com/charmbracelet/vhs) - [x] Could I simulate a corresponding event loop? - emmit as many as possible through another thread (until the event queue is full?) [1023] @@ -43,3 +48,5 @@ delay for each frame in each line of the output. -> Or buffered writer to the `std.posix.STDOUT_FILENO`? -> I could use this to see if it makes sense to implement a buffered version using a screen buffer (to only render the differences?) - seems pretty good (with some exceptions) + +- [ ] styling could be tricky with a given layout (which introduces corresponding line breaks ...) diff --git a/src/layout/VStack.zig b/src/layout/VStack.zig index 135dfb4..4332e24 100644 --- a/src/layout/VStack.zig +++ b/src/layout/VStack.zig @@ -8,6 +8,8 @@ const isTaggedUnion = @import("../event.zig").isTaggedUnion; const Error = @import("../event.zig").Error; const Key = @import("../key.zig"); +const log = std.log.scoped(.layout_vstack); + pub fn Layout(comptime Event: type) type { if (!isTaggedUnion(Event)) { @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); @@ -78,9 +80,17 @@ pub fn Layout(comptime Event: type) type { switch (event) { .resize => |size| { this.size = size; + log.debug("Using size: {{ .cols = {d}, .rows = {d} }}", .{ size.cols, size.rows }); + const len: u16 = @truncate(this.elements.items.len); + const rows = size.rows / len; // adjust size according to the containing elements for (this.elements.items) |*element| { - const sub_event = event; + const sub_event: Event = .{ + .resize = .{ + .cols = size.cols, + .rows = rows, + }, + }; switch (element.*) { .layout => |*layout| { const events = try layout.handle(sub_event); @@ -115,17 +125,22 @@ pub fn Layout(comptime Event: type) type { pub fn content(this: *@This()) !*Contents { this.contents.clearRetainingCapacity(); - // TODO: concat contents accordingly to create a vertical stack - for (this.elements.items) |*element| { + // TODO: concat contents accordingly to create a horizontal stack + for (this.elements.items, 1..) |*element, i| { switch (element.*) { .layout => |*layout| { const layout_content = try layout.content(); try this.contents.appendSlice(layout_content.items); }, .widget => |*widget| { - try this.contents.appendSlice(try widget.content()); + const widget_content = try widget.content(); + try this.contents.appendSlice(widget_content); }, } + // TODO: support clear positioning of content on the tui screen + if (i != this.elements.items.len) { + try this.contents.appendSlice("\n"); // NOTE: this assumes that the previous content fills all the provided size.rows accordingly with content, such that a newline introduces the start of the next content + } } return &this.contents; } diff --git a/src/main.zig b/src/main.zig index 71197b3..f668fc9 100644 --- a/src/main.zig +++ b/src/main.zig @@ -33,17 +33,21 @@ pub fn main() !void { var rawText = App.Widget.RawText.init(allocator, file); file.close(); + const doc = try std.fs.cwd().openFile("./doc/test.md", .{}); + var docText = App.Widget.RawText.init(allocator, doc); + doc.close(); + var framing = App.Layout.Framing.init(allocator, .{ .widget = App.Widget.createFrom(&rawText), }); - var vstack = App.Layout.VStack.init(allocator, .{ + var hstack = App.Layout.HStack.init(allocator, .{ App.Layout.createFrom(&framing), }); - var hstack = App.Layout.HStack.init(allocator, .{ - App.Layout.createFrom(&vstack), + var vstack = App.Layout.VStack.init(allocator, .{ + App.Widget.createFrom(&docText), + App.Layout.createFrom(&hstack), }); - - var layout = App.Layout.createFrom(&hstack); + var layout = App.Layout.createFrom(&vstack); defer layout.deinit(); try app.start(); diff --git a/src/widget/RawText.zig b/src/widget/RawText.zig index 90f07c3..ff2144e 100644 --- a/src/widget/RawText.zig +++ b/src/widget/RawText.zig @@ -6,30 +6,37 @@ const isTaggedUnion = @import("../event.zig").isTaggedUnion; const Error = @import("../event.zig").Error; const Key = @import("../key.zig"); +const log = std.log.scoped(.widget_rawtext); + pub fn Widget(comptime Event: type) type { if (!isTaggedUnion(Event)) { @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); } + const Contents = std.ArrayList(u8); return struct { - c: std.ArrayList(u8) = undefined, + contents: Contents = undefined, line_index: std.ArrayList(usize) = undefined, line: usize = 0, size: terminal.Size = undefined, pub fn init(allocator: std.mem.Allocator, file: std.fs.File) @This() { - var c = std.ArrayList(u8).init(allocator); + var contents = Contents.init(allocator); var line_index = std.ArrayList(usize).init(allocator); - file.reader().readAllArrayList(&c, 4192) catch {}; - for (c.items, 0..) |item, i| { + file.reader().readAllArrayList(&contents, std.math.maxInt(usize)) catch {}; + line_index.append(0) catch {}; + for (contents.items, 0..) |item, i| { if (item == '\n') { - line_index.append(i) catch {}; + line_index.append(i + 1) catch {}; } } - return .{ .c = c, .line_index = line_index }; + return .{ + .contents = contents, + .line_index = line_index, + }; } pub fn deinit(this: *@This()) void { - this.c.deinit(); + this.contents.deinit(); this.line_index.deinit(); this.* = undefined; } @@ -39,8 +46,9 @@ pub fn Widget(comptime Event: type) type { // store the received size .resize => |size| { this.size = size; - if (this.line > this.line_index.items.len - 1 - size.rows) { - this.line = this.line_index.items.len - 1 - size.rows; + log.debug("Using size: {{ .cols = {d}, .rows = {d} }}", .{ size.cols, size.rows }); + if (this.line > this.line_index.items.len -| 1 -| size.rows) { + this.line = this.line_index.items.len -| 1 -| size.rows; } }, .key => |key| { @@ -50,11 +58,11 @@ pub fn Widget(comptime Event: type) type { } if (key.matches(.{ .cp = 'G' })) { // bottom - this.line = this.line_index.items.len - 1 - this.size.rows; + this.line = this.line_index.items.len -| 1 -| this.size.rows; } if (key.matches(.{ .cp = 'j' })) { // down - if (this.line < this.line_index.items.len - 1 - this.size.rows) { + if (this.line < this.line_index.items.len -| 1 -| this.size.rows) { this.line +|= 1; } } @@ -70,16 +78,17 @@ pub fn Widget(comptime Event: type) type { pub fn content(this: *@This()) ![]u8 { if (this.size.rows >= this.line_index.items.len) { - return this.c.items; + return this.contents.items; } else { // more rows than we can display const i = this.line_index.items[this.line]; + log.debug("i := {d} this.line := {d}", .{ i, this.line }); const e = this.size.rows + this.line; if (e >= this.line_index.items.len) { - return this.c.items[i..]; + return this.contents.items[i..]; } - const x = this.line_index.items[e]; - return this.c.items[i..x]; + const x = this.line_index.items[e] - 1; + return this.contents.items[i..x]; } } }; -- 2.49.1 From 570dfc3c3bff19f3ebbe0dbe18a37dacdb308f6e Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Wed, 13 Nov 2024 19:41:18 +0100 Subject: [PATCH 19/20] add(zterm): intial implementation of tui app --- build.zig | 24 +-- build.zig.zon | 4 + src/app.zig | 202 ----------------------- src/ctlseqs.zig | 140 ---------------- src/event.zig | 81 ---------- src/key.zig | 149 ----------------- src/layout.zig | 85 ---------- src/layout/Framing.zig | 100 ------------ src/layout/HStack.zig | 133 --------------- src/layout/Padding.zig | 100 ------------ src/layout/Pane.zig | 66 -------- src/layout/VStack.zig | 148 ----------------- src/main.zig | 122 +++++--------- src/queue.zig | 322 ------------------------------------- src/render.zig | 52 ------ src/style.zig | 285 -------------------------------- src/terminal.zig | 207 ------------------------ src/widget.zig | 78 --------- src/widget/RawText.zig | 95 ----------- src/widget/node2buffer.zig | 314 ------------------------------------ 20 files changed, 50 insertions(+), 2657 deletions(-) delete mode 100644 src/app.zig delete mode 100644 src/ctlseqs.zig delete mode 100644 src/event.zig delete mode 100644 src/key.zig delete mode 100644 src/layout.zig delete mode 100644 src/layout/Framing.zig delete mode 100644 src/layout/HStack.zig delete mode 100644 src/layout/Padding.zig delete mode 100644 src/layout/Pane.zig delete mode 100644 src/layout/VStack.zig delete mode 100644 src/queue.zig delete mode 100644 src/render.zig delete mode 100644 src/style.zig delete mode 100644 src/terminal.zig delete mode 100644 src/widget.zig delete mode 100644 src/widget/RawText.zig delete mode 100644 src/widget/node2buffer.zig diff --git a/build.zig b/build.zig index f05cf3a..8f612a4 100644 --- a/build.zig +++ b/build.zig @@ -15,29 +15,16 @@ pub fn build(b: *std.Build) void { // set a preferred release mode, allowing the user to decide how to optimize. const optimize = b.standardOptimizeOption(.{}); - // build options to customize the log message formatting - const benchmark = b.option(bool, "benchmark", "Create a benchmark build (default: false)") orelse false; - - const options = b.addOptions(); - options.addOption(bool, "benchmark", benchmark); - - const options_module = options.createModule(); - // Dependencies - const vaxis_dep = b.dependency("vaxis", .{ - .optimize = optimize, - .target = target, - }); - const zlog_dep = b.dependency("zlog", .{ + const zlog = b.dependency("zlog", .{ .optimize = optimize, .target = target, .timestamp = true, }); - const zmd_dep = b.dependency("zmd", .{ + const zterm = b.dependency("zterm", .{ .optimize = optimize, .target = target, }); - const zg_dep = b.dependency("zg", .{}); const exe = b.addExecutable(.{ .name = "tui-website", @@ -45,11 +32,8 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); - exe.root_module.addImport("build_options", options_module); - exe.root_module.addImport("vaxis", vaxis_dep.module("vaxis")); - exe.root_module.addImport("zlog", zlog_dep.module("zlog")); - exe.root_module.addImport("zmd", zmd_dep.module("zmd")); - exe.root_module.addImport("code_point", zg_dep.module("code_point")); + exe.root_module.addImport("zlog", zlog.module("zlog")); + exe.root_module.addImport("zterm", zterm.module("zterm")); // This declares intent for the executable to be installed into the // standard location when the user invokes the "install" step (the default diff --git a/build.zig.zon b/build.zig.zon index 92c3069..0a8ab4f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -39,6 +39,10 @@ .url = "https://codeberg.org/dude_the_builder/zg/archive/v0.13.2.tar.gz", .hash = "122055beff332830a391e9895c044d33b15ea21063779557024b46169fb1984c6e40", }, + .zterm = .{ + .url = "git+https://gitea.yves-biener.de/yves-biener/zterm#0cc0ed10d20feadd053aa2c573b73cd8d67edf71", + .hash = "122072281f3dab8b8ce7ce407def708010b5282b9e31d9c998346c9a0094f3b8648f", + }, }, .paths = .{ "build.zig", diff --git a/src/app.zig b/src/app.zig deleted file mode 100644 index baf65e1..0000000 --- a/src/app.zig +++ /dev/null @@ -1,202 +0,0 @@ -//! Application type for TUI-applications -const std = @import("std"); -const terminal = @import("terminal.zig"); - -const mergeTaggedUnions = @import("event.zig").mergeTaggedUnions; -const isTaggedUnion = @import("event.zig").isTaggedUnion; - -const Key = @import("key.zig"); -const SystemEvent = @import("event.zig").SystemEvent; -const Queue = @import("queue.zig").Queue; - -const log = std.log.scoped(.app); - -/// Create the App Type with the associated user events _E_ which describes -/// 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 accessable 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 -/// an empty user Event: -/// -/// ```zig -/// const App = @import("app.zig").App( -/// union(enum) {}, -/// @import("render.zig").PlainRenderer, -/// true, -/// ); -/// // later on use -/// var app: App = .{}; -/// var renderer: App.Renderer = .{}; -/// ``` -pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fullscreen: bool) 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(SystemEvent, E); - pub const Layout = @import("layout.zig").Layout(Event); - pub const Widget = @import("widget.zig").Widget(Event); - pub const Renderer = R(fullscreen); - - queue: Queue(Event, 256) = .{}, - thread: ?std.Thread = null, - quit_event: std.Thread.ResetEvent = .{}, - termios: ?std.posix.termios = null, - attached_handler: bool = false, - - pub const SignalHandler = struct { - context: *anyopaque, - callback: *const fn (context: *anyopaque) void, - }; - - pub fn start(this: *@This()) !void { - if (this.thread) |_| return; - - if (!this.attached_handler) { - var winch_act = std.posix.Sigaction{ - .handler = .{ .handler = @This().handleWinch }, - .mask = std.posix.empty_sigset, - .flags = 0, - }; - std.posix.sigaction(std.posix.SIG.WINCH, &winch_act, null) catch @panic("could not attach signal WINCH"); - - try registerWinch(.{ - .context = this, - .callback = @This().winsizeCallback, - }); - this.attached_handler = true; - } - - this.quit_event.reset(); - this.thread = try std.Thread.spawn(.{}, @This().run, .{this}); - - 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(); - } - } - - pub fn interrupt(this: *@This()) !void { - this.quit_event.set(); - if (fullscreen) { - try terminal.existAltScreen(); - try terminal.restoreScreen(); - } - if (this.thread) |thread| { - thread.join(); - this.thread = null; - } - } - - pub fn stop(this: *@This()) !void { - try this.interrupt(); - if (this.termios) |*termios| { - try terminal.disableRawMode(termios); - if (fullscreen) { - try terminal.existAltScreen(); - try terminal.restoreScreen(); - } - } - this.termios = null; - } - - /// Quit the application loop. - /// This will stop the internal input thread and post a **.quit** `Event`. - pub fn quit(this: *@This()) void { - this.quit_event.set(); - this.postEvent(.quit); - } - - /// Returns the next available event, blocking until one is available. - pub fn nextEvent(this: *@This()) Event { - return this.queue.pop(); - } - - /// Post an event into the queue. Blocks if there is no capacity for the event. - pub fn postEvent(this: *@This(), event: Event) void { - this.queue.push(event); - } - - fn winsizeCallback(ptr: *anyopaque) void { - const this: *@This() = @ptrCast(@alignCast(ptr)); - this.postEvent(.{ .resize = terminal.getTerminalSize() }); - } - - var winch_handler: ?SignalHandler = null; - - fn registerWinch(handler: SignalHandler) !void { - if (winch_handler) |_| { - @panic("Cannot register another WINCH handler."); - } - winch_handler = handler; - } - - fn handleWinch(_: c_int) callconv(.C) void { - if (winch_handler) |handler| { - handler.callback(handler.context); - } - } - - fn run(this: *@This()) !void { - // send initial terminal size - // changes are handled by the winch signal handler - // see `App.start` and `App.registerWinch` for details - this.postEvent(.{ .resize = terminal.getTerminalSize() }); - - // thread to read user inputs - var buf: [256]u8 = undefined; - while (true) { - // FIX: I still think that there is a race condition (I'm just waiting 'long' enough) - this.quit_event.timedWait(20 * std.time.ns_per_ms) catch { - const read_bytes = try terminal.read(buf[0..]); - // escape key presses - if (buf[0] == 0x1b and read_bytes > 1) { - switch (buf[1]) { - // TODO: parse corresponding codes - else => {}, - } - } else { - const b = buf[0]; - const key: Key = switch (b) { - 0x00 => .{ .cp = '@', .mod = .{ .ctrl = true } }, - 0x08 => .{ .cp = Key.backspace }, - 0x09 => .{ .cp = Key.tab }, - 0x0a, 0x0d => .{ .cp = Key.enter }, - 0x01...0x07, 0x0b...0x0c, 0x0e...0x1a => .{ .cp = b + 0x60, .mod = .{ .ctrl = true } }, - 0x1b => escape: { - std.debug.assert(read_bytes == 1); - break :escape .{ .cp = Key.escape }; - }, - 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 } }); - } - continue; - }, - }; - this.postEvent(.{ .key = key }); - } - continue; - }; - break; - } - } - }; -} diff --git a/src/ctlseqs.zig b/src/ctlseqs.zig deleted file mode 100644 index 4e55448..0000000 --- a/src/ctlseqs.zig +++ /dev/null @@ -1,140 +0,0 @@ -// Queries -pub const primary_device_attrs = "\x1b[c"; -pub const tertiary_device_attrs = "\x1b[=c"; -pub const device_status_report = "\x1b[5n"; -pub const xtversion = "\x1b[>0q"; -pub const decrqm_focus = "\x1b[?1004$p"; -pub const decrqm_sgr_pixels = "\x1b[?1016$p"; -pub const decrqm_sync = "\x1b[?2026$p"; -pub const decrqm_unicode = "\x1b[?2027$p"; -pub const decrqm_color_scheme = "\x1b[?2031$p"; -pub const csi_u_query = "\x1b[?u"; -pub const kitty_graphics_query = "\x1b_Gi=1,a=q\x1b\\"; -pub const sixel_geometry_query = "\x1b[?2;1;0S"; - -// mouse. We try for button motion and any motion. terminals will enable the -// last one we tried (any motion). This was added because zellij doesn't -// support any motion currently -// See: https://github.com/zellij-org/zellij/issues/1679 -pub const mouse_set = "\x1b[?1002;1003;1004;1006h"; -pub const mouse_set_pixels = "\x1b[?1002;1003;1004;1016h"; -pub const mouse_reset = "\x1b[?1002;1003;1004;1006;1016l"; - -// in-band window size reports -pub const in_band_resize_set = "\x1b[?2048h"; -pub const in_band_resize_reset = "\x1b[?2048l"; - -// sync -pub const sync_set = "\x1b[?2026h"; -pub const sync_reset = "\x1b[?2026l"; - -// unicode -pub const unicode_set = "\x1b[?2027h"; -pub const unicode_reset = "\x1b[?2027l"; - -// bracketed paste -pub const bp_set = "\x1b[?2004h"; -pub const bp_reset = "\x1b[?2004l"; - -// color scheme updates -pub const color_scheme_request = "\x1b[?996n"; -pub const color_scheme_set = "\x1b[?2031h"; -pub const color_scheme_reset = "\x1b[?2031l"; - -// Key encoding -pub const csi_u_push = "\x1b[>{d}u"; -pub const csi_u_pop = "\x1b[ |u| { - if (u.tag_type) |_| {} else { - return false; - } - }, - else => return false, - } - return true; -} diff --git a/src/key.zig b/src/key.zig deleted file mode 100644 index eec5f8c..0000000 --- a/src/key.zig +++ /dev/null @@ -1,149 +0,0 @@ -//! Keybindings and Modifiers for user input detection and selection. -const std = @import("std"); - -pub const Modifier = struct { - shift: bool = false, - alt: bool = false, - ctrl: bool = false, -}; - -cp: u21, -mod: Modifier = .{}, - -/// Compare _this_ `Key` with an _other_ `Key`. -/// -/// # Example -/// -/// Configure `ctrl+c` to quit the application (done in main event loop of the application): -/// -/// ```zig -/// switch (event) { -/// .quit => break, -/// .key => |key| { -/// // ctrl+c to quit -/// if (terminal.Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) { -/// app.quit.set(); -/// } -/// }, -/// else => {}, -/// } -/// ``` -pub fn matches(this: @This(), other: @This()) bool { - return std.meta.eql(this, other); -} - -// codepoints for keys -pub const tab: u21 = 0x09; -pub const enter: u21 = 0x0D; -pub const escape: u21 = 0x1B; -pub const space: u21 = 0x20; -pub const backspace: u21 = 0x7F; - -// kitty key encodings (re-used here) -pub const insert: u21 = 57348; -pub const delete: u21 = 57349; -pub const left: u21 = 57350; -pub const right: u21 = 57351; -pub const up: u21 = 57352; -pub const down: u21 = 57353; -pub const page_up: u21 = 57354; -pub const page_down: u21 = 57355; -pub const home: u21 = 57356; -pub const end: u21 = 57357; -pub const caps_lock: u21 = 57358; -pub const scroll_lock: u21 = 57359; -pub const num_lock: u21 = 57360; -pub const print_screen: u21 = 57361; -pub const pause: u21 = 57362; -pub const menu: u21 = 57363; -pub const f1: u21 = 57364; -pub const f2: u21 = 57365; -pub const f3: u21 = 57366; -pub const f4: u21 = 57367; -pub const f5: u21 = 57368; -pub const f6: u21 = 57369; -pub const f7: u21 = 57370; -pub const f8: u21 = 57371; -pub const f9: u21 = 57372; -pub const f10: u21 = 57373; -pub const f11: u21 = 57374; -pub const f12: u21 = 57375; -pub const f13: u21 = 57376; -pub const f14: u21 = 57377; -pub const f15: u21 = 57378; -pub const @"f16": u21 = 57379; -pub const f17: u21 = 57380; -pub const f18: u21 = 57381; -pub const f19: u21 = 57382; -pub const f20: u21 = 57383; -pub const f21: u21 = 57384; -pub const f22: u21 = 57385; -pub const f23: u21 = 57386; -pub const f24: u21 = 57387; -pub const f25: u21 = 57388; -pub const f26: u21 = 57389; -pub const f27: u21 = 57390; -pub const f28: u21 = 57391; -pub const f29: u21 = 57392; -pub const f30: u21 = 57393; -pub const f31: u21 = 57394; -pub const @"f32": u21 = 57395; -pub const f33: u21 = 57396; -pub const f34: u21 = 57397; -pub const f35: u21 = 57398; -pub const kp_0: u21 = 57399; -pub const kp_1: u21 = 57400; -pub const kp_2: u21 = 57401; -pub const kp_3: u21 = 57402; -pub const kp_4: u21 = 57403; -pub const kp_5: u21 = 57404; -pub const kp_6: u21 = 57405; -pub const kp_7: u21 = 57406; -pub const kp_8: u21 = 57407; -pub const kp_9: u21 = 57408; -pub const kp_decimal: u21 = 57409; -pub const kp_divide: u21 = 57410; -pub const kp_multiply: u21 = 57411; -pub const kp_subtract: u21 = 57412; -pub const kp_add: u21 = 57413; -pub const kp_enter: u21 = 57414; -pub const kp_equal: u21 = 57415; -pub const kp_separator: u21 = 57416; -pub const kp_left: u21 = 57417; -pub const kp_right: u21 = 57418; -pub const kp_up: u21 = 57419; -pub const kp_down: u21 = 57420; -pub const kp_page_up: u21 = 57421; -pub const kp_page_down: u21 = 57422; -pub const kp_home: u21 = 57423; -pub const kp_end: u21 = 57424; -pub const kp_insert: u21 = 57425; -pub const kp_delete: u21 = 57426; -pub const kp_begin: u21 = 57427; -pub const media_play: u21 = 57428; -pub const media_pause: u21 = 57429; -pub const media_play_pause: u21 = 57430; -pub const media_reverse: u21 = 57431; -pub const media_stop: u21 = 57432; -pub const media_fast_forward: u21 = 57433; -pub const media_rewind: u21 = 57434; -pub const media_track_next: u21 = 57435; -pub const media_track_previous: u21 = 57436; -pub const media_record: u21 = 57437; -pub const lower_volume: u21 = 57438; -pub const raise_volume: u21 = 57439; -pub const mute_volume: u21 = 57440; -pub const left_shift: u21 = 57441; -pub const left_control: u21 = 57442; -pub const left_alt: u21 = 57443; -pub const left_super: u21 = 57444; -pub const left_hyper: u21 = 57445; -pub const left_meta: u21 = 57446; -pub const right_shift: u21 = 57447; -pub const right_control: u21 = 57448; -pub const right_alt: u21 = 57449; -pub const right_super: u21 = 57450; -pub const right_hyper: u21 = 57451; -pub const right_meta: u21 = 57452; -pub const iso_level_3_shift: u21 = 57453; -pub const iso_level_5_shift: u21 = 57454; diff --git a/src/layout.zig b/src/layout.zig deleted file mode 100644 index 478b856..0000000 --- a/src/layout.zig +++ /dev/null @@ -1,85 +0,0 @@ -//! Dynamic dispatch for layout implementations. -//! Each layout should at last implement these functions: -//! - handle(this: *@This(), event: Event) anyerror!*std.ArrayList(Event) {} -//! - content(this: *@This()) anyerror!*std.ArrayList(u8) {} -//! - deinit(this: *@This()) void {} -//! -//! Create a `Layout` using `createFrom(object: anytype)` and use them through -//! the defined 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 -//! widgets when deallocated. This means that `deinit()` will also deallocate -//! every used widget too. -const std = @import("std"); -const isTaggedUnion = @import("event.zig").isTaggedUnion; - -pub fn Layout(comptime Event: 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); - return struct { - const LayoutType = @This(); - const Ptr = usize; - - const VTable = struct { - handle: *const fn (this: *LayoutType, event: Event) anyerror!*Events, - content: *const fn (this: *LayoutType) anyerror!*std.ArrayList(u8), - deinit: *const fn (this: *LayoutType) void, - }; - - object: Ptr = 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); - } - - // Return the entire content of this `Layout`. - pub fn content(this: *LayoutType) !*std.ArrayList(u8) { - return try this.vtable.content(this); - } - - pub fn deinit(this: *LayoutType) void { - this.vtable.deinit(this); - this.* = undefined; - } - - pub fn createFrom(object: anytype) LayoutType { - return LayoutType{ - .object = @intFromPtr(object), - .vtable = &.{ - .handle = struct { - // Handle the provided `Event` for this `Layout`. - fn handle(this: *LayoutType, event: Event) !*Events { - const layout: @TypeOf(object) = @ptrFromInt(this.object); - return try layout.handle(event); - } - }.handle, - .content = struct { - // Return the entire content of this `Layout`. - fn content(this: *LayoutType) !*std.ArrayList(u8) { - const layout: @TypeOf(object) = @ptrFromInt(this.object); - return try layout.content(); - } - }.content, - .deinit = struct { - fn deinit(this: *LayoutType) void { - const layout: @TypeOf(object) = @ptrFromInt(this.object); - layout.deinit(); - } - }.deinit, - }, - }; - } - - // import and export of `Layout` implementations - pub const Pane = @import("layout/Pane.zig").Layout(Event); - pub const HStack = @import("layout/HStack.zig").Layout(Event); - pub const VStack = @import("layout/VStack.zig").Layout(Event); - pub const Padding = @import("layout/Padding.zig").Layout(Event); - pub const Framing = @import("layout/Framing.zig").Layout(Event); - }; -} diff --git a/src/layout/Framing.zig b/src/layout/Framing.zig deleted file mode 100644 index 9ea7abb..0000000 --- a/src/layout/Framing.zig +++ /dev/null @@ -1,100 +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 = @import("../key.zig"); - -pub fn Layout(comptime Event: type) type { - if (!isTaggedUnion(Event)) { - @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); - } - const Element = union(enum) { - layout: @import("../layout.zig").Layout(Event), - widget: @import("../widget.zig").Widget(Event), - }; - const Events = std.ArrayList(Event); - const Contents = std.ArrayList(u8); - return struct { - size: terminal.Size = undefined, - contents: Contents = undefined, - element: Element = undefined, - events: Events = undefined, - - pub fn init(allocator: std.mem.Allocator, element: Element) @This() { - return .{ - .contents = Contents.init(allocator), - .element = element, - .events = Events.init(allocator), - }; - } - - pub fn deinit(this: *@This()) void { - this.contents.deinit(); - this.events.deinit(); - switch ((&this.element).*) { - .layout => |*layout| { - layout.deinit(); - }, - .widget => |*widget| { - widget.deinit(); - }, - } - } - - 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 - const sub_event = event; - 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 content(this: *@This()) !*Contents { - this.contents.clearRetainingCapacity(); - // TODO: padding contents accordingly - switch ((&this.element).*) { - .layout => |*layout| { - const layout_content = try layout.content(); - try this.contents.appendSlice(layout_content.items); - }, - .widget => |*widget| { - try this.contents.appendSlice(try widget.content()); - }, - } - return &this.contents; - } - }; -} diff --git a/src/layout/HStack.zig b/src/layout/HStack.zig deleted file mode 100644 index 87aeea6..0000000 --- a/src/layout/HStack.zig +++ /dev/null @@ -1,133 +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 = @import("../key.zig"); - -pub fn Layout(comptime Event: type) type { - if (!isTaggedUnion(Event)) { - @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); - } - const Widget = @import("../widget.zig").Widget(Event); - const Lay = @import("../layout.zig").Layout(Event); - const Element = union(enum) { - layout: Lay, - widget: Widget, - }; - const Elements = std.ArrayList(Element); - const Events = std.ArrayList(Event); - const Contents = std.ArrayList(u8); - return struct { - // TODO: current focused `Element`? - size: terminal.Size = undefined, - contents: Contents = undefined, - elements: Elements = undefined, - events: Events = undefined, - - 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("OOM"); - inline for (comptime fields_info) |field| { - const child = @field(children, field.name); - const ChildType = @TypeOf(child); - if (ChildType == Widget) { - elements.append(.{ .widget = child }) catch {}; - continue; - } - if (ChildType == Lay) { - elements.append(.{ .layout = child }) catch {}; - continue; - } - @compileError("child: " ++ field.name ++ " is not of type " ++ @typeName(Lay) ++ " or " ++ @typeName(Widget) ++ " but " ++ @typeName(ChildType)); - } - return .{ - .contents = Contents.init(allocator), - .elements = elements, - .events = Events.init(allocator), - }; - } - - pub fn deinit(this: *@This()) void { - this.events.deinit(); - this.contents.deinit(); - for (this.elements.items) |*element| { - switch (element.*) { - .layout => |*layout| { - layout.deinit(); - }, - .widget => |*widget| { - widget.deinit(); - }, - } - } - this.elements.deinit(); - } - - 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 - for (this.elements.items) |*element| { - const sub_event = event; - 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 content(this: *@This()) !*Contents { - this.contents.clearRetainingCapacity(); - // TODO: concat contents accordingly to create a vertical stack - for (this.elements.items) |*element| { - switch (element.*) { - .layout => |*layout| { - const layout_content = try layout.content(); - try this.contents.appendSlice(layout_content.items); - }, - .widget => |*widget| { - try this.contents.appendSlice(try widget.content()); - }, - } - } - return &this.contents; - } - }; -} diff --git a/src/layout/Padding.zig b/src/layout/Padding.zig deleted file mode 100644 index ea32fa3..0000000 --- a/src/layout/Padding.zig +++ /dev/null @@ -1,100 +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 = @import("../key.zig"); - -pub fn Layout(comptime Event: type) type { - if (!isTaggedUnion(Event)) { - @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); - } - const Element = union(enum) { - layout: @import("../layout.zig").Layout(Event), - widget: @import("../widget.zig").Widget(Event), - }; - const Events = std.ArrayList(Event); - const Contents = std.ArrayList(u8); - return struct { - size: terminal.Size = undefined, - contents: Contents = undefined, - element: Element = undefined, - events: Events = undefined, - - pub fn init(allocator: std.mem.Allocator, element: Element) @This() { - return .{ - .contents = Contents.init(allocator), - .element = element, - .events = Events.init(allocator), - }; - } - - pub fn deinit(this: *@This()) void { - this.contents.deinit(); - this.events.deinit(); - switch ((&this.element).*) { - .layout => |*layout| { - layout.deinit(); - }, - .widget => |*widget| { - widget.deinit(); - }, - } - } - - 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 - const sub_event = event; - 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 content(this: *@This()) !*Contents { - this.contents.clearRetainingCapacity(); - // TODO: padding contents accordingly - switch ((&this.element).*) { - .layout => |*layout| { - const layout_content = try layout.content(); - try this.contents.appendSlice(layout_content.items); - }, - .widget => |*widget| { - try this.contents.appendSlice(try widget.content()); - }, - } - return &this.contents; - } - }; -} diff --git a/src/layout/Pane.zig b/src/layout/Pane.zig deleted file mode 100644 index 1d3678c..0000000 --- a/src/layout/Pane.zig +++ /dev/null @@ -1,66 +0,0 @@ -// TODO: remove this `Layout` -const std = @import("std"); -const terminal = @import("../terminal.zig"); -const isTaggedUnion = @import("../event.zig").isTaggedUnion; -const Error = @import("../event.zig").Error; -const Key = @import("../key.zig"); - -pub fn Layout(comptime Event: type) type { - if (!isTaggedUnion(Event)) { - @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); - } - const Widget = @import("../widget.zig").Widget(Event); - const Events = std.ArrayList(Event); - const Contents = std.ArrayList(u8); - return struct { - widget: Widget = undefined, - events: Events = undefined, - contents: Contents = undefined, - - pub fn init(allocator: std.mem.Allocator, widget: Widget) @This() { - return .{ - .widget = widget, - .events = Events.init(allocator), - .contents = Contents.init(allocator), - }; - } - - pub fn deinit(this: *@This()) void { - this.widget.deinit(); - this.events.deinit(); - this.contents.deinit(); - this.* = undefined; - } - - pub fn handle(this: *@This(), event: Event) !*Events { - this.events.clearRetainingCapacity(); - switch (event) { - .resize => |size| { - const widget_event: Event = .{ - .resize = .{ - .cols = size.cols, - .rows = size.rows -| 2, // remove top and bottom rows - }, - }; - if (this.widget.handle(widget_event)) |e| { - try this.events.append(e); - } - }, - else => { - if (this.widget.handle(event)) |e| { - try this.events.append(e); - } - }, - } - return &this.events; - } - - pub fn content(this: *@This()) !*std.ArrayList(u8) { - this.contents.clearRetainingCapacity(); - try this.contents.appendSlice("\n"); - try this.contents.appendSlice(try this.widget.content()); - try this.contents.appendSlice("\n"); - return &this.c; - } - }; -} diff --git a/src/layout/VStack.zig b/src/layout/VStack.zig deleted file mode 100644 index 4332e24..0000000 --- a/src/layout/VStack.zig +++ /dev/null @@ -1,148 +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 = @import("../key.zig"); - -const log = std.log.scoped(.layout_vstack); - -pub fn Layout(comptime Event: type) type { - if (!isTaggedUnion(Event)) { - @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); - } - const Widget = @import("../widget.zig").Widget(Event); - const Lay = @import("../layout.zig").Layout(Event); - const Element = union(enum) { - layout: Lay, - widget: Widget, - }; - const Elements = std.ArrayList(Element); - const Events = std.ArrayList(Event); - const Contents = std.ArrayList(u8); - return struct { - // TODO: current focused `Element`? - size: terminal.Size = undefined, - contents: Contents = undefined, - elements: Elements = undefined, - events: Events = undefined, - - 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("OOM"); - inline for (comptime fields_info) |field| { - const child = @field(children, field.name); - const ChildType = @TypeOf(child); - if (ChildType == Widget) { - elements.append(.{ .widget = child }) catch {}; - continue; - } - if (ChildType == Lay) { - elements.append(.{ .layout = child }) catch {}; - continue; - } - @compileError("child: " ++ field.name ++ " is not of type " ++ @typeName(Lay) ++ " or " ++ @typeName(Widget) ++ " but " ++ @typeName(ChildType)); - } - return .{ - .contents = Contents.init(allocator), - .elements = elements, - .events = Events.init(allocator), - }; - } - - pub fn deinit(this: *@This()) void { - this.events.deinit(); - this.contents.deinit(); - for (this.elements.items) |*element| { - switch (element.*) { - .layout => |*layout| { - layout.deinit(); - }, - .widget => |*widget| { - widget.deinit(); - }, - } - } - this.elements.deinit(); - } - - pub fn handle(this: *@This(), event: Event) !*Events { - this.events.clearRetainingCapacity(); - // order is important - switch (event) { - .resize => |size| { - this.size = size; - log.debug("Using size: {{ .cols = {d}, .rows = {d} }}", .{ size.cols, size.rows }); - const len: u16 = @truncate(this.elements.items.len); - const rows = size.rows / len; - // adjust size according to the containing elements - for (this.elements.items) |*element| { - const sub_event: Event = .{ - .resize = .{ - .cols = size.cols, - .rows = 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 content(this: *@This()) !*Contents { - this.contents.clearRetainingCapacity(); - // TODO: concat contents accordingly to create a horizontal stack - for (this.elements.items, 1..) |*element, i| { - switch (element.*) { - .layout => |*layout| { - const layout_content = try layout.content(); - try this.contents.appendSlice(layout_content.items); - }, - .widget => |*widget| { - const widget_content = try widget.content(); - try this.contents.appendSlice(widget_content); - }, - } - // TODO: support clear positioning of content on the tui screen - if (i != this.elements.items.len) { - try this.contents.appendSlice("\n"); // NOTE: this assumes that the previous content fills all the provided size.rows accordingly with content, such that a newline introduces the start of the next content - } - } - return &this.contents; - } - }; -} diff --git a/src/main.zig b/src/main.zig index f668fc9..57ee98d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,14 +1,15 @@ -const build_options = @import("build_options"); const std = @import("std"); -const terminal = @import("terminal.zig"); const zlog = @import("zlog"); +const zterm = @import("zterm"); -const App = @import("app.zig").App( +const App = zterm.App( union(enum) {}, - @import("render.zig").PlainRenderer, + zterm.Renderer.Direct, true, ); -const Key = @import("key.zig"); +const Key = zterm.Key; +const Layout = App.Layout; +const Widget = App.Widget; pub const std_options = zlog.std_options; const log = std.log.scoped(.default); @@ -29,38 +30,46 @@ pub fn main() !void { var app: App = .{}; var renderer: App.Renderer = .{}; - const file = try std.fs.cwd().openFile("./src/main.zig", .{}); - var rawText = App.Widget.RawText.init(allocator, file); - file.close(); - - const doc = try std.fs.cwd().openFile("./doc/test.md", .{}); - var docText = App.Widget.RawText.init(allocator, doc); - doc.close(); - - var framing = App.Layout.Framing.init(allocator, .{ - .widget = App.Widget.createFrom(&rawText), + var layout = Layout.createFrom(vstack: { + var vstack = Layout.VStack.init(allocator, .{ + Layout.createFrom(framing: { + var framing = Layout.Framing.init(allocator, .{ + .title = .{ + .str = "Welcome to my terminal website", + .style = .{ + .ul = .{ .index = 6 }, + .ul_style = .single, + }, + }, + }, .{ + .widget = Widget.createFrom(header: { + const doc = try std.fs.cwd().openFile("./doc/home.md", .{}); + defer doc.close(); + var header = Widget.RawText.init(allocator, doc); + break :header &header; + }), + }); + break :framing &framing; + }), + Layout.createFrom(margin: { + var margin = Layout.Margin.init(allocator, .{ .left = 15, .right = 15 }, .{ + .widget = Widget.createFrom(body: { + const doc = try std.fs.cwd().openFile("./doc/test.md", .{}); + defer doc.close(); + var body = Widget.RawText.init(allocator, doc); + break :body &body; + }), + }); + break :margin &margin; + }), + }); + break :vstack &vstack; }); - var hstack = App.Layout.HStack.init(allocator, .{ - App.Layout.createFrom(&framing), - }); - var vstack = App.Layout.VStack.init(allocator, .{ - App.Widget.createFrom(&docText), - App.Layout.createFrom(&hstack), - }); - var layout = App.Layout.createFrom(&vstack); defer layout.deinit(); try app.start(); defer app.stop() catch unreachable; - // Benchmarking - var instants: std.ArrayList(u64) = undefined; - var benchmark_thread: std.Thread = undefined; - if (comptime build_options.benchmark) { - instants = try std.ArrayList(u64).initCapacity(allocator, 1024); - benchmark_thread = try std.Thread.spawn(.{}, benchmark, .{&app}); - } - // App.Event loop while (true) { const event = app.nextEvent(); @@ -71,19 +80,7 @@ pub fn main() !void { // 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() 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 Helix failed", - }, - }); - }; + break; // no need to render this frame anyway } }, .err => |err| { @@ -92,45 +89,10 @@ pub fn main() !void { else => {}, } - var start_instant: std.time.Instant = undefined; - if (comptime build_options.benchmark) { - start_instant = try std.time.Instant.now(); - } - - // NOTE: this currently re-renders the screen for every key-press -> which might be a bit of an overkill const events = try layout.handle(event); for (events.items) |e| { app.postEvent(e); } - try renderer.render(try layout.content()); - - if (comptime build_options.benchmark) { - const end_instant = try std.time.Instant.now(); - try instants.append(end_instant.since(start_instant)); - } - } - - if (comptime build_options.benchmark) { - benchmark_thread.join(); - // print benchmark results - for (instants.items, 1..) |epoch, i| { - _ = i; - const epoch_float: f64 = @floatFromInt(epoch); - std.log.info("{d:.3}", .{epoch_float / std.time.ns_per_ms}); - } - instants.deinit(); + try layout.render(&renderer); } } - -fn benchmark(app: *App) void { - std.time.sleep(1 * std.time.ns_per_s); - for (0..512) |_| { - app.postEvent(.{ .key = .{ .cp = 'j' } }); - app.postEvent(.{ .key = .{ .cp = 'k' } }); - } - app.quit(); -} - -test { - _ = @import("queue.zig"); -} diff --git a/src/queue.zig b/src/queue.zig deleted file mode 100644 index a364e57..0000000 --- a/src/queue.zig +++ /dev/null @@ -1,322 +0,0 @@ -// taken from https://github.com/rockorager/libvaxis/blob/main/src/queue.zig (MIT-License) -// with slight modifications -const std = @import("std"); -const assert = std.debug.assert; - -/// Thread safe. Fixed size. Blocking push and pop. -pub fn Queue(comptime T: type, comptime size: usize) type { - return struct { - buf: [size]T = undefined, - - read_index: usize = 0, - write_index: usize = 0, - - mutex: std.Thread.Mutex = .{}, - // blocks when the buffer is full - not_full: std.Thread.Condition = .{}, - // ...or empty - not_empty: std.Thread.Condition = .{}, - - const QueueType = @This(); - - /// Pop an item from the queue. Blocks until an item is available. - pub fn pop(this: *QueueType) T { - this.mutex.lock(); - defer this.mutex.unlock(); - while (this.isEmptyLH()) { - this.not_empty.wait(&this.mutex); - } - assert(!this.isEmptyLH()); - if (this.isFullLH()) { - // If we are full, wake up a push that might be - // waiting here. - this.not_full.signal(); - } - - const result = this.buf[this.mask(this.read_index)]; - this.read_index = this.mask2(this.read_index + 1); - return result; - } - - /// Push an item into the queue. Blocks until an item has been - /// put in the queue. - pub fn push(this: *QueueType, item: T) void { - this.mutex.lock(); - defer this.mutex.unlock(); - while (this.isFullLH()) { - this.not_full.wait(&this.mutex); - } - if (this.isEmptyLH()) { - // If we were empty, wake up a pop if it was waiting. - this.not_empty.signal(); - } - assert(!this.isFullLH()); - - this.buf[this.mask(this.write_index)] = item; - this.write_index = this.mask2(this.write_index + 1); - } - - /// Push an item into the queue. Returns true when the item - /// was successfully placed in the queue, false if the queue - /// was full. - pub fn tryPush(this: *QueueType, item: T) bool { - this.mutex.lock(); - if (this.isFullLH()) { - this.mutex.unlock(); - return false; - } - this.mutex.unlock(); - this.push(item); - return true; - } - - /// Pop an item from the queue. Returns null when no item is - /// available. - pub fn tryPop(this: *QueueType) ?T { - this.mutex.lock(); - if (this.isEmptyLH()) { - this.mutex.unlock(); - return null; - } - this.mutex.unlock(); - return this.pop(); - } - - /// Poll the queue. This call blocks until events are in the queue - pub fn poll(this: *QueueType) void { - this.mutex.lock(); - defer this.mutex.unlock(); - while (this.isEmptyLH()) { - this.not_empty.wait(&this.mutex); - } - assert(!this.isEmptyLH()); - } - - fn isEmptyLH(this: QueueType) bool { - return this.write_index == this.read_index; - } - - fn isFullLH(this: QueueType) bool { - return this.mask2(this.write_index + this.buf.len) == - this.read_index; - } - - /// Returns `true` if the queue is empty and `false` otherwise. - pub fn isEmpty(this: *QueueType) bool { - this.mutex.lock(); - defer this.mutex.unlock(); - return this.isEmptyLH(); - } - - /// Returns `true` if the queue is full and `false` otherwise. - pub fn isFull(this: *QueueType) bool { - this.mutex.lock(); - defer this.mutex.unlock(); - return this.isFullLH(); - } - - /// Returns the length - fn len(this: QueueType) usize { - const wrap_offset = 2 * this.buf.len * - @intFromBool(this.write_index < this.read_index); - const adjusted_write_index = this.write_index + wrap_offset; - return adjusted_write_index - this.read_index; - } - - /// Returns `index` modulo the length of the backing slice. - fn mask(this: QueueType, index: usize) usize { - return index % this.buf.len; - } - - /// Returns `index` modulo twice the length of the backing slice. - fn mask2(this: QueueType, index: usize) usize { - return index % (2 * this.buf.len); - } - }; -} - -const testing = std.testing; -const cfg = Thread.SpawnConfig{ .allocator = testing.allocator }; -test "Queue: simple push / pop" { - var queue: Queue(u8, 16) = .{}; - queue.push(1); - queue.push(2); - const pop = queue.pop(); - try testing.expectEqual(1, pop); - try testing.expectEqual(2, queue.pop()); -} - -const Thread = std.Thread; -fn testPushPop(q: *Queue(u8, 2)) !void { - q.push(3); - try testing.expectEqual(2, q.pop()); -} - -test "Fill, wait to push, pop once in another thread" { - var queue: Queue(u8, 2) = .{}; - queue.push(1); - queue.push(2); - const t = try Thread.spawn(cfg, testPushPop, .{&queue}); - try testing.expectEqual(false, queue.tryPush(3)); - try testing.expectEqual(1, queue.pop()); - t.join(); - try testing.expectEqual(3, queue.pop()); - try testing.expectEqual(null, queue.tryPop()); -} - -fn testPush(q: *Queue(u8, 2)) void { - q.push(0); - q.push(1); - q.push(2); - q.push(3); - q.push(4); -} - -test "Try to pop, fill from another thread" { - var queue: Queue(u8, 2) = .{}; - const thread = try Thread.spawn(cfg, testPush, .{&queue}); - for (0..5) |idx| { - try testing.expectEqual(@as(u8, @intCast(idx)), queue.pop()); - } - thread.join(); -} - -fn sleepyPop(q: *Queue(u8, 2)) !void { - // First we wait for the queue to be full. - while (!q.isFull()) - try Thread.yield(); - - // Then we spuriously wake it up, because that's a thing that can - // happen. - q.not_full.signal(); - q.not_empty.signal(); - - // Then give the other thread a good chance of waking up. It's not - // clear that yield guarantees the other thread will be scheduled, - // so we'll throw a sleep in here just to be sure. The queue is - // still full and the push in the other thread is still blocked - // waiting for space. - try Thread.yield(); - std.time.sleep(std.time.ns_per_s); - // Finally, let that other thread go. - try std.testing.expectEqual(1, q.pop()); - - // This won't continue until the other thread has had a chance to - // put at least one item in the queue. - while (!q.isFull()) - try Thread.yield(); - // But we want to ensure that there's a second push waiting, so - // here's another sleep. - std.time.sleep(std.time.ns_per_s / 2); - - // Another spurious wake... - q.not_full.signal(); - q.not_empty.signal(); - // And another chance for the other thread to see that it's - // spurious and go back to sleep. - try Thread.yield(); - std.time.sleep(std.time.ns_per_s / 2); - - // Pop that thing and we're done. - try std.testing.expectEqual(2, q.pop()); -} - -test "Fill, block, fill, block" { - // Fill the queue, block while trying to write another item, have - // a background thread unblock us, then block while trying to - // write yet another thing. Have the background thread unblock - // that too (after some time) then drain the queue. This test - // fails if the while loop in `push` is turned into an `if`. - - var queue: Queue(u8, 2) = .{}; - const thread = try Thread.spawn(cfg, sleepyPop, .{&queue}); - queue.push(1); - queue.push(2); - const now = std.time.milliTimestamp(); - queue.push(3); // This one should block. - const then = std.time.milliTimestamp(); - - // Just to make sure the sleeps are yielding to this thread, make - // sure it took at least 900ms to do the push. - try std.testing.expect(then - now > 900); - - // This should block again, waiting for the other thread. - queue.push(4); - - // And once that push has gone through, the other thread's done. - thread.join(); - try std.testing.expectEqual(3, queue.pop()); - try std.testing.expectEqual(4, queue.pop()); -} - -fn sleepyPush(q: *Queue(u8, 1)) !void { - // Try to ensure the other thread has already started trying to pop. - try Thread.yield(); - std.time.sleep(std.time.ns_per_s / 2); - - // Spurious wake - q.not_full.signal(); - q.not_empty.signal(); - - try Thread.yield(); - std.time.sleep(std.time.ns_per_s / 2); - - // Stick something in the queue so it can be popped. - q.push(1); - // Ensure it's been popped. - while (!q.isEmpty()) - try Thread.yield(); - // Give the other thread time to block again. - try Thread.yield(); - std.time.sleep(std.time.ns_per_s / 2); - - // Spurious wake - q.not_full.signal(); - q.not_empty.signal(); - - q.push(2); -} - -test "Drain, block, drain, block" { - // This is like fill/block/fill/block, but on the pop end. This - // test should fail if the `while` loop in `pop` is turned into an - // `if`. - - var queue: Queue(u8, 1) = .{}; - const thread = try Thread.spawn(cfg, sleepyPush, .{&queue}); - try std.testing.expectEqual(1, queue.pop()); - try std.testing.expectEqual(2, queue.pop()); - thread.join(); -} - -fn readerThread(q: *Queue(u8, 1)) !void { - try testing.expectEqual(1, q.pop()); -} - -test "2 readers" { - // 2 threads read, one thread writes - var queue: Queue(u8, 1) = .{}; - const t1 = try Thread.spawn(cfg, readerThread, .{&queue}); - const t2 = try Thread.spawn(cfg, readerThread, .{&queue}); - try Thread.yield(); - std.time.sleep(std.time.ns_per_s / 2); - queue.push(1); - queue.push(1); - t1.join(); - t2.join(); -} - -fn writerThread(q: *Queue(u8, 1)) !void { - q.push(1); -} - -test "2 writers" { - var queue: Queue(u8, 1) = .{}; - const t1 = try Thread.spawn(cfg, writerThread, .{&queue}); - const t2 = try Thread.spawn(cfg, writerThread, .{&queue}); - - try testing.expectEqual(1, queue.pop()); - try testing.expectEqual(1, queue.pop()); - t1.join(); - t2.join(); -} diff --git a/src/render.zig b/src/render.zig deleted file mode 100644 index 58022ea..0000000 --- a/src/render.zig +++ /dev/null @@ -1,52 +0,0 @@ -//! Renderer which holds the screen to compare with the previous screen for efficient rendering. -const std = @import("std"); -const terminal = @import("terminal.zig"); - -pub fn BufferedRenderer(comptime fullscreen: bool) type { - return struct { - refresh: bool = false, - size: terminal.Size = undefined, - screen: std.ArrayList(u8) = undefined, - - pub fn init(allocator: std.mem.Allocator) @This() { - return .{ - .fullscreen = fullscreen, - .screen = std.ArrayList(u8).init(allocator), - }; - } - - pub fn deinit(this: *@This()) void { - this.screen.deinit(); - this.* = undefined; - } - - pub fn resize(this: *@This(), size: terminal.Size) void { - // TODO: are there size changes which impact the corresponding rendered content? - // -> can I even be sure nothing needs to be re-rendered? - this.size = size; - this.refresh = true; - } - - pub fn render(this: *@This(), content: *std.ArrayList(u8)) !void { - // TODO: put the corresponding screen to the terminal - // -> determine diff between screen and new content and only update the corresponding characters of the terminal - _ = this; - _ = content; - @panic("Not yet implemented."); - } - }; -} - -pub fn PlainRenderer(comptime fullscreen: bool) type { - return struct { - pub fn render(this: *@This(), content: *std.ArrayList(u8)) !void { - _ = this; - if (fullscreen) { - try terminal.clearScreen(); - try terminal.setCursorPositionHome(); - } - // TODO: how would I clear the screen in case of a non fullscreen application (i.e. to clear to the start of the command) - _ = try terminal.write(content.items); - } - }; -} diff --git a/src/style.zig b/src/style.zig deleted file mode 100644 index fb46c9d..0000000 --- a/src/style.zig +++ /dev/null @@ -1,285 +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, - -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? diff --git a/src/terminal.zig b/src/terminal.zig deleted file mode 100644 index 5ac995e..0000000 --- a/src/terminal.zig +++ /dev/null @@ -1,207 +0,0 @@ -const std = @import("std"); -const Key = @import("key.zig"); -pub const code_point = @import("code_point"); - -const log = std.log.scoped(.terminal); - -pub const Size = struct { - cols: u16, - rows: u16, -}; - -pub const Position = struct { - col: u16, - row: u16, -}; - -// Ref: https://vt100.net/docs/vt510-rm/DECRPM.html -pub const ReportMode = enum { - not_recognized, - set, - reset, - permanently_set, - permanently_reset, -}; - -/// Gets number of rows and columns in the terminal -pub fn getTerminalSize() Size { - var ws: std.posix.winsize = undefined; - _ = std.posix.system.ioctl(std.posix.STDIN_FILENO, std.posix.T.IOCGWINSZ, &ws); - return .{ .cols = ws.ws_col, .rows = ws.ws_row }; -} - -pub fn saveScreen() !void { - _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[?47h"); -} - -pub fn restoreScreen() !void { - _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[?47l"); -} - -pub fn enterAltScreen() !void { - _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[?1049h"); -} - -pub fn existAltScreen() !void { - _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[?1049l"); -} - -pub fn clearScreen() !void { - _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[2J"); -} - -pub fn setCursorPositionHome() !void { - _ = try std.posix.write(std.posix.STDIN_FILENO, "\x1b[H"); -} - -pub fn read(buf: []u8) !usize { - return try std.posix.read(std.posix.STDIN_FILENO, buf); -} - -pub fn write(buf: []const u8) !usize { - return try std.posix.write(std.posix.STDIN_FILENO, buf); -} - -pub fn getCursorPosition() !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"); - - var buf: [64]u8 = undefined; - - // format: \x1b, "[", R1,..., Rn, ";", C1, ..., Cn, "R" - const len = try std.posix.read(std.posix.STDIN_FILENO, &buf); - - if (!isCursorPosition(buf[0..len])) { - return error.InvalidValueReturned; - } - - var row: [8]u8 = undefined; - var col: [8]u8 = undefined; - - var ridx: u3 = 0; - var cidx: u3 = 0; - - var is_parsing_cols = false; - for (2..(len - 1)) |i| { - const b = buf[i]; - if (b == ';') { - is_parsing_cols = true; - continue; - } - - if (b == 'R') { - break; - } - - if (is_parsing_cols) { - col[cidx] = buf[i]; - cidx += 1; - } else { - row[ridx] = buf[i]; - ridx += 1; - } - } - - return .{ - .row = try std.fmt.parseInt(u16, row[0..ridx], 10), - .col = try std.fmt.parseInt(u16, col[0..cidx], 10), - }; -} - -/// Function is more of a heuristic as opposed -/// to an exact check. -pub fn isCursorPosition(buf: []u8) bool { - if (buf.len < 6) { - return false; - } - - if (buf[0] != 27 or buf[1] != '[') { - return false; - } - - return true; -} - -/// Sets the following -/// - IXON: disables start/stop output flow (reads CTRL-S, CTRL-Q) -/// - ICRNL: disables CR to NL translation (reads CTRL-M) -/// - IEXTEN: disable implementation defined functions (reads CTRL-V, CTRL-O) -/// - ECHO: user input is not printed to terminal -/// - ICANON: read runs for every input (no waiting for `\n`) -/// - ISIG: disable QUIT, ISIG, SUSP. -/// -/// `bak`: pointer to store termios struct backup before -/// altering, this is used to disable raw mode. -pub fn enableRawMode(bak: *std.posix.termios) !void { - var termios = try std.posix.tcgetattr(std.posix.STDIN_FILENO); - bak.* = termios; - - // termios flags used by termios(3) - termios.iflag.IGNBRK = false; - termios.iflag.BRKINT = false; - termios.iflag.PARMRK = false; - termios.iflag.ISTRIP = false; - termios.iflag.INLCR = false; - termios.iflag.IGNCR = false; - termios.iflag.ICRNL = false; - termios.iflag.IXON = false; - - // messes with output -> not used - // termios.oflag.OPOST = false; - - termios.lflag.ECHO = false; - termios.lflag.ECHONL = false; - termios.lflag.ICANON = false; - termios.lflag.ISIG = false; - termios.lflag.IEXTEN = false; - - termios.cflag.CSIZE = .CS8; - termios.cflag.PARENB = false; - - termios.cc[@intFromEnum(std.posix.V.MIN)] = 1; - termios.cc[@intFromEnum(std.posix.V.TIME)] = 0; - - try std.posix.tcsetattr( - std.posix.STDIN_FILENO, - .FLUSH, - termios, - ); -} - -/// Reverts `enableRawMode` to restore initial functionality. -pub fn disableRawMode(bak: *std.posix.termios) !void { - try std.posix.tcsetattr( - std.posix.STDIN_FILENO, - .FLUSH, - bak.*, - ); -} - -// Ref -pub fn canSynchornizeOutput() !bool { - // 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[?2026$p"); - - var buf: [64]u8 = undefined; - - // format: \x1b, "[", "?", "2", "0", "2", "6", ";", n, "$", "y" - const len = try std.posix.read(std.posix.STDIN_FILENO, &buf); - if (!std.mem.eql(u8, buf[0..len], "\x1b[?2026;") or len < 9) { - return false; - } - - // Check value of n - return getReportMode(buf[8]) == .reset; -} - -fn getReportMode(ps: u8) ReportMode { - return switch (ps) { - '1' => ReportMode.set, - '2' => ReportMode.reset, - '3' => ReportMode.permanently_set, - '4' => ReportMode.permanently_reset, - else => ReportMode.not_recognized, - }; -} diff --git a/src/widget.zig b/src/widget.zig deleted file mode 100644 index d99a1f3..0000000 --- a/src/widget.zig +++ /dev/null @@ -1,78 +0,0 @@ -//! Dynamic dispatch for widget implementations. -//! Each widget should at last implement these functions: -//! - handle(this: *@This(), event: Event) ?Event {} -//! - content(this: *@This()) ![]u8 {} -//! - deinit(this: *@This()) void {} -//! -//! Create a `Widget` using `createFrom(object: anytype)` and use them through -//! the defined 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. -const isTaggedUnion = @import("event.zig").isTaggedUnion; - -pub fn Widget(comptime Event: type) type { - if (!isTaggedUnion(Event)) { - @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); - } - return struct { - const WidgetType = @This(); - const Ptr = usize; - - const VTable = struct { - handle: *const fn (this: *WidgetType, event: Event) ?Event, - content: *const fn (this: *WidgetType) anyerror![]u8, - deinit: *const fn (this: *WidgetType) void, - }; - - object: Ptr = undefined, - vtable: *const VTable = undefined, - - // Handle the provided `Event` for this `Widget`. - pub fn handle(this: *WidgetType, event: Event) ?Event { - return this.vtable.handle(this, event); - } - - // Return the entire content of this `Widget`. - pub fn content(this: *WidgetType) ![]u8 { - return try this.vtable.content(this); - } - - pub fn deinit(this: *WidgetType) void { - this.vtable.deinit(this); - this.* = undefined; - } - - pub fn createFrom(object: anytype) WidgetType { - return WidgetType{ - .object = @intFromPtr(object), - .vtable = &.{ - .handle = struct { - // Handle the provided `Event` for this `Widget`. - fn handle(this: *WidgetType, event: Event) ?Event { - const widget: @TypeOf(object) = @ptrFromInt(this.object); - return widget.handle(event); - } - }.handle, - .content = struct { - // Return the entire content of this `Widget`. - fn content(this: *WidgetType) ![]u8 { - const widget: @TypeOf(object) = @ptrFromInt(this.object); - return try widget.content(); - } - }.content, - .deinit = struct { - fn deinit(this: *WidgetType) void { - const widget: @TypeOf(object) = @ptrFromInt(this.object); - widget.deinit(); - } - }.deinit, - }, - }; - } - - // TODO: import and export of `Widget` implementations (with corresponding intialization using `Event`) - pub const RawText = @import("widget/RawText.zig").Widget(Event); - }; -} diff --git a/src/widget/RawText.zig b/src/widget/RawText.zig deleted file mode 100644 index ff2144e..0000000 --- a/src/widget/RawText.zig +++ /dev/null @@ -1,95 +0,0 @@ -const std = @import("std"); -const terminal = @import("../terminal.zig"); -const Style = @import("../style.zig"); - -const isTaggedUnion = @import("../event.zig").isTaggedUnion; -const Error = @import("../event.zig").Error; -const Key = @import("../key.zig"); - -const log = std.log.scoped(.widget_rawtext); - -pub fn Widget(comptime Event: type) type { - if (!isTaggedUnion(Event)) { - @compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`."); - } - const Contents = std.ArrayList(u8); - return struct { - contents: Contents = undefined, - line_index: std.ArrayList(usize) = undefined, - line: usize = 0, - size: terminal.Size = undefined, - - pub fn init(allocator: std.mem.Allocator, file: std.fs.File) @This() { - var contents = Contents.init(allocator); - var line_index = std.ArrayList(usize).init(allocator); - file.reader().readAllArrayList(&contents, std.math.maxInt(usize)) catch {}; - line_index.append(0) catch {}; - for (contents.items, 0..) |item, i| { - if (item == '\n') { - line_index.append(i + 1) catch {}; - } - } - return .{ - .contents = contents, - .line_index = line_index, - }; - } - - pub fn deinit(this: *@This()) void { - this.contents.deinit(); - this.line_index.deinit(); - this.* = undefined; - } - - pub fn handle(this: *@This(), event: Event) ?Event { - switch (event) { - // store the received size - .resize => |size| { - this.size = size; - log.debug("Using size: {{ .cols = {d}, .rows = {d} }}", .{ size.cols, size.rows }); - if (this.line > this.line_index.items.len -| 1 -| size.rows) { - this.line = this.line_index.items.len -| 1 -| size.rows; - } - }, - .key => |key| { - if (key.matches(.{ .cp = 'g' })) { - // top - this.line = 0; - } - if (key.matches(.{ .cp = 'G' })) { - // bottom - this.line = this.line_index.items.len -| 1 -| this.size.rows; - } - if (key.matches(.{ .cp = 'j' })) { - // down - if (this.line < this.line_index.items.len -| 1 -| this.size.rows) { - this.line +|= 1; - } - } - if (key.matches(.{ .cp = 'k' })) { - // up - this.line -|= 1; - } - }, - else => {}, - } - return null; - } - - pub fn content(this: *@This()) ![]u8 { - if (this.size.rows >= this.line_index.items.len) { - return this.contents.items; - } else { - // more rows than we can display - const i = this.line_index.items[this.line]; - log.debug("i := {d} this.line := {d}", .{ i, this.line }); - const e = this.size.rows + this.line; - if (e >= this.line_index.items.len) { - return this.contents.items[i..]; - } - const x = this.line_index.items[e] - 1; - return this.contents.items[i..x]; - } - } - }; -} diff --git a/src/widget/node2buffer.zig b/src/widget/node2buffer.zig deleted file mode 100644 index dfa225c..0000000 --- a/src/widget/node2buffer.zig +++ /dev/null @@ -1,314 +0,0 @@ -///! Transform a given file type into a buffer which can be used by any `vaxis.widgets.View` -///! -///! Use the `toBuffer` method of any struct to convert the file type described by the -///! struct to convert the file contents accordingly. -const std = @import("std"); -const vaxis = @import("vaxis"); - -/// Markdown tronsformation to convert a markdown file as a `std.ArrayList(vaxis.cell)` -pub const Markdown = struct { - const zmd = @import("zmd"); - - const digits = "0123456789"; - - pub fn toBuffer( - input: []const u8, - allocator: std.mem.Allocator, - array: *std.ArrayList(vaxis.Cell), - ) void { - var z = zmd.Zmd.init(allocator); - defer z.deinit(); - - z.parse(input) catch @panic("failed to parse markdown contents"); - convert( - z.nodes.items[0], - allocator, - input, - array, - .{}, - null, - ) catch @panic("failed to transform parsed markdown to cell array"); - } - - fn convert( - node: *zmd.Node, - allocator: std.mem.Allocator, - input: []const u8, - array: *std.ArrayList(vaxis.Cell), - sty: vaxis.Cell.Style, - start: ?usize, - ) !void { - var next_start: ?usize = start; - var style = sty; - - // determine general styling changes - switch (node.token.element.type) { - .bold => { - style.bold = true; - next_start = node.token.start; - style.fg = .{ .index = 5 }; - }, - .italic => { - style.italic = true; - next_start = node.token.start; - style.fg = .{ .index = 2 }; - }, - .block => { - style.fg = .{ .index = 252 }; - style.dim = true; - next_start = node.token.start; - }, - .code => { - next_start = node.token.start; - style.fg = .{ .index = 6 }; - }, - .href => { - next_start = node.token.start; - style.fg = .{ .index = 8 }; - }, - .link => { - style.fg = .{ .index = 3 }; - style.ul_style = .single; - }, - .list_item => { - style.fg = .{ .index = 1 }; - }, - else => {}, - } - - // determine content that needs to be displayed - const content = value: { - switch (node.token.element.type) { - // NOTE: do not support ordered lists? (as it only accepts `1.` and not `2.`, etc.) - .text, .list_item => break :value input[node.token.start..node.token.end], - .link => break :value input[node.token.start + 1 .. node.token.start + 1 + node.title.?.len], - .code_close => { - if (next_start) |s| { - next_start = null; - break :value input[s + 1 .. node.token.end - 1]; - } - break :value ""; - }, - .bold_close, .italic_close, .block_close, .title_close, .href_close => { - if (next_start) |s| { - next_start = null; - break :value input[s..node.token.end]; - } - break :value ""; - }, - else => { - break :value ""; - }, - } - }; - - // display content - switch (node.token.element.type) { - .linebreak, .paragraph => { - try array.append(.{ - .char = .{ .grapheme = "\n" }, - .style = style, - }); - style.ul_style = .off; - }, - .h1 => { - style.ul_style = .single; - try array.append(.{ - .char = .{ .grapheme = "#" }, - .style = style, - }); - }, - .h2 => { - style.ul_style = .single; - for (0..2) |_| { - try array.append(.{ - .char = .{ .grapheme = "#" }, - .style = style, - }); - } - }, - .h3 => { - style.ul_style = .single; - for (0..3) |_| { - try array.append(.{ - .char = .{ .grapheme = "#" }, - .style = style, - }); - } - }, - .h4 => { - style.ul_style = .single; - for (0..4) |_| { - try array.append(.{ - .char = .{ .grapheme = "#" }, - .style = style, - }); - } - }, - .h5 => { - style.ul_style = .single; - for (0..5) |_| { - try array.append(.{ - .char = .{ .grapheme = "#" }, - .style = style, - }); - } - }, - .h6 => { - style.ul_style = .single; - for (0..6) |_| { - try array.append(.{ - .char = .{ .grapheme = "#" }, - .style = style, - }); - } - }, - .link => { - const uri = input[node.token.start + 1 + node.title.?.len + 2 .. node.token.start + 1 + node.title.?.len + 2 + node.href.?.len]; - for (content, 0..) |_, i| { - try array.append(.{ - .link = .{ .uri = uri }, - .char = .{ .grapheme = content[i .. i + 1] }, - .style = style, - }); - } - }, - .block_close => { - // generate a `block` i.e. - // 01 | ... - // 02 | ... - // 03 | ... - // ... - // 10 | ... - try array.append(.{ - .char = .{ .grapheme = "\n" }, - }); - var rows: usize = 0; - var c: usize = 0; - // TODO: would be cool to not have to re-iterate over the contents - for (content, 0..) |char, i| { - if (char == '\n') { - // NOTE: start after the ``` - if (c == 0) { - c = i + 1; - } - rows += 1; - } - } - rows = rows -| 1; - const pad = vaxis.widgets.LineNumbers.numDigits(rows); - for (1..rows + 1) |r| { - try array.append(.{ - .char = .{ .grapheme = " " }, - .style = style, - }); - try array.append(.{ - .char = .{ .grapheme = " " }, - .style = style, - }); - for (1..pad + 1) |i| { - const digit = vaxis.widgets.LineNumbers.extractDigit(r, pad - i); - try array.append(.{ - .char = .{ .grapheme = digits[digit .. digit + 1] }, - .style = style, - }); - } - try array.append(.{ - .char = .{ .grapheme = " " }, - .style = style, - }); - try array.append(.{ - .char = .{ .grapheme = "│" }, - .style = style, - }); - try array.append(.{ - .char = .{ .grapheme = " " }, - .style = style, - }); - for (c..content.len) |c_i| { - if (r == rows and content[c_i] == '\n') { - break; - } - try array.append(.{ - .char = .{ .grapheme = content[c_i .. c_i + 1] }, - .style = style, - }); - if (content[c_i] == '\n') { - c = c_i + 1; - break; - } - } - } - }, - .list_item => { - try array.append(.{ - .char = .{ .grapheme = "\n" }, - }); - for (content, 0..) |_, i| { - try array.append(.{ - .char = .{ .grapheme = content[i .. i + 1] }, - .style = style, - }); - } - }, - else => { - for (content, 0..) |_, i| { - try array.append(.{ - .char = .{ .grapheme = content[i .. i + 1] }, - .style = style, - }); - } - }, - } - - // close styling after creating the corresponding cells - switch (node.token.element.type) { - .bold_close => { - style.bold = false; - style.fg = .default; - }, - .italic_close => { - style.italic = false; - style.fg = .default; - }, - .block_close => { - style.fg = .default; - }, - .code_close => { - style.fg = .default; - }, - .href_close => { - style.fg = .default; - }, - .list_item => { - style.fg = .default; - }, - else => {}, - } - - // run conversion for all childrens - for (node.children.items) |child_node| { - try convert(child_node, allocator, input, array, style, next_start); - } - } -}; - -pub const Typst = struct { - pub fn toBuffer( - input: []const u8, - allocator: std.mem.Allocator, - array: *std.ArrayList(vaxis.Cell), - ) void { - // TODO: leverage the compiler to create corresponding file? - // -> would enable functions to be executed, however this is not possible currently - // -> this would only work if I serve the corresponding pdf's (maybe I can have a download server for these?) - // NOTE: currently the typst compiler does not allow such a usage from the outside - // -> I would need to wait for html export I guess - - // TODO: use pret parsing to parse typst file contents and transform them into `vaxis.Cell`s accordingly - _ = input; - _ = allocator; - _ = array; - @panic("Typst parsing not yet implemented"); - } -}; -- 2.49.1 From c47efefbc434d380ea2edd6951bd972e59eb79a1 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Wed, 13 Nov 2024 19:47:13 +0100 Subject: [PATCH 20/20] doc: update README documentation --- README.md | 41 +++-------------------------------------- 1 file changed, 3 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 562854c..e801e41 100644 --- a/README.md +++ b/README.md @@ -4,49 +4,14 @@ This is my terminal based website. It is served as a tui application via ssh and It contains information about me and my projects as well as blog entries about something I feel like writing something about. -## Terminal User Interface +## zterm -### Benchmark +This tui application also serves as an example implementation of the [zterm](https://gitea.yves-biener.de/yves-biener/zterm) library. -```sh -zig build -Dbenchmark run 2> log -``` - -_Press any button_ at the end of the benchmark (when you are back to the original screen). `log` now contains the frame -delay for each frame in each line of the output. +--- ## Open tasks - [ ] Improve navigation - [ ] Have clickable/navigatable links inside of the tui application - [ ] Launch simple http server alongside tui application - ---- - -- [ ] Split into own repository - - [ ] Create other layouts - - [ ] horizontal stack - - [ ] vertical stack - - [ ] `Layout` in `Layout`? -> interfaces are very similar anyway - - [ ] Building Block `Layout`s - - [ ] Framing `Layout` - - [ ] Padding `Layout` - - [ ] Create demo gifs using [vhs](https://github.com/charmbracelet/vhs) - - [ ] Move documentation into new repository -- [ ] add dependency to new repository into this project - ---- - -## Branch: `own-tty-visuals` - -- [ ] How can I support to run a sub-process inside of a given pane / layout? - -- [x] Could I simulate a corresponding event loop? - - emmit as many as possible through another thread (until the event queue is full?) [1023] - - see how fast the application can render each frame and measure the necessary time for each _frame_? - -> determine statistics like, min, max, median, mean, etc. - -> Or buffered writer to the `std.posix.STDOUT_FILENO`? - -> I could use this to see if it makes sense to implement a buffered version using a screen buffer (to only render the differences?) - - seems pretty good (with some exceptions) - -- [ ] styling could be tricky with a given layout (which introduces corresponding line breaks ...) -- 2.49.1