//! ANSI-aware word wrapping. Mirrors lipgloss `Wrap` (with empty //! breakpoints — breaking only on spaces). const std = @import("std"); const measure = @import("measure.zig"); const t = std.testing; const ArrayList = std.ArrayList; const Allocator = std.mem.Allocator; pub fn wrap(allocator: Allocator, input: []const u8, width: u16) Allocator.Error![]u8 { var out: ArrayList(u8) = .empty; var line_w: u16 = 0; var pending_spaces: u16 = 0; var i: usize = 0; while (i < input.len) { const c = input[i]; if (c == '\n') { try out.append(allocator, '\n'); line_w = 0; pending_spaces = 0; i += 1; continue; } if (c == ' ') { pending_spaces +|= 1; i += 1; continue; } const word_start = i; while (i < input.len and input[i] != ' ' and input[i] != '\n') { if (input[i] == 0x1b) { i = skipEscape(input, i); } else { i += utf8Len(input, i); } } const word = input[word_start..i]; const word_w = measure.cellWidth(word); if (line_w == 0) { try appendSpaces(allocator, &out, pending_spaces); line_w = pending_spaces; pending_spaces = 0; if (@as(u32, line_w) + word_w <= width) { try out.appendSlice(allocator, word); line_w +|= word_w; } else { line_w = try hardBreakWord(allocator, &out, word, width, line_w); } continue; } if (@as(u32, line_w) + pending_spaces + word_w <= width) { try appendSpaces(allocator, &out, pending_spaces); try out.appendSlice(allocator, word); line_w +|= pending_spaces +| word_w; pending_spaces = 0; continue; } try out.append(allocator, '\n'); line_w = 0; pending_spaces = 0; if (word_w <= width) { try out.appendSlice(allocator, word); line_w = word_w; } else { line_w = try hardBreakWord(allocator, &out, word, width, 0); } } return out.toOwnedSlice(allocator); } fn hardBreakWord( allocator: Allocator, out: *std.ArrayList(u8), word: []const u8, width: u16, start_line_w: u16, ) Allocator.Error!u16 { var line_w = start_line_w; var i: usize = 0; while (i < word.len) { if (word[i] == 0x1b) { const e = skipEscape(word, i); try out.appendSlice(allocator, word[i..e]); i = e; continue; } if (line_w >= width) { try out.append(allocator, '\n'); line_w = 0; } const l = utf8Len(word, i); try out.appendSlice(allocator, word[i .. i + l]); line_w += 1; i += l; } return line_w; } fn appendSpaces(allocator: Allocator, out: *ArrayList(u8), count: u16) Allocator.Error!void { var i: u16 = 0; while (i < count) : (i += 1) try out.append(allocator, ' '); } fn skipEscape(s: []const u8, index: usize) usize { if (index + 1 >= s.len) return s.len; switch (s[index + 1]) { '[' => { var j = index + 2; while (j < s.len and (s[j] < 0x40 or s[j] > 0x7e)) : (j += 1) {} return if (j < s.len) j + 1 else s.len; }, ']' => { var j = index + 2; while (j < s.len) : (j += 1) { if (s[j] == 0x07) return j + 1; if (s[j] == 0x1b and j + 1 < s.len and s[j + 1] == '\\') return j + 2; } return s.len; }, else => return index + 2, } } fn utf8Len(s: []const u8, i: usize) usize { return std.unicode.utf8ByteSequenceLength(s[i]) catch 1; } test "wrap breaks on spaces" { const out = try wrap(t.allocator, "hello world", 5); defer t.allocator.free(out); try t.expectEqualStrings("hello\nworld", out); } test "wrap keeps short text intact" { const out = try wrap(t.allocator, "hi there", 20); defer t.allocator.free(out); try t.expectEqualStrings("hi there", out); } test "wrap hard-breaks an over-long word" { const out = try wrap(t.allocator, "abcdefgh", 3); defer t.allocator.free(out); try t.expectEqualStrings("abc\ndef\ngh", out); } test "wrap preserves existing newlines" { const out = try wrap(t.allocator, "a\nb", 10); defer t.allocator.free(out); try t.expectEqualStrings("a\nb", out); }