feat(event): add focus in/out event to SystemEvents
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 3m54s

feat(app): add minimal size argument for App.start

Read more corresponding inputs from stdin and convert them correctly
(i.e. in band window resizing), further keys (arrow keys, F-keys, etc.).
Respect the provided minimal size for the application which posts an
error message in case the size is smaller than the requested minimal
size.
This commit is contained in:
2024-12-25 12:53:39 +01:00
parent f7cd61d619
commit 3a989321fc
11 changed files with 214 additions and 18 deletions

View File

@@ -82,7 +82,7 @@ pub fn main() !void {
}); });
defer layout.deinit(); defer layout.deinit();
try app.start(); try app.start(null);
defer app.stop() catch unreachable; defer app.stop() catch unreachable;
// App.Event loop // App.Event loop
@@ -104,6 +104,7 @@ pub fn main() !void {
.err => |err| { .err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg }); log.err("Received {any} with message: {s}", .{ err.err, err.msg });
}, },
else => {},
} }
const events = try layout.handle(event); const events = try layout.handle(event);
for (events.items) |e| { for (events.items) |e| {

View File

@@ -68,7 +68,12 @@ pub fn main() !void {
}); });
defer layout.deinit(); 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; defer app.stop() catch unreachable;
// App.Event loop // App.Event loop
@@ -88,7 +93,7 @@ pub fn main() !void {
} }
if (Key.matches(key, .{ .cp = 'n', .mod = .{ .ctrl = true } })) { if (Key.matches(key, .{ .cp = 'n', .mod = .{ .ctrl = true } })) {
try app.interrupt(); 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 // TODO: parse environment variables to extract the value of $EDITOR and use it here instead
var child = std.process.Child.init(&.{"hx"}, allocator); var child = std.process.Child.init(&.{"hx"}, allocator);
_ = child.spawnAndWait() catch |err| { _ = child.spawnAndWait() catch |err| {
@@ -104,6 +109,7 @@ pub fn main() !void {
.err => |err| { .err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg }); log.err("Received {any} with message: {s}", .{ err.err, err.msg });
}, },
else => {},
} }
const events = try layout.handle(event); const events = try layout.handle(event);
for (events.items) |e| { for (events.items) |e| {

View File

@@ -80,7 +80,7 @@ pub fn main() !void {
}); });
defer layout.deinit(); defer layout.deinit();
try app.start(); try app.start(null);
defer app.stop() catch unreachable; defer app.stop() catch unreachable;
// App.Event loop // App.Event loop
@@ -102,6 +102,7 @@ pub fn main() !void {
.err => |err| { .err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg }); log.err("Received {any} with message: {s}", .{ err.err, err.msg });
}, },
else => {},
} }
const events = try layout.handle(event); const events = try layout.handle(event);
for (events.items) |e| { for (events.items) |e| {

View File

@@ -123,7 +123,7 @@ pub fn main() !void {
}); });
defer layout.deinit(); defer layout.deinit();
try app.start(); try app.start(null);
defer app.stop() catch unreachable; defer app.stop() catch unreachable;
// App.Event loop // App.Event loop
@@ -145,6 +145,7 @@ pub fn main() !void {
.err => |err| { .err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg }); log.err("Received {any} with message: {s}", .{ err.err, err.msg });
}, },
else => {},
} }
const events = try layout.handle(event); const events = try layout.handle(event);
for (events.items) |e| { for (events.items) |e| {

View File

@@ -89,7 +89,7 @@ pub fn main() !void {
}); });
defer layout.deinit(); defer layout.deinit();
try app.start(); try app.start(null);
defer app.stop() catch unreachable; defer app.stop() catch unreachable;
// App.Event loop // App.Event loop
@@ -111,6 +111,7 @@ pub fn main() !void {
.err => |err| { .err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg }); log.err("Received {any} with message: {s}", .{ err.err, err.msg });
}, },
else => {},
} }
const events = try layout.handle(event); const events = try layout.handle(event);
for (events.items) |e| { for (events.items) |e| {

View File

@@ -94,7 +94,7 @@ pub fn main() !void {
}); });
defer layout.deinit(); defer layout.deinit();
try app.start(); try app.start(null);
defer app.stop() catch unreachable; defer app.stop() catch unreachable;
// App.Event loop // App.Event loop
@@ -115,6 +115,7 @@ pub fn main() !void {
.err => |err| { .err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg }); log.err("Received {any} with message: {s}", .{ err.err, err.msg });
}, },
else => {},
} }
const events = try layout.handle(event); const events = try layout.handle(event);

View File

