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

// Running text laid into a column: words wrapped at the column's width,
// a wide character a place to break on its own, and, in a reply, the
// three marks a model leans on. Inside backticks the code stays as written and
// the backticks are drawn faint; `**` makes the words between bold and
// is not drawn; a fence line opens or closes a block whose lines keep
// their spacing. Every continued line starts at the column's left edge.

const std = @import("std");
const text = @import("text.zig");
const paint = @import("paint.zig");

/// How a text is read: a reply's marks and words; plain words; words
/// with their spacing kept, as a console's own table is written; or every
/// character as written, which is how a fenced block's lines are kept.
pub const Marks = enum { prose, plain, kept, verbatim };

pub const Column = struct {
    left: usize,
    measure: usize,
    style: paint.Style,
    marks: Marks,
};

/// Lays `body` into `column`, starting on the painter's current line at
/// `column.left`, and leaves the painter at the end of the last line,
/// unterminated.
pub fn flow(p: *paint.Painter, body: []const u8, column: Column) paint.Error!void {
    var fenced = false;
    var lines = std.mem.splitScalar(u8, body, '\n');
    var first = true;
    while (lines.next()) |raw| {
        const line = std.mem.trimEnd(u8, raw, "\r");
        if (!first) try p.end();
        first = false;
        try p.to(column.left);
        const fence = column.marks == .prose and std.mem.startsWith(u8, std.mem.trimStart(u8, line, " "), "```");
        if (fence) {
            fenced = !fenced;
            try p.fit(.faint, line, column.measure);
            continue;
        }
        const marks: Marks = if (fenced) .verbatim else column.marks;
        try words(p, line, .{ .left = column.left, .measure = column.measure, .style = column.style, .marks = marks });
    }
}

/// How a run of characters is drawn while marks are being read.
const Inline = struct {
    code: bool = false,
    strong: bool = false,

    fn style(self: Inline, base: paint.Style) paint.Style {
        if (self.code) return .plain;
        if (self.strong) return .bold;
        return base;
    }
};

/// One source line, wrapped word by word.
fn words(p: *paint.Painter, line: []const u8, column: Column) paint.Error!void {
    const right = column.left + column.measure;
    var state: Inline = .{};
    var at: usize = 0;
    var pending_space = false;
    while (at < line.len) {
        if (line[at] == ' ' and (column.marks == .prose or column.marks == .plain)) {
            pending_space = p.column > column.left;
            at += 1;
            continue;
        }
        const word_end = endOfWord(line, at, column.marks);
        const needed = measure(line[at..word_end], column.marks) + @intFromBool(pending_space);
        if (p.column + needed > @min(right, p.limit) and p.column > column.left) {
            try p.end();
            try p.to(column.left);
            pending_space = false;
        }
        if (pending_space) {
            try p.to(p.column + 1);
            pending_space = false;
        }
        at = try emit(p, line, .{ .from = at, .to = word_end }, column, &state);
    }
}

const Span = struct { from: usize, to: usize };

/// The end of the word starting at `at`: the next space in prose, the
/// next character after a wide one, the end of the line verbatim.
fn endOfWord(line: []const u8, at: usize, marks: Marks) usize {
    const first = text.char(line, at);
    if (first.columns == 2 or first.cp == ' ' or marks == .verbatim) return at + first.len;
    var end = at;
    while (end < line.len and line[end] != ' ') {
        const c = text.char(line, end);
        if (c.columns == 2) break;
        end += c.len;
    }
    return end;
}

/// Columns a word takes once its marks are read: `**` takes none.
fn measure(word: []const u8, marks: Marks) usize {
    var total: usize = 0;
    var at: usize = 0;
    while (at < word.len) {
        if (marks == .prose and std.mem.startsWith(u8, word[at..], "**")) {
            at += 2;
            continue;
        }
        const c = text.char(word, at);
        total += c.columns;
        at += c.len;
    }
    return total;
}

/// Draws one word, breaking it by force where it is longer than the
/// column, and answers where the next one starts.
fn emit(p: *paint.Painter, line: []const u8, span: Span, column: Column, state: *Inline) paint.Error!usize {
    var at = span.from;
    while (at < span.to) {
        if (column.marks == .prose and !state.code and std.mem.startsWith(u8, line[at..], "**")) {
            state.strong = !state.strong;
            at += 2;
            continue;
        }
        const c = text.char(line, at);
        const tick = column.marks == .prose and c.cp == '`';
        if (tick) state.code = !state.code;
        const style: paint.Style = if (tick) .faint else state.style(column.style);
        if (!try p.char(style, c)) {
            try p.end();
            try p.to(column.left);
            _ = try p.char(style, c);
        }
        at += c.len;
    }
    return at;
}

test "words wrap at the column and continue at its left edge" {
    var buffer: [512]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buffer);
    var p = paint.Painter.init(&out, 13);
    try flow(&p, "one two three four", .{ .left = 2, .measure = 10, .style = .plain, .marks = .prose });
    try std.testing.expectEqual(@as(usize, 1), p.row);
}

test "the bold mark takes no column" {
    try std.testing.expectEqual(@as(usize, 4), measure("**ab**cd", .prose));
}