const std = @import("std"); const fs = std.fs; const posix = std.posix; const borders = @import("borders.zig"); const dim = @import("dimensions.zig"); const color = @import("colors.zig"); const term_test = @import("test/term.zig"); var orig_termios: posix.termios = undefined; pub const MenuError = error{ForgottenMenuItem}; pub const KeyType = enum { NO_KEY, ASCII, SEQUENCE, }; pub const SequenceKey = enum(u8) { UP, DOWN, LEFT, RIGHT, DELETE, UNKNOWN, }; pub const Key = struct { type: KeyType, value: u8, }; pub const PrintFlags = struct { dim: bool = false, bold: bool = false, italic: bool = false, underline: bool = false, highlight: bool = false, }; pub const SelectMenuItem = struct { name: []const u8, selected: bool = false, }; pub const SelectMenu = struct { title: []const u8, items: []SelectMenuItem, focused_item: usize = 0, cancelled: bool = false, pub fn init(title: []const u8, items: []SelectMenuItem) SelectMenu { return .{ .title = title, .items = items }; } pub fn reset(self: *SelectMenu) void { self.focused_item = 0; self.cancelled = false; } pub fn refresh(self: *SelectMenu, term_io: *TermIO) !void { term_io.clear(); try term_io.print("{s}", .{self.title}); for (self.items, 0..) |_, i| { try self.printItem(i, term_io); } } pub fn show(self: *SelectMenu, term_io: *TermIO) !void { try self.refresh(term_io); while (true) { const key: Key = term_io.getKey(true); if (key.type == KeyType.SEQUENCE) { const key_enum: SequenceKey = @enumFromInt(key.value); switch (key_enum) { SequenceKey.DOWN => try self.nextItem(term_io), SequenceKey.UP => try self.prevItem(term_io), else => continue, } } switch (key.value) { 13 => break, // Enter ' ' => { try self.toggleSelection(term_io); }, 0x1b, 'q' => { self.cancelled = true; return; }, 'j' => try self.nextItem(term_io), 'k' => try self.prevItem(term_io), else => continue, } } } fn printItem(self: *SelectMenu, item: usize, term_io: *TermIO) !void { if (self.items[item].selected) { try term_io.output(1, item + 2, "[X] {s}", .{self.items[item].name}, .{ .highlight = item == self.focused_item }); } else { try term_io.output(1, item + 2, "[ ] {s}", .{self.items[item].name}, .{ .highlight = item == self.focused_item }); } } fn toggleSelection(self: *SelectMenu, term_io: *TermIO) !void { self.items[self.focused_item].selected = !self.items[self.focused_item].selected; try self.printItem(self.focused_item, term_io); } fn nextItem(self: *SelectMenu, term_io: *TermIO) !void { if (self.focused_item < self.items.len - 1) { try self.focusItem(self.focused_item + 1, term_io); } } fn prevItem(self: *SelectMenu, term_io: *TermIO) !void { if (self.focused_item > 0) { try self.focusItem(self.focused_item - 1, term_io); } } fn focusItem(self: *SelectMenu, item: usize, term_io: *TermIO) !void { const previousSelection = self.focused_item; self.focused_item = item; if (previousSelection != self.focused_item) { try self.printItem(previousSelection, term_io); } try self.printItem(self.focused_item, term_io); } }; pub const Input = struct { pos_x: usize, pos_y: usize, max_len: usize, cursor_pos: usize = 0, len: usize = 0, text: []u8, _allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator, x: usize, y: usize, max_len: usize) !Input { return Input{ .pos_x = x, .pos_y = y, .max_len = max_len, .text = try allocator.alloc(u8, max_len), ._allocator = allocator, }; } pub fn deinit(self: *Input) void { self._allocator.free(self.text); } pub fn reset(self: *Input) void { @memset(self.text, ' '); } pub fn draw(self: *Input, term_io: *TermIO) !void { try term_io.output(self.pos_x, self.pos_y, "{s}", .{self.text}, .{ .underline = true }); } pub fn focus(self: *Input, term_io: *TermIO) !void { try term_io.moveCursor(self.pos_x + @min(self.cursor_pos, self.max_len - 1), self.pos_y); term_io.showCursor(); } pub fn handleKey(self: *Input, term_io: *TermIO, key: Key) !void { if (key.type == .ASCII) { if (std.ascii.isPrint(key.value) and self.len < self.max_len) { try self.insertChar(key.value); try self.draw(term_io); // try term_io.output(17 + input_len, 4, "{c}", .{key.value}, .{}); } else if (key.value == 0x7F and self.cursor_pos > 0 and self.len > 0) { // Backspace try self.removeChar(); try self.draw(term_io); // try term_io.output(17 + input_len, 4, " ", .{}, .{}); } } else { const key_enum: SequenceKey = @enumFromInt(key.value); if (key_enum == SequenceKey.RIGHT and self.cursor_pos < self.len) { self.cursor_pos += 1; } else if (key_enum == SequenceKey.LEFT and self.cursor_pos > 0) { self.cursor_pos -= 1; } else if (key_enum == SequenceKey.DELETE and self.cursor_pos < self.len) { self.cursor_pos += 1; try self.removeChar(); try self.draw(term_io); } } } fn insertChar(self: *Input, char: u8) !void { var i: usize = self.len; while (i > self.cursor_pos) : (i -= 1) { self.text[i] = self.text[i - 1]; } self.text[self.cursor_pos] = char; self.len += 1; self.cursor_pos += 1; } fn removeChar(self: *Input) !void { self.cursor_pos -= 1; for (self.cursor_pos..self.len - 1) |i| { self.text[i] = self.text[i + 1]; } self.text[self.len - 1] = ' '; self.len -= 1; } }; pub const TermFormat = packed struct { bold: bool = false, dim: bool = false, italic: bool = false, underline: bool = false, highlight: bool = false, invisible: bool = false, strikethrough: bool = false, }; pub const TermIO = struct { stdin: std.io.AnyReader, stdout: std.io.BufferedWriter(4096, std.io.AnyWriter).Writer, //stdin: std.fs.File.Reader, //stdout: std.io.BufferedWriter(4096, std.fs.File.Writer).Writer, tty_file: fs.File, current_format: TermFormat = .{}, current_background: color.Color = color.Default, current_foreground: color.Color = color.Default, escape_sequence_queued: bool = false, pub fn close(self: *const TermIO) void { self.tty_file.close(); } pub fn flush(self: *TermIO) !void { try self.stdout.context.flush(); } pub fn enterRawMode(term_io: *const TermIO) void { orig_termios = posix.tcgetattr(term_io.tty_file.handle) catch unreachable; var raw = orig_termios; raw.iflag.BRKINT = false; raw.iflag.ICRNL = false; raw.iflag.INPCK = false; raw.iflag.ISTRIP = false; raw.iflag.IXON = false; // raw.oflag.OPOST = false; raw.cflag.CSIZE = posix.CSIZE.CS8; raw.lflag.ECHO = false; raw.lflag.ICANON = false; raw.lflag.IEXTEN = false; raw.lflag.ISIG = false; raw.cc[@intFromEnum(posix.V.MIN)] = 0; raw.cc[@intFromEnum(posix.V.TIME)] = 1; posix.tcsetattr(term_io.tty_file.handle, posix.TCSA.FLUSH, raw) catch unreachable; } pub fn exitRawMode(term_io: *const TermIO) void { posix.tcsetattr(term_io.tty_file.handle, posix.TCSA.FLUSH, orig_termios) catch unreachable; } pub fn enableFormats(term_io: *TermIO, formats: TermFormat) void { if (formats.bold and !term_io.current_format.bold) { term_io.current_format.bold = true; term_io.print("\x1b[1m", .{}); } if (formats.dim and !term_io.current_format.dim) { term_io.current_format.dim = true; term_io.print("\x1b[2m", .{}); } if (formats.italic and !term_io.current_format.italic) { term_io.current_format.italic = true; term_io.print("\x1b[3m", .{}); } if (formats.underline and !term_io.current_format.underline) { term_io.current_format.underline = true; term_io.print("\x1b[4m", .{}); } if (formats.highlight and !term_io.current_format.highlight) { term_io.current_format.highlight = true; term_io.print("\x1b[7m", .{}); } if (formats.invisible and !term_io.current_format.invisible) { term_io.current_format.invisible = true; term_io.print("\x1b[8m", .{}); } if (formats.strikethrough and !term_io.current_format.strikethrough) { term_io.current_format.strikethrough = true; term_io.print("\x1b[9m", .{}); } } pub fn disableFormats(term_io: *TermIO, formats: TermFormat) void { if ((!term_io.current_format.bold or formats.bold) and (!term_io.current_format.dim or formats.dim) and (!term_io.current_format.italic or formats.italic) and (!term_io.current_format.underline or formats.underline) and (!term_io.current_format.highlight or formats.highlight) and (!term_io.current_format.invisible or formats.invisible) and (!term_io.current_format.strikethrough or formats.strikethrough)) { term_io.current_format = .{}; term_io.print("\x1b[0m", .{}); } if (formats.dim and !term_io.current_format.dim) { term_io.current_format.dim = false; term_io.print("\x1b[22m", .{}); } if (formats.italic and !term_io.current_format.italic) { term_io.current_format.italic = false; term_io.print("\x1b[23m", .{}); } if (formats.underline and !term_io.current_format.underline) { term_io.current_format.underline = false; term_io.print("\x1b[24m", .{}); } if (formats.highlight and !term_io.current_format.highlight) { term_io.current_format.highlight = false; term_io.print("\x1b[27m", .{}); } if (formats.invisible and !term_io.current_format.invisible) { term_io.current_format.invisible = false; term_io.print("\x1b[28m", .{}); } if (formats.strikethrough and !term_io.current_format.strikethrough) { term_io.current_format.strikethrough = false; term_io.print("\x1b[29m", .{}); } if (formats.bold and !term_io.current_format.bold) { term_io.current_format.bold = false; term_io.print("\x1b[21m", .{}); } } pub fn saveScreen(term_io: *const TermIO) void { term_io.print("\x1b[s", .{}); term_io.print("\x1b[?47h", .{}); term_io.print("\x1b[?1049h", .{}); } pub fn restoreScreen(term_io: *const TermIO) void { term_io.print("\x1b[?1049l", .{}); term_io.print("\x1b[?47l", .{}); term_io.print("\x1b[u", .{}); } pub fn clear(term_io: *const TermIO) void { term_io.print("\x1b[2J\x1b[H", .{}); } pub fn setColor(term_io: *TermIO, background: color.Color, foreground: color.Color) void { term_io.setBackgroundColor(background); term_io.setForegroundColor(foreground); } pub fn setBackgroundColor(term_io: *TermIO, background: color.Color) void { if (!background.equal(term_io.current_background)) { switch (background.type) { .Default => term_io.print("\x1b[49m", .{}), .RGB => term_io.print("\x1b[48;2;{};{};{}m", .{ background.red, background.green, background.blue }), } } term_io.current_background = background; } pub fn setForegroundColor(term_io: *TermIO, foreground: color.Color) void { if (!foreground.equal(term_io.current_foreground)) { switch (foreground.type) { .Default => term_io.print("\x1b[39m", .{}), .RGB => term_io.print("\x1b[38;2;{};{};{}m", .{ foreground.red, foreground.green, foreground.blue }), } } term_io.current_foreground = foreground; } pub fn hideCursor(term_io: *const TermIO) void { term_io.print("\x1b[?25l", .{}); } pub fn showCursor(term_io: *const TermIO) void { term_io.print("\x1b[?25h", .{}); } pub fn moveCursor(term_io: *const TermIO, x: usize, y: usize) void { term_io.print("\x1b[{d};{d}H", .{ y, x }); } pub fn saveTitle(term_io: *const TermIO) void { term_io.print("\x1b[22;2t", .{}); } pub fn restoreTitle(term_io: *const TermIO) void { term_io.print("\x1b[23;2t", .{}); } pub fn setTitle(term_io: *const TermIO, title: []const u8) void { term_io.print("\x1b]0;{s}\x1b\\", .{title}); } pub fn print(term_io: *const TermIO, comptime format: []const u8, args: anytype) void { term_io.stdout.print(format, args) catch @panic("Failed to print!!"); } pub fn output(term_io: *const TermIO, x: usize, y: usize, comptime format: []const u8, args: anytype) void { term_io.stdout.print("\x1b[{d};{d}H" ++ format, .{ y, x } ++ args) catch @panic("Failed to print!!"); } pub fn fillBox(term_io: *const TermIO, dims: dim.CalculatedDimensions) void { for (0..dims.size.height) |i| { term_io.moveCursor(dims.pos.x, dims.pos.y + i); term_io.print("\x1b[{d}X", .{dims.size.width}); } } pub fn drawBox(term_io: *TermIO, dims: dim.CalculatedDimensions, border: borders.Border, fill: bool) !void { if (dims.size.width < 2 or dims.size.height < 2) { return; } term_io.output(dims.pos.x, dims.pos.y, "{u}", .{border.top_left}); for (0..dims.size.width - 2) |_| { term_io.print("{u}", .{border.top}); } term_io.print("{u}", .{border.top_right}); for (1..dims.size.height - 1) |h| { term_io.output(dims.pos.x, dims.pos.y + h, "{u}", .{border.left}); if (fill) { term_io.print("\x1b[{d}X", .{dims.size.width - 2}); } term_io.output(dims.pos.x + dims.size.width - 1, dims.pos.y + h, "{u}", .{border.right}); if (term_io.stdout.context.buf.len - term_io.stdout.context.end < dims.size.width * 3) { try term_io.flush(); _ = term_io.getKey(true); } } term_io.output(dims.pos.x, dims.pos.y + dims.size.height - 1, "{u}", .{border.bottom_left}); for (0..dims.size.width - 2) |_| { term_io.print("{u}", .{border.bottom}); } term_io.print("{u}", .{border.bottom_right}); } pub fn getKey(term_io: *TermIO, block: bool) Key { var escape_sequence: [10]u8 = undefined; var sequence_len: usize = 0; // peek doesn't exist in 0.12.0 :( while (true) { if (!term_io.escape_sequence_queued) { const c = term_io.stdin.readByte() catch |err| { switch (err) { error.WouldBlock, error.EndOfStream => if (!block) return .{ .type = KeyType.NO_KEY, .value = 0 }, else => { std.debug.print("Error: {any}\n", .{err}); unreachable; }, } continue; }; if (c != 0x1b and sequence_len == 0) { return .{ .type = KeyType.ASCII, .value = c }; } } term_io.escape_sequence_queued = false; // Escape sequence sequence_len = 0; escape_sequence[sequence_len] = term_io.stdin.readByte() catch return .{ .type = KeyType.ASCII, .value = 0x1b }; while (true) { sequence_len += 1; const byte = term_io.stdin.readByte() catch return decipherEscapeSequence(escape_sequence[0..sequence_len]); if (byte == 0x1b) { term_io.escape_sequence_queued = true; return decipherEscapeSequence(escape_sequence[0..sequence_len]); } escape_sequence[sequence_len] = byte; } } } }; pub fn getTermSize(term_io: *const TermIO) dim.CalculatedDimensions { var ws: std.posix.winsize = undefined; const ret = std.os.linux.ioctl(term_io.tty_file.handle, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)); if (ret == -1 or ws.ws_col == 0) { std.debug.print("Failed to get terminal size\n", .{}); unreachable; } return dim.CalculatedDimensions{ .pos = .{ .x = 1, .y = 1 }, .size = .{ .width = ws.ws_col, .height = ws.ws_row }, }; } pub fn getTermIO() !TermIO { var bw = std.io.bufferedWriter(std.io.getStdOut().writer().any()); return TermIO{ .stdin = std.io.getStdIn().reader().any(), .stdout = bw.writer(), .tty_file = try fs.cwd().openFile("/dev/tty", .{ .mode = .read_write }), }; } fn decipherEscapeSequence(sequence: []u8) Key { if (sequence.len == 2) { if (std.mem.eql(u8, sequence, &[2]u8{ '[', 'A' })) return .{ .type = KeyType.SEQUENCE, .value = @intFromEnum(SequenceKey.UP) }; if (std.mem.eql(u8, sequence, &[2]u8{ '[', 'B' })) return .{ .type = KeyType.SEQUENCE, .value = @intFromEnum(SequenceKey.DOWN) }; if (std.mem.eql(u8, sequence, &[2]u8{ '[', 'C' })) return .{ .type = KeyType.SEQUENCE, .value = @intFromEnum(SequenceKey.RIGHT) }; if (std.mem.eql(u8, sequence, &[2]u8{ '[', 'D' })) return .{ .type = KeyType.SEQUENCE, .value = @intFromEnum(SequenceKey.LEFT) }; } else if (sequence.len == 3) { if (std.mem.eql(u8, sequence, &[3]u8{ '[', '3', '~' })) return .{ .type = KeyType.SEQUENCE, .value = @intFromEnum(SequenceKey.DELETE) }; } return .{ .type = KeyType.SEQUENCE, .value = @intFromEnum(SequenceKey.UNKNOWN) }; } test "cool test" { term_test.term_io.print("Hello {}", .{123}); try term_test.term_io.flush(); try std.testing.expectEqualStrings("Hello 123", term_test.readAllTestStdout()); } test "stdin" { try term_test.writeTestStdin(&[_]u8{ 'a', '\x1b', '[', 'A' }); var key = term_test.term_io.getKey(false); try std.testing.expect(key.type == .ASCII); try std.testing.expect(key.value == 'a'); key = term_test.term_io.getKey(false); try std.testing.expect(key.type == .SEQUENCE); try std.testing.expect(@as(SequenceKey, @enumFromInt(key.value)) == SequenceKey.UP); key = term_test.term_io.getKey(false); try std.testing.expect(key.type == .NO_KEY); }