diff --git a/README.md b/README.md index 611a19b..cee875f 100644 --- a/README.md +++ b/README.md @@ -93,22 +93,25 @@ the primary use-case for myself to create this library in the first place. - [x] separators - [x] Rectangle - [ ] User control - - [x] event handling + - [x] event loop handling + - [x] mouse support - [x] user content - [ ] Default `Element` implementations - [ ] Scrollable - - [ ] user input handling - - [ ] vertical - - [ ] horizontal + - [x] user input handling + - [x] vertical + - [x] horizontal + - [x] mouse input - [ ] scroll bar(s) rendering - [ ] vertical - [ ] horizontal - [ ] Content alignment (i.e. standard calculations done with the provided `Size`) - [ ] Text display - - [ ] User input - - [ ] single line - - [ ] multi line - - [ ] min size? (I don't have access to the `.resize` `Event`..) + - [x] User input + - [x] single line + - [x] multi line + - [x] min size (provide size to use which would be a minimal size - as if the actual size is smaller then the `Container` will scroll and otherwise the contents expand to the available space instead?) + - [ ] image support through kitty protocol Decorations should respect the layout and the viewport accordingly. This means that scrollbars are always visible (except there is no need to have a scrollbar) @@ -117,6 +120,12 @@ cells of the content (and may be overwritten by child elements contents). The border of an element should be around independent of the scrolling of the contents, just like padding. +For most of the `Element`s a standalone implementation would not make a lot +of sense due to the complexity of the user application states. Therefore the +library should instead provide small examples to show how you can implement +such user fields yourself and connect them using your own event system loops to +communicate with other `Container`s and/or `Element`s. + ### Scrollable contents Contents that is scrollable should be done *virtually* through the contents of diff --git a/examples/container.zig b/examples/container.zig index e15dce3..8246124 100644 --- a/examples/container.zig +++ b/examples/container.zig @@ -1,11 +1,32 @@ const std = @import("std"); const zterm = @import("zterm"); -const App = zterm.App(union(enum) {}); +const App = zterm.App(union(enum) { + send: []u21, +}); const log = std.log.scoped(.example); -pub const ExampleElement = packed struct { +pub const InputField = struct { + // TODO: I would need the following features: + // - current position of the input (i.e. a cursor) + // - gnu readline keybindings + // - truncate inputs? + input: std.ArrayList(u21), + /// The thread safe `App.Event` queue used to send events to the application's event loop. + queue: *App.Queue, + + pub fn init(allocator: std.mem.Allocator, queue: *App.Queue) @This() { + return .{ + .input = .init(allocator), + .queue = queue, + }; + } + + pub fn deinit(this: *@This()) void { + this.input.deinit(); + } + pub fn element(this: *@This()) App.Element { return .{ .ptr = this, @@ -16,27 +37,43 @@ pub const ExampleElement = packed struct { }; } - // example function to render contents for a `Container` - fn content(ctx: *anyopaque, cells: []zterm.Cell, size: zterm.Size) !void { - _ = ctx; - std.debug.assert(cells.len == @as(usize, size.cols) * @as(usize, size.rows)); + // example function to handle events for a `Container` + fn handle(ctx: *anyopaque, event: App.Event) !void { + const this: *@This() = @ptrCast(@alignCast(ctx)); + switch (event) { + .key => |key| { + if (key.isAscii()) try this.input.append(key.cp); - // NOTE: error should only be returned here in case an in-recoverable exception has occurred - const row = size.rows / 2; - const col = size.cols / 2 -| 3; + if (key.eql(.{ .cp = zterm.input.Enter }) or key.eql(.{ .cp = zterm.input.KpEnter })) + this.queue.push(.{ .send = try this.input.toOwnedSlice() }); - for (0..5) |c| { - cells[(row * size.cols) + col + c].style.fg = .black; - cells[(row * size.cols) + col + c].cp = '-'; + if (key.eql(.{ .cp = zterm.input.Backspace }) or key.eql(.{ .cp = zterm.input.Delete }) or key.eql(.{ .cp = zterm.input.KpDelete })) + _ = this.input.popOrNull(); + }, + else => {}, } } - // example function to handle events for a `Container` - fn handle(ctx: *anyopaque, event: App.Event) !void { - _ = ctx; - switch (event) { - .init => log.debug(".init event", .{}), - else => {}, + // example function to render contents for a `Container` + fn content(ctx: *anyopaque, cells: []zterm.Cell, size: zterm.Size) !void { + const this: *@This() = @ptrCast(@alignCast(ctx)); + std.debug.assert(cells.len == @as(usize, size.cols) * @as(usize, size.rows)); + + if (this.input.items.len == 0) return; + + const row = size.rows / 2; + const col: u16 = 5; + + for (this.input.items, 0..) |cp, idx| { + cells[(row * size.cols) + col + idx].style.fg = .black; + cells[(row * size.cols) + col + idx].cp = cp; + // NOTE: line wrapping happens automatically due to the way the container cells are constructed + // - it would also be possible to limit the line to cols you want to display (i.e. then only for a single row) + // - for areas you would rather go ahead and create another + // container and fit the text inside of that container (so it is dynamic to the screen size, etc.) + + // NOTE: do not write over the contents of this `Container`'s `Size` + if ((row * size.cols) + col + idx == cells.len - 1) break; } } }; @@ -58,7 +95,8 @@ pub fn main() !void { var renderer = zterm.Renderer.Buffered.init(allocator); defer renderer.deinit(); - var element_wrapper = ExampleElement{}; + var element_wrapper: InputField = .init(allocator, &app.queue); + defer element_wrapper.deinit(); const element = element_wrapper.element(); var container = try App.Container.init(allocator, .{ @@ -73,7 +111,7 @@ pub fn main() !void { .padding = .all(5), .direction = .vertical, }, - }, element); + }, .{}); var box = try App.Container.init(allocator, .{ .rectangle = .{ .fill = .blue }, .layout = .{ @@ -82,15 +120,15 @@ pub fn main() !void { .padding = .vertical(1), }, }, .{}); - try box.append(try App.Container.init(allocator, .{ - .rectangle = .{ .fill = .light_green }, - }, element)); try box.append(try App.Container.init(allocator, .{ .rectangle = .{ .fill = .light_green }, }, .{})); try box.append(try App.Container.init(allocator, .{ .rectangle = .{ .fill = .light_green }, }, element)); + try box.append(try App.Container.init(allocator, .{ + .rectangle = .{ .fill = .light_green }, + }, .{})); try container.append(box); try container.append(try App.Container.init(allocator, .{ .border = .{ @@ -124,6 +162,7 @@ pub fn main() !void { if (key.eql(.{ .cp = 'n', .mod = .{ .ctrl = true } })) { try app.interrupt(); defer app.start() catch @panic("could not start app event loop"); + // FIX: the rendering is afterwards incorrect (wrong state of the double buffered renderer?) var child = std.process.Child.init(&.{"hx"}, allocator); _ = child.spawnAndWait() catch |err| app.postEvent(.{ .err = .{ @@ -133,6 +172,12 @@ pub fn main() !void { }); } }, + .send => |input| { + // NOTE: as the input in owned by the caller, this means that it still needs to be freed + defer allocator.free(input); + + log.info("accepted user input: {any}", .{input}); + }, // NOTE: errors could be displayed in another container in case one was received, etc. to provide the user with feedback .err => |err| log.err("Received {s} with message: {s}", .{ @errorName(err.err), err.msg }), else => {}, diff --git a/src/app.zig b/src/app.zig index 3f392df..857f0f3 100644 --- a/src/app.zig +++ b/src/app.zig @@ -4,6 +4,7 @@ const code_point = @import("code_point"); const event = @import("event.zig"); const input = @import("input.zig"); const terminal = @import("terminal.zig"); +const queue = @import("queue.zig"); const mergeTaggedUnions = event.mergeTaggedUnions; const isTaggedUnion = event.isTaggedUnion; @@ -11,7 +12,6 @@ const isTaggedUnion = event.isTaggedUnion; const Mouse = input.Mouse; const Key = input.Key; const Size = @import("size.zig").Size; -const Queue = @import("queue.zig").Queue; const log = std.log.scoped(.app); @@ -44,8 +44,9 @@ pub fn App(comptime E: type) type { const element = @import("element.zig"); pub const Element = element.Element(Event); pub const Scrollable = element.Scrollable(Event); + pub const Queue = queue.Queue(Event, 256); - queue: Queue(Event, 256), + queue: Queue, thread: ?std.Thread, quit_event: std.Thread.ResetEvent, termios: ?std.posix.termios = null, @@ -175,6 +176,7 @@ pub fn App(comptime E: type) type { while (true) { // FIX: I still think that there is a race condition (I'm just waiting 'long' enough) this.quit_event.timedWait(20 * std.time.ns_per_ms) catch { + // FIX: in case the queue is full -> the next user input should panic and quit the application? because something seems to clock up the event queue const read_bytes = try terminal.read(buf[0..]); // TODO: `break` should not terminate the reading of the user inputs, but instead only the received faulty input! // escape key presses diff --git a/src/container.zig b/src/container.zig index 2bcc0dc..a6ae8d1 100644 --- a/src/container.zig +++ b/src/container.zig @@ -225,9 +225,8 @@ pub const Layout = packed struct { }; pub fn Container(comptime Event: type) type { - if (!isTaggedUnion(Event)) { - @compileError("Provided user event `Event` for `Container(comptime Event: type)`"); - } + if (!isTaggedUnion(Event)) @compileError("Provided user event `Event` for `Container(comptime Event: type)`"); + const Element = @import("element.zig").Element(Event); return struct { allocator: std.mem.Allocator, @@ -243,6 +242,7 @@ pub fn Container(comptime Event: type) type { border: Border = .{}, rectangle: Rectangle = .{}, layout: Layout = .{}, + min_size: Size = .{}, }; pub fn init( @@ -270,6 +270,24 @@ pub fn Container(comptime Event: type) type { try this.elements.append(element); } + pub fn minSize(this: @This()) Size { + var size: Size = .{}; + const len: u16 = @truncate(this.elements.items.len); + if (len > 0) { + for (this.elements.items) |element| { + size = size.merge(element.minSize()); + } + switch (this.properties.layout.direction) { + .horizontal => size.cols += this.properties.layout.gap * (len - 1), + .vertical => size.rows += this.properties.layout.gap * (len - 1), + } + } + return .{ + .cols = @max(size.cols, this.properties.min_size.cols), + .rows = @max(size.rows, this.properties.min_size.rows), + }; + } + pub fn handle(this: *@This(), event: Event) !void { switch (event) { .resize => |size| resize: { diff --git a/src/element.zig b/src/element.zig index b11daa4..fb56b44 100644 --- a/src/element.zig +++ b/src/element.zig @@ -1,9 +1,11 @@ //! Interface for Element's which describe the contents of a `Container`. const std = @import("std"); const s = @import("size.zig"); +const input = @import("input.zig"); const Container = @import("container.zig").Container; const Cell = @import("cell.zig"); +const Mouse = input.Mouse; const Position = s.Position; const Size = s.Size; @@ -82,9 +84,12 @@ pub fn Scrollable(Event: type) type { /// `Size` of the actual contents where the anchor and the size is /// representing the size and location on screen. size: Size = .{}, - /// Anchor + /// `Size` of the `Container` content that is scrollable and mapped to + /// the *size* of the `Scrollable` `Element`. + container_size: Size = .{}, + /// Anchor of the viewport of the scrollable `Container`. anchor: Position = .{}, - /// The actual container, that is *scrollable* + /// The actual container, that is scrollable. container: Container(Event), /// Enable horizontal scrolling. This also renders a scrollbar (along the bottom of the viewport). horizontal: bool = false, @@ -104,24 +109,42 @@ pub fn Scrollable(Event: type) type { fn handle(ctx: *anyopaque, event: Event) !void { const this: *@This() = @ptrCast(@alignCast(ctx)); switch (event) { - .init => try this.container.handle(event), // TODO: emit `.resize` event for the container to set the size for the scrollable `Container` // - how would I determine the required or necessary `Size`? .resize => |size| { this.size = size; // TODO: not just pass through the given size, but rather the size that is necessary for scrollable content - try this.container.handle(.{ .resize = size }); + const min_size = this.container.minSize(); + this.container_size = .{ + .anchor = size.anchor, + .cols = @max(min_size.cols, size.cols), + .rows = @max(min_size.rows, size.rows), + }; + try this.container.handle(.{ .resize = this.container_size }); }, - .mouse => |mouse| { - std.log.debug("mouse event detected in scrollable element {any}", .{mouse.in(this.size)}); - try this.container.handle(.{ + .mouse => |mouse| switch (mouse.button) { + Mouse.Button.wheel_up => if (this.vertical) { + this.anchor.row -|= 1; + }, + Mouse.Button.wheel_down => if (this.vertical) { + const max_anchor_row = this.container_size.rows -| this.size.rows; + this.anchor.row = @min(this.anchor.row + 1, max_anchor_row); + }, + Mouse.Button.wheel_left => if (this.horizontal) { + this.anchor.col -|= 1; + }, + Mouse.Button.wheel_right => if (this.horizontal) { + const max_anchor_col = this.container_size.cols -| this.size.cols; + this.anchor.col = @min(this.anchor.col + 1, max_anchor_col); + }, + else => try this.container.handle(.{ .mouse = .{ .col = mouse.col + this.anchor.col, .row = mouse.row + this.anchor.row, .button = mouse.button, .kind = mouse.kind, }, - }); + }), }, else => try this.container.handle(event), } @@ -131,39 +154,41 @@ pub fn Scrollable(Event: type) type { const this: *@This() = @ptrCast(@alignCast(ctx)); std.debug.assert(cells.len == @as(usize, this.size.cols) * @as(usize, this.size.rows)); - const container_cells = try this.container.allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows)); - @memset(cells, .{}); + const container_size = this.container.size; + const container_cells = try this.container.allocator.alloc(Cell, @as(usize, container_size.cols) * @as(usize, container_size.rows)); { const container_cells_const = try this.container.contents(); defer this.container.allocator.free(container_cells_const); - const container_size = this.container.size; std.debug.assert(container_cells_const.len == @as(usize, container_size.cols) * @as(usize, container_size.rows)); @memcpy(container_cells, container_cells_const); } + // TODO: render correct view port into the container_cells for (this.container.elements.items) |*e| { const e_size = e.size; const element_cells = try e.contents(); defer e.allocator.free(element_cells); - const anchor = (@as(usize, e_size.anchor.row -| size.anchor.row) * @as(usize, size.cols)) + @as(usize, e_size.anchor.col -| size.anchor.col); + const anchor = (@as(usize, e_size.anchor.row -| container_size.anchor.row) * @as(usize, container_size.cols)) + @as(usize, e_size.anchor.col -| container_size.anchor.col); var idx: usize = 0; - blk: for (0..e_size.rows) |row| { - for (0..e_size.cols) |col| { + blk: for (0..container_size.rows) |row| { + for (0..container_size.cols) |col| { const cell = element_cells[idx]; idx += 1; - container_cells[anchor + (row * size.cols) + col].style = cell.style; - container_cells[anchor + (row * size.cols) + col].cp = cell.cp; + container_cells[anchor + (row * container_size.cols) + col].style = cell.style; + container_cells[anchor + (row * container_size.cols) + col].cp = cell.cp; if (element_cells.len == idx) break :blk; } } } - const anchor = (@as(usize, this.anchor.row) * @as(usize, size.cols)) + @as(usize, this.anchor.col); - for (container_cells, 0..) |cell, idx| { - cells[anchor + idx] = cell; + const anchor = (@as(usize, this.anchor.row) * @as(usize, container_size.cols)) + @as(usize, this.anchor.col); + for (0..size.rows) |row| { + for (0..size.cols) |col| { + cells[(row * size.cols) + col] = container_cells[anchor + (row * container_size.cols) + col]; + } } this.container.allocator.free(container_cells); } diff --git a/src/input.zig b/src/input.zig index b7ba246..b80eb0d 100644 --- a/src/input.zig +++ b/src/input.zig @@ -60,17 +60,34 @@ pub const Key = packed struct { /// ```zig /// switch (event) { /// .quit => break, - /// .key => |key| { - /// // ctrl+c to quit - /// if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) - /// app.quit.set(); - /// }, + /// .key => |key| if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) app.quit.set(), /// else => {}, /// } /// ``` pub fn eql(this: @This(), other: @This()) bool { return std.meta.eql(this, other); } + + /// Determine if the `Key` is an ascii character that can be printed to + /// the screen. This means that the code point of the `Key` is an ascii + /// character between 32 - 255 (with the exception of 127 = Delete) and no + /// modifiers (alt and/or ctrl) are used. + /// + /// # Example + /// + /// Get user input's from the .key event from the application event loop: + /// + /// ```zig + /// switch (event) { + /// .key => |key| if (key.isAscii()) try this.input.append(key.cp), + /// else => {}, + /// } + /// ``` + pub fn isAscii(this: @This()) bool { + return this.mod.alt == false and this.mod.ctrl == false and // no modifier keys + (this.cp >= 32 and this.cp <= 126 or // ascii printable characters (except for input.Delete) + this.cp >= 128 and this.cp <= 255); // extended ascii codes + } }; // codepoints for keys diff --git a/src/main.zig b/src/main.zig index f64b35a..005901a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -42,15 +42,13 @@ pub const HelloWorldText = packed struct { const this: *@This() = @ptrCast(@alignCast(ctx)); switch (event) { .init => log.debug(".init event", .{}), - .key => |key| { - if (key.eql(.{ .cp = input.Space })) { - var next_color_idx = @intFromEnum(this.text_color); - next_color_idx += 1; - next_color_idx %= 17; // iterate over the first 16 colors (but exclude `.default` == 0) - if (next_color_idx == 0) next_color_idx += 1; - this.text_color = @enumFromInt(next_color_idx); - log.debug("Next color: {s}", .{@tagName(this.text_color)}); - } + .key => |key| if (key.eql(.{ .cp = input.Space })) { + var next_color_idx = @intFromEnum(this.text_color); + next_color_idx += 1; + next_color_idx %= 17; + if (next_color_idx == @intFromEnum(zterm.Color.default)) next_color_idx += 1; + this.text_color = @enumFromInt(next_color_idx); + log.debug("Next color: {s}", .{@tagName(this.text_color)}); }, else => {}, } @@ -84,6 +82,7 @@ pub fn main() !void { .direction = .vertical, .padding = .vertical(1), }, + .min_size = .{ .rows = 100 }, }, .{}); try box.append(try App.Container.init(allocator, .{ .rectangle = .{ .fill = .light_green }, diff --git a/src/size.zig b/src/size.zig index 0ada6dc..2147e29 100644 --- a/src/size.zig +++ b/src/size.zig @@ -2,6 +2,13 @@ pub const Size = packed struct { anchor: Position = .{}, cols: u16 = 0, rows: u16 = 0, + + pub fn merge(this: @This(), other: @This()) Size { + return .{ + .cols = this.cols + other.cols, + .rows = this.rows + other.rows, + }; + } }; pub const Position = packed struct {