Skip to content

Latest commit

 

History

History
588 lines (457 loc) · 29.2 KB

File metadata and controls

588 lines (457 loc) · 29.2 KB

sparcli – C++ Wrapper Reference

A header-only, RAII C++20 layer over the C API, in the sparcli:: namespace:

#include <sparcli.hpp>
using namespace sparcli;

It is zero-overhead (thin inline forwarders), move-only owning handles (no leaks / double-free), owns the strings/texts the C API only borrows (so temporaries are safe), and returns std::optional / std::vector from input prompts. The only exception thrown is std::bad_alloc, from a constructor when the underlying sc_*_new returns NULL.

This page documents what the wrapper adds (ownership, return semantics, exceptions). For the per-option meaning of the *Opts structs it reuses, see the C API reference. Every type/function below is defined in include/sparcli.hpp.

Build / link

c++ -std=c++20 app.cpp $(pkg-config --cflags --libs sparcli) -o app

Requires C++20 (designated initializers for the *Opts structs). Verified by tests/cpp/test_cpp.cpp (make test-cpp).

Why a wrapper? (safety)

Two easy C-API mistakes cannot happen with it:

// C API – two footguns:
ScTableData *t = sc_table_new();                 // 1) leaks without sc_table_free
sc_table_add_row(t, (ScCell[]){                  // 2) the table BORROWS the
    sc_cell(std::to_string(n).c_str()) }, 1);    //    string; the temporary dies
sc_table_print(t, (ScTableOpts){0});             //    here → dangling read

// C++ wrapper – RAII frees, and the cell string is copied into the table:
Table t;                                         // frees itself
t.add_row({ std::to_string(n) });                // owned → temporary is safe
t.print();

Types, opts & color helpers

The C structs/enums are reused verbatim via aliases, so options are written with C++20 designated initializers:

panel("Hi", { .border = { .type = SC_BORDER_ROUNDED },
              .title  = { .text = " Title ", .halign = SC_ALIGN_CENTER } });

Partial initializers and -Wmissing-designated-field-initializers

The *Opts structs are zero-init-friendly by design: you brace-initialize only the fields you care about, and every field you omit defaults to zero (which each option documents as its sensible default). That is the intended idiom – the short form above leaves the vast majority of fields unset on purpose.

Clang's -Wmissing-designated-field-initializers flags exactly this (e.g. "missing field 'initial' initializer" for { .placeholder = "…" }). The warning is not part of -Wall/-Wextra – it only appears if you opt in, or via an editor's language server (clangd commonly surfaces it). It is fundamentally at odds with this idiom: it even fires inside sparcli's own headers (the inline sc_cell* helpers brace-init a few ScCell fields and leave the rest zero).

Recommended: silence that one diagnostic in the consumer. The omitted fields are still well-defined zero-initialization, so nothing about behavior, codegen or ABI changes – only the (incorrect, for this style) message goes away:

# Makefile / build flags
CXXFLAGS += -Wno-missing-designated-field-initializers
# compile_flags.txt (so clangd is quiet too)
-Wno-missing-designated-field-initializers

This is safe and portable: the flag is clang-specific, and a -Wno-… for an unknown warning is silently ignored by GCC. The only trade-off is that it is project-wide, so you also lose the check for your own structs where omitting a field might be an oversight – minor if you, too, rely on zero-init defaults. If you'd rather keep that check for your code, suppress it editor-only via a .clangd file (Diagnostics: { Suppress: [missing-designated-field-initializers] }).

Verbose alternative (no flag): if you don't want to touch warnings, default- construct the opts and assign the fields you need – this initializes every field (via value-initialization) and never trips the warning:

TextInputOpts opts{};                 // all fields zero-initialized
opts.placeholder = "e.g. Buy milk";
auto name = text_input("Task title", opts);

It is correct but loses the concise call-site style the wrapper is built around, and you would repeat it at every such call – hence the -Wno-… flag is usually preferable.

Aliases: Color, TextStyle, TextAttribute, Title, Edges, BorderStyle, BorderType, HAlign, VAlign, Position, and every *Opts (PanelOpts = ScPanelOpts, TableOpts, ColOpts, RuleOpts, ColumnsOpts, ColItem, ListOpts, TreeOpts, KVOpts, BadgeOpts, ProgressBarOpts, SpinnerOpts, PadOpts, MarkupOpts, ConfirmOpts, TextInputOpts, PasswordOpts, NumberOpts, TextareaOpts, SelectOpts, FuzzyOpts, DatePickerOpts, InputTheme, AlertType, InputStatus, HintLayout, HintPosition, KeyChord, Shortcut, Key).

