~ntgg/kira

79e4d01c818c884d120698a3871b282a752707ea — Noah Graff 1 year, 8 months ago main
Initial Commit
A  => .gitignore +4 -0
@@ 1,4 @@
.DS*
*~
zig-cache/
zig-out/

A  => build.zig +33 -0
@@ 1,33 @@
const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    // Standard release options allow the person running `zig build` to select
    // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall.
    const mode = b.standardReleaseOptions();

    const lib = b.addStaticLibrary("kira", "src/main.zig");
    lib.setBuildMode(mode);
    lib.install();

    const examples_step = b.step("examples", "Build all examples");
    inline for (.{
        "input_info",
        "event_info",
        "mouse_test",
        "term_info",
        "cursor_mover",
    }) |example| {
        const exe = b.addExecutable(example, "examples/" ++ example ++ ".zig");
        exe.addPackagePath("kira", "src/main.zig");
        exe.setBuildMode(mode);
        const example_install_step = b.addInstallArtifact(exe);
        examples_step.dependOn(&example_install_step.step);
        examples_step.dependOn(&exe.step);
    }

    const main_tests = b.addTest("src/main.zig");
    main_tests.setBuildMode(mode);

    const test_step = b.step("test", "Run library tests");
    test_step.dependOn(&main_tests.step);
}

A  => examples/cursor_mover.zig +37 -0
@@ 1,37 @@
const std = @import("std");
const kira = @import("kira");

pub fn main() !void {
    var terminal = try kira.RawTerminal.init(true);
    defer terminal.deinit();

    const size = try terminal.getWindowSize();
    var cursor_position = kira.TerminalPosition{
        .col = size.cols / 2,
        .row = size.rows / 2,
    };

    while (true) {
        try terminal.clear(.screen);
        try terminal.cursorControl(.{ .goto = .{ .col = 0, .row = 0 } });
        try terminal.print("terminal size: {} by {}\r\n", .{ size.cols, size.rows });
        try terminal.print("cursor position: ({}, {})\r\n", .{ cursor_position.col, cursor_position.row });
        try terminal.print("press 'q' to quit\r\n", .{});
        try terminal.cursorControl(.{ .goto = cursor_position });
        if (try terminal.nextEvent()) |event| {
            switch (event) {
                .key => |key| switch (key) {
                    .char => |char| if (char == 'q') break,
                    .up => cursor_position.row -|= 1,
                    .down => cursor_position.row = std.math.min(size.rows - 1, cursor_position.row + 1),
                    .left => cursor_position.col -|= 1,
                    .right => cursor_position.col = std.math.min(size.cols - 1, cursor_position.col + 1),
                    else => {},
                },
                else => {},
            }
        }
    }
    try terminal.clear(.screen);
    try terminal.cursorControl(.{ .goto = .{ .col = 0, .row = 0 } });
}

A  => examples/event_info.zig +18 -0
@@ 1,18 @@
const std = @import("std");
const kira = @import("kira");

pub fn main() !void {
    var terminal = try kira.RawTerminal.init(true);
    defer terminal.deinit();

    while (try terminal.nextEvent()) |event| {
        try terminal.print("{}\r\n", .{event});
        switch (event) {
            .key => |key| switch (key) {
                .char => |char| if (char == 'q') break,
                else => {},
            },
            else => {},
        }
    }
}

A  => examples/input_info.zig +18 -0
@@ 1,18 @@
const std = @import("std");
const kira = @import("kira");

pub fn main() !void {
    var terminal = try kira.RawTerminal.init(true);
    defer terminal.deinit();

    var read_buffer: [32]u8 = undefined;
    while (true) {
        const read = try terminal.read(&read_buffer);
        if (std.ascii.isCntrl(read_buffer[0])) {
            try terminal.print("{d}\r\n", .{read_buffer[0..read]});
        } else {
            try terminal.print("{0d} ('{0s}')\r\n", .{read_buffer[0..read]});
        }
        if (read_buffer[0] == 'q') break;
    }
}

A  => examples/mouse_test.zig +21 -0
@@ 1,21 @@
const std = @import("std");
const kira = @import("kira");

pub fn main() !void {
    var terminal = try kira.RawTerminal.init(true);
    defer terminal.deinit();

    try terminal.enableMouse();
    defer terminal.disableMouse() catch {};

    while (try terminal.nextEvent()) |event| {
        try terminal.print("{}\r\n", .{event});
        switch (event) {
            .key => |key| switch (key) {
                .char => |char| if (char == 'q') break,
                else => {},
            },
            else => {},
        }
    }
}

