// Ghostty-free presentation types shared by the markdown-preview model and
// the ANSI decoder. Split out of `gui_ansi.d` so the preview model
// (`gui_preview.d`) compiles into the raylib-/ghostty-free `no-gui` build:
// only `decodeAnsi` (the off-screen libghostty-vt bridge) still lives in
// `gui_ansi.d` and is pulled in solely by the GUI build.
module (package) sparklessparkles.(package) sparkles.doc_viewdoc_view.(module) sparkles.doc_view.ansi_modelansi_model;
import sparkles.base.term_color : RgbColor;
import sparkles.syntax : AnsiOptions, ColorDepth;
import sparkles.ui.widget : TextSpan;
/// How the theme background is applied in terminal rendering (`--background`,
/// hue spec `BGM`) — shared by the whole-file ANSI emit, the markdown-preview
/// painter and the TUI. (Moved here from the retired `previewer.d`.)
enum (enum) sparkles.doc_view.ansi_model.BackgroundModeHow the theme background is applied in terminal rendering (--background,
hue spec BGM) — shared by the whole-file ANSI emit, the markdown-preview
painter and the TUI. (Moved here from the retired previewer.d.)
BackgroundMode
{
/// `no-background`: foreground only; the terminal's own background shows
/// through (`BGM1`).
(enum value) sparkles.doc_view.ansi_model.BackgroundMode.noBackground = 0no-background: foreground only; the terminal's own background shows
through (BGM1).
noBackground,
/// `spans`: emit a background only where a theme rule sets one, stopping at
/// each line's last glyph (`BGM2`).
(enum value) sparkles.doc_view.ansi_model.BackgroundMode.spans = 1spans: emit a background only where a theme rule sets one, stopping at
each line's last glyph (BGM2).
spans,
/// `full`: fill every line edge-to-edge with the theme's page background
/// (`BGM3`, the default), matching a back-color-erase look.
(enum value) sparkles.doc_view.ansi_model.BackgroundMode.full = 2full: fill every line edge-to-edge with the theme's page background
(BGM3, the default), matching a back-color-erase look.
full,
}
/// The `AnsiOptions` background flags for whole-file rendering under `mode`.
AnsiOptions sparkles.doc_view.ansi_model.backgroundOptionsThe AnsiOptions background flags for whole-file rendering under mode.
backgroundOptions((enum) sparkles.doc_view.ansi_model.BackgroundModeHow the theme background is applied in terminal rendering (--background,
hue spec BGM) — shared by the whole-file ANSI emit, the markdown-preview
painter and the TUI. (Moved here from the retired previewer.d.)
BackgroundMode mode, ColorDepth depth, bool italics)
@safe pure nothrow @nogc
{
return AnsiOptions(
depth: depth,
italics: italics,
emitBackground: mode != BackgroundMode.BackgroundMode.noBackgroundnoBackground,
fillLine: mode == BackgroundMode.BackgroundMode.fullfull,
);
}
/// Neutral text-attribute bits shared by the ANSI decoder (`gui_ansi`) and
/// the views that consume decoded spans (`gui.d`/`document.d` map them onto
/// the toolkit's `TextStyle`).
enum (enum) sparkles.doc_view.ansi_model.AttrNeutral text-attribute bits shared by the ANSI decoder (gui_ansi) and
the views that consume decoded spans (gui.d/document.d map them onto
the toolkit's TextStyle).
Attr : ubyte
{
(enum value) sparkles.doc_view.ansi_model.Attr.bold = cast(ubyte)1ubold = 1 << 0,
(enum value) sparkles.doc_view.ansi_model.Attr.italic = cast(ubyte)2uitalic = 1 << 1,
(enum value) sparkles.doc_view.ansi_model.Attr.underline = cast(ubyte)4uunderline = 1 << 2,
(enum value) sparkles.doc_view.ansi_model.Attr.strikethrough = cast(ubyte)8ustrikethrough = 1 << 3,
}
/// A maximal run of same-styled cells on one line. `fgDefault`/`bgDefault` mark
/// a cell that used the terminal *default* color (SGR 39/49 or never set): the
/// layout substitutes the theme's page fg/bg so ` ```ansi ` blocks stay
/// theme-consistent. `text` is owned (GC) UTF-8.
struct (struct) sparkles.doc_view.ansi_model.AnsiSpanA maximal run of same-styled cells on one line. fgDefault/bgDefault mark
a cell that used the terminal default color (SGR 39/49 or never set): the
layout substitutes the theme's page fg/bg so ``ansi blocks stay
theme-consistent. text` is owned (GC) UTF-8.
AnsiSpan
{
(alias) object.string = stringstring (field) string sparkles.doc_view.ansi_model.AnsiSpan.texttext;
RgbColor (field) _error_ sparkles.doc_view.ansi_model.AnsiSpan.fgfg;
RgbColor (field) _error_ sparkles.doc_view.ansi_model.AnsiSpan.bgbg;
bool (field) bool sparkles.doc_view.ansi_model.AnsiSpan.fgDefaultfgDefault;
bool (field) bool sparkles.doc_view.ansi_model.AnsiSpan.bgDefaultbgDefault;
ubyte (field) ubyte sparkles.doc_view.ansi_model.AnsiSpan.attrsattrs;
}
/// One decoded line: its styled spans, left to right.
struct (struct) sparkles.doc_view.ansi_model.AnsiLineOne decoded line: its styled spans, left to right.
AnsiLine
{
(struct) sparkles.doc_view.ansi_model.AnsiSpanA maximal run of same-styled cells on one line. fgDefault/bgDefault mark
a cell that used the terminal default color (SGR 39/49 or never set): the
layout substitutes the theme's page fg/bg so ``ansi blocks stay
theme-consistent. text` is owned (GC) UTF-8.
AnsiSpan[] (field) sparkles.doc_view.ansi_model.AnsiSpan[] sparkles.doc_view.ansi_model.AnsiLine.spansspans;
}
/++
Gives the spans of a decoded ` ```ansi ` fence their place in the source
(`SEL6`), so selecting inside the fence is as precise as in a terminal.
Each line of `lines` is the decoding of the same line of `body_`. A
decoded span's text is the source text between escapes, so the spans are
matched against the source in order, skipping every escape sequence. A span
that an escape interrupts (the decoder merges runs whose style the escape
did not change) is split there. Every resulting piece is a contiguous slice
of the source, which is what a pointer's column → byte mapping needs:
`srcStart + byte index`.
Offsets are relative to `body_`, as a fence renderer's are. Where the
decoded text stops matching the source (a cursor movement, a tab the
terminal expanded), the rest of that line keeps no identity rather than a
wrong one.
+/
TextSpan[][] sparkles.doc_view.ansi_model.anchorAnsiLinesGives the spans of a decoded ``ansi fence their place in the source
(SEL6`), so selecting inside the fence is as precise as in a terminal.
Each line of lines is the decoding of the same line of body_. A
decoded span's text is the source text between escapes, so the spans are
matched against the source in order, skipping every escape sequence. A span
that an escape interrupts (the decoder merges runs whose style the escape
did not change) is split there. Every resulting piece is a contiguous slice
of the source, which is what a pointer's column → byte mapping needs:
srcStart + byte index.
Offsets are relative to body_, as a fence renderer's are. Where the
decoded text stops matching the source (a cursor movement, a tab the
terminal expanded), the rest of that line keeps no identity rather than a
wrong one.
anchorAnsiLines(TextSpan[][] lines, scope const(char)[] (parameter) const(char)[] body_body_)
@safe pure
{
TextSpan[][] _error_ anchoredanchored;
anchored.reserve(lines.length);
(unresolved type) size_tsize_t _error_ lineStartlineStart = 0;
foreach ((parameter) lineline; lines)
{
(unresolved type) size_tsize_t _error_ lineEndlineEnd = lineStart;
while (lineEnd < body_.length && body_[lineEnd] != '\n')
++lineEnd;
TextSpan[] _error_ out_out_;
(unresolved type) size_tsize_t _error_ atat = lineStart;
bool _error_ lostlost = lineStart > body_.length;
foreach ((parameter) spsp; line)
{
(unresolved type) size_tsize_t _error_ ii = 0;
while (i < sp.sp.texttext.length)
{
if (!lost)
at = skipEscapes(body_, at, lineEnd);
(unresolved type) size_tsize_t _error_ jj = i, _error_ kk = at;
if (!lost)
while (j < sp.sp.texttext.length && k < lineEnd
&& body_[k] != '\x1b' && body_[k] == sp.sp.texttext[j])
{
++j;
++k;
}
TextSpan _error_ piecepiece = sp;
if (j == i) // no longer the source: no identity from here on
{
lost = true;
piece.piece.texttext = sp.sp.texttext[i .. $];
piece.srcStart = piece.srcEnd = size_t.size_t.maxmax;
out_ ~= piece;
break;
}
piece.piece.texttext = sp.sp.texttext[i .. j];
piece.srcStart = at;
piece.srcEnd = k;
out_ ~= piece;
i = j;
at = k;
}
}
anchored ~= out_;
lineStart = lineEnd + 1;
}
return anchored;
}
/// The first byte at or after `at` that is not inside an escape sequence:
/// CSI (`ESC [ … final`), OSC (`ESC ] … BEL` or `ESC \`), or a two-byte
/// `ESC x`.
private (alias) object.size_t = ulongsize_t ulong sparkles.doc_view.ansi_model.skipEscapes(scope const(char)[] s, ulong at, ulong end) pure nothrow @nogc @safeThe first byte at or after at that is not inside an escape sequence:
CSI (ESC [ … final), OSC (ESC ] … BEL or ESC \), or a two-byte
ESC x.
skipEscapes(scope const(char)[] (parameter) const(char)[] ss, (alias) object.size_t = ulongsize_t (parameter) ulong atat, (alias) object.size_t = ulongsize_t (parameter) ulong endend)
@safe pure nothrow @nogc
{
while ((parameter) ulong atat < (parameter) ulong endend && (parameter) const(char)[] ss[(parameter) ulong atat] == '\x1b')
{
if ((parameter) ulong atat + 1 >= (parameter) ulong endend)
return (parameter) ulong endend;
const (local variable) const(char) kindkind = (parameter) const(char)[] ss[(parameter) ulong atat + 1];
(parameter) ulong atat += 2;
if ((local variable) const(char) kindkind == '[')
{
while ((parameter) ulong atat < (parameter) ulong endend && !((parameter) const(char)[] ss[(parameter) ulong atat] >= 0x40 && (parameter) const(char)[] ss[(parameter) ulong atat] <= 0x7E))
++(parameter) ulong atat;
if ((parameter) ulong atat < (parameter) ulong endend)
++(parameter) ulong atat; // the final byte
}
else if ((local variable) const(char) kindkind == ']')
{
while ((parameter) ulong atat < (parameter) ulong endend && (parameter) const(char)[] ss[(parameter) ulong atat] != '\x07'
&& !((parameter) const(char)[] ss[(parameter) ulong atat] == '\x1b' && (parameter) ulong atat + 1 < (parameter) ulong endend && (parameter) const(char)[] ss[(parameter) ulong atat + 1] == '\\'))
++(parameter) ulong atat;
if ((parameter) ulong atat < (parameter) ulong endend)
(parameter) ulong atat += (parameter) const(char)[] ss[(parameter) ulong atat] == '\x07' ? 1 : 2;
}
}
return (parameter) ulong atat;
}
@("ansi_model.anchorAnsiLines.eachPieceIsTheSourceItCameFrom")
@safe pure
unittest
{
// 0 1 2
// 0123456789012345678901234
const (local variable) const(string) body_body_ = "\x1b[31mred\x1b[0m plain\nnext\n";
auto (local variable) _error_ decodeddecoded = [
[TextSpan("red"), TextSpan(" plain")],
[TextSpan("next")],
];
const (local variable) const(_error_) aa = anchorAnsiLines(decoded, body_);
assert(a.length == 2);
assert(a[0][0].(field) _error_ (__error)[0].texttext == "red" && a[0][0].srcStart == 5 && a[0][0].srcEnd == 8);
assert((local variable) const(string) body_body_[a[0][1].srcStart .. a[0][1].srcEnd] == " plain");
assert((local variable) const(string) body_body_[a[1][0].srcStart .. a[1][0].srcEnd] == "next");
}
@("ansi_model.anchorAnsiLines.aSpanAnEscapeInterruptsIsSplitThere")
@safe pure
unittest
{
// A reset that changes nothing visible: the decoder reports one run "ab",
// but its bytes are not contiguous in the source.
const (local variable) const(string) body_body_ = "a\x1b[0mb";
const (local variable) const(_error_) aa = anchorAnsiLines([[TextSpan("ab")]], body_);
assert(a[0].length == 2, "split at the escape");
assert(a[0][0].(field) _error_ (__error)[0].texttext == "a" && a[0][0].srcStart == 0 && a[0][0].srcEnd == 1);
assert(a[0][1].(field) _error_ (__error)[1].texttext == "b" && a[0][1].srcStart == 5 && a[0][1].srcEnd == 6);
// An OSC hyperlink around text is skipped the same way.
const (local variable) const(string) linklink = "\x1b]8;;https://x\x07go\x1b]8;;\x1b\\";
const (local variable) const(_error_) bb = anchorAnsiLines([[TextSpan("go")]], link);
assert((local variable) const(string) linklink[b[0][0].srcStart .. b[0][0].srcEnd] == "go");
}
@("ansi_model.anchorAnsiLines.textThatIsNotTheSourceKeepsNoIdentity")
@safe pure
unittest
{
// A terminal-expanded tab: the decoded spaces are not in the source.
const (local variable) const(_error_) aa = anchorAnsiLines([[TextSpan("x"), TextSpan(" y")]], "x\ty");
assert(a[0][0].srcStart == 0 && a[0][0].srcEnd == 1, "the match holds");
assert(a[0][1].srcStart == (ulong) ulongsize_t.max, "and stops where the text diverges");
assert(a[0][1].(field) _error_ (__error)[1].texttext == " y", "the text itself is kept");
}