Colors use functions, not the SC_ANSI_COLOR_* compound-literal macros (which are non-standard in C++):

Color c1 = red();                 // none/black/red/green/yellow/blue/
Color c2 = rgb(120, 200, 255);    // magenta/cyan/white, plus rgb(r,g,b)
TextStyle s = style(SC_TEXT_ATTR_BOLD, green());   // attr, fg, bg
std::optional<Color> c3 = color_by_name("accent"); // ANSI or palette names
Version v = version();            // {major,minor,patch}; or version_string()

Named RGB palette — the C SC_COLOR_* set is exposed as functions under sparcli::palette (the C macros are compound literals, unusable in standard C++). All 53 colors are available; each returns a 24-bit RGB Color:

using namespace sparcli;
panel_str("hi", PanelOpts{ .border = { SC_BORDER_ROUNDED, palette::accent() },
                           .bg = palette::bg_darken_1() });
alert_text(SC_ALERT_ERROR, *markup::parse("[error]disk full[/]"));   // markup names too

Names mirror the macros lower-cased: palette::accent(), palette::error(), palette::orange(), palette::red_vivid(), palette::bg_selected(), … In string contexts (markup tags) the eight plain hue names stay ANSI; see docs/api-c.md. Runtime override: palette::set("accent", color) / palette::get("accent") (→ std::optional<Color>) / palette::reset() recolor a name at runtime — honored by markup, the CLI and palette-name widget defaults (e.g. the fuzzy accent). Set once before spawning threads.

All ScColor/ScTextStyle enum/macro constants (SC_TEXT_ATTR_*, SC_BORDER_*, SC_ALIGN_*, …) are plain enums and work as-is.

RAII handles

All are move-only (non-copyable); the destructor frees the underlying sc_* object. Each exposes .get() for the raw C handle (escape hatch). Constructors throw std::bad_alloc on allocation failure.

Calling a method on a moved-from handle is a use-after-move: in debug builds it trips an assert ("use of a moved-from sparcli handle"); under -DNDEBUG the assert compiles away (so it is undefined behavior, as usual). Destroying or move-assigning into a moved-from handle is always safe.

Text – rich multi-span text (ScText)

Text t;                              // empty
Text t2("plain");                    // one unstyled span
Text t3 = Text::markup("[bold]hi[/]");
t.append("x", style(SC_TEXT_ATTR_DIM))
 .append_markup("[red]y[/]")
 .append_link("docs", "https://example.com/docs")   // OSC-8 hyperlink
 .append_badge("v1");
t.print();                           // → current output stream
t.visible_width(); t.span_count();

All append* calls copy their input. append_link wraps the text in an OSC-8 terminal hyperlink (clickable in supporting terminals, plain text elsewhere); markup [link=URL]text[/link] does the same.

Table – ScTableData (owns its cell strings)

The C table borrows cell strings, so the wrapper copies them into an internal arena that lives as long as the table – std::string temporaries are safe, and the table stays valid across a move.

Table t;
t.add_column("Name", { .halign = SC_ALIGN_LEFT });
t.add_row({ "Ada", std::to_string(42) });          // string-like cells
t.add_row({ cell_markup("[green]OK[/]"),            // markup cell
            cell("100").align(SC_ALIGN_RIGHT) });   // per-cell options
t.add_row({ "x", "y" }, rgb(40,40,60));             // per-row background
t.add_footer_row({ "total", "142" });
t.print({ .header = { .row = true } });

A Cell is implicitly constructible from any string-like; chain .align(h), .valign(v), .colspan(n), .rowspan(n). Free helpers: cell("…"), cell_markup("…"). For a fully custom multi-span cell, drop to t.get() + sc_cell_from_text (managing the Text lifetime yourself).

List – ScList (owns rich-text items)

String items are copied by the C side; rich (Text/markup) items are kept alive in a shared arena that outlives the root list, so sub-list rich items are safe too.

