Skip to content

Latest commit

 

History

History
506 lines (447 loc) · 18.3 KB

USAGE.md

File metadata and controls

506 lines (447 loc) · 18.3 KB

Usage

Custom Event Loops

Vaxis provides an abstract enough API to allow the usage of a custom event loop. An event loop implementation is responsible for three primary tasks:

  1. Read raw bytes from the TTY
  2. Pass bytes to the Vaxis input event parser
  3. Handle the returned events

Everything after this can be left up to user code, or brought into an event loop to be a more abstract application layer. One important part of handling the events is to update the Vaxis struct with discovered terminal capabilities. This let's Vaxis know what features it can use. For example, the Kitty Keyboard protocol, in-band-resize reports, and Unicode width measurements are just a few examples.

libxev

Below is an example libxev event loop. Note that this code is not necessarily up-to-date with the latest libxev release and is shown here merely as a proof of concept.

const std = @import("std");
const xev = @import("xev");

const Tty = @import("main.zig").Tty;
const Winsize = @import("main.zig").Winsize;
const Vaxis = @import("Vaxis.zig");
const Parser = @import("Parser.zig");
const Key = @import("Key.zig");
const Mouse = @import("Mouse.zig");
const Color = @import("Cell.zig").Color;

const log = std.log.scoped(.vaxis_xev);

pub const Event = union(enum) {
    key_press: Key,
    key_release: Key,
    mouse: Mouse,
    focus_in,
    focus_out,
    paste_start, // bracketed paste start
    paste_end, // bracketed paste end
    paste: []const u8, // osc 52 paste, caller must free
    color_report: Color.Report, // osc 4, 10, 11, 12 response
    color_scheme: Color.Scheme,
    winsize: Winsize,
};

