Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 40s
This also means that currently the dynamic resizing through the app's detached thread is not working, as it cannot send size updates. The examples have been overhauled to still implement intermediate mode applications accordingly.
382 lines
19 KiB
Zig
382 lines
19 KiB
Zig
//! Application type for TUI-applications
|
|
const std = @import("std");
|
|
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;
|
|
|
|
const Mouse = input.Mouse;
|
|
const Key = input.Key;
|
|
const Point = @import("point.zig").Point;
|
|
|
|
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.
|
|
///
|
|
/// # 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) {},
|
|
/// );
|
|
/// // later on create an `App` instance and start the event loop
|
|
/// var app: App = .init;
|
|
/// try app.start();
|
|
/// defer app.stop() catch unreachable;
|
|
/// ```
|
|
pub fn App(comptime E: type) type {
|
|
if (!isTaggedUnion(E)) {
|
|
@compileError("Provided user event `E` for `App(comptime E: type)` is not of type `union(enum)`.");
|
|
}
|
|
return struct {
|
|
pub const Event = mergeTaggedUnions(event.SystemEvent, E);
|
|
pub const Container = @import("container.zig").Container(Event);
|
|
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,
|
|
thread: ?std.Thread,
|
|
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 const init: @This() = .{
|
|
.queue = .{},
|
|
.thread = null,
|
|
.quit_event = .{},
|
|
.termios = null,
|
|
.attached_handler = false,
|
|
};
|
|
|
|
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);
|
|
|
|
try registerWinch(.{
|
|
.context = this,
|
|
.callback = @This().winsizeCallback,
|
|
});
|
|
this.attached_handler = true;
|
|
|
|
// post init event (as the very first element to be in the queue - event loop)
|
|
this.postEvent(.init);
|
|
}
|
|
|
|
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;
|
|
|
|
try terminal.saveScreen();
|
|
try terminal.enterAltScreen();
|
|
try terminal.hideCursor();
|
|
try terminal.enableMouseSupport();
|
|
}
|
|
|
|
pub fn interrupt(this: *@This()) !void {
|
|
this.quit_event.set();
|
|
try terminal.disableMouseSupport();
|
|
try terminal.exitAltScreen();
|
|
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.disableMouseSupport();
|
|
try terminal.showCursor();
|
|
try terminal.exitAltScreen();
|
|
try terminal.disableRawMode(termios);
|
|
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;
|
|
// this.postEvent(.{ .size = terminal.getTerminalSize() });
|
|
}
|
|
|
|
var winch_handler: ?SignalHandler = null;
|
|
|
|
fn registerWinch(handler: SignalHandler) !void {
|
|
if (winch_handler) |_| {
|
|
@panic("Cannot register another WINCH handler.");
|
|
}
|
|
winch_handler = handler;
|
|
}
|
|
|
|
fn handleWinch(_: c_int) callconv(.C) void {
|
|
if (winch_handler) |handler| {
|
|
handler.callback(handler.context);
|
|
}
|
|
}
|
|
|
|
fn run(this: *@This()) !void {
|
|
// thread to read user inputs
|
|
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 {
|
|
// 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
|
|
if (buf[0] == 0x1b and read_bytes > 1) {
|
|
switch (buf[1]) {
|
|
0x4F => { // ss3
|
|
if (read_bytes < 3) continue;
|
|
|
|
const key: Key = switch (buf[2]) {
|
|
'A' => .{ .cp = input.Up },
|
|
'B' => .{ .cp = input.Down },
|
|
'C' => .{ .cp = input.Right },
|
|
'D' => .{ .cp = input.Left },
|
|
'E' => .{ .cp = input.KpBegin },
|
|
'F' => .{ .cp = input.End },
|
|
'H' => .{ .cp = input.Home },
|
|
'P' => .{ .cp = input.F1 },
|
|
'Q' => .{ .cp = input.F2 },
|
|
'R' => .{ .cp = input.F3 },
|
|
'S' => .{ .cp = input.F4 },
|
|
else => continue,
|
|
};
|
|
this.postEvent(.{ .key = key });
|
|
},
|
|
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' => input.Up,
|
|
'B' => input.Down,
|
|
'C' => input.Right,
|
|
'D' => input.Left,
|
|
'E' => input.KpBegin,
|
|
'F' => input.End,
|
|
'H' => input.Home,
|
|
'P' => input.F1,
|
|
'Q' => input.F2,
|
|
'R' => input.F3,
|
|
'S' => input.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 => input.Insert,
|
|
3 => input.Delete,
|
|
5 => input.PageUp,
|
|
6 => input.PageDown,
|
|
7 => input.Home,
|
|
8 => input.End,
|
|
11 => input.F1,
|
|
12 => input.F2,
|
|
13 => input.F3,
|
|
14 => input.F4,
|
|
15 => input.F5,
|
|
17 => input.F6,
|
|
18 => input.F7,
|
|
19 => input.F8,
|
|
20 => input.F9,
|
|
21 => input.F10,
|
|
23 => input.F11,
|
|
24 => input.F12,
|
|
// 200 => return .{ .event = .paste_start, .n = sequence.len },
|
|
// 201 => return .{ .event = .paste_end, .n = sequence.len },
|
|
57427 => input.KpBegin,
|
|
else => unreachable,
|
|
},
|
|
};
|
|
this.postEvent(.{ .key = key });
|
|
},
|
|
// TODO focus usage? should this even be in the default event system?
|
|
'I' => this.postEvent(.{ .focus = true }),
|
|
'O' => this.postEvent(.{ .focus = false }),
|
|
'M', 'm' => {
|
|
std.debug.assert(sequence.len >= 4);
|
|
if (sequence[2] != '<') break;
|
|
|
|
const delim1 = std.mem.indexOfScalarPos(u8, sequence, 3, ';') orelse break;
|
|
const button_mask = std.fmt.parseUnsigned(u16, sequence[3..delim1], 10) catch break;
|
|
const delim2 = std.mem.indexOfScalarPos(u8, sequence, delim1 + 1, ';') orelse break;
|
|
const px = std.fmt.parseUnsigned(u16, sequence[delim1 + 1 .. delim2], 10) catch break;
|
|
const py = std.fmt.parseUnsigned(u16, sequence[delim2 + 1 .. sequence.len - 1], 10) catch break;
|
|
|
|
const mouse_bits = packed struct {
|
|
const motion: u8 = 0b00100000;
|
|
const buttons: u8 = 0b11000011;
|
|
const shift: u8 = 0b00000100;
|
|
const alt: u8 = 0b00001000;
|
|
const ctrl: u8 = 0b00010000;
|
|
};
|
|
|
|
const button: Mouse.Button = @enumFromInt(button_mask & mouse_bits.buttons);
|
|
const motion = button_mask & mouse_bits.motion > 0;
|
|
// const shift = button_mask & mouse_bits.shift > 0;
|
|
// const alt = button_mask & mouse_bits.alt > 0;
|
|
// const ctrl = button_mask & mouse_bits.ctrl > 0;
|
|
|
|
const mouse: Mouse = .{
|
|
.button = button,
|
|
.x = px -| 1,
|
|
.y = py -| 1,
|
|
.kind = blk: {
|
|
if (motion and button != Mouse.Button.none) {
|
|
break :blk .drag;
|
|
}
|
|
if (motion and button == Mouse.Button.none) {
|
|
break :blk .motion;
|
|
}
|
|
if (sequence[sequence.len - 1] == 'm') break :blk .release;
|
|
break :blk .press;
|
|
},
|
|
};
|
|
this.postEvent(.{ .mouse = mouse });
|
|
},
|
|
'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 width_char = iter.next() orelse break;
|
|
const height_char = iter.next() orelse break;
|
|
|
|
_ = width_char;
|
|
_ = height_char;
|
|
// this.postEvent(.{ .size = .{
|
|
// .x = std.fmt.parseUnsigned(u16, width_char, 10) catch break,
|
|
// .y = std.fmt.parseUnsigned(u16, height_char, 10) catch break,
|
|
// } });
|
|
}
|
|
},
|
|
'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 => {},
|
|
}
|
|
} else {
|
|
const b = buf[0];
|
|
const key: Key = switch (b) {
|
|
0x00 => .{ .cp = '@', .mod = .{ .ctrl = true } },
|
|
0x08 => .{ .cp = input.Backspace },
|
|
0x09 => .{ .cp = input.Tab },
|
|
0x0a, 0x0d => .{ .cp = input.Enter },
|
|
0x01...0x07, 0x0b...0x0c, 0x0e...0x1a => .{ .cp = b + 0x60, .mod = .{ .ctrl = true } },
|
|
0x1b => escape: {
|
|
std.debug.assert(read_bytes == 1);
|
|
break :escape .{ .cp = input.Escape };
|
|
},
|
|
0x7f => .{ .cp = input.Backspace },
|
|
else => {
|
|
var iter = code_point.Iterator{ .bytes = buf[0..read_bytes] };
|
|
while (iter.next()) |cp| this.postEvent(.{ .key = .{ .cp = cp.code } });
|
|
continue;
|
|
},
|
|
};
|
|
this.postEvent(.{ .key = key });
|
|
}
|
|
continue;
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
}
|