List l({ .marker = SC_LIST_NUMBER });
l.add("first");
auto item = l.add("second");
{ auto sub = item.sub({ .marker = SC_LIST_ALPHA_LC });
  sub.add("nested"); sub.add_markup("[cyan]rich[/]"); }
l.add_markup("[bold]rich root[/]");
l.print();

add(...) returns a non-owning Item; Item::sub(opts) nests a list.

Tree – ScTree (owns rich-text nodes)

Tree tr;
auto root = tr.add("project");
tr.add("src", root);
tr.add_markup("[dim]README.md[/]", root);
tr.print();

add(...) / add_markup(...) return a non-owning Node; pass it as the parent of a child (default Node{} = root). String/prefix args are copied; Text nodes are owned by the tree.

Kv – ScKV

Kv kv;
kv.add("Version", sc_version_string());   // both strings copied
kv.print();

Columns – ScColumns (captures eagerly)

Each add* captures the widget's rendering immediately, so the source may be modified or destroyed afterwards.

Columns cols({ .gap = 3 });
cols.add(table, { .header = { .row = true } });
cols.add_panel("side", { .border = { .type = SC_BORDER_SINGLE } });
cols.add(rendered);                       // a captured Rendered
cols.print();

Overloads: add(Table, TableOpts, ColItem), add_panel(string|Text, …), add(Text|string|List|Tree|Columns|Rendered, ColItem).

ProgressBar / Spinner – ScProgressBar / ScSpinner

ProgressBar bar({ .show_percent = true });
bar.set_label("Installing");
for (int v = 0; v <= 100; ++v) { bar.draw(v, 100); /* sleep */ }
bar.finish(100, 100);

Spinner sp("Loading");
for (int i = 0; i < 30; ++i) { sp.tick(); /* sleep */ }
sp.finish(true, "Done");

draw(value, max): max == 0value is a 0..1 ratio.

Stateless output

print("x", style(SC_TEXT_ATTR_DIM));   println("y");
panel("body", opts);                   panel(text, opts);
rule("title", opts);  rule(opts);      rule(text, opts);
badge("DONE", { .pad = 1 });

alert::info("");  alert::debug/warning/error/success("");
alert::show(SC_ALERT_INFO, "");

markup::println("[bold red]Error[/]");  markup::print("");
Text t = markup::parse("[green]ok[/]");

Capture & compose

Rendered r  = capture::table(t, opts);   // also: str/text/list/tree/kv/
Rendered r2 = capture::panel("x", opts); //        columns/panel/rule
Rendered s  = vstack({ &r, &r2 }, 1);    // stack into one column (gap lines)
r.pad({ .left = 4 });                     // print with padding
r.align(SC_ALIGN_CENTER);                 // print aligned (0 = terminal width)

pad("text", { .top = 1 });  align("text", SC_ALIGN_RIGHT, 40);

std::string plain = strip_ansi(colored);
std::string cut   = truncate("long…", 12);
clear_line();

// ANSI-injection protection: user strings are sanitized by default.
// Opt out globally or per widget (AnsiMode = ScAnsiMode):
set_allow_ansi(true);                                  // bool allow_ansi();
panel("", { .ansi = SC_ANSI_MODE_ALLOW });            // per-widget override

ScopedOutput redirects the (thread-local) output stream for a scope and restores it on exit:

{ ScopedOutput to(fp); println("goes to fp"); }   // restored here
set_output(fp);  set_output(nullptr);  // manual; nullptr = stdout

Application helpers (XDG paths, pager)

// XDG directories (created on first use); empty optional on failure
std::optional<std::string> cfg = paths::config("myapp");   // ~/.config/myapp
auto log = paths::file(SC_PATH_STATE, "myapp", "logs/run.log");

// RAII pager: output is paged until end()/destruction; no-op off terminal
{
    Pager pager;                       // PagerOpts{ .command, .always }
    table.print(opts);                 // paged through $PAGER / less -R
    int status = pager.end();          // or implicit on scope exit
}

// External editor on an existing file (inherits the terminal, edits in place).
// edit_file(path) uses $VISUAL/$EDITOR; -1 when no terminal is available.
int rc = edit_file("/tmp/note.md");    // or edit_file("nvim", "/tmp/note.md")

