diff --git a/examples/container.zig b/examples/container.zig index 2b4f1e7..2e5a47c 100644 --- a/examples/container.zig +++ b/examples/container.zig @@ -82,7 +82,7 @@ pub fn main() !void { }); defer layout.deinit(); - try app.start(); + try app.start(null); defer app.stop() catch unreachable; // App.Event loop @@ -104,6 +104,7 @@ pub fn main() !void { .err => |err| { log.err("Received {any} with message: {s}", .{ err.err, err.msg }); }, + else => {}, } const events = try layout.handle(event); for (events.items) |e| { diff --git a/examples/exec.zig b/examples/exec.zig index 669230c..3fb1c2c 100644 --- a/examples/exec.zig +++ b/examples/exec.zig @@ -68,7 +68,12 @@ pub fn main() !void { }); defer layout.deinit(); - try app.start(); + const min_size: zterm.Size = .{ + .cols = 25, + .rows = 20, + }; + + try app.start(min_size); defer app.stop() catch unreachable; // App.Event loop @@ -88,7 +93,7 @@ pub fn main() !void { } if (Key.matches(key, .{ .cp = 'n', .mod = .{ .ctrl = true } })) { try app.interrupt(); - defer app.start() catch @panic("could not start app event loop"); + defer app.start(min_size) catch @panic("could not start app event loop"); // TODO: parse environment variables to extract the value of $EDITOR and use it here instead var child = std.process.Child.init(&.{"hx"}, allocator); _ = child.spawnAndWait() catch |err| { @@ -104,6 +109,7 @@ pub fn main() !void { .err => |err| { log.err("Received {any} with message: {s}", .{ err.err, err.msg }); }, + else => {}, } const events = try layout.handle(event); for (events.items) |e| { diff --git a/examples/padding.zig b/examples/padding.zig index 2f260f9..859ce15 100644 --- a/examples/padding.zig +++ b/examples/padding.zig @@ -80,7 +80,7 @@ pub fn main() !void { }); defer layout.deinit(); - try app.start(); + try app.start(null); defer app.stop() catch unreachable; // App.Event loop @@ -102,6 +102,7 @@ pub fn main() !void { .err => |err| { log.err("Received {any} with message: {s}", .{ err.err, err.msg }); }, + else => {}, } const events = try layout.handle(event); for (events.items) |e| { diff --git a/examples/stack.zig b/examples/stack.zig index 67f4390..9e0d395 100644 --- a/examples/stack.zig +++ b/examples/stack.zig @@ -123,7 +123,7 @@ pub fn main() !void { }); defer layout.deinit(); - try app.start(); + try app.start(null); defer app.stop() catch unreachable; // App.Event loop @@ -145,6 +145,7 @@ pub fn main() !void { .err => |err| { log.err("Received {any} with message: {s}", .{ err.err, err.msg }); }, + else => {}, } const events = try layout.handle(event); for (events.items) |e| { diff --git a/examples/tabs.zig b/examples/tabs.zig index 4c0cee3..173bfb0 100644 --- a/examples/tabs.zig +++ b/examples/tabs.zig @@ -89,7 +89,7 @@ pub fn main() !void { }); defer layout.deinit(); - try app.start(); + try app.start(null); defer app.stop() catch unreachable; // App.Event loop @@ -111,6 +111,7 @@ pub fn main() !void { .err => |err| { log.err("Received {any} with message: {s}", .{ err.err, err.msg }); }, + else => {}, } const events = try layout.handle(event); for (events.items) |e| { diff --git a/examples/tui.zig b/examples/tui.zig index 62b8e48..c1c38bd 100644 --- a/examples/tui.zig +++ b/examples/tui.zig @@ -94,7 +94,7 @@ pub fn main() !void { }); defer layout.deinit(); - try app.start(); + try app.start(null); defer app.stop() catch unreachable; // App.Event loop @@ -115,6 +115,7 @@ pub fn main() !void { .err => |err| { log.err("Received {any} with message: {s}", .{ err.err, err.msg }); }, + else => {}, } const events = try layout.handle(event); diff --git a/src/app.zig b/src/app.zig index eb5441a..98f47b7 100644 --- a/src/app.zig +++ b/src/app.zig @@ -54,13 +54,18 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls quit_event: std.Thread.ResetEvent = .{}, termios: ?std.posix.termios = null, attached_handler: bool = false, + min_size: ?terminal.Size = null, + prev_size: terminal.Size = .{ .cols = 0, .rows = 0 }, pub const SignalHandler = struct { context: *anyopaque, callback: *const fn (context: *anyopaque) void, }; - pub fn start(this: *@This()) !void { + pub fn start(this: *@This(), min_size: ?terminal.Size) !void { + if (fullscreen) { // a minimal size only really makes sense if the application is rendered fullscreen + this.min_size = min_size; + } if (this.thread) |_| return; if (!this.attached_handler) { @@ -137,7 +142,20 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls fn winsizeCallback(ptr: *anyopaque) void { const this: *@This() = @ptrCast(@alignCast(ptr)); - this.postEvent(.{ .resize = terminal.getTerminalSize() }); + const size = terminal.getTerminalSize(); + // check for minimal size (if any was provided) + if (this.min_size) |min_size| { + if (size.cols < min_size.cols or size.rows < min_size.rows) { + this.postEvent(.{ + .err = .{ .err = error.InsufficientSize, .msg = "Terminal size is too small for the requested minimal size" }, + }); + return; + } + } + if (size.cols != this.prev_size.cols or size.rows != this.prev_size.rows) { + this.postEvent(.{ .resize = size }); + this.prev_size = size; + } } var winch_handler: ?SignalHandler = null; @@ -159,7 +177,19 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls // send initial terminal size // changes are handled by the winch signal handler // see `App.start` and `App.registerWinch` for details - this.postEvent(.{ .resize = terminal.getTerminalSize() }); + { + // TODO: what should happen if the initial window size is too small? + // -> currently the first render call will then crash the application (which happens anyway) + const size = terminal.getTerminalSize(); + if (this.min_size) |min_size| { + if (size.cols < min_size.cols or size.rows < min_size.rows) { + this.postEvent(.{ + .err = .{ .err = error.InsufficientSize, .msg = "Terminal size is too small for the requested minimal size" }, + }); + } + } + this.postEvent(.{ .resize = size }); + } // thread to read user inputs var buf: [256]u8 = undefined; @@ -170,6 +200,159 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls // escape key presses if (buf[0] == 0x1b and read_bytes > 1) { switch (buf[1]) { + 0x4F => { // ss3 + if (read_bytes < 3) { + continue; + } + const key: ?Key = switch (buf[2]) { + 0x1B => null, + 'A' => .{ .cp = Key.up }, + 'B' => .{ .cp = Key.down }, + 'C' => .{ .cp = Key.right }, + 'D' => .{ .cp = Key.left }, + 'E' => .{ .cp = Key.kp_begin }, + 'F' => .{ .cp = Key.end }, + 'H' => .{ .cp = Key.home }, + 'P' => .{ .cp = Key.f1 }, + 'Q' => .{ .cp = Key.f2 }, + 'R' => .{ .cp = Key.f3 }, + 'S' => .{ .cp = Key.f4 }, + else => null, + }; + if (key) |k| { + this.postEvent(.{ .key = k }); + } + }, + 0x5B => { // csi + if (read_bytes < 3) { + continue; + } + // We start iterating at index 2 to get past the '[' + const sequence = for (buf[2..], 2..) |b, i| { + switch (b) { + 0x40...0xFF => break buf[0 .. i + 1], + else => continue, + } + } else continue; + + const final = sequence[sequence.len - 1]; + switch (final) { + 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'R', 'S' => { + // Legacy keys + // CSI {ABCDEFHPQS} + // CSI 1 ; modifier:event_type {ABCDEFHPQS} + const key: Key = .{ + .cp = switch (final) { + 'A' => Key.up, + 'B' => Key.down, + 'C' => Key.right, + 'D' => Key.left, + 'E' => Key.kp_begin, + 'F' => Key.end, + 'H' => Key.home, + 'P' => Key.f1, + 'Q' => Key.f2, + 'R' => Key.f3, + 'S' => Key.f4, + else => unreachable, // switch case prevents in this case form ever happening + }, + }; + this.postEvent(.{ .key = key }); + }, + '~' => { + // Legacy keys + // CSI number ~ + // CSI number ; modifier ~ + // CSI number ; modifier:event_type ; text_as_codepoint ~ + var field_iter = std.mem.splitScalar(u8, sequence[2 .. sequence.len - 1], ';'); + const number_buf = field_iter.next() orelse unreachable; // always will have one field + const number = std.fmt.parseUnsigned(u16, number_buf, 10) catch break; + + const key: Key = .{ + .cp = switch (number) { + 2 => Key.insert, + 3 => Key.delete, + 5 => Key.page_up, + 6 => Key.page_down, + 7 => Key.home, + 8 => Key.end, + 11 => Key.f1, + 12 => Key.f2, + 13 => Key.f3, + 14 => Key.f4, + 15 => Key.f5, + 17 => Key.f6, + 18 => Key.f7, + 19 => Key.f8, + 20 => Key.f9, + 21 => Key.f10, + 23 => Key.f11, + 24 => Key.f12, + // 200 => return .{ .event = .paste_start, .n = sequence.len }, + // 201 => return .{ .event = .paste_end, .n = sequence.len }, + 57427 => Key.kp_begin, + else => unreachable, + }, + }; + this.postEvent(.{ .key = key }); + }, + + 'I' => this.postEvent(.{ .focus = true }), + 'O' => this.postEvent(.{ .focus = false }), + // 'M', 'm' => return parseMouse(sequence), // TODO: parse mouse inputs + 'c' => { + // Primary DA (CSI ? Pm c) + }, + 'n' => { + // Device Status Report + // CSI Ps n + // CSI ? Ps n + std.debug.assert(sequence.len >= 3); + }, + 't' => { + // XTWINOPS + // Split first into fields delimited by ';' + var iter = std.mem.splitScalar(u8, sequence[2 .. sequence.len - 1], ';'); + const ps = iter.first(); + if (std.mem.eql(u8, "48", ps)) { + // in band window resize + // CSI 48 ; height ; width ; height_pix ; width_pix t + const height_char = iter.next() orelse break; + const width_char = iter.next() orelse break; + + // TODO: only post the event if the size has changed? + // because there might be too many resize events (which force a re-draw of the entire screen) + const size: terminal.Size = .{ + .rows = std.fmt.parseUnsigned(u16, height_char, 10) catch break, + .cols = std.fmt.parseUnsigned(u16, width_char, 10) catch break, + }; + // check for minimal size (if any was provided) + if (this.min_size) |min_size| { + if (size.cols < min_size.cols or size.rows < min_size.rows) { + this.postEvent(.{ + .err = .{ .err = error.InsufficientSize, .msg = "Terminal size is too small for the requested minimal size" }, + }); + break; + } + } + if (size.cols != this.prev_size.cols or size.rows != this.prev_size.rows) { + this.postEvent(.{ .resize = size }); + this.prev_size = size; + } + } + }, + 'u' => { + // Kitty keyboard + // CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u + // Not all fields will be present. Only unicode-key-code is + // mandatory + }, + 'y' => { + // DECRPM (CSI ? Ps ; Pm $ y) + }, + else => {}, + } + }, // TODO: parse corresponding codes // 0x5B => parseCsi(input, &self.buf), // CSI see https://github.com/rockorager/libvaxis/blob/main/src/Parser.zig else => {}, diff --git a/src/event.zig b/src/event.zig index 2686275..b8814da 100644 --- a/src/event.zig +++ b/src/event.zig @@ -17,6 +17,7 @@ pub const SystemEvent = union(enum) { err: Error, resize: Size, key: Key, + focus: bool, }; pub fn mergeTaggedUnions(comptime A: type, comptime B: type) type { diff --git a/src/widget.zig b/src/widget.zig index f748c11..7534220 100644 --- a/src/widget.zig +++ b/src/widget.zig @@ -89,6 +89,7 @@ pub fn Widget(comptime Event: type, comptime Renderer: type) type { }; } + // TODO: implement a minimal size requirement for Widgets to render correctly? // import and export of `Widget` implementations pub const Text = @import("widget/Text.zig").Widget(Event, Renderer); pub const RawText = @import("widget/RawText.zig").Widget(Event, Renderer); diff --git a/src/widget/List.zig b/src/widget/List.zig index 84388f9..cac7ff0 100644 --- a/src/widget/List.zig +++ b/src/widget/List.zig @@ -62,28 +62,28 @@ pub fn Widget(comptime Event: type, comptime Renderer: type) type { }, .key => |key| { var require_render = true; - if (key.matches(.{ .cp = 'g' })) { + if (key.matches(.{ .cp = 'g' }) or key.matches(.{ .cp = terminal.Key.home })) { // top if (this.idx != 0) { this.idx = 0; } else { require_render = false; } - } else if (key.matches(.{ .cp = 'G' })) { + } else if (key.matches(.{ .cp = 'G' }) or key.matches(.{ .cp = terminal.Key.end })) { // bottom if (this.idx < this.contents.items.len -| 1) { this.idx = this.contents.items.len -| 1; } else { require_render = false; } - } else if (key.matches(.{ .cp = 'j' })) { + } else if (key.matches(.{ .cp = 'j' }) or key.matches(.{ .cp = terminal.Key.down })) { // down if (this.idx < this.contents.items.len -| 1) { this.idx += 1; } else { require_render = false; } - } else if (key.matches(.{ .cp = 'k' })) { + } else if (key.matches(.{ .cp = 'k' }) or key.matches(.{ .cp = terminal.Key.up })) { // up if (this.idx > 0) { this.idx -= 1; diff --git a/src/widget/RawText.zig b/src/widget/RawText.zig index c51ca97..6d72e9f 100644 --- a/src/widget/RawText.zig +++ b/src/widget/RawText.zig @@ -53,28 +53,28 @@ pub fn Widget(comptime Event: type, comptime Renderer: type) type { } }, .key => |key| { - if (key.matches(.{ .cp = 'g' })) { + if (key.matches(.{ .cp = 'g' }) or key.matches(.{ .cp = Key.home })) { // top if (this.line != 0) { this.line = 0; } else { require_render = false; } - } else if (key.matches(.{ .cp = 'G' })) { + } else if (key.matches(.{ .cp = 'G' }) or key.matches(.{ .cp = Key.end })) { // bottom if (this.line < this.line_index.items.len -| 1 -| this.size.rows) { this.line = this.line_index.items.len -| 1 -| this.size.rows; } else { require_render = false; } - } else if (key.matches(.{ .cp = 'j' })) { + } else if (key.matches(.{ .cp = 'j' }) or key.matches(.{ .cp = Key.down })) { // down if (this.line < this.line_index.items.len -| 1 -| this.size.rows) { this.line += 1; } else { require_render = false; } - } else if (key.matches(.{ .cp = 'k' })) { + } else if (key.matches(.{ .cp = 'k' }) or key.matches(.{ .cp = Key.up })) { // up if (this.line > 0) { this.line -= 1;