pub fn TtyWatcher(comptime Userdata: type) type {
    return struct {
        const Self = @This();

        file: xev.File,
        tty: *Tty,

        read_buf: [4096]u8,
        read_buf_start: usize,
        read_cmp: xev.Completion,

        winsize_wakeup: xev.Async,
        winsize_cmp: xev.Completion,

        callback: *const fn (
            ud: ?*Userdata,
            loop: *xev.Loop,
            watcher: *Self,
            event: Event,
        ) xev.CallbackAction,

        ud: ?*Userdata,
        vx: *Vaxis,
        parser: Parser,

        pub fn init(
            self: *Self,
            tty: *Tty,
            vaxis: *Vaxis,
            loop: *xev.Loop,
            userdata: ?*Userdata,
            callback: *const fn (
                ud: ?*Userdata,
                loop: *xev.Loop,
                watcher: *Self,
                event: Event,
            ) xev.CallbackAction,
        ) !void {
            self.* = .{
                .tty = tty,
                .file = xev.File.initFd(tty.fd),
                .read_buf = undefined,
                .read_buf_start = 0,
                .read_cmp = .{},

                .winsize_wakeup = try xev.Async.init(),
                .winsize_cmp = .{},

                .callback = callback,
                .ud = userdata,
                .vx = vaxis,
                .parser = .{ .grapheme_data = &vaxis.unicode.width_data.g_data },
            };

            self.file.read(
                loop,
                &self.read_cmp,
                .{ .slice = &self.read_buf },
                Self,
                self,
                Self.ttyReadCallback,
            );
            self.winsize_wakeup.wait(
                loop,
                &self.winsize_cmp,
                Self,
                self,
                winsizeCallback,
            );
            const handler: Tty.SignalHandler = .{
                .context = self,
                .callback = Self.signalCallback,
            };
            try Tty.notifyWinsize(handler);
        }

        fn signalCallback(ptr: *anyopaque) void {
            const self: *Self = @ptrCast(@alignCast(ptr));
            self.winsize_wakeup.notify() catch |err| {
                log.warn("couldn't wake up winsize callback: {}", .{err});
            };
        }

        fn ttyReadCallback(
            ud: ?*Self,
            loop: *xev.Loop,
            c: *xev.Completion,
            _: xev.File,
            buf: xev.ReadBuffer,
            r: xev.ReadError!usize,
        ) xev.CallbackAction {
            const n = r catch |err| {
                log.err("read error: {}", .{err});
                return .disarm;
            };
            const self = ud orelse unreachable;

            // reset read start state
            self.read_buf_start = 0;

            var seq_start: usize = 0;
            parse_loop: while (seq_start < n) {
                const result = self.parser.parse(buf.slice[seq_start..n], null) catch |err| {
                    log.err("couldn't parse input: {}", .{err});
                    return .disarm;
                };
                if (result.n == 0) {
                    // copy the read to the beginning. We don't use memcpy because
                    // this could be overlapping, and it's also rare
                    const initial_start = seq_start;
                    while (seq_start < n) : (seq_start += 1) {
                        self.read_buf[seq_start - initial_start] = self.read_buf[seq_start];
                    }
                    self.read_buf_start = seq_start - initial_start + 1;
                    return .rearm;
                }
                seq_start += n;
                const event_inner = result.event orelse {
                    log.debug("unknown event: {s}", .{self.read_buf[seq_start - n + 1 .. seq_start]});
                    continue :parse_loop;
                };

                // Capture events we want to bubble up
                const event: ?Event = switch (event_inner) {
                    .key_press => |key| .{ .key_press = key },
                    .key_release => |key| .{ .key_release = key },
                    .mouse => |mouse| .{ .mouse = mouse },
                    .focus_in => .focus_in,
                    .focus_out => .focus_out,
                    .paste_start => .paste_start,
                    .paste_end => .paste_end,
                    .paste => |paste| .{ .paste = paste },
                    .color_report => |report| .{ .color_report = report },
                    .color_scheme => |scheme| .{ .color_scheme = scheme },
                    .winsize => |ws| .{ .winsize = ws },

                    // capability events which we handle below
                    .cap_kitty_keyboard,
                    .cap_kitty_graphics,
                    .cap_rgb,
                    .cap_unicode,
                    .cap_sgr_pixels,
                    .cap_color_scheme_updates,
                    .cap_da1,
                    => null, // handled below
                };

                if (event) |ev| {
                    const action = self.callback(self.ud, loop, self, ev);
                    switch (action) {
                        .disarm => return .disarm,
                        else => continue :parse_loop,
                    }
                }

                switch (event_inner) {
                    .key_press,
                    .key_release,
                    .mouse,
                    .focus_in,
                    .focus_out,
                    .paste_start,
                    .paste_end,
                    .paste,
                    .color_report,
                    .color_scheme,
                    .winsize,
                    => unreachable, // handled above

                    .cap_kitty_keyboard => {
                        log.info("kitty keyboard capability detected", .{});
                        self.vx.caps.kitty_keyboard = true;
                    },
                    .cap_kitty_graphics => {
                        if (!self.vx.caps.kitty_graphics) {
                            log.info("kitty graphics capability detected", .{});
                            self.vx.caps.kitty_graphics = true;
                        }
                    },
                    .cap_rgb => {
                        log.info("rgb capability detected", .{});
                        self.vx.caps.rgb = true;
                    },
                    .cap_unicode => {
                        log.info("unicode capability detected", .{});
                        self.vx.caps.unicode = .unicode;
                        self.vx.screen.width_method = .unicode;
                    },
                    .cap_sgr_pixels => {
                        log.info("pixel mouse capability detected", .{});
                        self.vx.caps.sgr_pixels = true;
                    },
                    .cap_color_scheme_updates => {
                        log.info("color_scheme_updates capability detected", .{});
                        self.vx.caps.color_scheme_updates = true;
                    },
                    .cap_da1 => {
                        self.vx.enableDetectedFeatures(self.tty.anyWriter()) catch |err| {
                            log.err("couldn't enable features: {}", .{err});
                        };
                    },
                }
            }

            self.file.read(
                loop,
                c,
                .{ .slice = &self.read_buf },
                Self,
                self,
                Self.ttyReadCallback,
            );
            return .disarm;
        }

        fn winsizeCallback(
            ud: ?*Self,
            l: *xev.Loop,
            c: *xev.Completion,
            r: xev.Async.WaitError!void,
        ) xev.CallbackAction {
            _ = r catch |err| {
                log.err("async error: {}", .{err});
                return .disarm;
            };
            const self = ud orelse unreachable; // no userdata
            const winsize = Tty.getWinsize(self.tty.fd) catch |err| {
                log.err("couldn't get winsize: {}", .{err});
                return .disarm;
            };
            const ret = self.callback(self.ud, l, self, .{ .winsize = winsize });
            if (ret == .disarm) return .disarm;

            self.winsize_wakeup.wait(
                l,
                c,
                Self,
                self,
                winsizeCallback,
            );
            return .disarm;
        }
    };
}

