summaryrefslogtreecommitdiff
path: root/src/tui_components.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_components.zig')
-rw-r--r--src/tui_components.zig113
1 files changed, 93 insertions, 20 deletions
diff --git a/src/tui_components.zig b/src/tui_components.zig
index b93c30b..7532fac 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -97,6 +97,15 @@ pub fn codepointWidth(cp: u21) usize {
return 1;
}
+fn tabWidth(col: usize) usize {
+ const rem = col % 8;
+ return if (rem == 0) 8 else 8 - rem;
+}
+
+fn codepointWidthAt(cp: u21, col: usize) usize {
+ return if (cp == '\t') tabWidth(col) else codepointWidth(cp);
+}
+
/// Number of display columns occupied by `text`. `text` must be PLAIN (no
/// escape sequences); components wrap on plain text and only add styling
/// escapes afterward.
@@ -107,7 +116,7 @@ pub fn displayWidth(text: []const u8) usize {
const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const adv = @min(seq_len, text.len - i);
const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?';
- cols += codepointWidth(cp);
+ cols += codepointWidthAt(cp, cols);
i += adv;
}
return cols;
@@ -123,7 +132,7 @@ pub fn truncateToCols(text: []const u8, max_cols: usize) []const u8 {
const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const adv = @min(seq_len, text.len - i);
const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?';
- const w = codepointWidth(cp);
+ const w = codepointWidthAt(cp, cols);
if (cols + w > max_cols) break; // adding this glyph would exceed limit
i += adv;
cols += w;
@@ -138,24 +147,44 @@ pub fn displayWidthStyled(text: []const u8) usize {
var cols: usize = 0;
var i: usize = 0;
while (i < text.len) {
- if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') {
- i += 2;
- while (i < text.len) {
- const c = text[i];
- i += 1;
- if (c >= '@' and c <= '~') break;
- }
+ if (skipStyledEscape(text, i)) |next| {
+ i = next;
continue;
}
const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const adv = @min(seq_len, text.len - i);
const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?';
- cols += codepointWidth(cp);
+ cols += codepointWidthAt(cp, cols);
i += adv;
}
return cols;
}
+fn skipStyledEscape(text: []const u8, start_i: usize) ?usize {
+ if (start_i + 1 >= text.len or text[start_i] != '\x1b') return null;
+ switch (text[start_i + 1]) {
+ '[' => {
+ var i = start_i + 2;
+ while (i < text.len) {
+ const c = text[i];
+ i += 1;
+ if (c >= '@' and c <= '~') return i;
+ }
+ return text.len;
+ },
+ ']' => {
+ var i = start_i + 2;
+ while (i < text.len) {
+ if (text[i] == 0x07) return i + 1;
+ if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '\\') return i + 2;
+ i += 1;
+ }
+ return text.len;
+ },
+ else => return null,
+ }
+}
+
fn reapplyAfterReset(alloc: std.mem.Allocator, text: []const u8, bg_open: []const u8, fg_open: []const u8) ![]const u8 {
const reset = theme.reset;
var count: usize = 0;
@@ -186,19 +215,14 @@ pub fn truncateStyledToCols(text: []const u8, max_cols: usize) []const u8 {
var cols: usize = 0;
var i: usize = 0;
while (i < text.len) {
- if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') {
- i += 2;
- while (i < text.len) {
- const c = text[i];
- i += 1;
- if (c >= '@' and c <= '~') break;
- }
+ if (skipStyledEscape(text, i)) |next| {
+ i = next;
continue;
}
const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const adv = @min(seq_len, text.len - i);
const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?';
- const w = codepointWidth(cp);
+ const w = codepointWidthAt(cp, cols);
if (cols + w > max_cols) break;
i += adv;
cols += w;
@@ -234,7 +258,7 @@ fn wrapParagraph(text: []const u8, width: usize, out: *std.ArrayList([]const u8)
const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const adv = @min(seq_len, text.len - i);
const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?';
- const cp_w = codepointWidth(cp);
+ const cp_w = codepointWidthAt(cp, line_cols);
const is_space = adv == 1 and text[i] == ' ';
if (is_space and line_cols == width) {
@@ -1430,7 +1454,7 @@ pub const InputBox = struct {
const seq_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1;
const adv = @min(seq_len, line.len - i);
const cp = std.unicode.utf8Decode(line[i .. i + adv]) catch '?';
- const w = codepointWidth(cp);
+ const w = codepointWidthAt(cp, cols);
if (cols > 0 and cols + w > width) break;
i += adv;
cols += w;
@@ -2650,6 +2674,12 @@ pub const ToolUse = struct {
@memset(rp, ' ');
const inner = try reapplyAfterReset(ctx.a, text, ctx.bg.open(), fg.open());
defer ctx.a.free(inner);
+ if (std.mem.indexOfScalar(u8, text, '\t') != null) {
+ const prepaint = try ctx.a.alloc(u8, ctx.width);
+ defer ctx.a.free(prepaint);
+ @memset(prepaint, ' ');
+ return std.fmt.allocPrint(ctx.a, "{s}{s}\r{s}{s}{s}{s}{s}{s}", .{ ctx.bg.open(), prepaint, ctx.bg.open(), fg.open(), indent, inner, rp, ansi_reset });
+ }
return std.fmt.allocPrint(ctx.a, "{s}{s}{s}{s}{s}{s}", .{ ctx.bg.open(), fg.open(), indent, inner, rp, ansi_reset });
}
fn blank(ctx: @This()) ![]u8 {
@@ -2778,8 +2808,13 @@ fn vw(line: []const u8) usize {
test "displayWidth counts codepoints; truncateToCols respects boundaries" {
try testing.expectEqual(@as(usize, 3), displayWidth("abc"));
try testing.expectEqual(@as(usize, 3), displayWidth("aé✓"));
+ try testing.expectEqual(@as(usize, 8), displayWidth("\t"));
+ try testing.expectEqual(@as(usize, 9), displayWidth("\tX"));
+ try testing.expectEqual(@as(usize, 9), displayWidth("a\tX"));
try testing.expectEqualStrings("aé", truncateToCols("aé✓", 2));
try testing.expectEqualStrings("abc", truncateToCols("abcdef", 3));
+ try testing.expectEqualStrings("", truncateToCols("\tX", 7));
+ try testing.expectEqualStrings("\t", truncateToCols("\tX", 8));
// Never splits a multibyte codepoint.
const t = truncateToCols("é", 1);
try testing.expectEqualStrings("é", t);
@@ -2792,6 +2827,16 @@ test "displayWidth counts codepoints; truncateToCols respects boundaries" {
try testing.expectEqualStrings("hello 🤖", truncateToCols("hello 🤖", 8));
}
+test "displayWidthStyled treats OSC hyperlinks as zero width" {
+ try testing.expectEqual(@as(usize, 4), displayWidthStyled("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"));
+ try testing.expectEqualStrings("\x1b]8;;https://example.com\x1b\\link", truncateStyledToCols("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\", 4));
+}
+
+test "displayWidthStyled counts styled hard tabs as tab stops" {
+ try testing.expectEqual(@as(usize, 8), displayWidthStyled("\x1b[48;2;40;50;40m\t\x1b[0m"));
+ try testing.expectEqual(@as(usize, 9), displayWidthStyled("\x1b[48;2;40;50;40m\tX\x1b[0m"));
+}
+
test "wrapParagraph word-wraps and hard-splits long words" {
var out: std.ArrayList([]const u8) = .empty;
defer out.deinit(testing.allocator);
@@ -2807,6 +2852,12 @@ test "wrapParagraph word-wraps and hard-splits long words" {
try testing.expectEqualStrings("abcd", out.items[0]);
try testing.expectEqualStrings("efgh", out.items[1]);
try testing.expectEqualStrings("ij", out.items[2]);
+
+ out.clearRetainingCapacity();
+ try wrapParagraph("\treturn err", 14, &out, testing.allocator);
+ try testing.expectEqual(@as(usize, 2), out.items.len);
+ try testing.expectEqualStrings("\treturn", out.items[0]);
+ try testing.expectEqualStrings("err", out.items[1]);
}
// -- AssistantText ---------------------------------------------------------
@@ -3999,3 +4050,25 @@ test "ToolUse: long output lines honor the width contract" {
const lines = try t.comp().render(10, testing.allocator);
for (lines) |l| try testing.expect(vw(l) <= 10);
}
+
+test "ToolUse: tabbed output prepaints background without expanding tabs" {
+ var t = ToolUse.init(testing.allocator);
+ defer t.deinit();
+ try t.setName("read");
+ try t.setInput("{}");
+ t.setCollapsed(false);
+ try t.setOutput("\treturn err");
+ t.setResultOk(true);
+
+ const lines = try t.comp().render(32, testing.allocator);
+ var found = false;
+ for (lines) |line| {
+ if (std.mem.indexOf(u8, line, "\treturn err")) |_| {
+ found = true;
+ try testing.expect(std.mem.indexOfScalar(u8, line, '\r') != null);
+ try testing.expect(vw(line) <= 32);
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, line, "\t"));
+ }
+ }
+ try testing.expect(found);
+}