Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166//! Applying styles to sub-ranges of a string. Mirrors lipgloss `StyleRanges`.//!//! Range bounds are cell indices into the *stripped* (escape-free) text, like//! lipgloss's `ansi.Cut`. ANSI escape sequences in the input are passed//! through in place; styling state set before a kept window is not//! reconstructed (a minor fidelity gap vs lipgloss for pre-styled input —//! the common case of a plain input string is exact).
const std = @import("std");const Allocator = std.mem.Allocator;const Writer = std.Io.Writer;const Style = @import("Style.zig");const cw_mod = @import("width.zig");
/// A span of text and the style applied to it. `start`/`end` are cell indices/// into the stripped string; `end` is exclusive.pub const Range = struct { start: usize, end: usize, style: Style,};
/// Convenience constructor for a [Range] (lipgloss `NewRange`).pub fn newRange(start: usize, end: usize, style: Style) Range { return .{ .start = start, .end = end, .style = style };}
/// Styles the given `ranges` of `s`, leaving the rest untouched. Ranges must/// be sorted and non-overlapping. Mirrors lipgloss `StyleRanges`. The caller/// owns the returned slice.pub fn styleRanges(allocator: Allocator, s: []const u8, ranges: []const Range) Style.RenderError![]u8 { if (ranges.len == 0) return allocator.dupe(u8, s);
const stripped = try strip(allocator, s); defer allocator.free(stripped);
var buf: Writer.Allocating = .init(allocator); defer buf.deinit(); const w = &buf.writer;
var last_idx: usize = 0; for (ranges) |rng| { // Untouched text before this range, keeping its original styling. if (rng.start > last_idx) { try writeCut(w, s, last_idx, rng.start); } // The range's text (taken stripped) rendered with the range's style. const seg = try cutAlloc(allocator, stripped, rng.start, rng.end); defer allocator.free(seg); try rng.style.render(allocator, w, seg); last_idx = rng.end; } // Untouched tail after the final range. try writeCut(w, s, last_idx, std.math.maxInt(usize));
return allocator.dupe(u8, buf.written());}
/// Removes all ANSI escape sequences from `s`.fn strip(allocator: Allocator, s: []const u8) Allocator.Error![]u8 { var out: std.ArrayList(u8) = .empty; var i: usize = 0; while (i < s.len) { if (s[i] == 0x1b) { i = skipEscape(s, i); continue; } try out.append(allocator, s[i]); i += 1; } return out.toOwnedSlice(allocator);}
/// Writes the portion of `s` spanning cells [`start`, `end`). Escape/// sequences inside the window are kept; printable cells outside it are/// dropped.fn writeCut(w: *Writer, s: []const u8, start: usize, end: usize) Writer.Error!void { if (end <= start) return; var visible: usize = 0; var i: usize = 0; while (i < s.len) { if (s[i] == 0x1b) { const e = skipEscape(s, i); if (visible >= start and visible <= end) try w.writeAll(s[i..e]); i = e; continue; } if (visible >= end) break; const len = utf8Len(s, i); const cp_end = @min(i + len, s.len); var width: usize = 1; if (std.unicode.utf8Decode(s[i..cp_end])) |cp| { width = cw_mod.codepointWidth(cp); } else |_| {} if (visible >= start) try w.writeAll(s[i..cp_end]); visible += width; i = cp_end; }}
fn cutAlloc(allocator: Allocator, s: []const u8, start: usize, end: usize) Style.RenderError![]u8 { var buf: Writer.Allocating = .init(allocator); defer buf.deinit(); try writeCut(&buf.writer, s, start, end); return allocator.dupe(u8, buf.written());}
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 "styleRanges highlights a range" { const hl = Style.init(.{}).attr(.bold, true); const out = try styleRanges(std.testing.allocator, "hello", &.{newRange(1, 3, hl)}); defer std.testing.allocator.free(out); try std.testing.expectEqualStrings("h\x1b[1mel\x1b[0mlo", out);}
test "styleRanges no ranges returns a copy" { const out = try styleRanges(std.testing.allocator, "abc", &.{}); defer std.testing.allocator.free(out); try std.testing.expectEqualStrings("abc", out);}
test "styleRanges multiple ranges with a gap" { const a = Style.init(.{}).fg(.red); const b = Style.init(.{}).fg(.blue); const out = try styleRanges(std.testing.allocator, "abcdef", &.{ newRange(0, 2, a), newRange(4, 6, b), }); defer std.testing.allocator.free(out); try std.testing.expectEqualStrings("\x1b[31mab\x1b[0mcd\x1b[34mef\x1b[0m", out);}
test "styleRanges preserves ansi in untouched text" { const hl = Style.init(.{}).attr(.bold, true); const out = try styleRanges( std.testing.allocator, "a\x1b[31mb\x1b[0mc", &.{newRange(2, 3, hl)}, ); defer std.testing.allocator.free(out); try std.testing.expectEqualStrings("a\x1b[31mb\x1b[0m\x1b[1mc\x1b[0m", out);}