// RAII live display: re-render a composed frame in place (dashboard).
// Off-terminal, only the final frame is printed when the session ends.
{
    Live live;                         // LiveOpts{ .alt_screen, .transient, ... }
    for (int i = 0; i <= 100; i += 10) {
        live.update(capture::str("progress: " + std::to_string(i) + "%"));
    }
    // update() is overloaded for a Rendered, a string, a Text, and a Table:
    // live.update(my_text);  live.update(my_table, TableOpts{ ... });
}                                      // destructor restores the terminal

// RAII alt-screen session for full-screen widgets (no flicker on switch).
// Fuzzy/Form take fullscreen + valign + a BORROWED header (the Rendered must
// outlive the run) and fill the screen: the finder grows then scrolls, the
// leftover space is placed by valign.
{
    AltScreen screen;                  // enters the alt screen; restores on drop
    Rendered header = capture::panel("My App", PanelOpts{ .full_width = true });
    FuzzyOpts opts{ .fullscreen = true, .valign = SC_VALIGN_MIDDLE,
                    .header = header.get() };
    Fuzzy f(opts);
    f.add("alpha").add("beta");
    f.run();                           // or a Form with the same fields
}                                      // restores the previous screen

// Pretty errors: message + causes + hint + exit code as a red panel
ErrorReport("Config could not be loaded")
    .cause("file not found: ~/.config/app/config.toml")
    .hint("Run 'app init' to create a default config")
    .code(2)
    .die();                            // renders to stderr, exits(2)
die(2, "No config found", "Run 'app init'");   // one-shot

// Logging: global logger (colored stderr at INFO + optional files) ...
logging::set_level(SC_LOG_DEBUG);
logging::add_file("app.log", SC_LOG_DEBUG);
logging::info("server started");          // message is data, never a format

// ... or an independent handle-based logger (RAII)
Logger logger(LoggerOpts{ .hide_timestamps = true });
logger.add_terminal(stderr, SC_LOG_INFO).add_file("debug.log");
logger.warn("low disk space");

// Argument parser: declarative tree + typed getters (see api-framework.md)
Args args({ .prog = "mytool", .version = "1.0", .about = "Demo" });
args.root().flag("verbose", 'v', "Verbose output")
    .subcommand("build", "Build the project")
    .opt("jobs", 'j', SC_ARG_INT, "N", "Parallel jobs")
    .positional("TARGET", SC_ARG_STR, "Build target", true);
if (auto matched = args.parse(argc, argv)) {
    long jobs = args.get_int("jobs");
} else {
    return args.exit_code();   // 0 for --help/--version, 2 for errors
}

Input widgets

Each returns std::optional / std::vector; the result is empty (std::nullopt) when the user cancels (Esc / Ctrl-C) or no interactive terminal is available. input_available() reports whether a prompt can run (the env var SPARCLI_NO_TTY=1 forces false, see the C reference).

if (auto ok   = confirm("Proceed?")) { /* *ok is bool */ }
if (auto name = text_input("Name", { .placeholder = "Ada" })) { /* *name */ }

// Autocomplete dropdown (C opts used directly): suggestions as a navigable
// list below the field; arrows move, Tab/Enter accept, prefix/fuzzy matching.
static const char *cmds[] = { "commit", "checkout", "cherry-pick" };
if (auto cmd = text_input("Git command", {
        .suggestions = cmds, .n_suggestions = 3,
        .suggest = { .mode = SC_SUGGEST_DROPDOWN,
                     .match = SC_SUGGEST_MATCH_FUZZY,
                     .border = { .type = SC_BORDER_ROUNDED } } })) { /* *cmd */ }
if (auto pw   = password_input("Password")) { /* *pw */ }
if (auto note = textarea("Notes")) { /* *note (Ctrl-D submits) */ }
if (auto qty  = number_input("Qty", { .min = 0, .max = 100, .step = 5 })) { }
if (auto date = datepicker({}, { .prompt = "Pick a date" })) { /* std::tm */ }

// Calculator mode: "=" starts an expression (=1,5+2*3); Enter accepts the
// result, a second Enter submits it (full precision by default). A dim
// " = <exact>" indicator marks a pending full-precision result; editing it
// away raises a yellow warning (text/style: .calc_warn_text/.calc_warn_style).
if (auto total = number_input_text("Total", { .start_empty = true,
        .decimals = 2, .calculator = true })) { /* "38.97" */ }
if (auto v = calc_eval("1,5+2*3")) { /* *v == 7.5 – pure, no TTY */ }