@@ -54,13 +54,18 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
quit_event: std.Thread.ResetEvent = .{}, quit_event: std.Thread.ResetEvent = .{},
termios: ?std.posix.termios = null, termios: ?std.posix.termios = null,
attached_handler: bool = false, attached_handler: bool = false,
min_size: ?terminal.Size = null,
prev_size: terminal.Size = .{ .cols = 0, .rows = 0 },
pub const SignalHandler = struct { pub const SignalHandler = struct {
context: *anyopaque, context: *anyopaque,
callback: *const fn (context: *anyopaque) void, 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.thread) |_| return;
if (!this.attached_handler) { 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 { fn winsizeCallback(ptr: *anyopaque) void {
const this: *@This() = @ptrCast(@alignCast(ptr)); 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; 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 // send initial terminal size
// changes are handled by the winch signal handler // changes are handled by the winch signal handler
// see `App.start` and `App.registerWinch` for details // 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 // thread to read user inputs
var buf: [256]u8 = undefined; 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 // escape key presses
if (buf[0] == 0x1b and read_bytes > 1) { if (buf[0] == 0x1b and read_bytes > 1) {
switch (buf[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 // TODO: parse corresponding codes
// 0x5B => parseCsi(input, &self.buf), // CSI see https://github.com/rockorager/libvaxis/blob/main/src/Parser.zig // 0x5B => parseCsi(input, &self.buf), // CSI see https://github.com/rockorager/libvaxis/blob/main/src/Parser.zig
else => {}, else => {},

View File

@@ -17,6 +17,7 @@ pub const SystemEvent = union(enum) {
err: Error, err: Error,
resize: Size, resize: Size,
key: Key, key: Key,
focus: bool,
}; };
pub fn mergeTaggedUnions(comptime A: type, comptime B: type) type { pub fn mergeTaggedUnions(comptime A: type, comptime B: type) type {

View File

@@ -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 // import and export of `Widget` implementations
pub const Text = @import("widget/Text.zig").Widget(Event, Renderer); pub const Text = @import("widget/Text.zig").Widget(Event, Renderer);
pub const RawText = @import("widget/RawText.zig").Widget(Event, Renderer); pub const RawText = @import("widget/RawText.zig").Widget(Event, Renderer);

View File

@@ -62,28 +62,28 @@ pub fn Widget(comptime Event: type, comptime Renderer: type) type {
}, },
.key => |key| { .key => |key| {
var require_render = true; var require_render = true;
if (key.matches(.{ .cp = 'g' })) { if (key.matches(.{ .cp = 'g' }) or key.matches(.{ .cp = terminal.Key.home })) {
// top // top
if (this.idx != 0) { if (this.idx != 0) {
this.idx = 0; this.idx = 0;
} else { } else {
require_render = false; require_render = false;
} }
} else if (key.matches(.{ .cp = 'G' })) { } else if (key.matches(.{ .cp = 'G' }) or key.matches(.{ .cp = terminal.Key.end })) {
// bottom // bottom
if (this.idx < this.contents.items.len -| 1) { if (this.idx < this.contents.items.len -| 1) {
this.idx = this.contents.items.len -| 1; this.idx = this.contents.items.len -| 1;
} else { } else {
require_render = false; require_render = false;
} }
} else if (key.matches(.{ .cp = 'j' })) { } else if (key.matches(.{ .cp = 'j' }) or key.matches(.{ .cp = terminal.Key.down })) {
// down // down
if (this.idx < this.contents.items.len -| 1) { if (this.idx < this.contents.items.len -| 1) {
this.idx += 1; this.idx += 1;
} else { } else {
require_render = false; require_render = false;
} }
} else if (key.matches(.{ .cp = 'k' })) { } else if (key.matches(.{ .cp = 'k' }) or key.matches(.{ .cp = terminal.Key.up })) {
// up // up
if (this.idx > 0) { if (this.idx > 0) {
this.idx -= 1; this.idx -= 1;

View File

@@ -53,28 +53,28 @@ pub fn Widget(comptime Event: type, comptime Renderer: type) type {
} }
}, },
.key => |key| { .key => |key| {
if (key.matches(.{ .cp = 'g' })) { if (key.matches(.{ .cp = 'g' }) or key.matches(.{ .cp = Key.home })) {
// top // top
if (this.line != 0) { if (this.line != 0) {
this.line = 0; this.line = 0;
} else { } else {
require_render = false; require_render = false;
} }
} else if (key.matches(.{ .cp = 'G' })) { } else if (key.matches(.{ .cp = 'G' }) or key.matches(.{ .cp = Key.end })) {
// bottom // bottom
if (this.line < this.line_index.items.len -| 1 -| this.size.rows) { if (this.line < this.line_index.items.len -| 1 -| this.size.rows) {
this.line = this.line_index.items.len -| 1 -| this.size.rows; this.line = this.line_index.items.len -| 1 -| this.size.rows;
} else { } else {
require_render = false; require_render = false;
} }
} else if (key.matches(.{ .cp = 'j' })) { } else if (key.matches(.{ .cp = 'j' }) or key.matches(.{ .cp = Key.down })) {
// down // down
if (this.line < this.line_index.items.len -| 1 -| this.size.rows) { if (this.line < this.line_index.items.len -| 1 -| this.size.rows) {
this.line += 1; this.line += 1;
} else { } else {
require_render = false; require_render = false;
} }
} else if (key.matches(.{ .cp = 'k' })) { } else if (key.matches(.{ .cp = 'k' }) or key.matches(.{ .cp = Key.up })) {
// up // up
if (this.line > 0) { if (this.line > 0) {
this.line -= 1; this.line -= 1;