├── .gitignore ├── LICENSE ├── README.md ├── build.zig ├── examples ├── dvd.zig ├── invader-zig.gif ├── invaders.zig └── log_handler.zig └── src ├── box.zig ├── prim.zig └── util.zig /.gitignore: -------------------------------------------------------------------------------- 1 | zig-cache/ 2 | .vscode/ 3 | std -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Jesse Rudolph 2 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 3 | 4 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWAR 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zbox 2 | 3 | A very minimal terminal UI library inspired by termbox. This exists primarily 4 | as a UI library for a separate text editor project. 5 | 6 | 7 | 8 | ### Usage 9 | see [examples](examples) for sample usage 10 | 11 | ![invaderdemo](examples/invader-zig.gif) 12 | 13 | ### Features 14 | * exposes some primitives for terminal setup/control 15 | * exposes an interface to terminal printing/drawing that de-duplicates and 16 | batches operations sent to the terminal. 17 | * create and manipulate offscreen cell buffers with drawing and printing 18 | primitives, compose them, and 'push' them out to the terminal. 19 | * Simple input events 20 | 21 | ### Design Philosophy and differences from termbox 22 | * Zig (possible C-API eventually) 23 | * Prefer lack of features to external dependencies (no terminfo/termcap). 24 | Portability is arrived at by minimizing the interface to terminal primitives 25 | to the most portable subset. 26 | * input handling follows the above statement. different terminals communicate 27 | hotkey modifiers differently. I hope to eventually support Ctrl as a modifier 28 | portably and consistently, but currently do not. Mouse input will probably 29 | never be supported. 30 | Parsing most input is currently the responsibility of the user. 31 | * event handling should be compatible with zig's async IO. As such, this 32 | intentionally avoids poll/signal handlers, and so `sigwinch` (terminal window 33 | resize signal) is not used for window resizing. 34 | 35 | ### Portability & Stability 36 | currently only tested on linux, but should be broadly posix compliant. Not 37 | thoroughly tested in general, but everything seems to work in common terminals 38 | like (ux)term, linux console, whatever crap ubuntu uses by default, kitty, and 39 | alacritty. 40 | 41 | Highly unlikely to work on windows given that we are not using libc (which could 42 | do some mocking of linux syscalls related APIs on windows ala Cygwin). Planned to 43 | at least support windows for putting the tty in raw mode, but will not translate 44 | ANSI/VT control sequences to windows API calls. You'd have to use something like 45 | windows terminal or mintty. 46 | 47 | Still very rough, and probably broken in most places. 48 | 49 | ## API overview 50 | 51 | ```zig 52 | const zbox = @import("zbox"); 53 | ``` 54 | 55 | #### Interacting with the terminal 56 | 57 | ```zig 58 | /// setup the tty and internal state of the library for use. 59 | zbox.init(allocator: *Allocator) !void 60 | 61 | /// restore tty to state before initialization and clean up internal library state. 62 | zbox.deinit() void 63 | 64 | 65 | /// request the size of the tty in characters. Can fail if the tty is in an unexpected state. 66 | zbox.size() !struct { width: usize, height: usize } 67 | 68 | // treat special hotkeys like ctrl-c, ctrl-d, ctrl-z, and ctrl-z as normal input 69 | zbox.ignoreSignalInput() !void 70 | 71 | /// allow the tty to handle the above hotkeys 72 | zbox.handleSignalInput() !void 73 | 74 | /// send VT control sequences for hiding and showing cursor, and clearing the terminal respectively 75 | /// note: these will not be flushed to the terminal until the next call to `zbox.push()` 76 | zbox.cursorShow() !void 77 | zbox.cursorHide() !void 78 | zbox.clear() !void 79 | 80 | 81 | /// wait for the next event from the tty. 82 | zbox.nextEvent() !zbox.Event 83 | 84 | //TODO: document event structure 85 | 86 | // render the given cell buffer to the tty. 87 | zbox.push(Buffer) 88 | 89 | ``` 90 | #### Cell buffers 91 | 92 | Cell buffers are an off-screen abstraction of the terminal. Rather than directly sending operations to the 93 | system's terminal, you create, operate on, and compose cell buffers. The library itself also maintains its own 94 | internal cell buffer so it knows what was drawn to the screen on the last 'push'. This is so that on the next push, 95 | it can compare it to the last state and only generate drawing operations for the parts that have changed. 96 | 97 | ```zig 98 | // allocate an initialize a new cell buffer with the given dimensions. 99 | // the default cell this uses for initialization is a space character with no ANSI style attributes. 100 | var buffer = zbox.Buffer.init(allocator,height,width) !zbox.Buffer 101 | 102 | // free the buffer's backing memory 103 | buffer.deinit() 104 | 105 | // the height and width of the buffer respectively. users should not directly modify these 106 | // see `buffer.resize()` 107 | buffer.height: usize 108 | buffer.width: usize 109 | 110 | /// returns a copy of the cell at a given row and column offset, row and column offsets are 0-indexed 111 | buffer.cell(row_num,col_num) Cell 112 | 113 | /// returns a pointer to the cell at the given row and column offset 114 | /// if the buffer that this is called on is const, the cell pointed to is also const 115 | /// this will be invalidated after a call to `buffer.resize` 116 | buffer.cellRef(row_num,col_num) *Cell 117 | buffer.cellRef(row_num,col_num) *const Cell 118 | 119 | /// returns a slice of cells representing the row at a given offset. 120 | /// like `buffer.cellRef` the constness of the slice elements depends on the constness of `buffer` 121 | /// also invalidated after any call to `buffer.resize()` 122 | buffer.row(row_num) []Cell 123 | buffer.row(row_num) []const Cell 124 | 125 | /// fill the entire buffer with a given cell value. 126 | buffer.fill(cell) void 127 | 128 | /// resize and reallocate the memory for a buffer, truncating an filling dimension approriately 129 | /// can fail on OOM 130 | /// invalidates all outstanding row and cell references 131 | buffer.resize(new_height,new_width) !void 132 | 133 | // draw other_buffer on top of buffer at the given row and column offsets. Row and column offsets can 134 | // be negative, and if the other buffer crosses the boundary of the target buffer, out of bounds 135 | // data will be ignored. 136 | 137 | buffer.blit(other_buffer, row_offset, col_offset) void 138 | ``` 139 | #### Buffer Cursors 140 | 141 | in order to facilitate textual drawing, one can 'capture a cursor' at a given row and column, which can 142 | be passed around and written to like a normal IO endpoint. 143 | 144 | ```zig 145 | // buffer.cursorAt().writer().write() will not compile because 146 | // it captures the cursor as const, and write mutates the cursor's offset. 147 | // must use intermediate variable for the cursor as follows: 148 | var cursor = buffer.cursorAt(row_num,col_num) zbox.Buffer.WriteCursor 149 | var writer = cursor.writer(); // std.io.Writer; 150 | const bytes_written = try writer.write("hello"); 151 | ``` 152 | 153 | by default, buffer cursors are 'row-truncating'; if you write beyond the right boundary of the buffer, characters 154 | after the right boundary up to and including newline will be dropped, and input will proceed on the following row if one 155 | exists. If there is no next row to write to, no more data will be written, and `write` will return a count of the 156 | bytes written (including truncated/dropped bytes) up to that point. 157 | 158 | However, cursors can also operate in a 'wrapped' mode, which is exactly what it sounds like. In wrapped mode, 159 | writing beyond the right-most boundary of a row writes to the next line. Like a truncating cursor, writing stops 160 | when the cursor tries to move below the last line. 161 | 162 | ```zig 163 | var cursor = buffer.wrappedCursorAt(row_num,col_num) zbox.Buffer.WriteCursor 164 | ``` 165 | 166 | In both cases, cursors are structured to provide useful feedback about how much data was able to be written so that 167 | interfaces revolving around text can properly scroll/page the text using the feedback from the `write` operation like 168 | one would with a normal IO endpoint. 169 | 170 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const Builder = @import("std").build.Builder; 2 | const std = @import("std"); 3 | pub fn build(b: *Builder) void { 4 | const target = b.standardTargetOptions(.{}); 5 | const mode = b.standardReleaseOptions(); 6 | const b_opts = b.addOptions(); 7 | 8 | const dvd = b.addExecutable("dvd", "examples/dvd.zig"); 9 | const invaders = b.addExecutable("invaders", "examples/invaders.zig"); 10 | const tests = b.addTest("src/box.zig"); 11 | 12 | const example_log = b.fmt("{s}/{s}/{s}", .{ b.build_root, b.cache_root, "example.log" }); 13 | b_opts.addOption([]const u8, "log_path", example_log); 14 | dvd.addOptions("build_options",b_opts); 15 | dvd.setTarget(target); 16 | dvd.setBuildMode(mode); 17 | dvd.addPackagePath("zbox", "src/box.zig"); 18 | //dvd.addBuildOption([]const u8, "log_path", example_log); 19 | dvd.install(); 20 | 21 | invaders.addOptions("build_options",b_opts); 22 | invaders.setTarget(target); 23 | invaders.setBuildMode(mode); 24 | invaders.addPackagePath("zbox", "src/box.zig"); 25 | //invaders.addBuildOption([]const u8, "log_path", example_log); 26 | invaders.install(); 27 | 28 | tests.setTarget(target); 29 | tests.setBuildMode(mode); 30 | 31 | const dvd_cmd = dvd.run(); 32 | dvd_cmd.step.dependOn(b.getInstallStep()); 33 | 34 | const dvd_step = b.step("dvd", "Run bouncing DVD logo demo"); 35 | dvd_step.dependOn(&dvd_cmd.step); 36 | 37 | const invaders_cmd = invaders.run(); 38 | invaders_cmd.step.dependOn(b.getInstallStep()); 39 | 40 | const invaders_step = b.step("invaders", "console space invaders"); 41 | invaders_step.dependOn(&invaders_cmd.step); 42 | 43 | const test_step = b.step("test", "run package's test suite"); 44 | test_step.dependOn(&tests.step); 45 | } 46 | -------------------------------------------------------------------------------- /examples/dvd.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const display = @import("zbox"); 3 | const options = @import("build_options"); 4 | const page_allocator = std.heap.page_allocator; 5 | 6 | pub usingnamespace @import("log_handler.zig"); 7 | 8 | const dvd_text: []const u8 = 9 | \\ ## # ### 10 | \\ # # # # # # 11 | \\ # # # # # # 12 | \\ # # # # # 13 | \\ ## # ## 14 | \\ 15 | \\ ########## 16 | \\##### ##### 17 | \\ ########## 18 | ; 19 | pub fn main() !void { 20 | var alloc = page_allocator; 21 | 22 | // initialize the display with stdin/out 23 | try display.init(alloc); 24 | defer display.deinit(); 25 | 26 | // die on ctrl+C 27 | try display.handleSignalInput(); 28 | 29 | // load our cool 'image' 30 | var dvd_logo = try display.Buffer.init(alloc, 9, 13); 31 | defer dvd_logo.deinit(); 32 | var logo_cursor = dvd_logo.cursorAt(0, 0); 33 | try logo_cursor.writer().writeAll(dvd_text); 34 | 35 | //setup our drawing buffer 36 | var size = try display.size(); 37 | 38 | var output = try display.Buffer.init(alloc, size.height, size.width); 39 | defer output.deinit(); 40 | 41 | // variables for tracking the movement of the logo 42 | var x: isize = 0; 43 | var x_vel: isize = 1; 44 | var y: isize = 0; 45 | var y_vel: isize = 1; 46 | 47 | while (true) { 48 | 49 | // update the size of output buffer 50 | size = try display.size(); 51 | try output.resize(size.height, size.width); 52 | 53 | // draw our dvd logo 54 | output.clear(); 55 | output.blit(dvd_logo, y, x); 56 | try display.push(output); 57 | 58 | // update logo position by velocity 59 | x += x_vel; 60 | y += y_vel; 61 | 62 | // change our velocities if we are running into a wall 63 | if ((x_vel < 0 and x < 0) or 64 | (x_vel > 0 and @intCast(isize, dvd_logo.width) + x >= size.width)) 65 | x_vel *= -1; 66 | 67 | if ((y_vel < 0 and y < 0) or 68 | (y_vel > 0 and @intCast(isize, dvd_logo.height) + y >= size.height)) 69 | y_vel *= -1; 70 | 71 | std.os.nanosleep(0, 80_000_000); 72 | } 73 | } 74 | 75 | test "static anal" { 76 | std.meta.refAllDecls(@This()); 77 | } 78 | -------------------------------------------------------------------------------- /examples/invader-zig.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edt-xx/zbox/4b1c7ebb5cd7ea9982d1799ed2557eb49c28203d/examples/invader-zig.gif -------------------------------------------------------------------------------- /examples/invaders.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const display = @import("zbox"); 3 | const options = @import("build_options"); 4 | const page_allocator = std.heap.page_allocator; 5 | const ArrayList = std.ArrayList; 6 | 7 | pub usingnamespace @import("log_handler.zig"); 8 | 9 | const bad_char = '%'; 10 | const ship_char = '^'; 11 | const bullet_char = '.'; 12 | 13 | const bb_width = 7; 14 | const bb_height = 3; 15 | const baddie_block_init = [bb_height][bb_width]u8{ 16 | .{ 1, 0, 1, 0, 1, 0, 1 }, 17 | .{ 0, 1, 0, 1, 0, 1, 0 }, 18 | .{ 1, 0, 1, 0, 1, 0, 1 }, 19 | }; 20 | var baddie_block = baddie_block_init; 21 | var bb_y: usize = 0; 22 | var bb_countdown: usize = 3; 23 | 24 | const Bullet = struct { 25 | active: bool = false, 26 | x: usize = 0, 27 | y: usize = 0, 28 | }; 29 | 30 | var bullets = [_]Bullet{.{}} ** 4; 31 | var score: usize = 0; 32 | const width: usize = 7; 33 | const mid_width: usize = 4; 34 | const height: usize = 24; 35 | const mid_height = 11; 36 | var ship_x: usize = 4; // center of the screen. 37 | 38 | var state: enum { 39 | start, 40 | playing, 41 | win, 42 | lose, 43 | } = .playing; 44 | 45 | pub fn main() !void { 46 | var alloc = std.heap.page_allocator; 47 | 48 | // initialize the display with stdin/out 49 | try display.init(alloc); 50 | defer display.deinit(); 51 | 52 | // ignore ctrl+C 53 | try display.ignoreSignalInput(); 54 | try display.cursorHide(); 55 | defer display.cursorShow() catch {}; 56 | 57 | var game_display = try display.Buffer.init(alloc, height, width); 58 | defer game_display.deinit(); 59 | 60 | var output = try display.Buffer.init(alloc, height, width); 61 | defer output.deinit(); 62 | 63 | while (try display.nextEvent()) |e| { 64 | const size = try display.size(); 65 | output.clear(); 66 | try output.resize(size.height, size.width); 67 | 68 | if (size.height < height or size.width < width) { 69 | const row = std.math.max(0, size.height / 2); 70 | var cursor = output.cursorAt(row, 0); 71 | try cursor.writer().writeAll("display too small; resize."); 72 | try display.push(output); 73 | continue; 74 | } 75 | 76 | switch (e) { 77 | .left => if (ship_x > 0) { 78 | ship_x -= 1; 79 | }, 80 | .right => if (ship_x < width - 1) { 81 | ship_x += 1; 82 | }, 83 | 84 | .other => |data| { 85 | const eql = std.mem.eql; 86 | if (eql(u8, " ", data)) { 87 | std.log.scoped(.invaders).debug("pyoo", .{}); 88 | for (bullets) |*bullet| if (!bullet.active) { 89 | bullet.active = true; 90 | bullet.y = height - 1; 91 | bullet.x = ship_x; 92 | break; 93 | }; 94 | } 95 | }, 96 | 97 | .escape => return, 98 | else => {}, 99 | } 100 | 101 | game_display.clear(); 102 | 103 | game_display.cellRef(height - 1, ship_x).char = ship_char; 104 | 105 | for (bullets) |*bullet| { 106 | if (bullet.active) { 107 | if (bullet.y > 0) bullet.y -= 1; 108 | if (bullet.y == 0) { 109 | bullet.active = false; 110 | if (score > 0) score -= 1; 111 | } 112 | } 113 | } 114 | if (bb_countdown == 0) { 115 | bb_countdown = 6; 116 | bb_y += 1; 117 | } else bb_countdown -= 1; 118 | 119 | var baddie_count: usize = 0; 120 | for (baddie_block) |*baddie_row, row_offset| for (baddie_row.*) |*baddie, col_num| { 121 | const row_num = row_offset + bb_y; 122 | if (row_num >= height) continue; 123 | 124 | if (baddie.* > 0) { 125 | for (bullets) |*bullet| { 126 | if (bullet.x == col_num and 127 | bullet.y <= row_num and 128 | bullet.active) 129 | { 130 | score += 3; 131 | baddie.* -= 1; 132 | bullet.active = false; 133 | bullet.y = 0; 134 | bullet.x = 0; 135 | } 136 | if (row_num == height - 1) { // baddie reached bottom 137 | if (score >= 5) { 138 | score -= 5; 139 | } else { 140 | score = 0; 141 | } 142 | } 143 | } 144 | 145 | game_display.cellRef(row_num, col_num).* = .{ 146 | .char = bad_char, 147 | .attribs = .{ .fg_magenta = true }, 148 | }; 149 | baddie_count += 1; 150 | } 151 | }; 152 | 153 | if ((baddie_count == 0) or (bb_y >= height)) { 154 | bb_y = 0; 155 | baddie_block = baddie_block_init; 156 | bullets = [_]Bullet{.{}} ** 4; // clear all the bullets 157 | } 158 | 159 | for (bullets) |bullet| { 160 | if (bullet.active) 161 | game_display.cellRef(bullet.y, bullet.x).* = .{ 162 | .char = bullet_char, 163 | .attribs = .{ .fg_yellow = true }, 164 | }; 165 | } 166 | var score_curs = game_display.cursorAt(0, 3); 167 | score_curs.attribs = .{ .underline = true }; 168 | 169 | try score_curs.writer().print("{:0>4}", .{score}); 170 | 171 | const game_row = if (size.height >= height + 2) 172 | size.height / 2 - mid_height 173 | else 174 | 0; 175 | 176 | const game_col = if (size.width >= height + 2) 177 | size.width / 2 - mid_width 178 | else 179 | 0; 180 | 181 | output.blit(game_display, @intCast(isize, game_row), @intCast(isize, game_col)); 182 | try display.push(output); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /examples/log_handler.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const options = @import("build_options"); 3 | const term = @import("zbox"); 4 | 5 | pub fn log( 6 | comptime message_level: std.log.Level, 7 | comptime scope: @Type(.EnumLiteral), 8 | comptime format: []const u8, 9 | args: anytype, 10 | ) void { 11 | const logfile = std.fs.cwd().createFile(options.log_path, .{ .truncate = false }) catch return; 12 | defer logfile.close(); 13 | const writer = logfile.writer(); 14 | const end = logfile.getEndPos() catch return; 15 | logfile.seekTo(end) catch return; 16 | 17 | writer.print("{s}: {s}:" ++ format ++ "\n", .{ @tagName(message_level), @tagName(scope) } ++ args) catch return; 18 | } 19 | 20 | pub fn panic(msg: []const u8, trace: ?*std.builtin.StackTrace) noreturn { 21 | term.deinit(); 22 | //std.debug.print("wtf?", .{}); 23 | log(.emerg, .examples, "{s}", .{msg}); 24 | std.builtin.default_panic(msg, trace); 25 | } 26 | -------------------------------------------------------------------------------- /src/box.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const mem = std.mem; 3 | const math = std.math; 4 | const assert = std.debug.assert; 5 | const Allocator = mem.Allocator; 6 | const term = @import("prim.zig"); 7 | 8 | // promote some primitive ops 9 | pub const size = term.size; 10 | pub const ignoreSignalInput = term.ignoreSignalInput; 11 | pub const handleSignalInput = term.handleSignalInput; 12 | pub const cursorShow = term.cursorShow; 13 | pub const cursorHide = term.cursorHide; 14 | pub const nextEvent = term.nextEvent; 15 | pub const setTimeout = term.setTimeout; 16 | pub const clear = term.clear; 17 | pub const Event = term.Event; 18 | 19 | pub const ErrorSet = struct { 20 | pub const Term = term.ErrorSet; 21 | pub const Write = Term.Write || std.os.WriteError; 22 | pub const Utf8Encode = error{ 23 | Utf8CannotEncodeSurrogateHalf, 24 | CodepointTooLarge, 25 | }; 26 | }; 27 | 28 | usingnamespace @import("util.zig"); 29 | 30 | /// must be called before any buffers are `push`ed to the terminal. 31 | pub fn init(allocator: Allocator) ErrorSet.Term.Setup!void { 32 | front = try Buffer.init(allocator, 24, 80); 33 | errdefer front.deinit(); 34 | 35 | try term.setup(allocator); 36 | } 37 | 38 | /// should be called prior to program exit 39 | pub fn deinit() void { 40 | front.deinit(); 41 | term.teardown(); 42 | } 43 | 44 | /// compare state of input buffer to a buffer tracking display state 45 | /// and send changes to the terminal. 46 | pub fn push(buffer: Buffer) (Allocator.Error || ErrorSet.Utf8Encode || ErrorSet.Write)!void { 47 | 48 | // resizing the front buffer naively can lead to artifacting 49 | // if we do not clear the terminal here. 50 | if ((buffer.width != front.width) or (buffer.height != front.height)) { 51 | try term.clear(); 52 | front.clear(); 53 | } 54 | 55 | try front.resize(buffer.height, buffer.width); 56 | var row: usize = 0; 57 | 58 | //try term.beginSync(); 59 | while (row < buffer.height) : (row += 1) { 60 | var col: usize = 0; 61 | var last_touched: usize = buffer.width; // out of bounds, can't match col 62 | while (col < buffer.width) : (col += 1) { 63 | 64 | // go to the next character if these are the same. 65 | if (Cell.eql( 66 | front.cell(row, col), 67 | buffer.cell(row, col), 68 | )) continue; 69 | 70 | // only send cursor movement sequence if the last modified 71 | // cell was not the immediately previous cell in this row 72 | if (last_touched != col) 73 | try term.cursorTo(row, col); 74 | 75 | last_touched = col+1; 76 | 77 | const cell = buffer.cell(row, col); 78 | front.cellRef(row, col).* = cell; 79 | 80 | var codepoint: [4]u8 = undefined; 81 | const len = try std.unicode.utf8Encode(cell.char, &codepoint); 82 | 83 | try term.sendSGR(cell.attribs); 84 | try term.send(codepoint[0..len]); 85 | } 86 | } 87 | //try term.endSync(); 88 | 89 | try term.flush(); 90 | } 91 | 92 | /// structure that represents a single textual character on screen 93 | pub const Cell = struct { 94 | char: u21 = ' ', 95 | attribs: term.SGR = term.SGR{}, 96 | fn eql(self: Cell, other: Cell) bool { 97 | return self.char == other.char and self.attribs.eql(other.attribs); 98 | } 99 | }; 100 | 101 | /// structure on which terminal drawing and printing operations are performed. 102 | pub const Buffer = struct { 103 | data: []Cell, 104 | height: usize, 105 | width: usize, 106 | 107 | allocator: Allocator, 108 | 109 | pub const Writer = std.io.Writer( 110 | *WriteCursor, 111 | WriteCursor.Error, 112 | WriteCursor.writeFn, 113 | ); 114 | 115 | /// State tracking for an `io.Writer` into a `Buffer`. Buffers do not hold onto 116 | /// any information about cursor position, so a sequential operations like writing to 117 | /// it is not well defined without a helper like this. 118 | pub const WriteCursor = struct { 119 | row_num: usize, 120 | col_num: usize, 121 | /// wrap determines how to continue writing when the the text meets 122 | /// the last column in a row. In truncate mode, the text until the next newline 123 | /// is dropped. In wrap mode, input is moved to the first column of the next row. 124 | wrap: bool = false, 125 | 126 | attribs: term.SGR = term.SGR{}, 127 | buffer: *Buffer, 128 | 129 | const Error = error{ InvalidUtf8, InvalidCharacter }; 130 | 131 | fn writeFn(self: *WriteCursor, bytes: []const u8) Error!usize { 132 | if (self.row_num >= self.buffer.height) return 0; 133 | 134 | var cp_iter = (try std.unicode.Utf8View.init(bytes)).iterator(); 135 | var bytes_written: usize = 0; 136 | while (cp_iter.nextCodepoint()) |cp| { 137 | if (self.col_num >= self.buffer.width and self.wrap) { 138 | self.col_num = 0; 139 | self.row_num += 1; 140 | } 141 | if (self.row_num >= self.buffer.height) return bytes_written; 142 | 143 | switch (cp) { 144 | //TODO: handle other line endings and return an error when 145 | // encountering unpritable or width-breaking codepoints. 146 | '\n' => { 147 | self.col_num = 0; 148 | self.row_num += 1; 149 | }, 150 | else => { 151 | if (self.col_num < self.buffer.width) 152 | self.buffer.cellRef(self.row_num, self.col_num).* = .{ 153 | .char = cp, 154 | .attribs = self.attribs, 155 | }; 156 | self.col_num += 1; 157 | }, 158 | } 159 | bytes_written = cp_iter.i; 160 | } 161 | return bytes_written; 162 | } 163 | 164 | pub fn writer(self: *WriteCursor) Writer { 165 | return .{ .context = self }; 166 | } 167 | }; 168 | 169 | /// constructs a `WriteCursor` for the buffer at a given offset. 170 | pub fn cursorAt(self: *Buffer, row_num: usize, col_num: usize) WriteCursor { 171 | return .{ 172 | .row_num = row_num, 173 | .col_num = col_num, 174 | .buffer = self, 175 | }; 176 | } 177 | 178 | /// constructs a `WriteCursor` for the buffer at a given offset. data written 179 | /// through a wrapped cursor wraps around to the next line when it reaches the right 180 | /// edge of the row. 181 | pub fn wrappedCursorAt(self: *Buffer, row_num: usize, col_num: usize) WriteCursor { 182 | var cursor = self.cursorAt(row_num, col_num); 183 | cursor.wrap = true; 184 | return cursor; 185 | } 186 | 187 | pub fn clear(self: *Buffer) void { 188 | mem.set(Cell, self.data, .{}); 189 | } 190 | 191 | pub fn init(allocator: Allocator, height: usize, width: usize) Allocator.Error!Buffer { 192 | var self = Buffer{ 193 | .data = try allocator.alloc(Cell, width * height), 194 | .width = width, 195 | .height = height, 196 | .allocator = allocator, 197 | }; 198 | self.clear(); 199 | return self; 200 | } 201 | 202 | pub fn deinit(self: *Buffer) void { 203 | self.allocator.free(self.data); 204 | } 205 | 206 | /// return a slice representing a row at a given context. Generic over the constness 207 | /// of self; if the buffer is const, the slice elements are const. 208 | pub fn row(self: anytype, row_num: usize) RowType: { 209 | switch (@typeInfo(@TypeOf(self))) { 210 | .Pointer => |p| { 211 | if (p.child != Buffer) @compileError("expected Buffer"); 212 | if (p.is_const) 213 | break :RowType []const Cell 214 | else 215 | break :RowType []Cell; 216 | }, 217 | else => { 218 | if (@TypeOf(self) != Buffer) @compileError("expected Buffer"); 219 | break :RowType []const Cell; 220 | }, 221 | } 222 | } { 223 | assert(row_num < self.height); 224 | const row_idx = row_num * self.width; 225 | return self.data[row_idx .. row_idx + self.width]; 226 | } 227 | 228 | /// return a reference to the cell at the given row and column number. generic over 229 | /// the constness of self; if self is const, the cell pointed to is also const. 230 | pub fn cellRef(self: anytype, row_num: usize, col_num: usize) RefType: { 231 | switch (@typeInfo(@TypeOf(self))) { 232 | .Pointer => |p| { 233 | if (p.child != Buffer) @compileError("expected Buffer"); 234 | if (p.is_const) 235 | break :RefType *const Cell 236 | else 237 | break :RefType *Cell; 238 | }, 239 | else => { 240 | if (@TypeOf(self) != Buffer) @compileError("expected Buffer"); 241 | break :RefType *const Cell; 242 | }, 243 | } 244 | } { 245 | assert(col_num < self.width); 246 | 247 | return &self.row(row_num)[col_num]; 248 | } 249 | 250 | /// return a copy of the cell at a given offset 251 | pub fn cell(self: Buffer, row_num: usize, col_num: usize) Cell { 252 | assert(col_num < self.width); 253 | return self.row(row_num)[col_num]; 254 | } 255 | 256 | /// fill a buffer with the given cell 257 | pub fn fill(self: *Buffer, a_cell: Cell) void { 258 | mem.set(Cell, self.data, a_cell); 259 | } 260 | 261 | /// grows or shrinks a cell buffer ensuring alignment by line and column 262 | /// data is lost in shrunk dimensions, and new space is initialized 263 | /// as the default cell in grown dimensions. 264 | pub fn resize(self: *Buffer, height: usize, width: usize) Allocator.Error!void { 265 | if (self.height == height and self.width == width) return; 266 | //TODO: figure out more ways to minimize unnecessary reallocation and 267 | //redrawing here. for instance: 268 | // `if self.width < width and self.height < self.height` no redraw or 269 | // realloc required 270 | // more difficult: 271 | // `if self.width * self.height >= width * height` requires redraw 272 | // but could possibly use some sort of scratch buffer thing. 273 | const old = self.*; 274 | self.* = .{ 275 | .allocator = old.allocator, 276 | .width = width, 277 | .height = height, 278 | .data = try old.allocator.alloc(Cell, width * height), 279 | }; 280 | 281 | if (width > old.width or 282 | height > old.height) self.clear(); 283 | 284 | const min_height = math.min(old.height, height); 285 | const min_width = math.min(old.width, width); 286 | 287 | var n: usize = 0; 288 | while (n < min_height) : (n += 1) { 289 | mem.copy(Cell, self.row(n), old.row(n)[0..min_width]); 290 | } 291 | self.allocator.free(old.data); 292 | } 293 | 294 | // draw the contents of 'other' on top of the contents of self at the provided 295 | // offset. anything out of bounds of the destination is ignored. row_num and col_num 296 | // are still 1-indexed; this means 0 is out of bounds by 1, and -1 is out of bounds 297 | // by 2. This may change. 298 | pub fn blit(self: *Buffer, other: Buffer, row_num: isize, col_num: isize) void { 299 | var self_row_idx = row_num; 300 | var other_row_idx: usize = 0; 301 | 302 | while (self_row_idx < self.height and other_row_idx < other.height) : ({ 303 | self_row_idx += 1; 304 | other_row_idx += 1; 305 | }) { 306 | if (self_row_idx < 0) continue; 307 | 308 | var self_col_idx = col_num; 309 | var other_col_idx: usize = 0; 310 | 311 | while (self_col_idx < self.width and other_col_idx < other.width) : ({ 312 | self_col_idx += 1; 313 | other_col_idx += 1; 314 | }) { 315 | if (self_col_idx < 0) continue; 316 | 317 | self.cellRef( 318 | @intCast(usize, self_row_idx), 319 | @intCast(usize, self_col_idx), 320 | ).* = other.cell(other_row_idx, other_col_idx); 321 | } 322 | } 323 | } 324 | 325 | // std.fmt compatibility for debugging 326 | pub fn format( 327 | self: Buffer, 328 | comptime fmt: []const u8, 329 | options: std.fmt.FormatOptions, 330 | writer: anytype, 331 | ) @TypeOf(writer).Error!void { 332 | _ = fmt; 333 | _ = options; 334 | var row_num: usize = 0; 335 | try writer.print("\n\x1B[4m|", .{}); 336 | 337 | while (row_num < self.height) : (row_num += 1) { 338 | for (self.row(row_num)) |this_cell| { 339 | var utf8Seq: [4]u8 = undefined; 340 | const len = std.unicode.utf8Encode(this_cell.char, &utf8Seq) catch unreachable; 341 | try writer.print("{}|", .{utf8Seq[0..len]}); 342 | } 343 | 344 | if (row_num != self.height - 1) 345 | try writer.print("\n|", .{}); 346 | } 347 | 348 | try writer.print("\x1B[0m\n", .{}); 349 | } 350 | }; 351 | 352 | const Size = struct { 353 | height: usize, 354 | width: usize, 355 | }; 356 | /// represents the last drawn state of the terminal 357 | var front: Buffer = undefined; 358 | 359 | // tests /////////////////////////////////////////////////////////////////////// 360 | //////////////////////////////////////////////////////////////////////////////// 361 | test "Buffer.resize()" { 362 | var buffer = try Buffer.init(std.testing.allocator, 10, 10); 363 | defer buffer.deinit(); 364 | 365 | // newly initialized buffer should have all cells set to default value 366 | for (buffer.data) |cell| { 367 | std.testing.expectEqual(Cell{}, cell); 368 | } 369 | for (buffer.row(4)[0..3]) |*cell| { 370 | cell.char = '.'; 371 | } 372 | 373 | try buffer.resize(5, 12); 374 | 375 | // make sure data is preserved between resizes 376 | for (buffer.row(4)[0..3]) |cell| { 377 | std.testing.expectEqual(@as(u21, '.'), cell.char); 378 | } 379 | 380 | // ensure nothing weird was written to expanded rows 381 | for (buffer.row(2)[3..]) |cell| { 382 | std.testing.expectEqual(Cell{}, cell); 383 | } 384 | } 385 | 386 | // most useful tests of this are function tests 387 | // see `examples/` 388 | test "buffer.cellRef()" { 389 | var buffer = try Buffer.init(std.testing.allocator, 1, 1); 390 | defer buffer.deinit(); 391 | 392 | const ref = buffer.cellRef(0, 0); 393 | ref.* = Cell{ .char = '.' }; 394 | 395 | std.testing.expectEqual(@as(u21, '.'), buffer.cell(0, 0).char); 396 | } 397 | 398 | test "buffer.cursorAt()" { 399 | var buffer = try Buffer.init(std.testing.allocator, 10, 10); 400 | defer buffer.deinit(); 401 | 402 | var cursor = buffer.cursorAt(9, 5); 403 | const n = try cursor.writer().write("hello!!!!!\n!!!!"); 404 | 405 | std.debug.print("{}", .{buffer}); 406 | 407 | std.testing.expectEqual(@as(usize, 11), n); 408 | } 409 | 410 | test "Buffer.blit()" { 411 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 412 | defer arena.deinit(); 413 | var alloc = &arena.allocator; 414 | var buffer1 = try Buffer.init(alloc, 10, 10); 415 | var buffer2 = try Buffer.init(alloc, 5, 5); 416 | buffer2.fill(.{ .char = '#' }); 417 | std.debug.print("{}", .{buffer2}); 418 | std.debug.print("blit(-2,6)", .{}); 419 | buffer1.blit(buffer2, -2, 6); 420 | std.debug.print("{}", .{buffer1}); 421 | } 422 | 423 | test "wrappedWrite" { 424 | var buffer = try Buffer.init(std.testing.allocator, 5, 5); 425 | defer buffer.deinit(); 426 | 427 | var cursor = buffer.wrappedCursorAt(4, 0); 428 | 429 | const n = try cursor.writer().write("hello!!!!!"); 430 | 431 | std.debug.print("{}", .{buffer}); 432 | 433 | std.testing.expectEqual(@as(usize, 5), n); 434 | } 435 | 436 | test "static anal" { 437 | std.meta.refAllDecls(@This()); 438 | std.meta.refAllDecls(Cell); 439 | std.meta.refAllDecls(Buffer); 440 | std.meta.refAllDecls(Buffer.WriteCursor); 441 | } 442 | -------------------------------------------------------------------------------- /src/prim.zig: -------------------------------------------------------------------------------- 1 | //! the primitive terminal module is mainly responsible for providing a simple 2 | //! and portable interface to pseudo terminal IO and control primitives to 3 | //! higher level modules. You probably shouldn't be using this directly from 4 | //! application code. 5 | 6 | const std = @import("std"); 7 | const fs = std.fs; 8 | const os = std.os; 9 | const io = std.io; 10 | const mem = std.mem; 11 | const fmt = std.fmt; 12 | 13 | const assert = std.debug.assert; 14 | const ArrayList = std.ArrayList; 15 | const Allocator = mem.Allocator; 16 | 17 | usingnamespace @import("util.zig"); 18 | 19 | /// Input events 20 | pub const Event = union(enum) { 21 | tick, 22 | escape, 23 | up, 24 | down, 25 | left, 26 | right, 27 | other: []const u8, 28 | }; 29 | 30 | pub const SGR = packed struct { 31 | bold: bool = false, 32 | underline: bool = false, 33 | reverse: bool = false, 34 | fg_black: bool = false, 35 | bg_black: bool = false, 36 | fg_red: bool = false, 37 | bg_red: bool = false, 38 | fg_green: bool = false, 39 | bg_green: bool = false, 40 | fg_yellow: bool = false, 41 | bg_yellow: bool = false, 42 | fg_blue: bool = false, 43 | bg_blue: bool = false, 44 | fg_magenta: bool = false, 45 | bg_magenta: bool = false, 46 | fg_cyan: bool = false, 47 | bg_cyan: bool = false, 48 | fg_white: bool = false, 49 | bg_white: bool = false, 50 | 51 | // not 52 | pub fn invert(self: SGR) SGR { 53 | var other = SGR{}; 54 | inline for (@typeInfo(SGR).Struct.fields) |field| { 55 | @field(other, field.name) = !@field(self, field.name); 56 | } 57 | return other; 58 | } 59 | // and 60 | pub fn intersect(self: SGR, other: SGR) SGR { 61 | var new = SGR{}; 62 | inline for (@typeInfo(SGR).Struct.fields) |field| { 63 | @field(new, field.name) = 64 | @field(self, field.name) and @field(other, field.name); 65 | } 66 | return new; 67 | } 68 | // or 69 | pub fn unify(self: SGR, other: SGR) SGR { 70 | var new = SGR{}; 71 | inline for (@typeInfo(SGR).Struct.fields) |field| { 72 | @field(new, field.name) = 73 | @field(self, field.name) or @field(other, field.name); 74 | } 75 | return new; 76 | } 77 | pub fn eql(self: SGR, other: SGR) bool { 78 | inline for (@typeInfo(SGR).Struct.fields) |field| { 79 | if (!(@field(self, field.name) == @field(other, field.name))) 80 | return false; 81 | } 82 | return true; 83 | } 84 | }; 85 | 86 | pub const InTty = fs.File.Reader; 87 | pub const OutTty = fs.File.Writer; 88 | 89 | pub const ErrorSet = struct { 90 | pub const BufWrite = ArrayList(u8).Writer.Error; 91 | pub const TtyWrite = OutTty.Error; 92 | pub const TtyRead = InTty.Error; 93 | pub const Write = ErrorSet.BufWrite || ErrorSet.TtyWrite; 94 | pub const Read = ErrorSet.TtyRead; 95 | pub const Termios = std.os.TermiosGetError || std.os.TermiosSetError; 96 | pub const Setup = Allocator.Error || ErrorSet.Termios || ErrorSet.TtyWrite || fs.File.OpenError; 97 | }; 98 | 99 | /// write raw text to the terminal output buffer 100 | pub fn send(seq: []const u8) ErrorSet.BufWrite!void { 101 | try state().buffer.out.writer().writeAll(seq); 102 | } 103 | 104 | pub fn sendSGR(sgr: SGR) ErrorSet.BufWrite!void { 105 | try send(csi ++ "0"); // always clear 106 | if (sgr.bold) try send(";1"); 107 | if (sgr.underline) try send(";4"); 108 | if (sgr.reverse) try send(";7"); 109 | if (sgr.fg_black) try send(";30"); 110 | if (sgr.bg_black) try send(";40"); 111 | if (sgr.fg_red) try send(";31"); 112 | if (sgr.bg_red) try send(";41"); 113 | if (sgr.fg_green) try send(";32"); 114 | if (sgr.bg_green) try send(";42"); 115 | if (sgr.fg_yellow) try send(";33"); 116 | if (sgr.bg_yellow) try send(";43"); 117 | if (sgr.fg_blue) try send(";34"); 118 | if (sgr.bg_blue) try send(";44"); 119 | if (sgr.fg_magenta) try send(";35"); 120 | if (sgr.bg_magenta) try send(";45"); 121 | if (sgr.fg_cyan) try send(";36"); 122 | if (sgr.bg_cyan) try send(";46"); 123 | if (sgr.fg_white) try send(";37"); 124 | if (sgr.bg_white) try send(";74"); 125 | try send("m"); 126 | } 127 | 128 | /// flush the terminal output buffer to the terminal 129 | pub fn flush() ErrorSet.TtyWrite!void { 130 | const self = state(); 131 | try self.tty.out.writeAll(self.buffer.out.items); 132 | self.buffer.out.items.len = 0; 133 | } 134 | /// clear the entire terminal 135 | pub fn clear() ErrorSet.BufWrite!void { 136 | try sequence("2J"); 137 | } 138 | 139 | pub fn beginSync() ErrorSet.BufWrite!void { 140 | try send("\x1BP=1s\x1B\\"); 141 | } 142 | 143 | pub fn endSync() ErrorSet.BufWrite!void { 144 | try send("\x1BP=2s\x1B\\"); 145 | } 146 | 147 | /// provides size of screen as the bottom right most position that you can move 148 | /// your cursor to. 149 | const TermSize = struct { height: usize, width: usize }; 150 | pub fn size() os.UnexpectedError!TermSize { 151 | var winsize = mem.zeroes(os.system.winsize); 152 | const err = os.system.ioctl(state().tty.in.context.handle, os.system.T.IOCGWINSZ, @ptrToInt(&winsize)); 153 | if (os.errno(err) == .SUCCESS) 154 | return TermSize{ .height = winsize.ws_row, .width = winsize.ws_col }; 155 | return os.unexpectedErrno(os.errno(err)); 156 | } 157 | 158 | /// Hides cursor if visible 159 | pub fn cursorHide() ErrorSet.BufWrite!void { 160 | try sequence("?25l"); 161 | } 162 | 163 | /// Shows cursor if hidden. 164 | pub fn cursorShow() ErrorSet.BufWrite!void { 165 | try sequence("?25h"); 166 | } 167 | 168 | /// warp the cursor to the specified `row` and `col` in the current scrolling 169 | /// region. 170 | pub fn cursorTo(row: usize, col: usize) ErrorSet.BufWrite!void { 171 | try formatSequence("{};{}H", .{ row + 1, col + 1 }); 172 | } 173 | 174 | /// set up terminal for graphical operation 175 | pub fn setup(alloc: Allocator) ErrorSet.Setup!void { 176 | errdefer termState = null; 177 | termState = .{}; 178 | const self = state(); 179 | 180 | self.buffer.in = try ArrayList(u8).initCapacity(alloc, 4096); 181 | errdefer self.buffer.in.deinit(); 182 | self.buffer.out = try ArrayList(u8).initCapacity(alloc, 4096); 183 | errdefer self.buffer.out.deinit(); 184 | 185 | //TODO: check that we are actually dealing with a tty here 186 | // and either downgrade or error 187 | self.tty.in = (try fs.cwd().openFile("/dev/tty", .{ .mode = .read_only })).reader(); 188 | errdefer self.tty.in.context.close(); 189 | self.tty.out = (try fs.cwd().openFile("/dev/tty", .{ .mode = .write_only })).writer(); 190 | errdefer self.tty.out.context.close(); 191 | 192 | // store current terminal settings 193 | // and setup the terminal for graphical IO 194 | self.original_termios = try os.tcgetattr(self.tty.in.context.handle); 195 | var termios = self.original_termios; 196 | 197 | // termios flags for 'raw' mode. 198 | termios.iflag &= ~@as( 199 | os.system.tcflag_t, 200 | os.system.IGNBRK | os.system.BRKINT | os.system.PARMRK | os.system.ISTRIP | 201 | os.system.INLCR | os.system.IGNCR | os.system.ICRNL | os.system.IXON, 202 | ); 203 | termios.lflag &= ~@as( 204 | os.system.tcflag_t, 205 | os.system.ICANON | os.system.ECHO | os.system.ECHONL | os.system.IEXTEN | os.system.ISIG, 206 | ); 207 | termios.oflag &= ~@as(os.system.tcflag_t, os.system.OPOST); 208 | termios.cflag &= ~@as(os.system.tcflag_t, os.system.CSIZE | os.system.PARENB); 209 | 210 | termios.cflag |= os.system.CS8; 211 | 212 | termios.cc[VMIN] = 0; // read can timeout before any data is actually written; async timer 213 | termios.cc[VTIME] = 1; // 1/10th of a second 214 | 215 | try os.tcsetattr(self.tty.in.context.handle, .FLUSH, termios); 216 | errdefer os.tcsetattr(self.tty.in.context.handle, .FLUSH, self.original_termios) catch {}; 217 | try enterAltScreen(); 218 | errdefer exitAltScreen() catch unreachable; 219 | 220 | try truncMode(); 221 | try overwriteMode(); 222 | try keypadMode(); 223 | try cursorTo(1, 1); 224 | try flush(); 225 | } 226 | 227 | // set terminal input maximum wait time in 1/10 seconds unit, zero is no wait 228 | pub fn setTimeout(tenths:u8) ErrorSet.Termios!void { 229 | const handle = state().tty.in.context.handle; 230 | 231 | var termios = try os.tcgetattr(handle); 232 | termios.cc[VTIME] = tenths; 233 | try os.tcsetattr(handle, .FLUSH, termios); 234 | } 235 | 236 | /// generate a terminal/job control signals with certain hotkeys 237 | /// Ctrl-C, Ctrl-Z, Ctrl-S, etc 238 | pub fn handleSignalInput() ErrorSet.Termios!void { 239 | const handle = state().tty.in.context.handle; 240 | 241 | var termios = try os.tcgetattr(handle); 242 | termios.lflag |= os.system.ISIG; 243 | 244 | try os.tcsetattr(handle, .FLUSH, termios); 245 | } 246 | 247 | /// treat terminal/job control hotkeys as normal input 248 | /// Ctrl-C, Ctrl-Z, Ctrl-S, etc 249 | pub fn ignoreSignalInput() ErrorSet.Termios!void { 250 | const handle = state().tty.in.context.handle; 251 | var termios = try os.tcgetattr(handle); 252 | 253 | termios.lflag &= ~@as(os.system.tcflag_t, os.system.ISIG); 254 | 255 | try os.tcsetattr(handle, .FLUSH, termios); 256 | } 257 | 258 | /// restore as much of the terminals's original state as possible 259 | pub fn teardown() void { 260 | const self = state(); 261 | 262 | exitAltScreen() catch {}; 263 | flush() catch {}; 264 | os.tcsetattr(self.tty.in.context.handle, .FLUSH, self.original_termios) catch {}; 265 | 266 | self.tty.in.context.close(); 267 | self.tty.out.context.close(); 268 | self.buffer.in.deinit(); 269 | self.buffer.out.deinit(); 270 | 271 | termState = null; 272 | } 273 | 274 | /// read next message from the tty and parse it. takes 275 | /// special action for certain events 276 | pub fn nextEvent() (Allocator.Error || ErrorSet.TtyRead)!?Event { 277 | const max_bytes = 4096; 278 | var total_bytes: usize = 0; 279 | 280 | const self = state(); 281 | while (true) { 282 | try self.buffer.in.resize(total_bytes + max_bytes); 283 | const bytes_read = try self.tty.in.context.read( 284 | self.buffer.in.items[total_bytes .. max_bytes + total_bytes], 285 | ); 286 | total_bytes += bytes_read; 287 | 288 | if (bytes_read < max_bytes) { 289 | self.buffer.in.items.len = total_bytes; 290 | break; 291 | } 292 | } 293 | const event = parseEvent(); 294 | //std.log.debug("event: {}", .{event}); 295 | return event; 296 | } 297 | 298 | // internals /////////////////////////////////////////////////////////////////// 299 | 300 | const TermState = struct { 301 | tty: struct { 302 | in: InTty = undefined, 303 | out: OutTty = undefined, 304 | } = .{}, 305 | buffer: struct { 306 | in: ArrayList(u8) = undefined, 307 | out: ArrayList(u8) = undefined, 308 | } = .{}, 309 | original_termios: os.system.termios = undefined, 310 | }; 311 | var termState: ?TermState = null; 312 | 313 | fn state() callconv(.Inline) *TermState { 314 | if (std.debug.runtime_safety) { 315 | if (termState) |*self| return self else @panic("terminal is not initialized"); 316 | } else return &termState.?; 317 | } 318 | 319 | fn parseEvent() ?Event { 320 | const data = state().buffer.in.items; 321 | const eql = std.mem.eql; 322 | 323 | if (data.len == 0) return Event.tick; 324 | 325 | if (eql(u8, data, "\x1B")) 326 | return Event.escape 327 | else if (eql(u8, data, "\x1B[A") or eql(u8, data, "\x1BOA")) 328 | return Event.up 329 | else if (eql(u8, data, "\x1B[B") or eql(u8, data, "\x1BOB")) 330 | return Event.down 331 | else if (eql(u8, data, "\x1B[C") or eql(u8, data, "\x1BOC")) 332 | return Event.right 333 | else if (eql(u8, data, "\x1B[D") or eql(u8, data, "\x1BOD")) 334 | return Event.left 335 | else 336 | return Event{ .other = data }; 337 | } 338 | 339 | // terminal mode setting functions. //////////////////////////////////////////// 340 | 341 | /// sending text to the terminal at a specific offset overwrites preexisting text 342 | /// in this mode. 343 | fn overwriteMode() ErrorSet.BufWrite!void { 344 | try sequence("4l"); 345 | } 346 | 347 | /// sending text to the terminat at a specific offset pushes preexisting text to 348 | /// the right of the the line in this mode 349 | fn insertMode() ErrorSet.BufWrite!void { 350 | try sequence("4h"); 351 | } 352 | 353 | /// when the cursor, or text being moved by insertion reaches the last column on 354 | /// the terminal in this mode, it moves to the next like 355 | fn wrapMode() ErrorSet.BufWrite!void { 356 | try sequence("?7h"); 357 | } 358 | /// when the cursor reaches the last column on the terminal in this mode, it 359 | /// stops, and further writing changes the contents of the final column in place. 360 | /// when text being pushed by insertion reaches the final column, it is pushed 361 | /// out of the terminal buffer and lost. 362 | fn truncMode() ErrorSet.BufWrite!void { 363 | try sequence("?7l"); 364 | } 365 | 366 | /// not entirely sure what this does, but it is something about changing the 367 | /// sequences generated by certain types of input, and is usually called when 368 | /// initializing the terminal for 'non-cannonical' input. 369 | fn keypadMode() ErrorSet.BufWrite!void { 370 | try sequence("?1h"); 371 | try send("\x1B="); 372 | } 373 | 374 | // saves the cursor and then sends a couple of version of the altscreen 375 | // sequence 376 | // this allows you to restore the contents of the display by calling 377 | // exitAltScreeen() later when the program is exiting. 378 | fn enterAltScreen() ErrorSet.BufWrite!void { 379 | try sequence("s"); 380 | try sequence("?47h"); 381 | try sequence("?1049h"); 382 | } 383 | 384 | // restores the cursor and then sends a couple version sof the exit_altscreen 385 | // sequence. 386 | fn exitAltScreen() ErrorSet.BufWrite!void { 387 | try sequence("u"); 388 | try sequence("?47l"); 389 | try sequence("?1049l"); 390 | } 391 | 392 | // escape sequence construction and printing /////////////////////////////////// 393 | const csi = "\x1B["; 394 | 395 | fn sequence(comptime seq: []const u8) ErrorSet.BufWrite!void { 396 | try send(csi ++ seq); 397 | } 398 | 399 | fn format(comptime template: []const u8, args: anytype) ErrorSet.BufWrite!void { 400 | const self = state(); 401 | try self.buffer.out.writer().print(template, args); 402 | } 403 | fn formatSequence(comptime template: []const u8, args: anytype) ErrorSet.BufWrite!void { 404 | try format(csi ++ template, args); 405 | } 406 | 407 | // TODO: these are not portable across architectures 408 | // they should be getting pulled in from c headers or 409 | // make it into linux/bits per architecture. 410 | const VTIME = 5; 411 | const VMIN = 6; 412 | 413 | test "static anal" { 414 | std.meta.refAllDecls(@This()); 415 | std.meta.refAllDecls(Event); 416 | std.meta.refAllDecls(SGR); 417 | } 418 | -------------------------------------------------------------------------------- /src/util.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | /// https://github.com/ziglang/zig/issues/5641 4 | pub fn todo() noreturn { 5 | if (std.builtin.mode == .Debug) { 6 | std.debug.panic("TODO: implement me", .{}); 7 | } else { 8 | @compileError("TODO: implement me"); 9 | } 10 | } 11 | pub fn debug(comptime template: []const u8, args: anytype) void { 12 | // log only if the user provides a logger 13 | // as the stderr default will break terminal state 14 | if (!@hasDecl(@import("root"), "log")) return; 15 | 16 | //TODO: possible implementation that uses the library 17 | // to provide an in-application logging buffer. 18 | std.log.scoped(.zbox).debug(template, args); 19 | } 20 | 21 | pub fn utf8ToWide(utf8: []const u8, chars: []u21) ![]u21 { 22 | var iter = (try std.unicode.Utf8View.init(utf8)).iterator(); 23 | var offset: usize = 0; 24 | while (iter.nextCodepoint()) |cp| : (offset += 1) 25 | chars[offset] = cp; 26 | 27 | var new_chars = chars; 28 | new_chars.len = offset; 29 | return new_chars; 30 | } 31 | 32 | test "static anal" { 33 | std.meta.refAllDecls(@This()); 34 | } 35 | --------------------------------------------------------------------------------