// Exact value as text ('.'-normalized, never via double) – e.g. for money.
// decimal_sep = ',' shows/accepts a comma while editing; start_empty starts
// with an empty field instead of "0,00" (Enter on an empty field is ignored).
if (auto amount = number_input_text(
        "Amount", { .start_empty = true, .decimals = 2, .decimal_sep = ',' })) {
    /* *amount == "12.99" – parse into your decimal type */
}

Select sel({ .prompt = "Pick", .multi = true });
sel.add("a").add("b").add("c");
sel.set_checked(0);
if (auto idx = sel.run()) { /* std::vector<size_t> in display order */ }
// single-select convenience: sel.run_one() → std::optional<size_t>

Fuzzy fz({ .prompt = "Find" });
fz.add("Tokyo").add("London");
if (auto i = fz.run()) { /* add-order index */ }

// Table view: add_row gives multi-column rows; by default the query searches
// (and highlights) every column. Restrict it with search_columns (bitmask).
Fuzzy langs({ .table = true, .headers = headers, .n_cols = 2 });
langs.add_row({ "C", "Static" }).add_row({ "Python", "Dynamic" });

// Todo-style finder: day sections, multi-select, per-cell colors, ordering.
Fuzzy todo({ .table = true, .headers = todo_headers, .n_cols = 3,
             .multi = true, .checkbox_column = true, .section_counts = true,
             .order = SC_FUZZY_ORDER_COLUMN, .order_column = 0,
             .toggle_all_key = key_ctrl('a') });
todo.add_section("Monday");
todo.add_row_styled({ "09:00", "Pay invoice", "overdue" },
                    { {}, {}, { SC_TEXT_ATTR_BOLD, SC_ANSI_COLOR_RED, {} } });
todo.set_id(1, 102);
if (auto checked = todo.run_multi()) { /* std::vector<size_t> of checked rows */ }
// also: add_section_styled(title, TextStyle) / add_section_text(const Text&,
//       TextStyle) (per-section bg/fg/bar), add_styled / add_row_rich(std::vector<Text>&), set_disabled,
// set_checked / check_all / checked_count, set_cursor / set_label / set_row /
// set_row_style, id_at / cursor_id. Demo: examples/c/apps/todo_fuzzy.c.

// Modal (vim-style) mode: normal mode fires bare-letter shortcuts + j/k/g/G,
// insert mode (press `i`) types a filter, `Esc` toggles back; the query line is
// badged + tinted per mode. key_char builds a bare (no-modifier) chord.
Fuzzy modal({ .modal = true,            // .start_in_insert = true to flip
              .clear_key = key_char('c'),   // normal-mode: clear the query
              .normal_label = "CMD", .insert_label = "EDIT" });

bool m = fuzzy_match("to", "Tokyo");      // pure, no TTY
set_theme({ .accent = magenta() });  reset_theme();   // InputTheme theme();

// Input history (REPLs): Up/Down recall + auto-add on submit + persistence.
// History is move-only RAII; the destructor saves the configured file.
History history({ .app = "myapp" });   // ~/.local/state/myapp/history
for (;;) {
    TextInputOpts opts{};
    history.apply(opts);               // or: opts.history = history.get();
    auto line = text_input("repl>", opts);
    if (!line) { break; }
    // dispatch(*line) - it was already recorded in the history
}
// history.add/count/get/save/load for manual control;
// Args::parse_line(line) tokenizes + parses it in one call (see api-framework.md)

Text/number input constraints reuse the built-in filters via the opts; the filters namespace aliases them: text_input("PIN", { .char_filter = filters::digits }) (also decimal/alpha/alnum/no_space; see api-c.md).

The key-hint footer is configured through the opts like any other field: .hint_layout (SC_HINT_INLINE / SC_HINT_STACKED / SC_HINT_HIDDEN) and .hint_pos (SC_HINT_POS_TOP / _BOTTOM / _LEFT / _RIGHT), e.g. confirm("Deploy?", { .hint_pos = SC_HINT_POS_RIGHT }). Both also work via the InputTheme. See api-c.md.

Custom shortcuts