A  => examples/term_info.zig +16 -0
@@ 1,16 @@
const std = @import("std");
const kira = @import("kira");

pub fn main() !void {
    var terminal = try kira.RawTerminal.init(true);

    const size = try terminal.getWindowSize();
    const cursor = try terminal.getCursorPosition();

    terminal.deinit();

    std.debug.print(
        "size: {}\ncursor position: {}\n",
        .{ size, cursor },
    );
}

A  => src/ByteIterator.zig +20 -0
@@ 1,20 @@
const Self = @This();

bytes: []const u8,
index: usize,

pub fn init(bytes: []const u8) Self {
    return Self{
        .bytes = bytes,
        .index = 0,
    };
}

pub fn next(self: *Self) ?u8 {
    if (self.index < self.bytes.len) {
        defer self.index += 1;
        return self.bytes[self.index];
    } else {
        return null;
    }
}

A  => src/RawTerminal.zig +200 -0
@@ 1,200 @@
const std = @import("std");

const Event = @import("events.zig").Event;
const parseCsi = @import("events.zig").parseCsi;
const ByteIterator = @import("ByteIterator.zig");
const TerminalSize = @import("main.zig").TerminalSize;
const TerminalPosition = @import("main.zig").TerminalPosition;

const Self = @This();

original_termios: std.os.termios,
out: std.fs.File,
in: std.fs.File,
read_buffer: [32]u8 = undefined,

pub fn init(blocking: bool) !Self {
    var std_in = std.io.getStdIn();
    const original_termios = try std.os.tcgetattr(std_in.handle);
    var raw_termios = original_termios;
    raw_termios.iflag &= ~(std.c.BRKINT | std.c.ICRNL | std.c.INPCK | std.c.ISTRIP | std.c.IXON);
    raw_termios.oflag &= ~(std.c.OPOST);
    raw_termios.cflag |= (std.c.CS8);
    raw_termios.lflag &= ~(std.c.ECHO | std.c.ICANON | std.c.IEXTEN | std.c.ISIG);
    if (!blocking) {
        raw_termios.cc[std.c.V.MIN] = 0;
        raw_termios.cc[std.c.V.TIME] = 1;
    }
    try std.os.tcsetattr(std_in.handle, .FLUSH, raw_termios);

    var std_out = std.io.getStdOut();

    return Self{
        .original_termios = original_termios,
        .in = std_in,
        .out = std_out,
    };
}

pub fn deinit(self: *Self) void {
    std.os.tcsetattr(self.out.handle, .FLUSH, self.original_termios) catch {};
}

pub fn enableMouse(self: *Self) !void {
    try self.writeAll("\x1b[?1000h\x1b[?1002h\x1b[?1015h\x1b[?1006h");
}

pub fn disableMouse(self: *Self) !void {
    try self.writeAll("\x1b[?1006l\x1b[?1015l\x1b[?1002l\x1b[?1000l");
}

pub fn write(self: *Self, bytes: []const u8) !usize {
    return self.out.write(bytes);
}

pub fn writeAll(self: *Self, bytes: []const u8) !void {
    return self.out.writeAll(bytes);
}

pub fn print(self: *Self, comptime format: []const u8, args: anytype) !void {
    return self.out.writer().print(format, args);
}

pub fn read(self: *Self, buffer: []u8) !usize {
    return self.in.read(buffer);
}

