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.
c++ -std=c++20 app.cpp $(pkg-config --cflags --libs sparcli) -o appRequires C++20 (designated initializers for the *Opts structs). Verified by tests/cpp/test_cpp.cpp (make test-cpp).
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();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 } });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 tooNames 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.
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 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.
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).
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 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 kv;
kv.add("Version", sc_version_string()); // both strings copied
kv.print();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 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 == 0 → value is a 0..1 ratio.
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[/]");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 overrideScopedOutput 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// 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
}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.
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: p ≠ P), 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 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 */ }
}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 });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') });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("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/columnsMultiProgress 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 destructionProcResult 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 callout()/err() return std::string_view into the owned buffers; the result frees them on destruction. exit_code() is 127 for a missing command.
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);#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::MarkdownEvery 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 handleSee the C API reference for the underlying functions and the exact option fields.