zig-aio

Below is an example zig-aio event loop. Note that this code is not necessarily up-to-date with the latest zig-aio release and is shown here merely as a proof of concept.

const builtin = @import("builtin");
const std = @import("std");
const vaxis = @import("main.zig");
const handleEventGeneric = @import("Loop.zig").handleEventGeneric;
const log = std.log.scoped(.vaxis_aio);

const Yield = enum { no_state, took_event };

pub fn Loop(T: type) type {
    return LoopWithModules(T, @import("aio"), @import("coro"));
}

/// zig-aio based event loop
/// <https://github.com/Cloudef/zig-aio>
pub fn LoopWithModules(T: type, aio: type, coro: type) type {
    return struct {
        const Event = T;

        winsize_task: ?coro.Task.Generic2(winsizeTask) = null,
        reader_task: ?coro.Task.Generic2(ttyReaderTask) = null,
        queue: std.BoundedArray(T, 512) = .{},
        source: aio.EventSource,
        fatal: bool = false,

        pub fn init() !@This() {
            return .{ .source = try aio.EventSource.init() };
        }

        pub fn deinit(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty) void {
            vx.deviceStatusReport(tty.anyWriter()) catch {};
            if (self.winsize_task) |task| task.cancel();
            if (self.reader_task) |task| task.cancel();
            self.source.deinit();
            self.* = undefined;
        }

        fn winsizeInner(self: *@This(), tty: *vaxis.Tty) !void {
            const Context = struct {
                loop: *@TypeOf(self.*),
                tty: *vaxis.Tty,
                winsize: ?vaxis.Winsize = null,
                fn cb(ptr: *anyopaque) void {
                    std.debug.assert(coro.current() == null);
                    const ctx: *@This() = @ptrCast(@alignCast(ptr));
                    ctx.winsize = vaxis.Tty.getWinsize(ctx.tty.fd) catch return;
                    ctx.loop.source.notify();
                }
            };

            // keep on stack
            var ctx: Context = .{ .loop = self, .tty = tty };
            if (builtin.target.os.tag != .windows) {
                if (@hasField(Event, "winsize")) {
                    const handler: vaxis.Tty.SignalHandler = .{ .context = &ctx, .callback = Context.cb };
                    try vaxis.Tty.notifyWinsize(handler);
                }
            }

            while (true) {
                try coro.io.single(aio.WaitEventSource{ .source = &self.source });
                if (ctx.winsize) |winsize| {
                    if (!@hasField(Event, "winsize")) unreachable;
                    ctx.loop.postEvent(.{ .winsize = winsize }) catch {};
                    ctx.winsize = null;
                }
            }
        }

        fn winsizeTask(self: *@This(), tty: *vaxis.Tty) void {
            self.winsizeInner(tty) catch |err| {
                if (err != error.Canceled) log.err("winsize: {}", .{err});
                self.fatal = true;
            };
        }

        fn windowsReadEvent(tty: *vaxis.Tty) !vaxis.Event {
            var state: vaxis.Tty.EventState = .{};
            while (true) {
                var bytes_read: usize = 0;
                var input_record: vaxis.Tty.INPUT_RECORD = undefined;
                try coro.io.single(aio.ReadTty{
                    .tty = .{ .handle = tty.stdin },
                    .buffer = std.mem.asBytes(&input_record),
                    .out_read = &bytes_read,
                });

                if (try tty.eventFromRecord(&input_record, &state)) |ev| {
                    return ev;
                }
            }
        }

        fn ttyReaderWindows(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty) !void {
            var cache: vaxis.GraphemeCache = .{};
            while (true) {
                const event = try windowsReadEvent(tty);
                try handleEventGeneric(self, vx, &cache, Event, event, null);
            }
        }

        fn ttyReaderPosix(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_allocator: ?std.mem.Allocator) !void {
            // initialize a grapheme cache
            var cache: vaxis.GraphemeCache = .{};

            // get our initial winsize
            const winsize = try vaxis.Tty.getWinsize(tty.fd);
            if (@hasField(Event, "winsize")) {
                try self.postEvent(.{ .winsize = winsize });
            }

            var parser: vaxis.Parser = .{
                .grapheme_data = &vx.unicode.width_data.g_data,
            };

            const file: std.fs.File = .{ .handle = tty.fd };
            while (true) {
                var buf: [4096]u8 = undefined;
                var n: usize = undefined;
                var read_start: usize = 0;
                try coro.io.single(aio.ReadTty{ .tty = file, .buffer = buf[read_start..], .out_read = &n });
                var seq_start: usize = 0;
                while (seq_start < n) {
                    const result = try parser.parse(buf[seq_start..n], paste_allocator);
                    if (result.n == 0) {
                        // copy the read to the beginning. We don't use memcpy because
                        // this could be overlapping, and it's also rare
                        const initial_start = seq_start;
                        while (seq_start < n) : (seq_start += 1) {
                            buf[seq_start - initial_start] = buf[seq_start];
                        }
                        read_start = seq_start - initial_start + 1;
                        continue;
                    }
                    read_start = 0;
                    seq_start += result.n;

                    const event = result.event orelse continue;
                    try handleEventGeneric(self, vx, &cache, Event, event, paste_allocator);
                }
            }
        }

        fn ttyReaderTask(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_allocator: ?std.mem.Allocator) void {
            return switch (builtin.target.os.tag) {
                .windows => self.ttyReaderWindows(vx, tty),
                else => self.ttyReaderPosix(vx, tty, paste_allocator),
            } catch |err| {
                if (err != error.Canceled) log.err("ttyReader: {}", .{err});
                self.fatal = true;
            };
        }

        /// Spawns tasks to handle winsize signal and tty
        pub fn spawn(
            self: *@This(),
            scheduler: *coro.Scheduler,
            vx: *vaxis.Vaxis,
            tty: *vaxis.Tty,
            paste_allocator: ?std.mem.Allocator,
            spawn_options: coro.Scheduler.SpawnOptions,
        ) coro.Scheduler.SpawnError!void {
            if (self.reader_task) |_| unreachable; // programming error
            // This is required even if app doesn't care about winsize
            // It is because it consumes the EventSource, so it can wakeup the scheduler
            // Without that custom `postEvent`'s wouldn't wake up the scheduler and UI wouldn't update
            self.winsize_task = try scheduler.spawn(winsizeTask, .{ self, tty }, spawn_options);
            self.reader_task = try scheduler.spawn(ttyReaderTask, .{ self, vx, tty, paste_allocator }, spawn_options);
        }

        pub const PopEventError = error{TtyCommunicationSevered};

        /// Call this in a while loop in the main event handler until it returns null
        pub fn popEvent(self: *@This()) PopEventError!?T {
            if (self.fatal) return error.TtyCommunicationSevered;
            defer self.winsize_task.?.wakeupIf(Yield.took_event);
            defer self.reader_task.?.wakeupIf(Yield.took_event);
            return self.queue.popOrNull();
        }

        pub const PostEventError = error{Overflow};

        pub fn postEvent(self: *@This(), event: T) !void {
            if (coro.current()) |_| {
                while (true) {
                    self.queue.insert(0, event) catch {
                        // wait for the app to take event
                        try coro.yield(Yield.took_event);
                        continue;
                    };
                    break;
                }
            } else {
                // queue can be full, app could handle this error by spinning the scheduler
                try self.queue.insert(0, event);
            }
            // wakes up the scheduler, so custom events update UI
            self.source.notify();
        }
    };
}