Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions TUI/hover.zig
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ pub fn paint(m: *Model, a: std.mem.Allocator, frame: []const u8, width: usize) !
m.hover.hit = now; // the group may have folded or opened under the pointer
const bg = tint.hoverBg(m.theme_id);
var out = std.array_list.Managed(u8).init(a);
errdefer out.deinit();
var r: usize = 0;
var it = std.mem.splitScalar(u8, frame, '\n');
var found = false;
Expand All @@ -241,7 +242,12 @@ pub fn paint(m: *Model, a: std.mem.Allocator, frame: []const u8, width: usize) !
try out.appendSlice(ln);
}
// The pointer is parked on a row the frame no longer reaches (the transcript
// shrank under it). Nothing to tint, and the stale row must not paint.
if (!found) return frame;
// shrank under it). Nothing to tint, and the stale row must not paint —
// clear so the next frame does not keep announcing a click that cannot land.
if (!found) {
out.deinit();
clear(m);
return frame;
}
return out.items;
}
15 changes: 15 additions & 0 deletions TUI/hover_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,21 @@ test "a group that folds under the pointer starts announcing that it opens" {
try testing.expect(std.mem.indexOf(u8, shut, glyphs.expand) != null);
}

test "a hover row the frame no longer reaches clears instead of staying armed" {
var m: Model = undefined;
try folded(&m);
defer m.deinit();
// Composer is a real target at prompt_origin. Park there, then hand paint
// a frame too short to contain that row — the tint has nowhere to go.
m.hover.row = 20;
m.hover.hit = .{ .target = .composer };
const frame = "a\nb\nc";
const out = try hover.paint(&m, testing.allocator, frame, 80);
try testing.expectEqualStrings(frame, out);
try testing.expectEqual(@as(?usize, null), m.hover.row);
try testing.expectEqual(hover.Target.none, m.hover.hit.target);
}

test "hover clears when the pointer is over an overlay, which has no row targets" {
var m: Model = undefined;
try folded(&m);
Expand Down
41 changes: 35 additions & 6 deletions TUI/paint.zig
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,10 @@ pub fn paintRow(w: *Io.Writer, ln: []const u8, cols: usize, bg: []const u8) !voi
try w.writeAll(bg);
// Measure and write the same cells: a smuggled CR/BEL/BS is not a column
// and must not reach the terminal (CR rewinds the row, BEL rings, BS
// walks the cursor back). Tab stays — it is legal prose. ESC stays —
// that is SGR. The frame builders already strip this; this is the last
// door so a leak cannot desync the row↔line map.
// walks the cursor back). Tab expands to the next stop of 8 — counting
// it as one cell left pad() short and the rest of the previous frame on
// the row. ESC stays — that is SGR. The frame builders already strip
// this; this is the last door so a leak cannot desync the row↔line map.
const vis = cellLen(ln);
if (vis < cols) {
try writeCells(w, ln);
Expand Down Expand Up @@ -128,12 +129,19 @@ fn pad(w: *Io.Writer, cells: usize) !void {
while (n > 0) : (n -= 1) try w.writeByte(' ');
}

/// C0/DEL that must never be a cell. Tab is kept (legal prose); ESC is kept
/// (SGR) and handled by the callers before they reach this.
/// C0/DEL that must never be a cell. Tab is expanded to spaces (stop 8) by
/// the callers, never written raw and never dropped as zero-width. ESC is
/// kept (SGR) and handled before they reach this.
fn isRowControl(b: u8) bool {
return (b < 0x20 and b != '\t') or b == 0x7f;
}

/// Columns a tab occupies at `col`, matching common emulators (stops of 8).
/// A tab on a stop advances to the next one, never zero.
fn tabPad(col: usize) usize {
return 8 - (col % 8);
}

fn cellLen(ln: []const u8) usize {
var n: usize = 0;
var i: usize = 0;
Expand All @@ -142,6 +150,11 @@ fn cellLen(ln: []const u8) usize {
i = theme_mod.skipEsc(ln, i);
continue;
}
if (ln[i] == '\t') {
n += tabPad(n);
i += 1;
continue;
}
if (isRowControl(ln[i])) {
i += 1;
continue;
Expand All @@ -161,6 +174,13 @@ fn takeCells(ln: []const u8, max: usize) []const u8 {
i = theme_mod.skipEsc(ln, i);
continue;
}
if (ln[i] == '\t') {
const w = tabPad(n);
if (n + w > max) break;
i += 1;
n += w;
continue;
}
if (isRowControl(ln[i])) {
i += 1;
continue;
Expand All @@ -175,21 +195,30 @@ fn takeCells(ln: []const u8, max: usize) []const u8 {

fn writeCells(w: *Io.Writer, ln: []const u8) !void {
var i: usize = 0;
var col: usize = 0;
while (i < ln.len) {
if (ln[i] == 0x1b) {
const e = theme_mod.skipEsc(ln, i);
try w.writeAll(ln[i..e]);
i = e;
continue;
}
if (ln[i] == '\t') {
const n = tabPad(col);
try pad(w, n);
col += n;
i += 1;
continue;
}
if (isRowControl(ln[i])) {
i += 1;
continue;
}
const start = i;
i += 1;
while (i < ln.len and ln[i] != 0x1b and !isRowControl(ln[i])) : (i += 1) {}
while (i < ln.len and ln[i] != 0x1b and ln[i] != '\t' and !isRowControl(ln[i])) : (i += 1) {}
try w.writeAll(ln[start..i]);
col += theme_mod.visibleLen(ln[start..i]);
}
}

Expand Down
24 changes: 22 additions & 2 deletions TUI/paint_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -446,11 +446,31 @@ test "a smuggled CR or BEL in a frame row never reaches the terminal" {

test "SGR and tab survive the cell filter; only C0 is dropped" {
const a = std.testing.allocator;
// "red" is 3 cells; tab → 5 spaces (stop 8); "a" then tab → 7 spaces; "b".
const frame = "\x1b[31mred\x1b[0m\ta\tb";
const out = try paintToBuf(a, frame, 1, 20, "");
defer a.free(out);
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[31mred\x1b[0m") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "\ta\tb") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[31mred\x1b[0m a b") != null);
try std.testing.expect(std.mem.indexOfScalar(u8, out, '\t') == null);
}

test "a tab expands to the next stop of 8 so a shorter row leaves no residue" {
const a = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(a);
defer arena.deinit();
const ar = arena.allocator();
const cols: usize = 20;
const filled = "XXXXXXXXXXXXXXXXXXXX";
const with_tab = "ab\tcd";
const expanded = "ab cd"; // "ab" + 6 spaces (stop 8) + "cd"
try std.testing.expectEqual(cols, filled.len);
var screen = try Screen.init(ar, 1, cols);
screen.feed(try paintToBuf(ar, filled, 1, cols, ""));
screen.feed(try paintToBuf(ar, with_tab, 1, cols, filled));
var want = try Screen.expect(ar, expanded, 1, cols, true);
try screen.expectMatches(&want);
const out = try paintToBuf(ar, with_tab, 1, cols, filled);
try std.testing.expect(std.mem.indexOfScalar(u8, out, '\t') == null);
}

test "history C0 never reaches the composed frame or the paint stream" {
Expand Down
Loading