/// If the event is of type unknown, it is
/// valid only until the next nextEvent call
// TODO: properly handle when more than one
// event happens in the same 'read'
pub fn nextEvent(self: *Self) !?Event {
    self.read_buffer = undefined;
    var count = try self.read(&self.read_buffer);
    if (count == 0) {
        return null;
    }

    const bytes = self.read_buffer[0..count];

    if (bytes[0] == '\x1b') {
        var iterator = ByteIterator.init(bytes[1..]);
        if (iterator.next()) |next| {
            switch (next) {
                'O' => {
                    if (iterator.next()) |next2| switch (next2) {
                        'P' => return Event{ .key = .f1 },
                        'Q' => return Event{ .key = .f2 },
                        'R' => return Event{ .key = .f3 },
                        'S' => return Event{ .key = .f4 },
                        else => {},
                    };
                },
                '[' => if (parseCsi(bytes[2..])) |csi_event| return csi_event,
                else => if (std.unicode.utf8ValidateSlice(bytes[1..])) {
                    const codepoint = std.unicode.utf8Decode(bytes[1..]) catch unreachable;
                    return Event{ .key = .{ .alt = codepoint } };
                },
            }
        }
    } else if (bytes[0] == '\n' or bytes[0] == '\r')
        return Event{ .key = .enter }
    else if (bytes[0] == '\t')
        return Event{ .key = .tab }
    else if (bytes[0] == '\x7f')
        return Event{ .key = .backspace }
    else if (bytes[0] >= '\x01' and bytes[0] <= '\x1a')
        return Event{ .key = .{ .ctrl = bytes[0] - 0x1 + 'a' } }
    else if (bytes[0] >= '\x1c' and bytes[0] <= '\x1f')
        return Event{ .key = .{ .ctrl = bytes[0] - 0x1c + '4' } }
    else if (bytes[0] == 0) {
        return Event{ .key = .{ .ctrl = ' ' } };
    } else if (std.unicode.utf8ValidateSlice(bytes) and
        try std.unicode.utf8CountCodepoints(bytes) == 1)
    {
        return Event{ .key = .{ .char = try std.unicode.utf8Decode(bytes) } };
    }

    return Event{ .unknown = bytes };
}

pub fn getWindowSize(self: *Self) !TerminalSize {
    var size: std.c.winsize = undefined;
    if (std.c.ioctl(self.out.handle, std.c.T.IOCGWINSZ, &size) == -1 or size.ws_col == 0) {
        const old_cursor_position = try self.getCursorPosition();
        try self.writeAll("\x1b[999C\x1b[999B");
        const cursor_position = try self.getCursorPosition();
        try self.print("\x1b[{d};{d}H", .{ old_cursor_position.row, old_cursor_position.col });
        return TerminalSize{
            .rows = cursor_position.row + 1,
            .cols = cursor_position.col + 1,
        };
    } else {
        return TerminalSize{
            .rows = size.ws_row,
            .cols = size.ws_col,
        };
    }
}

pub fn getCursorPosition(self: *Self) !TerminalPosition {
    try self.writeAll("\x1b[6n");
    var buffer: [32]u8 = undefined;
    var count: usize = 0;
    while (count < buffer.len) {
        if ((try self.read(buffer[count .. count + 1])) != 1) break;
        if (buffer[count] == 'R') break;
        count += 1;
    }
    if (count < 5) return error.InvalidResponse;
    if (buffer[0] != '\x1b' or buffer[1] != '[') return error.InvalidResponse;
    var split = std.mem.split(u8, buffer[2..count], ";");
    var row = try std.fmt.parseInt(u16, split.next() orelse return error.InvalidResponse, 0);
    var col = try std.fmt.parseInt(u16, split.next() orelse return error.InvalidResponse, 0);
    return TerminalPosition{
        .row = row - 1,
        .col = col - 1,
    };
}

pub fn cursorControl(self: *Self, control: CursorControl) !void {
    switch (control) {
        .goto => |position| try self.print("\x1b[{};{}H", .{ position.row + 1, position.col + 1 }),
        .up => |lines| try self.print("\x1b[{}A", .{lines}),
        .down => |lines| try self.print("\x1b[{}B", .{lines}),
        .left => |lines| try self.print("\x1b[{}D", .{lines}),
        .right => |lines| try self.print("\x1b[{}C", .{lines}),
        .save => try self.print("\x1b7", .{}),
        .restore => try self.print("\x1b8", .{}),
    }
}

pub fn clear(self: *Self, what: ClearTarget) !void {
    switch (what) {
        .from_cursor => try self.print("\x1b[0J", .{}),
        .to_cursor => try self.print("\x1b[1J", .{}),
        .screen => try self.print("\x1b[2J", .{}),
        .line_from_cursor => try self.print("\x1b[0K", .{}),
        .line_to_cursor => try self.print("\x1b[1K", .{}),
        .line => try self.print("\x1b[2K", .{}),
    }
}

pub const ClearTarget = enum {
    screen,
    to_cursor,
    from_cursor,
    line,
    line_to_cursor,
    line_from_cursor,
};

pub const CursorControl = union(enum) {
    goto: TerminalPosition,
    up: u16,
    down: u16,
    left: u16,
    right: u16,
    save,
    restore,
};

A  => src/events.zig +276 -0
@@ 1,276 @@
const std = @import("std");

const ByteIterator = @import("ByteIterator.zig");
const TerminalPosition = @import("main.zig").TerminalPosition;

