From 0c2b8046e4f657710ef02b77e070c116852b3a2c Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Wed, 5 Feb 2025 13:30:05 -0600 Subject: [PATCH] feat: implement scaled text Implement the kitty scaled text extension. We do this by adding a Scale struct to the core Cell structure. The scale struct defines the scale, numerator, denominator, and vertical positioning. We also query independently for scaled text vs explicit width text. Users of scaled text must be very careful of how they model their cells, as cells must be written ahead of time with knowledge of the capabilities of the underlying terminal. Libvaxis does not move contents of cells based on scale (ie 3 contiguous cells of scale=2 with contents "abc" will not transform into what you think - the user of the library must take car to put a, b, and c into appropriate cells. Libvaxis will do the work to prevent overwriting the cell). Capability can be checked in the vx.caps.scaled_text value. --- examples/main.zig | 25 +++++++++++++- src/Cell.zig | 20 ++++++++++++ src/InternalScreen.zig | 6 ++++ src/Loop.zig | 22 ++++++++++++- src/Vaxis.zig | 74 +++++++++++++++++++++++++++++++++++++++++- src/ctlseqs.zig | 5 +++ 6 files changed, 149 insertions(+), 3 deletions(-) diff --git a/examples/main.zig b/examples/main.zig index fbf116f..4b30187 100644 --- a/examples/main.zig +++ b/examples/main.zig @@ -34,6 +34,8 @@ pub fn main() !void { var color_idx: u8 = 0; const msg = "Hello, world!"; + var scale: u3 = 1; + // The main event loop. Vaxis provides a thread safe, blocking, buffered // queue which can serve as the primary event queue for an application while (true) { @@ -51,6 +53,16 @@ pub fn main() !void { if (key.codepoint == 'c' and key.mods.ctrl) { break; } + if (key.matches('j', .{})) { + if (vx.caps.scaled_text and scale > 1) { + scale -= 1; + } + } + if (key.matches('k', .{})) { + if (vx.caps.scaled_text and scale < 7) { + scale += 1; + } + } }, .winsize => |ws| { try vx.resize(alloc, tty.anyWriter(), ws); @@ -86,8 +98,19 @@ pub fn main() !void { .style = .{ .fg = .{ .index = color_idx }, }, + .scale = .{ + .scale = scale, + }, + }; + const second_cell: Cell = .{ + .char = .{ .grapheme = msg[i .. i + 1] }, + .style = .{ + .fg = .{ .index = color_idx }, + }, }; - child.writeCell(@intCast(i), 0, cell); + child.writeCell(@intCast(i * scale), 0, cell); + child.writeCell(@intCast(i), scale - 1, second_cell); + child.writeCell(@intCast(i), scale, second_cell); } // Render the screen try vx.render(tty.anyWriter()); diff --git a/src/Cell.zig b/src/Cell.zig index 2249b20..2850f42 100644 --- a/src/Cell.zig +++ b/src/Cell.zig @@ -9,6 +9,7 @@ default: bool = false, /// Set to true if this cell is the last cell printed in a row before wrap. Vaxis will determine if /// it should rely on the terminal's autowrap feature which can help with primary screen resizes wrapped: bool = false, +scale: Scale = .{}, /// Segment is a contiguous run of text that has a constant style pub const Segment = struct { @@ -42,6 +43,25 @@ pub const Hyperlink = struct { params: []const u8 = "", }; +pub const Scale = packed struct { + scale: u3 = 1, + // The spec allows up to 15, but we limit to 7 + numerator: u4 = 1, + // The spec allows up to 15, but we limit to 7 + denominator: u4 = 1, + vertical_alignment: enum(u2) { + top = 0, + bottom = 1, + center = 2, + } = .top, + + pub fn eql(self: Scale, other: Scale) bool { + const a_scale: u13 = @bitCast(self); + const b_scale: u13 = @bitCast(other); + return a_scale == b_scale; + } +}; + pub const Style = struct { pub const Underline = enum { off, diff --git a/src/InternalScreen.zig b/src/InternalScreen.zig index ef95924..8db2bdb 100644 --- a/src/InternalScreen.zig +++ b/src/InternalScreen.zig @@ -18,6 +18,12 @@ pub const InternalCell = struct { skipped: bool = false, default: bool = true, + // If we should skip rendering *this* round due to being printed over previously (from a scaled + // cell, for example) + skip: bool = false, + + scale: Cell.Scale = .{}, + pub fn eql(self: InternalCell, cell: Cell) bool { // fastpath when both cells are default diff --git a/src/Loop.zig b/src/Loop.zig index de47a3d..05245da 100644 --- a/src/Loop.zig +++ b/src/Loop.zig @@ -177,7 +177,7 @@ pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Even } }, .key_press => |key| { - // Check for a cursor position response for our explicity width query. This will + // Check for a cursor position response for our explicit width query. This will // always be an F3 key with shift = true, and we must be looking for queries if (key.codepoint == vaxis.Key.f3 and key.mods.shift and @@ -189,6 +189,16 @@ pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Even vx.screen.width_method = .unicode; return; } + // Check for a cursor position response for our scaled text query. This will + // always be an F3 key with alt = true, and we must be looking for queries + if (key.codepoint == vaxis.Key.f3 and + key.mods.alt and + !vx.queries_done.load(.unordered)) + { + log.info("scaled text capability detected", .{}); + vx.caps.scaled_text = true; + return; + } if (@hasField(Event, "key_press")) { // HACK: yuck. there has to be a better way var mut_key = key; @@ -245,6 +255,16 @@ pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Even vx.screen.width_method = .unicode; return; } + // Check for a cursor position response for our scaled text query. This will + // always be an F3 key with alt = true, and we must be looking for queries + if (key.codepoint == vaxis.Key.f3 and + key.mods.alt and + !vx.queries_done.load(.unordered)) + { + log.info("scaled text capability detected", .{}); + vx.caps.scaled_text = true; + return; + } if (@hasField(Event, "key_press")) { // HACK: yuck. there has to be a better way var mut_key = key; diff --git a/src/Vaxis.zig b/src/Vaxis.zig index d179e8d..5ac4451 100644 --- a/src/Vaxis.zig +++ b/src/Vaxis.zig @@ -37,6 +37,7 @@ pub const Capabilities = struct { sgr_pixels: bool = false, color_scheme_updates: bool = false, explicit_width: bool = false, + scaled_text: bool = false, }; pub const Options = struct { @@ -280,6 +281,14 @@ pub fn queryTerminalSend(vx: *Vaxis, tty: AnyWriter) !void { ctlseqs.home ++ ctlseqs.explicit_width_query ++ ctlseqs.cursor_position_request ++ + // Explicit width query. We send the cursor home, then do an scaled text command, then + // query the position. If the parsed value is an F3 with al, we support scaled text. + // The returned response will be something like \x1b[1;3R...which when parsed as a Key is a + // alt + F3 (the row is ignored). We only care if the column has moved from 1->3, which is + // why we see a Shift modifier + ctlseqs.home ++ + ctlseqs.scaled_text_query ++ + ctlseqs.cursor_position_request ++ ctlseqs.xtversion ++ ctlseqs.csi_u_query ++ ctlseqs.kitty_graphics_query ++ @@ -373,6 +382,11 @@ pub fn render(self: *Vaxis, tty: AnyWriter) !void { if (self.caps.kitty_graphics) try tty.writeAll(ctlseqs.kitty_graphics_clear); + // Reset skip flag on all last_screen cells + for (self.screen_last.buf) |*last_cell| { + last_cell.skip = false; + } + var i: usize = 0; while (i < self.screen.buf.len) { const cell = self.screen.buf[i]; @@ -404,7 +418,12 @@ pub fn render(self: *Vaxis, tty: AnyWriter) !void { // If cell is the same as our last frame, we don't need to do // anything const last = self.screen_last.buf[i]; - if (!self.refresh and last.eql(cell) and !last.skipped and cell.image == null) { + if ((!self.refresh and + last.eql(cell) and + !last.skipped and + cell.image == null) or + last.skip) + { reposition = true; // Close any osc8 sequence we might be in before // repositioning @@ -421,6 +440,24 @@ pub fn render(self: *Vaxis, tty: AnyWriter) !void { // Set this cell in the last frame self.screen_last.writeCell(col, row, cell); + // If we support scaled text, we set the flags now + if (self.caps.scaled_text and cell.scale.scale > 1) { + // The cell is scaled. Set appropriate skips. We only need to do this if the scale factor is + // > 1 + assert(cell.char.width > 0); + const cols = cell.scale.scale * cell.char.width; + const rows = cell.scale.scale; + for (0..rows) |skipped_row| { + for (0..cols) |skipped_col| { + if (skipped_row == 0 and skipped_col == 0) { + continue; + } + const skipped_i = (@as(usize, @intCast(skipped_row + row)) * self.screen_last.width) + (skipped_col + col); + self.screen_last.buf[skipped_i].skip = true; + } + } + } + // reposition the cursor, if needed if (reposition) { reposition = false; @@ -630,6 +667,41 @@ pub fn render(self: *Vaxis, tty: AnyWriter) !void { try tty.print(ctlseqs.osc8, .{ ps, cell.link.uri }); } + // scale + if (self.caps.scaled_text and !cell.scale.eql(.{})) { + const scale = cell.scale; + // We have a scaled cell. + switch (cell.scale.denominator) { + // Denominator cannot be 0 + 0 => unreachable, + 1 => { + // no fractional scaling, just a straight scale factor + try tty.print( + ctlseqs.scaled_text, + .{ scale.scale, w, cell.char.grapheme }, + ); + }, + else => { + // fractional scaling + // no fractional scaling, just a straight scale factor + try tty.print( + ctlseqs.scaled_text_with_fractions, + .{ + scale.scale, + w, + scale.numerator, + scale.denominator, + @intFromEnum(scale.vertical_alignment), + cell.char.grapheme, + }, + ); + }, + } + cursor_pos.col = col + (w * scale.scale); + cursor_pos.row = row; + continue; + } + // If we have explicit width and our width is greater than 1, let's use it if (self.caps.explicit_width and w > 1) { try tty.print(ctlseqs.explicit_width, .{ w, cell.char.grapheme }); diff --git a/src/ctlseqs.zig b/src/ctlseqs.zig index 41aefe7..73a869b 100644 --- a/src/ctlseqs.zig +++ b/src/ctlseqs.zig @@ -13,6 +13,7 @@ pub const kitty_graphics_query = "\x1b_Gi=1,a=q\x1b\\"; pub const sixel_geometry_query = "\x1b[?2;1;0S"; pub const cursor_position_request = "\x1b[6n"; pub const explicit_width_query = "\x1b]66;w=1; \x1b\\"; +pub const scaled_text_query = "\x1b]66;s=2; \x1b\\"; // mouse. We try for button motion and any motion. terminals will enable the // last one we tried (any motion). This was added because zellij doesn't @@ -35,6 +36,10 @@ pub const unicode_set = "\x1b[?2027h"; pub const unicode_reset = "\x1b[?2027l"; pub const explicit_width = "\x1b]66;w={d};{s}\x1b\\"; +// text sizing +pub const scaled_text = "\x1b]66;s={d}:w={d};{s}\x1b\\"; +pub const scaled_text_with_fractions = "\x1b]66;s={d}:w={d}:n={d}:d={d}:v={d};{s}\x1b\\"; + // bracketed paste pub const bp_set = "\x1b[?2004h"; pub const bp_reset = "\x1b[?2004l"; -- 2.51.2