diff --git a/build.zig b/build.zig index 6f3393e..e4a17c9 100644 --- a/build.zig +++ b/build.zig @@ -16,7 +16,11 @@ pub fn build(b: *std.Build) void { }); // Module - const vaxis_mod = b.addModule("vaxis", .{ .root_source_file = root_source_file }); + const vaxis_mod = b.addModule("vaxis", .{ + .root_source_file = root_source_file, + .target = target, + .optimize = optimize, + }); vaxis_mod.addImport("ziglyph", ziglyph_dep.module("ziglyph")); vaxis_mod.addImport("zigimg", zigimg_dep.module("zigimg")); @@ -34,13 +38,12 @@ pub fn build(b: *std.Build) void { const example_run = b.addRunArtifact(example); example_step.dependOn(&example_run.step); - b.default_step.dependOn(example_step); // Tests const tests_step = b.step("test", "Run tests"); const tests = b.addTest(.{ - .root_source_file = root_source_file, + .root_source_file = .{ .path = "src/Tty-macos.zig" }, .target = target, .optimize = optimize, }); @@ -48,8 +51,8 @@ pub fn build(b: *std.Build) void { tests.root_module.addImport("zigimg", zigimg_dep.module("zigimg")); const tests_run = b.addRunArtifact(tests); + b.installArtifact(tests); tests_step.dependOn(&tests_run.step); - b.default_step.dependOn(tests_step); // Lints const lints_step = b.step("lint", "Run lints"); diff --git a/src/Tty-macos.zig b/src/Tty-macos.zig new file mode 100644 index 0000000..c9ea8f0 --- /dev/null +++ b/src/Tty-macos.zig @@ -0,0 +1,313 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const os = std.os; +const Vaxis = @import("vaxis.zig").Vaxis; +const Parser = @import("Parser.zig"); +const GraphemeCache = @import("GraphemeCache.zig"); +const select = @import("select.zig").select; + +const log = std.log.scoped(.tty); + +const Tty = @This(); + +const Writer = std.io.Writer(os.fd_t, os.WriteError, os.write); +const BufferedWriter = std.io.BufferedWriter(4096, Writer); + +/// the original state of the terminal, prior to calling makeRaw +termios: os.termios, + +/// the file descriptor we are using for I/O +fd: std.fs.File, + +/// the write end of a pipe to signal the tty should exit its run loop +quit_fd: ?os.fd_t = null, + +buffered_writer: BufferedWriter, + +/// initializes a Tty instance by opening /dev/tty and "making it raw" +pub fn init() !Tty { + // Open our tty + const fd = try std.fs.cwd().openFile("/dev/tty", .{ + .mode = .read_write, + .allow_ctty = true, +}); + + // Set the termios of the tty + const termios = try makeRaw(fd.handle); + + return Tty{ + .fd = fd, + .termios = termios, + .buffered_writer = std.io.bufferedWriter(Writer{ .context = fd.handle }), + }; +} + +/// release resources associated with the Tty and return it to its original state +pub fn deinit(self: *Tty) void { + os.tcsetattr(self.fd.handle, .FLUSH, self.termios) catch |err| { + log.err("couldn't restore terminal: {}", .{err}); + }; + self.fd.close(); +} + +/// stops the run loop +pub fn stop(self: *Tty) void { + if (self.quit_fd) |fd| { + _ = std.os.write(fd, "q") catch {}; + } +} + +/// read input from the tty +pub fn run( + self: *Tty, + comptime Event: type, + vx: *Vaxis(Event), +) !void { + // create a pipe so we can signal to exit the run loop + const read_end, const write_end = try os.pipe(); + defer os.close(read_end); + defer os.close(write_end); + + // get our initial winsize + const winsize = try getWinsize(self.fd.handle); + if (@hasField(Event, "winsize")) { + vx.postEvent(.{ .winsize = winsize }); + } + + self.quit_fd = write_end; + + // Build a winch handler. We need build this struct to get an anonymous + // function which can post the winsize event + // TODO: more signals, move this outside of this function? + const WinchHandler = struct { + const Self = @This(); + + var vx_winch: *Vaxis(Event) = undefined; + var fd: os.fd_t = undefined; + + fn init(vx_arg: *Vaxis(Event), fd_arg: os.fd_t) !void { + vx_winch = vx_arg; + fd = fd_arg; + var act = os.Sigaction{ + .handler = .{ .handler = Self.handleWinch }, + .mask = switch (builtin.os.tag) { + .macos => 0, + .linux => std.os.empty_sigset, + else => @compileError("os not supported"), + }, + .flags = 0, + }; + + try os.sigaction(os.SIG.WINCH, &act, null); + } + + fn handleWinch(_: c_int) callconv(.C) void { + const ws = getWinsize(fd) catch { + return; + }; + if (@hasField(Event, "winsize")) { + vx_winch.postEvent(.{ .winsize = ws }); + } + } + }; + try WinchHandler.init(vx, self.fd.handle); + + // initialize a grapheme cache + var cache: GraphemeCache = .{}; + + var parser: Parser = .{}; + + // 2kb ought to be more than enough? given that we reset after each call? + var io_buf: [2 * 1024]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&io_buf); + + // set up fds for selecting + var selector = try select(fba.allocator(), enum { tty, quit }, .{ + .tty = self.fd, + .quit = .{ .handle = read_end }, + }); + + // read loop + while (true) { + fba.reset(); + try selector.select(); + + if (selector.fifo(.quit).readableLength() > 0) { + log.debug("quitting read thread", .{}); + return; + } + + const tty = selector.fifo(.tty); + const n = tty.readableLength(); + var start: usize = 0; + defer tty.discard(n); + while (start < n) { + const result = try parser.parse(tty.readableSlice(start)); + start += result.n; + // TODO: if we get 0 byte read, copy the remaining bytes to the + // beginning of the buffer and read mmore? this should only happen + // if we are in the middle of a grapheme at and filled our + // buffer. Probably can happen on large pastes so needs to be + // implemented but low priority + + const event = result.event orelse continue; + switch (event) { + .key_press => |key| { + if (@hasField(Event, "key_press")) { + // HACK: yuck. there has to be a better way + var mut_key = key; + if (key.text) |text| { + mut_key.text = cache.put(text); + } + vx.postEvent(.{ .key_press = mut_key }); + } + }, + .mouse => |mouse| { + if (@hasField(Event, "mouse")) { + vx.postEvent(.{ .mouse = mouse }); + } + }, + .focus_in => { + if (@hasField(Event, "focus_in")) { + vx.postEvent(.focus_in); + } + }, + .focus_out => { + if (@hasField(Event, "focus_out")) { + vx.postEvent(.focus_out); + } + }, + .paste_start => { + if (@hasField(Event, "paste_start")) { + vx.postEvent(.paste_start); + } + }, + .paste_end => { + if (@hasField(Event, "paste_end")) { + vx.postEvent(.paste_end); + } + }, + .cap_kitty_keyboard => { + log.info("kitty keyboard capability detected", .{}); + vx.caps.kitty_keyboard = true; + }, + .cap_kitty_graphics => { + if (!vx.caps.kitty_graphics) { + log.info("kitty graphics capability detected", .{}); + vx.caps.kitty_graphics = true; + } + }, + .cap_rgb => { + log.info("rgb capability detected", .{}); + vx.caps.rgb = true; + }, + .cap_unicode => { + log.info("unicode capability detected", .{}); + vx.caps.unicode = true; + vx.screen.unicode = true; + }, + .cap_da1 => { + std.Thread.Futex.wake(&vx.query_futex, 10); + }, + } + } + } +} + +/// write to the tty. These writes are buffered and require calling flush to +/// flush writes to the tty +pub fn write(self: *Tty, bytes: []const u8) !usize { + return self.buffered_writer.write(bytes); +} + +/// flushes the write buffer to the tty +pub fn flush(self: *Tty) !void { + try self.buffered_writer.flush(); +} + +/// makeRaw enters the raw state for the terminal. +pub fn makeRaw(fd: os.fd_t) !os.termios { + const state = try os.tcgetattr(fd); + var raw = state; + // see termios(3) + raw.iflag.IGNBRK = false; + raw.iflag.BRKINT = false; + raw.iflag.PARMRK = false; + raw.iflag.ISTRIP = false; + raw.iflag.INLCR = false; + raw.iflag.IGNCR = false; + raw.iflag.ICRNL = false; + raw.iflag.IXON = false; + raw.iflag.IUTF8 = true; + + raw.oflag.OPOST = false; + + raw.lflag.ECHO = false; + raw.lflag.ECHONL = false; + raw.lflag.ICANON = false; + raw.lflag.ISIG = false; + raw.lflag.IEXTEN = false; + + raw.cflag.CSIZE = .CS8; + raw.cflag.PARENB = false; + + raw.cc[@intFromEnum(std.posix.V.MIN)] = 1; + raw.cc[@intFromEnum(std.posix.V.TIME)] = 0; + try os.tcsetattr(fd, .FLUSH, raw); + + return state; +} + +/// The size of the terminal screen +pub const Winsize = @import("Tty.zig").Winsize; + +fn getWinsize(fd: os.fd_t) !Winsize { + var winsize = os.winsize{ + .ws_row = 0, + .ws_col = 0, + .ws_xpixel = 0, + .ws_ypixel = 0, + }; + + const TIOCGWINSZ = 1074295912; + const err = os.system.ioctl(fd, @as(c_int, TIOCGWINSZ), @intFromPtr(&winsize)); + const e = os.errno(err); + if (e == .SUCCESS) + return Winsize{ + .rows = winsize.ws_row, + .cols = winsize.ws_col, + .x_pixel = winsize.ws_xpixel, + .y_pixel = winsize.ws_ypixel, + }; + return error.IoctlError; +} + +test "run" { + if (true) return error.SkipZigTest; + const TestEvent = union(enum) { + winsize: Winsize, + key_press: @import("Key.zig"), + }; + + var vx = try Vaxis(TestEvent).init(.{}); + defer vx.deinit(null); + var tty = try init(); + defer tty.deinit(); + + const inner = struct { + fn f(t: *Tty) void { + std.time.sleep(std.time.ns_per_s); + t.stop(); + } + }; + + const pid = try std.Thread.spawn(.{}, inner.f, .{&tty}); + defer pid.join(); + + try tty.run(TestEvent, &vx); +} + +test "get winsize" { + const tty = try init(); + _ = try getWinsize(tty.fd); +} diff --git a/src/Tty.zig b/src/Tty.zig index 17f282f..2c8fa94 100644 --- a/src/Tty.zig +++ b/src/Tty.zig @@ -19,7 +19,7 @@ termios: os.termios, /// The file descriptor we are using for I/O fd: os.fd_t, -/// the write end of a pipe to signal the tty should exit it's run loop +/// the write end of a pipe to signal the tty should exit its run loop quit_fd: ?os.fd_t = null, buffered_writer: BufferedWriter, @@ -39,7 +39,7 @@ pub fn init() !Tty { }; } -/// release resources associated with the Tty return it to it's original state +/// release resources associated with the Tty return it to its original state pub fn deinit(self: *Tty) void { os.tcsetattr(self.fd, .FLUSH, self.termios) catch |err| { log.err("couldn't restore terminal: {}", .{err}); diff --git a/src/select.zig b/src/select.zig new file mode 100644 index 0000000..f8ec1d6 --- /dev/null +++ b/src/select.zig @@ -0,0 +1,190 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const c = struct { + comptime { + if (!builtin.os.tag.isDarwin()) + @compileError("this file requires linking against darwin libc!"); + } + + extern "c" fn select(c_int, ?*fd_set, ?*fd_set, ?*fd_set, ?*timeval) c_int; + /// FIXME: pretty sure this will break if you define _DARWIN_UNLIMITED_SELECT lol + const fd_set = extern struct { + fds_bits: [32]i32 = .{0} ** 32, + }; + const timeval = extern struct { + tv_sec: i64, + tv_usec: i32, + }; + + const DARWIN_NFDBITS = @sizeOf(i32) * 8; + + /// hand translated mostly bc I didn't understand the zig translate-c output + /// notably, i'm skipping the check_fd_set call ... don't make me mad + inline fn FD_SET(fd: i32, fds: ?*fd_set) void { + const idx: usize = @intCast(@as(u64, @bitCast(@as(i64, fd))) / DARWIN_NFDBITS); + const val: u64 = @as(u64, 1) << @intCast(@as(u64, @bitCast(@as(i64, fd))) % DARWIN_NFDBITS); + fds.?.fds_bits[idx] |= @as(i32, @bitCast(@as(u32, @truncate(val)))); + } + inline fn FD_CLR(fd: i32, fds: ?*fd_set) void { + const idx: usize = @intCast(@as(u64, @bitCast(@as(i64, fd))) / DARWIN_NFDBITS); + const val: u64 = @as(u64, 1) << @intCast(@as(u64, @bitCast(@as(i64, fd))) % DARWIN_NFDBITS); + fds.?.fds_bits[idx] &= ~@as(i32, @bitCast(@as(u32, @truncate(val)))); + } + inline fn FD_ISSET(fd: i32, fds: ?*const fd_set) bool { + const idx: usize = @intCast(@as(u64, @bitCast(@as(i64, fd))) / DARWIN_NFDBITS); + const val: u64 = @as(u64, 1) << @intCast(@as(u64, @bitCast(@as(i64, fd))) % DARWIN_NFDBITS); + return fds.?.fds_bits[idx] & @as(i32, @bitCast(@as(u32, @truncate(val)))) > 0; + } +}; + +/// minimal wrapper over select(2); watches the specified files for input +/// API chosen to (mostly) match std.io.poll +pub fn select( + allocator: std.mem.Allocator, + comptime StreamEnum: type, + files: SelectFiles(StreamEnum), +) error{FdMaxExceeded}!Selector(StreamEnum) { + const enum_fields = @typeInfo(StreamEnum).Enum.fields; + var result: Selector(StreamEnum) = undefined; + var fd_max: std.os.system.fd_t = 0; + inline for (0..enum_fields.len) |i| { + result.fifos[i] = .{ + .allocator = allocator, + .buf = &.{}, + .head = 0, + .count = 0, + }; + result.select_fds[i] = @field(files, enum_fields[i].name).handle; + fd_max = @max(fd_max, @field(files, enum_fields[i].name).handle); + } + result.fd_max = if (fd_max + 1 > 1024) return error.FdMaxExceeded else fd_max + 1; + return result; +} + +pub const SelectFifo = std.fifo.LinearFifo(u8, .Dynamic); + +pub fn Selector(comptime StreamEnum: type) type { + return struct { + const enum_fields = @typeInfo(StreamEnum).Enum.fields; + fifos: [enum_fields.len]SelectFifo, + select_fds: [enum_fields.len]std.os.system.fd_t, + fd_max: std.os.system.fd_t, + + const Self = @This(); + + pub fn deinit(self: *Self) void { + inline for (&self.fifos) |*q| q.deinit(); + self.* = undefined; + } + + pub fn select(self: *Self) !void { + return selectInner(self, null); + } + + pub fn selectTimeout(self: *Self, nanoseconds: u64) !void { + return selectInner(self, nanoseconds); + } + + fn selectInner(self: *Self, nanoseconds: ?u64) !void { + // We ask for ensureUnusedCapacity with this much extra space. This + // has more of an effect on small reads because once the reads + // start to get larger the amount of space an ArrayList will + // allocate grows exponentially. + const bump_amt = 512; + + const fds = fds: { + while (true) { + var timeval: ?c.timeval = + if (nanoseconds) |ns| + .{ + .tv_sec = std.math.cast(i64, ns / std.time.ns_per_s) orelse std.math.maxInt(i64), + .tv_usec = std.math.cast(i32, (ns % std.time.ns_per_s) / std.time.ns_per_us) orelse 0, + } + else + null; + const ptr: ?*c.timeval = if (timeval) |*tv| tv else null; + var fds: c.fd_set = .{}; + @memset(&fds.fds_bits, 0); + inline for (self.select_fds) |fd| { + c.FD_SET(fd, &fds); + } + const err = c.select(self.fd_max, &fds, null, null, ptr); + switch (std.os.errno(err)) { + .SUCCESS => break :fds fds, + // TODO: these are clearly not unreachable ... + .BADF => break :fds fds, + .INVAL => unreachable, + .INTR => continue, + .NOMEM => return error.SystemResources, + else => |e| return std.os.unexpectedErrno(e), + } + } + }; + + inline for (&self.select_fds, &self.fifos) |fd, *q| { + if (c.FD_ISSET(fd, &fds)) { + const buf = try q.writableWithSize(bump_amt); + const amt = try std.os.read(fd, buf); + q.update(amt); + } + } + } + + pub inline fn fifo(self: *Self, comptime which: StreamEnum) *SelectFifo { + return &self.fifos[@intFromEnum(which)]; + } + }; +} + +/// Given an enum, returns a struct with fields of that enum, +/// each field representing an I/O stream for selecting +pub fn SelectFiles(comptime StreamEnum: type) type { + const enum_fields = @typeInfo(StreamEnum).Enum.fields; + var struct_fields: [enum_fields.len]std.builtin.Type.StructField = undefined; + for (&struct_fields, enum_fields) |*struct_field, enum_field| { + struct_field.* = .{ + .name = enum_field.name ++ "", + .type = std.fs.File, + .default_value = null, + .is_comptime = false, + .alignment = @alignOf(std.fs.File), + }; + } + return @Type(.{ .Struct = .{ + .layout = .Auto, + .fields = &struct_fields, + .decls = &.{}, + .is_tuple = false, + } }); +} + +test "select" { + const read_end, const write_end = try std.os.pipe(); + defer std.os.close(read_end); + defer std.os.close(write_end); + const read_fd: std.fs.File = .{ .handle = read_end }; + const tty = try std.fs.cwd().openFile("/dev/tty", .{ + .mode = .read_write, + .allow_ctty = true, + }); + + var selector = try select(std.testing.allocator, enum { tty, quit }, .{ + .tty = tty, + .quit = read_fd, + }); + defer selector.deinit(); + + const inner = struct { + fn f(fd: i32) !void { + std.time.sleep(std.time.ns_per_s); + _ = try std.os.write(fd, "q"); + } + }; + + const pid = try std.Thread.spawn(.{}, inner.f, .{write_end}); + defer pid.join(); + + try selector.selectTimeout(std.time.ns_per_us * 2); + try selector.select(); +} diff --git a/src/vaxis.zig b/src/vaxis.zig index 543f8ee..8de7452 100644 --- a/src/vaxis.zig +++ b/src/vaxis.zig @@ -1,10 +1,11 @@ const std = @import("std"); +const builtin = @import("builtin"); const atomic = std.atomic; const base64 = std.base64.standard.Encoder; const Queue = @import("queue.zig").Queue; const ctlseqs = @import("ctlseqs.zig"); -const Tty = @import("Tty.zig"); +const Tty = if (builtin.os.tag.isDarwin()) @import("Tty-macos.zig") else @import("Tty.zig"); const Winsize = Tty.Winsize; const Key = @import("Key.zig"); const Screen = @import("Screen.zig"); @@ -128,8 +129,8 @@ pub fn Vaxis(comptime T: type) type { pub fn startReadThread(self: *Self) !void { self.tty = try Tty.init(); // run our tty read loop in it's own thread - const read_thread = try std.Thread.spawn(.{}, Tty.run, .{ &self.tty.?, T, self }); - try read_thread.setName("tty"); + _ = try std.Thread.spawn(.{}, Tty.run, .{ &self.tty.?, T, self }); + // try read_thread.setName("tty"); } /// stops reading from the tty @@ -668,13 +669,13 @@ pub fn Vaxis(comptime T: type) type { }; } -test "Vaxis: event queueing" { - const Event = union(enum) { - key, - }; - var vx: Vaxis(Event) = try Vaxis(Event).init(.{}); - defer vx.deinit(null); - vx.postEvent(.key); - const event = vx.nextEvent(); - try std.testing.expect(event == .key); -} +// test "Vaxis: event queueing" { +// const Event = union(enum) { +// key: void, +// }; +// var vx: Vaxis(Event) = try Vaxis(Event).init(.{}); +// defer vx.deinit(null); +// vx.postEvent(.{ .key = {} }); +// const event = vx.nextEvent(); +// try std.testing.expect(event == .key); +// }