pub fn parseCsi(bytes: []const u8) ?Event {
    var iterator = ByteIterator.init(bytes);
    if (iterator.next()) |next| switch (next) {
        '[' => if (iterator.next()) |next2| switch (next2) {
            'A' => return Event{ .key = .f1 },
            'B' => return Event{ .key = .f2 },
            'C' => return Event{ .key = .f3 },
            'D' => return Event{ .key = .f4 },
            'E' => return Event{ .key = .f5 },
            else => {},
        },
        'A' => return Event{ .key = .up },
        'B' => return Event{ .key = .down },
        'D' => return Event{ .key = .left },
        'C' => return Event{ .key = .right },
        'H' => return Event{ .key = .home },
        'F' => return Event{ .key = .end },
        'Z' => return Event{ .key = .back_tab },
        // X10 mouse encoding: esc [ M Cb Cx Cy
        'M' => {
            const cb = @intCast(i8, iterator.next() orelse return null) - 32;
            const position = TerminalPosition{
                .col = @as(u16, iterator.next() orelse return null) -| 33,
                .row = @as(u16, iterator.next() orelse return null) -| 33,
            };
            const event = switch (cb) {
                0 => if (cb & 0x40 != 0)
                    MouseEvent{ .press = .{ .button = .wheel_up, .position = position } }
                else
                    MouseEvent{ .press = .{ .button = .left, .position = position } },
                1 => if (cb & 0x40 != 0)
                    MouseEvent{ .press = .{ .button = .wheel_down, .position = position } }
                else
                    MouseEvent{ .press = .{ .button = .middle, .position = position } },
                2 => MouseEvent{ .press = .{ .button = .right, .position = position } },
                3 => MouseEvent{ .release = position },
                else => return null,
            };
            return Event{ .mouse = event };
        },
        // xterm mouse encoding: esc [ < Cb ; Cx ; Cy (;) (m|M)
        '<' => {
            var buffer: [32]u8 = undefined;
            var index: usize = 0;
            var char = iterator.next() orelse return null;
            while (char != 'm' and char != 'M') {
                buffer[index] = char;
                index += 1;
                char = iterator.next() orelse return null;
            }

            var nums = std.mem.split(u8, buffer[0..index], ";");
            const cb = std.fmt.parseInt(u16, nums.next() orelse return null, 0) catch return null;
            const position = TerminalPosition{
                .col = (std.fmt.parseInt(u16, nums.next() orelse return null, 0) catch return null) - 1,
                .row = (std.fmt.parseInt(u16, nums.next() orelse return null, 0) catch return null) - 1,
            };

            const event = switch (cb) {
                0...2, 64...65 => press_release: {
                    var button: MouseButton = switch (cb) {
                        0 => .left,
                        1 => .middle,
                        2 => .right,
                        64 => .wheel_up,
                        65 => .wheel_down,
                        else => unreachable,
                    };
                    break :press_release switch (char) {
                        'M' => MouseEvent{ .press = .{ .button = button, .position = position } },
                        'm' => MouseEvent{ .release = position },
                        else => unreachable,
                    };
                },
                32 => MouseEvent{ .hold = position },
                3 => MouseEvent{ .release = position },
                else => return null,
            };
            return Event{ .mouse = event };
        },
        // numbered escape code
        '0'...'9' => {
            var buffer: [32]u8 = undefined;
            var index: usize = 1;
            buffer[0] = next;
            var byte = iterator.next() orelse return null;
            while (byte < 64 or byte > 126) {
                buffer[index] = byte;
                index += 1;
                byte = iterator.next() orelse return null;
            }

            switch (byte) {
                // rxvt mouse encoding: esc [ Cb ; Cx ; Cy ; M
                'M' => {
                    var nums = std.mem.split(u8, buffer[0..index], ";");
                    const cb = std.fmt.parseInt(u16, nums.next() orelse return null, 0) catch return null;
                    const position = TerminalPosition{
                        .col = std.fmt.parseInt(u16, nums.next() orelse return null, 0) catch return null,
                        .row = std.fmt.parseInt(u16, nums.next() orelse return null, 0) catch return null,
                    };

                    const event = switch (cb) {
                        32 => MouseEvent{ .press = .{ .button = .left, .position = position } },
                        33 => MouseEvent{ .press = .{ .button = .middle, .position = position } },
                        34 => MouseEvent{ .press = .{ .button = .right, .position = position } },
                        35 => MouseEvent{ .release = position },
                        64 => MouseEvent{ .hold = position },
                        96, 97 => MouseEvent{ .press = .{ .button = .wheel_up, .position = position } },
                        else => return null,
                    };
                    return Event{ .mouse = event };
                },
                '~' => {
                    var nums = std.mem.split(u8, buffer[0..index], ";");
                    var num = std.fmt.parseInt(
                        u16,
                        nums.next() orelse return null,
                        0,
                    ) catch return null;

                    // multi-keys not handled
                    if (nums.next() != null) return null;

                    switch (num) {
                        1, 7 => return Event{ .key = .home },
                        2 => return Event{ .key = .insert },
                        3 => return Event{ .key = .delete },
                        4, 8 => return Event{ .key = .end },
                        5 => return Event{ .key = .page_up },
                        6 => return Event{ .key = .page_down },
                        11 => return Event{ .key = .f1 },
                        12 => return Event{ .key = .f2 },
                        13 => return Event{ .key = .f3 },
                        14 => return Event{ .key = .f4 },
                        15 => return Event{ .key = .f5 },
                        17 => return Event{ .key = .f6 },
                        18 => return Event{ .key = .f7 },
                        19 => return Event{ .key = .f8 },
                        20 => return Event{ .key = .f9 },
                        21 => return Event{ .key = .f10 },
                        23 => return Event{ .key = .f11 },
                        24 => return Event{ .key = .f12 },
                        else => {},
                    }
                },
                else => {},
            }
        },
        else => {},
    };
    return null;
}