Bind extra keys (Ctrl-letter / F-key / Alt-letter) to actions on any widget. Shortcuts is an owning builder (it keeps callback std::functions alive); apply(opts) wires it into any *Opts, and fired() reports which RETURN-mode shortcut ended the prompt (-1 if none). Chords: key_ctrl('e'), key_fn(2), key_alt('e'), key_char('p') (case-sensitive: pP), the named keys key_left/right/up/down/enter/tab() (or key_special(SC_KEY_LEFT)), and key_mod(SC_KEY_UP, SC_MOD_ALT) / key_shift(SC_KEY_UP) for modified named keys (Alt/Shift/Ctrl + arrows/Home/…, incl. combos); key_name(chord) formats one ("F2", "^E", "M-e", "←", "Del", "M-↑"). (key_matches(Key, KeyChord) and shortcut_find(Key, vector<Shortcut>) wrap the low-level matchers for callers that decode keys themselves.)

Select sel({ .prompt = "Pick" });
sel.add("Apple").add("Banana");

Shortcuts sc;
sc.on_return(key_fn(2), 1, "rename")               // closes; fired()==1
  .on_callback(key_ctrl('x'), [&] {                // stays open
      sel.remove(sel.cursor()); return true; }, "delete");

SelectOpts opts{ .prompt = "Pick" };
sc.apply(opts);
Select s2(opts);  s2.add("Apple").add("Banana");
auto pick = s2.run();
if (sc.fired() == 1) { /* F2 → edit s2.label(...) / s2.set_label(...) */ }

A RETURN shortcut ends the prompt; a CALLBACK runs in place and keeps it open unless its lambda returns false (it must not open another prompt). For live edits use Select::cursor/label/set_label/remove and Fuzzy::cursor_index/remove; Fuzzy::has_selection() reports whether a row currently matches (so a forward/submit shortcut can avoid acting on an empty filter). Esc / Ctrl-C stay reserved.

Display metadata + help screen. Each binding carries a footer label, a (longer) help-screen description and a section. The rich overloads take a ShortcutDisplay{ footer, help, in_footer } (the const char*-label overloads are thin wrappers); in_footer = false keeps a binding active but off the footer. section(title) groups the entries added after it, and help_row(key_display, desc) adds a help-only line documenting a built-in key (no binding). show_shortcuts(sc, ShortcutHelpOpts{ title, accent, footer_hint, in_alt_screen }) then renders a modal, filterable, full-terminal-height help screen from the set (sections + key column + descriptions, author order). It spans its own alternate screen unless in_alt_screen = true (the caller — e.g. a long-running TUI — already holds one).

Shortcuts sc;
sc.section("Actions")
  .on_return(key_ctrl('n'), 1, { .footer = "new",    .help = "create an item" })
  .on_return(key_ctrl('x'), 2, { .footer = "delete", .help = "delete the item",
                                  .in_footer = false })   // bound, not in footer
  .section("Navigation")
  .help_row("↑/↓ or j/k", "move the cursor");             // help-only (no binding)
show_shortcuts(sc, { .title = "My app · shortcuts" });

Form (grid layout)

Form wraps sc_form: add fields row by row, run(), then read values back. FieldOpts/FormOpts are aliases of the C opts (set validate as a raw function pointer if needed); dates use std::tm. FormOpts{ .autoedit = true } opens the first field's editor immediately at start. Form::modified() reports whether any field changed from its initial value (for an "unsaved changes?" prompt on cancel), and FormOpts{ .modified_marker = "[*] " } prefixes a changed field's box title.

Per-field colors. set_choice_styles(field, { style… }) colors a select/multiselect's choices (in the dropdown and the selected grid cell) — e.g. a color-coded priority list. FieldOpts{ .value_style = cb } (an ScFieldCellStyle raw fn ptr (const ScForm*, int field, void*) → TextStyle, like validate) colors just a cell's value text and is called on every render, so it updates live — e.g. color a date by overdue/today/future (read the value with get_date inside the callback).

Form f({ .title = "Contact", .accent = cyan() });
f.row_begin();
int name = f.add_text("Name", "Ada", { .width_mode = SC_FWIDTH_PCT, .width = 50 });
int tier = f.add_select("Tier", { "Bronze", "Silver", "Gold" }, 1, { .col_span = 2 });
f.set_choice_styles(tier, { style(SC_TEXT_ATTR_NONE, green()),
                            style(SC_TEXT_ATTR_NONE, yellow()),
                            style(SC_TEXT_ATTR_BOLD, red()) });
