From 6d389bcd4b287ab8da78e4945867c3a6ba6efc6a Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Sat, 9 Nov 2024 21:24:42 +0100 Subject: [PATCH] initial commit --- .gitignore | 3 + build.zig | 88 ++++++++++ build.zig.zon | 39 +++++ src/app.zig | 203 +++++++++++++++++++++++ src/event.zig | 83 ++++++++++ src/layout.zig | 84 ++++++++++ src/layout/Framing.zig | 103 ++++++++++++ src/layout/HStack.zig | 136 ++++++++++++++++ src/layout/Padding.zig | 103 ++++++++++++ src/layout/VStack.zig | 149 +++++++++++++++++ src/main.zig | 90 +++++++++++ src/queue.zig | 322 +++++++++++++++++++++++++++++++++++++ src/render.zig | 55 +++++++ src/terminal.zig | 207 ++++++++++++++++++++++++ src/terminal/ctlseqs.zig | 140 ++++++++++++++++ src/terminal/key.zig | 149 +++++++++++++++++ src/terminal/style.zig | 285 ++++++++++++++++++++++++++++++++ src/widget.zig | 78 +++++++++ src/widget/RawText.zig | 95 +++++++++++ src/widget/node2buffer.zig | 314 ++++++++++++++++++++++++++++++++++++ src/zterm.zig | 12 ++ 21 files changed, 2738 insertions(+) create mode 100644 .gitignore create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 src/app.zig create mode 100644 src/event.zig create mode 100644 src/layout.zig 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 create mode 100644 src/main.zig create mode 100644 src/queue.zig create mode 100644 src/render.zig create mode 100644 src/terminal.zig create mode 100644 src/terminal/ctlseqs.zig create mode 100644 src/terminal/key.zig create mode 100644 src/terminal/style.zig create mode 100644 src/widget.zig create mode 100644 src/widget/RawText.zig create mode 100644 src/widget/node2buffer.zig create mode 100644 src/zterm.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fc31d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.zig-cache/ +zig-out/ +log diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..3cd44e9 --- /dev/null +++ b/build.zig @@ -0,0 +1,88 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + const zg = b.dependency("zg", .{}); + + const lib = b.addModule("zterm", .{ + .root_source_file = b.path("src/zterm.zig"), + .target = target, + .optimize = optimize, + }); + lib.addImport("code_point", zg.module("code_point")); + + const exe = b.addExecutable(.{ + .name = "zterm", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + exe.root_module.addImport("zterm", lib); + + // This declares intent for the executable to be installed into the + // standard location when the user invokes the "install" step (the default + // step when running `zig build`). + b.installArtifact(exe); + + // This *creates* a Run step in the build graph, to be executed when another + // step is evaluated that depends on it. The next line below will establish + // such a dependency. + const run_cmd = b.addRunArtifact(exe); + + // By making the run step depend on the install step, it will be run from the + // installation directory rather than directly from within the cache directory. + // This is not necessary, however, if the application depends on other installed + // files, this ensures they will be present and in the expected location. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + // Creates a step for unit testing. This only builds the test executable + // but does not run it. + const lib_unit_tests = b.addTest(.{ + .root_source_file = b.path("src/zterm.zig"), + .target = target, + .optimize = optimize, + }); + lib_unit_tests.root_module.addImport("zg", zg.module("code_point")); + + const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); + + const exe_unit_tests = b.addTest(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_lib_unit_tests.step); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..7619cd0 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,39 @@ +.{ + // This is the default name used by packages depending on this one. For + // example, when a user runs `zig fetch --save `, this field is used + // as the key in the `dependencies` table. Although the user can choose a + // different name, most users will stick with this provided value. + // + // It is redundant to include "zig" in this name because it is already + // within the Zig package namespace. + .name = "zterm", + + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + + // This field is optional. + // This is currently advisory only; Zig does not yet do anything + // with this value. + //.minimum_zig_version = "0.11.0", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + .zg = .{ + .url = "https://codeberg.org/dude_the_builder/zg/archive/v0.13.2.tar.gz", + .hash = "122055beff332830a391e9895c044d33b15ea21063779557024b46169fb1984c6e40", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + // For example... + //"LICENSE", + //"README.md", + }, +} diff --git a/src/app.zig b/src/app.zig new file mode 100644 index 0000000..13eba3b --- /dev/null +++ b/src/app.zig @@ -0,0 +1,203 @@ +//! Application type for TUI-applications +const std = @import("std"); +const terminal = @import("terminal.zig"); +const event = @import("event.zig"); + +const mergeTaggedUnions = event.mergeTaggedUnions; +const isTaggedUnion = event.isTaggedUnion; + +const Key = terminal.Key; +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 zterm = @import("zterm"); +/// const App = zterm.App( +/// union(enum) {}, +/// zterm.Renderer.Plain, +/// 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(event.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(), e: Event) void { + this.queue.push(e); + } + + 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/event.zig b/src/event.zig new file mode 100644 index 0000000..3a93935 --- /dev/null +++ b/src/event.zig @@ -0,0 +1,83 @@ +//! Events which are defined by the library. They might be extended by user +//! events. See `App` for more details about user defined events. +const std = @import("std"); +const terminal = @import("terminal.zig"); + +const Size = terminal.Size; +const Key = terminal.Key; + +pub const Error = struct { + err: anyerror, + msg: []const u8, +}; + +// System events available to every application. +pub const SystemEvent = union(enum) { + quit, + err: Error, + resize: Size, + key: Key, +}; + +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..398971b --- /dev/null +++ b/src/layout.zig @@ -0,0 +1,84 @@ +//! 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 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..f8931d6 --- /dev/null +++ b/src/layout/Framing.zig @@ -0,0 +1,103 @@ +//! Framing layout for a nested `Layout`s or `Widget`s. +//! +//! # Example +//! ... +const std = @import("std"); +const terminal = @import("../terminal.zig"); + +const isTaggedUnion = @import("../event.zig").isTaggedUnion; +const Error = @import("../event.zig").Error; +const Key = terminal.Key; + +const log = std.log.scoped(.layout_framing); + +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 new file mode 100644 index 0000000..331dd12 --- /dev/null +++ b/src/layout/HStack.zig @@ -0,0 +1,136 @@ +//! Horizontal Stacking layout for nested `Layout`s and/or `Widget`s. +//! +//! # Example +//! ... +const std = @import("std"); +const terminal = @import("../terminal.zig"); + +const isTaggedUnion = @import("../event.zig").isTaggedUnion; +const Error = @import("../event.zig").Error; +const Key = terminal.Key; + +const log = std.log.scoped(.layout_hstack); + +pub fn Layout(comptime Event: type) 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 new file mode 100644 index 0000000..9d5d477 --- /dev/null +++ b/src/layout/Padding.zig @@ -0,0 +1,103 @@ +//! Padding layout for a nested `Layout`s or `Widget`s. +//! +//! # Example +//! ... +const std = @import("std"); +const terminal = @import("../terminal.zig"); + +const isTaggedUnion = @import("../event.zig").isTaggedUnion; +const Error = @import("../event.zig").Error; +const Key = terminal.Key; + +const log = std.log.scoped(.layout_padding); + +pub fn Layout(comptime Event: type) 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/VStack.zig b/src/layout/VStack.zig new file mode 100644 index 0000000..e04262c --- /dev/null +++ b/src/layout/VStack.zig @@ -0,0 +1,149 @@ +//! Vertical Stacking layout for nested `Layout`s and/or `Widget`s. +//! +//! # Example +//! ... +const std = @import("std"); +const terminal = @import("../terminal.zig"); + +const isTaggedUnion = @import("../event.zig").isTaggedUnion; +const Error = @import("../event.zig").Error; +const Key = terminal.Key; + +const log = std.log.scoped(.layout_vstack); + +pub fn Layout(comptime Event: type) 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 new file mode 100644 index 0000000..409f5dd --- /dev/null +++ b/src/main.zig @@ -0,0 +1,90 @@ +const std = @import("std"); +const zterm = @import("zterm"); + +const App = zterm.App( + union(enum) {}, + zterm.Renderer.Plain, + true, +); +const Key = zterm.Key; + +const log = std.log.scoped(.default); + +pub fn main() !void { + errdefer |err| log.err("Application Error: {any}", .{err}); + + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{}; + defer { + const deinit_status = gpa.deinit(); + // fail test; can't try in defer as defer is executed after we return + if (deinit_status == .leak) { + log.err("memory leak", .{}); + } + } + const allocator = gpa.allocator(); + + var app: App = .{}; + var renderer: App.Renderer = .{}; + + const mainFile = try std.fs.cwd().openFile("./src/main.zig", .{}); + var mainFileText = App.Widget.RawText.init(allocator, mainFile); + mainFile.close(); + + const appFile = try std.fs.cwd().openFile("./src/app.zig", .{}); + var appFileText = App.Widget.RawText.init(allocator, appFile); + appFile.close(); + + var framing = App.Layout.Framing.init(allocator, .{ + .widget = App.Widget.createFrom(&mainFileText), + }); + var hstack = App.Layout.HStack.init(allocator, .{ + App.Layout.createFrom(&framing), + }); + var vstack = App.Layout.VStack.init(allocator, .{ + App.Widget.createFrom(&appFileText), + App.Layout.createFrom(&hstack), + }); + var layout = App.Layout.createFrom(&vstack); + defer layout.deinit(); + + try app.start(); + defer app.stop() catch unreachable; + + // App.Event loop + while (true) { + const event = app.nextEvent(); + + switch (event) { + .quit => break, + .key => |key| { + // ctrl+c to quit + if (Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) { + app.quit(); + } + if (Key.matches(key, .{ .cp = 'n', .mod = .{ .ctrl = true } })) { + try app.interrupt(); + defer app.start() 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); + } + try renderer.render(try layout.content()); + } +} 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/render.zig b/src/render.zig new file mode 100644 index 0000000..3b968d2 --- /dev/null +++ b/src/render.zig @@ -0,0 +1,55 @@ +//! Renderer which holds the screen to compare with the previous screen for efficient rendering. +const std = @import("std"); +const terminal = @import("terminal.zig"); + +const Contents = std.ArrayList(u8); +const Size = terminal.Size; + +pub fn Buffered(comptime fullscreen: bool) type { + return struct { + refresh: bool = false, + size: terminal.Size = undefined, + screen: Contents = undefined, + + pub fn init(allocator: std.mem.Allocator) @This() { + return .{ + .fullscreen = fullscreen, + .screen = Contents.init(allocator), + }; + } + + pub fn deinit(this: *@This()) void { + this.screen.deinit(); + this.* = undefined; + } + + pub fn resize(this: *@This(), size: 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: *Contents) !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 Plain(comptime fullscreen: bool) type { + return struct { + pub fn render(this: *@This(), content: *Contents) !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/terminal.zig b/src/terminal.zig new file mode 100644 index 0000000..721cd4e --- /dev/null +++ b/src/terminal.zig @@ -0,0 +1,207 @@ +const std = @import("std"); +pub const Key = @import("terminal/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, @intFromPtr(&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/terminal/ctlseqs.zig b/src/terminal/ctlseqs.zig new file mode 100644 index 0000000..4e55448 --- /dev/null +++ b/src/terminal/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[ 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/terminal/style.zig b/src/terminal/style.zig new file mode 100644 index 0000000..fb46c9d --- /dev/null +++ b/src/terminal/style.zig @@ -0,0 +1,285 @@ +//! 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/widget.zig b/src/widget.zig new file mode 100644 index 0000000..d99a1f3 --- /dev/null +++ b/src/widget.zig @@ -0,0 +1,78 @@ +//! 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 new file mode 100644 index 0000000..a208bae --- /dev/null +++ b/src/widget/RawText.zig @@ -0,0 +1,95 @@ +const std = @import("std"); +const terminal = @import("../terminal.zig"); + +const isTaggedUnion = @import("../event.zig").isTaggedUnion; +const Error = @import("../event.zig").Error; +const Key = terminal.Key; +const Style = terminal.Style; + +const log = std.log.scoped(.widget_rawtext); + +pub fn Widget(comptime Event: type) 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 new file mode 100644 index 0000000..dfa225c --- /dev/null +++ b/src/widget/node2buffer.zig @@ -0,0 +1,314 @@ +///! 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"); + } +}; diff --git a/src/zterm.zig b/src/zterm.zig new file mode 100644 index 0000000..0ebb2d2 --- /dev/null +++ b/src/zterm.zig @@ -0,0 +1,12 @@ +// private imports +const terminal = @import("terminal.zig"); + +// public import / exports +pub const App = @import("app.zig").App; +pub const Renderer = @import("render.zig"); +pub const Key = terminal.Key; +pub const Size = terminal.Size; + +test { + _ = @import("queue.zig"); +}