pub const Event = union(enum) {
    key: Key,
    mouse: MouseEvent,
    // resize: TerminalSize,
    unknown: []u8,
};

pub const MouseEvent = union(enum) {
    press: struct { button: MouseButton, position: TerminalPosition },
    release: TerminalPosition,
    hold: TerminalPosition,

    pub fn format(
        self: MouseEvent,
        comptime _: []const u8,
        _: std.fmt.FormatOptions,
        writer: anytype,
    ) !void {
        switch (self) {
            .press => |press| try std.fmt.format(
                writer,
                "MouseEvent{{ .press = {s} @ ({d}, {d}) }}",
                .{ @tagName(press.button), press.position.col, press.position.row },
            ),
            .release => |position| try std.fmt.format(
                writer,
                "MouseEvent{{ .release = ({d}, {d}) }}",
                .{ position.col, position.row },
            ),
            .hold => |position| try std.fmt.format(
                writer,
                "MouseEvent{{ .hold = ({d}, {d}) }}",
                .{ position.col, position.row },
            ),
        }
    }
};

pub const MouseButton = enum {
    left,
    right,
    middle,
    wheel_up,
    wheel_down,
};

pub const Key = union(enum) {
    backspace,
    enter,

    left,
    right,
    up,
    down,

    home,
    end,

    page_up,
    page_down,

    tab,
    back_tab,

    delete,
    insert,

    f1,
    f2,
    f3,
    f4,
    f5,
    f6,
    f7,
    f8,
    f9,
    f10,
    f11,
    f12,

    char: u21,
    alt: u21,
    ctrl: u21,

    @"null",
    escape,

    pub fn format(
        self: Key,
        comptime _: []const u8,
        _: std.fmt.FormatOptions,
        writer: anytype,
    ) !void {
        switch (self) {
            .char => |char| try std.fmt.format(
                writer,
                "Key{{ .char = '{u}' }}",
                .{char},
            ),
            .ctrl => |char| try std.fmt.format(
                writer,
                "Key{{ .ctrl = '{u}' }}",
                .{char},
            ),
            .alt => |char| try std.fmt.format(
                writer,
                "Key{{ .alt = '{u}' }}",
                .{char},
            ),
            else => try std.fmt.format(
                writer,
                "Key.{s}",
                .{@tagName(self)},
            ),
        }
    }
};

A  => src/main.zig +15 -0
@@ 1,15 @@
const std = @import("std");

pub const RawTerminal = @import("RawTerminal.zig");

pub const Event = @import("events.zig").Event;
pub const MouseEvent = @import("events.zig").MouseEvent;
pub const MouseButton = @import("events.zig").MouseButton;
pub const Key = @import("events.zig").Key;

pub const TerminalPosition = struct { col: u16, row: u16 };
pub const TerminalSize = struct { cols: u16, rows: u16 };

test "static analysis" {
    std.testing.refAllDecls(@This());
}