f.add_multiselect("Tags", { "vip", "net-30" }, { 0 });
f.add_date("Since", {});
f.add_text("Notes", "", { .multiline = true });   // Enter / Ctrl-G open $EDITOR
if (f.run()) {
    auto who  = f.get_string(name);
    auto t    = f.get_choice(tier);
    auto tags = f.get_checked(2);
    if (auto d = f.get_date(3)) { /* std::tm */ }
}

Rich prompts

For partial styling (e.g. only the old name italic), set prompt_text (a borrowed ScText *, overrides the string prompt) or prompt_markup = true on any input opts. Works inline and boxed.

Text p;                                   // owns the ScText for the call
p.append("Rename ").append(name, style(SC_TEXT_ATTR_ITALIC)).append(" to");
auto renamed = text_input("", { .initial = name, .prompt_text = p.get() });
// or, compact (escape '[' as '[[' in dynamic text):
auto r2 = text_input("Rename [italic]Apple[/] to", { .prompt_markup = true });

External editor

text_input and textarea can open the value in $EDITOR. Opt in via the opts: .external_editor = true, optional .editor = "nvim", and .editor_key (default Ctrl-G). On save+quit the file replaces the value (text_input keeps the newlines collapsed to spaces); a non-zero exit keeps the old value. Runs shell-free with a 0600 temp file; not available for password_input (secret would hit disk). The editor key is matched before custom shortcuts (a chord bound to both → editor wins).

auto msg = textarea("Commit message",
                    { .external_editor = true, .editor_key = key_ctrl('g') });

Utility wrappers

Humanize (namespace humanize)

Returns std::string copies of the C heap strings. @see api-c.md.

humanize::bytes(1536);                 // "1.5 KB"  (or bytes(n, SC_BYTES_IEC))
humanize::number(1234567);             // "1,234,567"
humanize::compact(12400);              // "12.4k"
humanize::percent(0.42);               // "42%"
humanize::duration(8054);              // "2h 14m"
humanize::duration_clock(3725);        // "01:02:05"
humanize::relative(then, now);         // "3 hours ago"
humanize::bytes(1536, SC_BYTES_IEC, { .decimal_sep = ',' });   // "1,5 KiB"

Diff

diff("a\nb\n", "a\nB\n");                       // print a colored unified diff
Text t  = diff_text(old_s, new_s, { .context = 1 });
Rendered r = capture::diff(old_s, new_s);       // compose into panels/columns

MultiProgress (RAII)

MultiProgress mp;
int a = mp.add("download", { .show_percent = true });
int b = mp.add("extract",  { .show_percent = true });
for (int i = 0; i <= 100; ++i) { mp.update(a, i, 100); mp.update(b, i/2, 100); }
mp.end();   // also runs on destruction

Subprocess

ProcResult r = run({ "git", "rev-parse", "HEAD" });
if (r.ok() && r.exit_code() == 0) std::print("{}", std::string(r.out()));
// stdin: set opts.input / opts.input_len to a buffer that lives through the call

out()/err() return std::string_view into the owned buffers; the result frees them on destruction. exit_code() is 127 for a missing command.

Config (opt-in: <app/sparcli_config.hpp>)

Serde-dependent, so not pulled in by <sparcli.hpp> – include it explicitly.

#include <app/sparcli_config.hpp>
sparcli::Config cfg;
cfg.set_defaults(defaults);                 // a serde::Value object
cfg.load_file("~/.config/app.toml");        // by extension; MISSING is not an error
cfg.load_env("APP_");                       // APP_SERVER__PORT → server.port
cfg.set("debug", serde::Value::boolean(true));
auto port = cfg.get_int("server.port", 8080);

View: render serde models (opt-in: <view/sparcli_view.hpp>)

#include <view/sparcli_view.hpp>
view::value_render(value);                  // jq-style colored ScValue
view::markdown_render_str("# Title\n\n- a\n- b\n");
Rendered r = view::capture_markdown(doc);   // doc = serde::Markdown

Escape hatch

Every handle exposes .get() returning the raw Sc* pointer, so you can always mix wrapper and C calls or reach functionality the wrapper doesn't surface:

Table t;
sc_table_add_row(t.get(), cells, n);   // raw C on a wrapper-owned handle

See the C API reference for the underlying functions and the exact option fields.