sprawling-console-ffi 0.0.12

The Zig leaf that draws the console's frames, offered as one safe function.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

// Characters as a terminal shows them: how many columns each one takes,
// and which ones never reach the terminal as themselves. A record's text
// is a model's or a person's words, and a control character inside it
// would be a command to the terminal, so every C0 and C1 control, DEL,
// and every byte that is not UTF-8 is drawn as U+FFFD instead.

const std = @import("std");

pub const replacement = "\u{FFFD}";

/// One character of a text: what it is, how many bytes it took, and
/// whether it is drawn as itself.
pub const Char = struct {
    cp: u21,
    len: usize,
    /// The bytes to write: the character itself, or U+FFFD.
    shown: []const u8,
    columns: usize,
};

/// The character starting at `at`, which must be inside `bytes`.
pub fn char(bytes: []const u8, at: usize) Char {
    const n = sequenceLength(bytes[at]) orelse return unreadable();
    if (at + n > bytes.len) return unreadable();
    const slice = bytes[at .. at + n];
    const cp = std.unicode.utf8Decode(slice) catch return unreadable();
    if (isControl(cp)) return .{ .cp = 0xFFFD, .len = n, .shown = replacement, .columns = 1 };
    return .{ .cp = cp, .len = n, .shown = slice, .columns = columns(cp) };
}

/// How many bytes a UTF-8 sequence takes, read from its first byte;
/// nothing for a byte no sequence starts with. Whether the sequence is
/// well formed is `utf8Decode`'s to judge.
fn sequenceLength(lead: u8) ?usize {
    if (lead < 0x80) return 1;
    if (lead & 0xE0 == 0xC0) return 2;
    if (lead & 0xF0 == 0xE0) return 3;
    if (lead & 0xF8 == 0xF0) return 4;
    return null;
}

fn unreadable() Char {
    return .{ .cp = 0xFFFD, .len = 1, .shown = replacement, .columns = 1 };
}

fn isControl(cp: u21) bool {
    return cp < 0x20 or cp == 0x7F or (cp >= 0x80 and cp < 0xA0);
}

/// Columns one character takes: none for a combining mark, a joiner or
/// a variation selector; two for the East Asian wide and fullwidth
/// ranges and the emoji blocks; one otherwise.
pub fn columns(cp: u21) usize {
    return switch (cp) {
        0x0300...0x036F, 0x1AB0...0x1AFF, 0x1DC0...0x1DFF, 0x200B...0x200F, 0x20D0...0x20FF, 0xFE00...0xFE0F, 0xFE20...0xFE2F => 0,
        0x1100...0x115F, 0x2E80...0x303E, 0x3041...0x33FF, 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xA000...0xA4CF, 0xAC00...0xD7A3, 0xF900...0xFAFF, 0xFE30...0xFE4F, 0xFF00...0xFF60, 0xFFE0...0xFFE6, 0x1F300...0x1F64F, 0x1F680...0x1F6FF, 0x1F900...0x1F9FF, 0x20000...0x3FFFD => 2,
        else => 1,
    };
}

/// Columns a whole text takes on one line.
pub fn width(bytes: []const u8) usize {
    var at: usize = 0;
    var total: usize = 0;
    while (at < bytes.len) {
        const c = char(bytes, at);
        total += c.columns;
        at += c.len;
    }
    return total;
}

test "a control character is drawn as the replacement character" {
    const c = char("\x1b[2J", 0);
    try std.testing.expectEqualStrings(replacement, c.shown);
}

test "Chinese takes two columns a character, a combining mark none" {
    try std.testing.expectEqual(@as(usize, 4), width("中文"));
    try std.testing.expectEqual(@as(usize, 1), width("e\u{0301}"));
}

test "a cut UTF-8 sequence is one replacement character" {
    const c = char("\xe4\xb8", 0);
    try std.testing.expectEqual(@as(usize, 1), c.len);
    try std.testing.expectEqualStrings(replacement, c.shown);
}