diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/tty/tty.zig | 206 |
1 files changed, 190 insertions, 16 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 01d2f08e..efccd98e 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -148,14 +148,19 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { if (scalar_len > end - (lead - 1)) parse_end = lead - 1; } var consumed: usize = 0; - var waited = false; + // A read that is one ESC and nothing else is the Escape key: a + // terminal writes each key's sequence whole and ssh carries each of + // its writes whole, so no sequence arrives cut right after its ESC. + // Waiting on it cost every Escape press 50 ms on a legacy terminal + // (tmux, most ssh sessions), and 1 s before kitty's flags were on. + var waited = carried == 0 and received == 1; while (consumed < parse_end) { - // A lone ESC that ends the read is the Escape key, or the first - // byte of a sequence whose rest the read boundary held back (an - // ssh packet, a busy link), which parsed now left the rest to be - // typed as text. Wait a moment for more. Under the kitty keyboard - // protocol Escape is `CSI 27 u`, so there the ESC can only start - // a sequence and the wait can be long. + // A lone ESC that ends a longer read is the Escape key, or the + // first byte of a sequence whose rest the read boundary held + // back (an ssh packet, a busy link), which parsed now left the + // rest to be typed as text. Wait a moment for more. Under the + // kitty keyboard protocol Escape is `CSI 27 u`, so there the ESC + // can only start a sequence and the wait can be long. const rest = buf[consumed..parse_end]; if (!waited and parse_end == end and rest[0] == 0x1b and (rest.len == 1 or (rest.len == 2 and rest[1] == 0x1b))) { waited = true; @@ -173,6 +178,13 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { if (event == .paste) if (loop.vaxis.opts.system_clipboard_allocator) |gpa| gpa.free(@constCast(event.paste)); return err; }; + // The last answer to the startup queries is no event of its + // own: wake the loop so it takes the capabilities up (pollFrame) + // now. Left to the first key, that key paid for the full redraw + // they cost, and kitty's keys came in the legacy way till then. + if (result.event) |event| if (event == .cap_da1) { + _ = loop.tryPostEvent(.nop) catch {}; + }; } carried = end - consumed; std.mem.copyForwards(u8, buf[0..carried], buf[consumed..end]); @@ -271,6 +283,8 @@ test "terminal input preserves fragmented keys queries paste and text after EOF" try std.testing.expectEqualStrings("clip", bytes); clipboard = true; }, + // The device attributes' wake (a capability answer). + .nop => try std.testing.expect(vx.queries_done.load(.unordered)), else => return error.UnexpectedInputEvent, }; try std.testing.expectEqualStrings("plain café 界 😀tail", text.items); @@ -353,6 +367,7 @@ test "terminal replies and malformed sequences never become typed text" { try std.testing.expectEqual(@as(i16, 0), mouse.col); clicked = true; }, + .nop => {}, // the device attributes' wake else => return error.UnexpectedInputEvent, }; try std.testing.expectEqualStrings("abc", text.items); @@ -455,6 +470,57 @@ test "terminal input split at every byte with a gap reads as it does whole" { vx.caps.kitty_keyboard = false; } +test "an ESC read by itself is the Escape key at once" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{}); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + // More always follows at once: a reader that waited on the ESC would + // take `j` for the rest of its sequence (Alt-j). + const Reader = struct { + parts: []const []const u8, + next: usize = 0, + asked: bool = false, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn inputFollows(self: *@This(), _: u32) bool { + self.asked = true; + return self.next < self.parts.len; + } + fn read(self: *@This(), buf: []u8) !usize { + if (self.next == self.parts.len) return 0; + const part = self.parts[self.next]; + self.next += 1; + @memcpy(buf[0..part.len], part); + return part.len; + } + }; + for ([_]bool{ false, true }) |kitty| { + vx.caps.kitty_keyboard = kitty; + var reader: Reader = .{ .parts = &.{ "\x1b", "j" } }; + try readInput(&loop, &reader, &cache); + try std.testing.expect(!reader.asked); + try std.testing.expectEqual(.winsize, std.meta.activeTag((try loop.tryEvent()).?)); + const escape = (try loop.tryEvent()).?.key_press; + try std.testing.expectEqual(vaxis.Key.escape, escape.codepoint); + try std.testing.expect(!escape.mods.alt); + const j = (try loop.tryEvent()).?.key_press; + try std.testing.expectEqual(@as(u21, 'j'), j.codepoint); + try std.testing.expect(!j.mods.alt); + try std.testing.expect(try loop.tryEvent() == null); + } + vx.caps.kitty_keyboard = false; +} + test "terminal input cancellation joins blocked reads and queued EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; @@ -1364,6 +1430,7 @@ const Shell = struct { s.core.native_images = s.vx.caps.kitty_graphics; if (s.vx.caps.kitty_graphics) kittyShmProbe(s.tty.writer()); s.vx.queueRefresh(); + s.core.needs_frame = true; } } @@ -1386,7 +1453,7 @@ const Shell = struct { const shadowed = chipShadows(s.frame.allocator(), surface, s.core, truecolor) catch surface; const flashed = s.flash.apply(s.frame.allocator(), shadowed, s.core, truecolor) catch shadowed; const trailed = s.trail.apply(s.frame.allocator(), flashed, s.core, truecolor) catch flashed; - paintCells(win, trailed.cells, trailed.cols, trailed.rows); + paintCells(vx, win, trailed.cells, trailed.cols, trailed.rows); tz_cells.end(); for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; @@ -2103,22 +2170,129 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { } }; } -fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { +fn paintCells(vx: *const vaxis.Vaxis, win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { win.clear(); var y: u16 = 0; while (y < rows) : (y += 1) { + const row = cells[@as(usize, y) * cols ..][0..cols]; var x: u16 = 0; + // The cell to the left as written, for a blank that needs it. + var left: ?Written = null; while (x < cols) : (x += 1) { - const cell = &cells[@as(usize, y) * cols + x]; - if (cell.default) continue; - win.writeCell(x, y, .{ - .char = .{ .grapheme = cell.grapheme() }, - .style = vaxisStyle(cell.style), - }); + const cell = &row[x]; + if (cell.default) { + left = null; + continue; + } + const grapheme = cell.grapheme(); + var style = vaxisStyle(cell.style); + if (inkless(grapheme, style)) blankInk(vx, row, x, y, &style, left); + left = .{ .grapheme = grapheme, .style = style }; + win.writeCell(x, y, .{ .char = .{ .grapheme = grapheme }, .style = style }); } } } +const Written = struct { grapheme: []const u8, style: vaxis.Style }; + +/// A blank's ink shows nowhere (no underline, strike or reverse to show it). +fn inkless(grapheme: []const u8, style: vaxis.Style) bool { + return std.mem.eql(u8, grapheme, " ") and !style.reverse and !style.strikethrough and style.ul_style == .off; +} + +/// A blank whose ink alone changed (a fade: InactiveDim on a focus change) +/// keeps the ink the terminal has there and so is not sent at all: +/// otherwise every blank of every pane a fade touched went, 7 KB a click +/// for three shells. Only a short gap between two cells that are sent +/// anyway is cheaper sent than jumped (a cursor move): it takes the ink of +/// the cell before it, so the run needs no colour change either. +fn blankInk(vx: *const vaxis.Vaxis, row: []const pardes.Cell, x: u16, y: u16, style: *vaxis.Style, left: ?Written) void { + const last = lastCell(vx, x, y) orelse return; + if (vx.refresh or last.default or last.skipped or !std.mem.eql(u8, last.char.items, " ")) return; + if (sameColor(last.style.fg, style.fg) or !sameButInk(last.style, style.*)) return; + var kept = style.*; + kept.fg = last.style.fg; + if (left) |l| if (sends(vx, x - 1, y, l.grapheme, l.style)) { + var end = x + 1; + while (end < row.len and end - x < 8) : (end += 1) { + const next = &row[end]; + if (next.default) break; + const next_style = vaxisStyle(next.style); + if (inkless(next.grapheme(), next_style)) continue; + if (sends(vx, end, y, next.grapheme(), next_style)) { + style.fg = l.style.fg; + return; + } + break; + } + }; + style.* = kept; +} + +/// `Style.eql` but for the ink, and cheap: this runs for every blank of +/// every frame. +fn sameButInk(a: vaxis.Style, b: vaxis.Style) bool { + return sameColor(a.bg, b.bg) and sameColor(a.ul, b.ul) and a.ul_style == b.ul_style and + a.bold == b.bold and a.dim == b.dim and a.italic == b.italic and a.blink == b.blink and + a.reverse == b.reverse and a.invisible == b.invisible and a.strikethrough == b.strikethrough; +} + +fn sameColor(a: vaxis.Color, b: vaxis.Color) bool { + return switch (a) { + .default => b == .default, + .index => |i| b == .index and b.index == i, + .rgb => |rgb| b == .rgb and std.mem.eql(u8, &rgb, &b.rgb), + }; +} + +/// Whether vaxis sends this cell: it differs from what the terminal shows. +fn sends(vx: *const vaxis.Vaxis, x: u16, y: u16, grapheme: []const u8, style: vaxis.Style) bool { + const last = lastCell(vx, x, y) orelse return true; + return vx.refresh or last.default or last.skipped or !std.mem.eql(u8, last.char.items, grapheme) or + !sameColor(last.style.fg, style.fg) or !sameButInk(last.style, style); +} + +fn lastCell(vx: *const vaxis.Vaxis, x: u16, y: u16) ?*const vaxis.AllocatingScreen.InternalCell { + if (x >= vx.screen.width) return null; + const i = @as(usize, y) * vx.screen.width + x; + return if (i < vx.screen_last.buf.len) &vx.screen_last.buf[i] else null; +} + +test "a fade sends the text it dims, not the blanks beside it" { + const gpa = std.testing.allocator; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(std.testing.io, gpa, &env, .{}); + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + defer vx.deinit(gpa, &out.writer); + try vx.resize(gpa, &out.writer, .{ .rows = 1, .cols = 32, .x_pixel = 0, .y_pixel = 0 }); + // `a`, a long gap, `b`, a two-cell gap, `c`, a long gap, an underlined + // blank (which shows its ink) and one more blank. + var cells: [32]pardes.Cell = @splat(.{ .default = false }); + cells[0].text[0] = 'a'; + cells[12].text[0] = 'b'; + cells[15].text[0] = 'c'; + cells[30].style.ul = .single; + var sent: [2][]u8 = .{ &.{}, &.{} }; + defer for (sent) |bytes| gpa.free(bytes); + for ([_][3]u8{ .{ 200, 200, 200 }, .{ 90, 90, 90 } }, 0..) |ink, frame| { + for (&cells) |*cell| cell.style.fg = .{ .rgb = ink }; + out.clearRetainingCapacity(); + paintCells(&vx, vx.window(), &cells, 32, 1); + try vx.render(&out.writer); + sent[frame] = try out.toOwnedSlice(); + } + // Against a cleared screen every cell goes. After the fade the letters + // go, the short gap between two of them (cheaper than a cursor move), + // and the underlined blank; the long gaps are jumped. + try std.testing.expectEqual(@as(usize, 29), std.mem.count(u8, sent[0], " ")); + try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, sent[1], " ")); + try std.testing.expect(std.mem.indexOf(u8, sent[1], "b c") != null); + try std.testing.expect(std.mem.indexOf(u8, sent[1], "90:90:90") != null); + try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'a') != null); +} + /// T1 (§9.2): each notice chip casts a shadow one cell right and one down, /// inside its own pane's body: its ground 35% toward black in OKLab on a /// cell with text; on a blank one a half block (▌ on the right, ▀ below, @@ -2514,7 +2688,7 @@ const Attach = struct { fn paint(a: *Attach) void { a.dirty = false; const win = a.vx.window(); - paintCells(win, a.client.grid.items, a.client.cols, a.client.rows); + paintCells(a.vx, win, a.client.grid.items, a.client.cols, a.client.rows); if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); a.vx.render(a.tty.writer()) catch {}; } |
