summaryrefslogtreecommitdiffstats
path: root/include/clio.h
diff options
context:
space:
mode:
authorEthan Werner <mail@ethanwerner.com>2026-10-10 20:55:33 +0000
committerEthan Werner <mail@ethanwerner.com>2026-10-10 20:56:50 +0000
commitf8423ebea413605b42de23f5dd06c5479d3fa1dd (patch)
tree2612a5eb026c59e7aae01f10a1cf62f2ee43042c /include/clio.h
initial commitHEADmaster
Diffstat (limited to 'include/clio.h')
-rw-r--r--include/clio.h1493
1 files changed, 1493 insertions, 0 deletions
diff --git a/include/clio.h b/include/clio.h
new file mode 100644
index 0000000..8899197
--- /dev/null
+++ b/include/clio.h
@@ -0,0 +1,1493 @@
+/* clio.h — minimal pseudo-TUI layout layer over escape.h.
+ *
+ * escape.h owns styling (SGR, cursor motion, clear, color); clio.h owns
+ * layout: where text goes, how it wraps and clips, and how a composed
+ * frame is blitted to the terminal with the minimal diff of escape
+ * sequences. Apps never emit raw escape codes themselves.
+ *
+ * Model:
+ * clio_term the real terminal: size, capabilities, raw mode, alt
+ * screen, cursor, bracketed-paste/kitty state, key queue,
+ * and the last flushed frame (for diffing).
+ * clio_buf an in-memory cell grid (the canvas). Apps compose frames
+ * here; nothing is written until clio_flush.
+ * clio_flush diff the canvas against the previous frame and blit only
+ * changed rows through escape.h. Non-tty (pipe, CI) falls
+ * back to plain text lines so piped output stays clean.
+ * input a pure, chunk-fed key decoder (testable without a tty),
+ * raw-mode key reading with a bounded queue, and a
+ * single-line editor (clio_readline) that renders into the
+ * canvas.
+ *
+ * Conventions: C99, header-only (static inline), no global state,
+ * extern "C", POSIX-first (termios/poll/TIOCGWINSZ) — Windows is a
+ * documented limitation, as in escape.h.
+ *
+ * Usage: #include "clio.h". The header is complete on its own; escape.h
+ * is pulled in for the styling layer. The esc_* function symbols need one
+ * defining TU with ESCAPE_IMPLEMENTATION (llshcpp's src/escape.c, or the
+ * test TU itself for zero-link builds).
+ *
+ * Layout is column-based: one cell per column, one code point per cell.
+ * Wide characters (width 2) occupy one cell; a wide character landing
+ * in the last column may be clipped by the terminal (v1 limitation).
+ *
+ * API:
+ * style: clio_style, clio_style_default, clio_style_eq
+ * text: clio_utf8_next_n, clio_utf8_enc, clio_cp_width,
+ * clio_text_width, clio_char_start, clio_char_end,
+ * clio_word_back
+ * canvas: clio_buf, clio_cell, clio_buf_init/free/clear,
+ * clio_buf_at, clio_buf_clear_region, clio_buf_fill_region,
+ * clio_buf_put, clio_buf_box, clio_buf_scroll_up
+ * keys: clio_key, CLIOK_*, clio_keydec, clio_keydec_init,
+ * clio_keydec_feed, clio_keydec_flush
+ * terminal: clio_term, clio_color_policy, clio_term_init,
+ * clio_term_resize, clio_term_raw_on/off,
+ * clio_term_alt_on/off, clio_term_cursor_hide/show,
+ * clio_term_paste_on/off, clio_term_kitty_on/off,
+ * clio_term_bell, clio_term_shutdown, clio_term_free
+ * output: clio_fstyle, clio_flush, clio_cursor
+ * input: clio_next_key, clio_next_key_stdin, clio_read_key,
+ * clio_readline
+ *
+ * Concurrency: no shared state; each clio_term is independent.
+ */
+#ifndef CLIO_H
+#define CLIO_H
+
+#include "escape.h"
+#include "type.h"
+
+#include <ctype.h>
+#include <limits.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <termios.h>
+#include <unistd.h>
+#include <wchar.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Style */
+/* ------------------------------------------------------------------ */
+
+/* Attribute bits (OR-combined into clio_style.attr). */
+enum {
+ CLIO_BOLD = 1u,
+ CLIO_DIM = 2u,
+ CLIO_ITALIC = 4u,
+ CLIO_UNDERLINE = 8u,
+ CLIO_REVERSE = 16u,
+ CLIO_STRIKE = 32u,
+};
+
+/* fg/bg: -1 = terminal default, otherwise a 0..255 palette index.
+ * attr: OR of CLIO_* bits. */
+typedef struct clio_style {
+ int fg;
+ int bg;
+ unsigned attr;
+} clio_style;
+
+static inline clio_style clio_style_default(void) {
+ clio_style s;
+ s.fg = -1;
+ s.bg = -1;
+ s.attr = 0;
+ return s;
+}
+
+static inline int clio_style_eq(const clio_style *a, const clio_style *b) {
+ return a->fg == b->fg && a->bg == b->bg && a->attr == b->attr;
+}
+
+/* ------------------------------------------------------------------ */
+/* Text (UTF-8 + display width) */
+/* ------------------------------------------------------------------ */
+
+/* Decode the next code point in [p, end); returns the byte length used.
+ * Truncated or invalid sequences still consume at least one byte. */
+static inline size_t clio_utf8_next_n(const char *p, const char *end, u32 *cp) {
+ unsigned char b = (unsigned char) p[0];
+ size_t n;
+ if (b < 0x80)
+ n = 1;
+ else if ((b & 0xE0) == 0xC0)
+ n = 2;
+ else if ((b & 0xF0) == 0xE0)
+ n = 3;
+ else if ((b & 0xF8) == 0xF0)
+ n = 4;
+ else {
+ *cp = 0;
+ return 1;
+ }
+ if ((size_t)(end - p) < n)
+ n = (size_t)(end - p);
+ u32 c = 0;
+ for (size_t i = 0; i < n; i++) {
+ unsigned char ch = (unsigned char) p[i];
+ unsigned int mask =
+ (i == 0) ? (n == 1 ? 0x7Fu : n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u)
+ : 0x3Fu;
+ c = (c << 6) | (u32)(ch & mask);
+ }
+ *cp = c;
+ return n;
+}
+
+static inline size_t clio_utf8_next(const char *s, u32 *cp) {
+ return clio_utf8_next_n(s, s + strlen(s), cp);
+}
+
+/* Encode a code point into out (at least 5 bytes). -1 on invalid input. */
+static inline int clio_utf8_enc(u32 cp, char *out) {
+ size_t n;
+ if (cp < 0x80)
+ n = 1;
+ else if (cp < 0x800)
+ n = 2;
+ else if (cp < 0x10000)
+ n = 3;
+ else if (cp <= 0x10FFFF)
+ n = 4;
+ else
+ return -1;
+ if (cp >= 0xD800 && cp <= 0xDFFF)
+ return -1;
+ unsigned char b[4];
+ switch (n) {
+ case 1:
+ b[0] = (unsigned char) cp;
+ break;
+ case 2:
+ b[0] = (unsigned char)(0xC0 | (cp >> 6));
+ b[1] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ case 3:
+ b[0] = (unsigned char)(0xE0 | (cp >> 12));
+ b[1] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
+ b[2] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ default:
+ b[0] = (unsigned char)(0xF0 | (cp >> 18));
+ b[1] = (unsigned char)(0x80 | ((cp >> 12) & 0x3F));
+ b[2] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
+ b[3] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ }
+ memcpy(out, b, n);
+ out[n] = '\0';
+ return (int) n;
+}
+
+/* Display width of a code point, locale-independent (wcwidth is not:
+ * glibc's C locale reports 0 for CJK, musl reports 2 — we must be the
+ * same everywhere). 0 = zero-width, 2 = wide (East Asian Wide/Fullwidth
+ * + common emoji), 1 = everything else. Simplified W=|F table. */
+static inline size_t clio_cp_width(u32 cp) {
+ if (cp == 0 || cp < 0x20 || (cp >= 0x7F && cp <= 0x9F))
+ return 0;
+ if (cp == 0xAD || cp == 0x200B || cp == 0x200C || cp == 0x200D ||
+ cp == 0x2060 || (cp >= 0x300 && cp <= 0x36F))
+ return 0;
+ if ((cp >= 0x1100 && cp <= 0x115F) || (cp >= 0x2E80 && cp <= 0x303E) ||
+ (cp >= 0x3041 && cp <= 0x33FF) || (cp >= 0x3400 && cp <= 0x4DBF) ||
+ (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0xA000 && cp <= 0xA4CF) ||
+ (cp >= 0xA960 && cp <= 0xA97F) || (cp >= 0xAC00 && cp <= 0xD7A3) ||
+ (cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0xFE10 && cp <= 0xFE19) ||
+ (cp >= 0xFE30 && cp <= 0xFE6F) || (cp >= 0xFF00 && cp <= 0xFF60) ||
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || (cp >= 0x1F300 && cp <= 0x1F64F) ||
+ (cp >= 0x1F900 && cp <= 0x1F9FF) || (cp >= 0x20000 && cp <= 0x3FFFD))
+ return 2;
+ return 1;
+}
+
+static inline size_t clio_text_width_n(const char *s, size_t n) {
+ size_t w = 0;
+ const char *p = s;
+ const char *end = s + n;
+ while (p < end) {
+ u32 cp;
+ size_t len = clio_utf8_next_n(p, end, &cp);
+ if (cp != 0)
+ w += clio_cp_width(cp);
+ p += len;
+ }
+ return w;
+}
+
+static inline size_t clio_text_width(const char *s) {
+ return clio_text_width_n(s, strlen(s));
+}
+
+/* Byte offset of the start of the code point containing pos. */
+static inline size_t clio_char_start(const char *s, size_t pos) {
+ if (pos == 0)
+ return 0;
+ size_t i = pos;
+ while (i > 0 && (((unsigned char) s[i]) & 0xC0) == 0x80)
+ i--;
+ return i;
+}
+
+/* Byte offset of the start of the code point immediately before pos
+ * (pos is a cursor position, i.e. a char boundary). */
+static inline size_t clio_char_before(const char *s, size_t pos) {
+ size_t i = pos;
+ if (i == 0)
+ return 0;
+ i--; /* last byte belongs to the previous code point */
+ while (i > 0 && (((unsigned char) s[i - 1]) & 0xC0) == 0x80)
+ i--;
+ return i;
+}
+
+/* Byte offset just past the code point at pos. */
+static inline size_t clio_char_end(const char *s, size_t len, size_t pos) {
+ if (pos >= len)
+ return len;
+ u32 cp;
+ size_t n = clio_utf8_next_n(s + pos, s + len, &cp);
+ return pos + n;
+}
+
+/* Byte offset of the start of the word before pos (ed-style Ctrl+W). */
+static inline size_t clio_word_back(const char *s, size_t pos) {
+ size_t i = pos;
+ while (i > 0 && s[i - 1] == ' ')
+ i--;
+ while (i > 0 && (isalnum((unsigned char) s[i - 1]) || s[i - 1] == '_'))
+ i--;
+ while (i > 0 && s[i - 1] == ' ')
+ i--;
+ return i;
+}
+
+/* ------------------------------------------------------------------ */
+/* Canvas (cell grid) */
+/* ------------------------------------------------------------------ */
+
+/* One column of a row. ch is a code point (0 = blank slot; bg/attr still
+ * apply). fg/bg of 0xFFFF mean "terminal default". A blank default cell
+ * is {0, 0xFFFF, 0xFFFF, 0}. */
+typedef struct clio_cell {
+ u32 ch;
+ u16 fg;
+ u16 bg;
+ u16 attr;
+} clio_cell;
+
+typedef struct clio_buf {
+ clio_cell *cells;
+ size_t rows;
+ size_t cols;
+} clio_buf;
+
+static inline u16 clio_style_u16(int v) {
+ return (u16)(v < 0 ? 0xFFFF : (v > 255 ? 255 : v));
+}
+
+static inline void clio_cell_set(clio_cell *c, u32 ch, clio_style st) {
+ c->ch = ch;
+ c->fg = clio_style_u16(st.fg);
+ c->bg = clio_style_u16(st.bg);
+ c->attr = (u16) st.attr;
+}
+
+static inline clio_style clio_cell_style(const clio_cell *c) {
+ clio_style s;
+ s.fg = (c->fg == 0xFFFF) ? -1 : (int) c->fg;
+ s.bg = (c->bg == 0xFFFF) ? -1 : (int) c->bg;
+ s.attr = c->attr;
+ return s;
+}
+
+static inline int clio_cell_eq(const clio_cell *a, const clio_cell *b) {
+ return memcmp(a, b, sizeof(*a)) == 0;
+}
+
+static inline int clio_buf_init(clio_buf *b, size_t rows, size_t cols) {
+ if (rows == 0)
+ rows = 1;
+ if (cols == 0)
+ cols = 1;
+ b->rows = rows;
+ b->cols = cols;
+ b->cells = (clio_cell *) calloc(rows * cols, sizeof(clio_cell));
+ if (!b->cells) {
+ b->rows = b->cols = 0;
+ return -1;
+ }
+ for (size_t i = 0; i < rows * cols; i++) {
+ b->cells[i].fg = 0xFFFF;
+ b->cells[i].bg = 0xFFFF;
+ }
+ return 0;
+}
+
+static inline void clio_buf_free(clio_buf *b) {
+ free(b->cells);
+ b->cells = NULL;
+ b->rows = b->cols = 0;
+}
+
+static inline void clio_buf_clear(clio_buf *b) {
+ for (size_t i = 0; i < b->rows * b->cols; i++) {
+ b->cells[i].ch = 0;
+ b->cells[i].attr = 0;
+ b->cells[i].fg = 0xFFFF;
+ b->cells[i].bg = 0xFFFF;
+ }
+}
+
+static inline clio_cell *clio_buf_at(clio_buf *b, size_t row, size_t col) {
+ if (row >= b->rows || col >= b->cols)
+ return NULL;
+ return &b->cells[row * b->cols + col];
+}
+
+static inline void clio_buf_clear_region(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1) {
+ if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows - 1)
+ r1 = b->rows - 1;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r <= r1; r++)
+ for (size_t c = c0; c <= c1; c++) {
+ clio_cell *cell = &b->cells[r * b->cols + c];
+ cell->ch = 0;
+ cell->attr = 0;
+ cell->fg = 0xFFFF;
+ cell->bg = 0xFFFF;
+ }
+}
+
+static inline void clio_buf_fill_region(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1, u32 ch,
+ clio_style st) {
+ if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows - 1)
+ r1 = b->rows - 1;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r <= r1; r++)
+ for (size_t c = c0; c <= c1; c++)
+ clio_cell_set(&b->cells[r * b->cols + c], ch, st);
+}
+
+/* Write text at (row, col); wraps onto the next row (when wrap != 0 and
+ * rows remain) and clips at the buffer edges. '\n' moves to the next row
+ * at column 0. Zero-width code points occupy no cell. Returns the number
+ * of bytes written (including newlines). */
+static inline size_t clio_buf_put(clio_buf *b, size_t row, size_t col,
+ const char *text, clio_style st, int wrap) {
+ size_t written = 0;
+ size_t len = strlen(text);
+ const char *p = text;
+ while (p < text + len) {
+ u32 cp;
+ size_t n = clio_utf8_next_n(p, text + len, &cp);
+ p += n;
+ if (cp == '\n') {
+ row++;
+ col = 0;
+ if (row >= b->rows)
+ break;
+ written += n;
+ continue;
+ }
+ size_t w = clio_cp_width(cp);
+ if (w == 0) {
+ written += n;
+ continue;
+ }
+ if (col + w > b->cols) {
+ if (!wrap || row + 1 >= b->rows)
+ break;
+ row++;
+ col = 0;
+ }
+ for (size_t i = 0; i < w; i++) {
+ if (col + i >= b->cols)
+ break;
+ clio_cell *cell = &b->cells[row * b->cols + (col + i)];
+ cell->ch = (i == 0) ? cp : 0;
+ cell->fg = clio_style_u16(st.fg);
+ cell->bg = clio_style_u16(st.bg);
+ cell->attr = (u16) st.attr;
+ }
+ col += w;
+ written += n;
+ }
+ return written;
+}
+
+/* Draw a single-character border (ch, e.g. U+2502) around the rectangle
+ * (r0, c0) with width w and height h. */
+static inline void clio_buf_box(clio_buf *b, size_t r0, size_t c0, size_t w,
+ size_t h, u32 ch, clio_style st) {
+ if (r0 >= b->rows || c0 >= b->cols || w == 0 || h == 0)
+ return;
+ if (w > b->cols - c0)
+ w = b->cols - c0;
+ if (h > b->rows - r0)
+ h = b->rows - r0;
+ for (size_t x = 0; x < w; x++) {
+ clio_buf_fill_region(b, r0, c0 + x, r0, c0 + x, ch, st);
+ clio_buf_fill_region(b, r0 + h - 1, c0 + x, r0 + h - 1, c0 + x, ch, st);
+ }
+ for (size_t y = 1; y + 1 < h; y++) {
+ clio_buf_fill_region(b, r0 + y, c0, r0 + y, c0, ch, st);
+ clio_buf_fill_region(b, r0 + y, c0 + w - 1, r0 + y, c0 + w - 1, ch, st);
+ }
+}
+
+/* Shift rows [r0, r1) up by one row within columns [c0, c1]; the bottom
+ * row becomes blank. */
+static inline void clio_buf_scroll_up(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1) {
+ if (r1 <= r0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows)
+ r1 = b->rows;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r + 1 <= r1; r++)
+ memcpy(&b->cells[r * b->cols + c0], &b->cells[(r + 1) * b->cols + c0],
+ (c1 - c0 + 1) * sizeof(clio_cell));
+ for (size_t c = c0; c <= c1; c++) {
+ clio_cell *cell = &b->cells[r1 * b->cols + c];
+ cell->ch = 0;
+ cell->attr = 0;
+ cell->fg = 0xFFFF;
+ cell->bg = 0xFFFF;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Key types + pure decoder */
+/* ------------------------------------------------------------------ */
+
+typedef struct clio_key {
+ int kind;
+ u32 cp; /* CLIOK_CHAR: the code point */
+} clio_key;
+
+enum {
+ CLIOK_UNKNOWN = 0,
+ CLIOK_CHAR,
+ CLIOK_ENTER,
+ CLIOK_TAB,
+ CLIOK_BACKTAB,
+ CLIOK_BACKSPACE,
+ CLIOK_DELETE,
+ CLIOK_LEFT,
+ CLIOK_RIGHT,
+ CLIOK_UP,
+ CLIOK_DOWN,
+ CLIOK_HOME,
+ CLIOK_END,
+ CLIOK_PAGEUP,
+ CLIOK_PAGEDOWN,
+ CLIOK_ESC,
+ CLIOK_CTRL_A,
+ CLIOK_CTRL_C,
+ CLIOK_CTRL_D,
+ CLIOK_CTRL_E,
+ CLIOK_CTRL_K,
+ CLIOK_CTRL_L,
+ CLIOK_CTRL_U,
+ CLIOK_CTRL_W,
+ CLIOK_CTRL_Y,
+ CLIOK_CTRL_Z,
+ CLIOK_CTRL_G,
+};
+
+/* State machine for decoding raw terminal input into clio_key values.
+ * Pure: no I/O, no allocation. Feed chunks of any size (byte boundaries
+ * may split UTF-8 or escape sequences); state persists across feeds. */
+typedef struct clio_keydec {
+ int state; /* 0 ground, 1 escape, 2 csi, 3 ss3 */
+ int utf8_n;
+ int utf8_need;
+ u32 utf8[4];
+ int csi_n;
+ unsigned char csi[32];
+ int pasting;
+} clio_keydec;
+
+static inline void clio_keydec_init(clio_keydec *d) {
+ memset(d, 0, sizeof *d);
+}
+
+static inline int clio_kd_ground(clio_keydec *d, unsigned char b, clio_key *k) {
+ if (b == 0x1B) {
+ d->state = 1;
+ return 0;
+ }
+ if (b == 0x0D) {
+ k->kind = CLIOK_ENTER;
+ k->cp = 0;
+ return 1;
+ }
+ if (b == 0x0A) {
+ if (d->pasting) {
+ k->kind = CLIOK_CHAR;
+ k->cp = '\n';
+ return 1;
+ }
+ return 0; /* bare LF outside paste: dead key */
+ }
+ if (b == 0x7F || b == 0x08) {
+ k->kind = CLIOK_BACKSPACE;
+ k->cp = 0;
+ return 1;
+ }
+ if (b == 0x09) {
+ k->kind = CLIOK_TAB;
+ k->cp = 0;
+ return 1;
+ }
+ if (b < 0x20) {
+ switch (b) {
+ case 0x01u:
+ k->kind = CLIOK_CTRL_A;
+ break;
+ case 0x03u:
+ k->kind = CLIOK_CTRL_C;
+ d->pasting = 0; /* Ctrl+C interrupts a dangling paste */
+ break;
+ case 0x04u:
+ k->kind = CLIOK_CTRL_D;
+ break;
+ case 0x05u:
+ k->kind = CLIOK_CTRL_E;
+ break;
+ case 0x0Bu:
+ k->kind = CLIOK_CTRL_K;
+ break;
+ case 0x0Cu:
+ k->kind = CLIOK_CTRL_L;
+ break;
+ case 0x15u:
+ k->kind = CLIOK_CTRL_U;
+ break;
+ case 0x17u:
+ k->kind = CLIOK_CTRL_W;
+ break;
+ case 0x19u:
+ k->kind = CLIOK_CTRL_Y;
+ break;
+ case 0x1Au:
+ k->kind = CLIOK_CTRL_Z;
+ break;
+ case 0x07u:
+ k->kind = CLIOK_CTRL_G;
+ break;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+ }
+ if (b < 0x80) {
+ k->kind = CLIOK_CHAR;
+ k->cp = b;
+ return 1;
+ }
+ if (b >= 0xC0 && b < 0xF8) {
+ d->utf8_need = (b >= 0xF0) ? 4 : (b >= 0xE0) ? 3 : 2;
+ d->utf8[0] = b;
+ d->utf8_n = 1;
+ return 0;
+ }
+ if (d->utf8_n > 0 && b >= 0x80 && b < 0xC0) {
+ d->utf8[d->utf8_n++] = b;
+ if (d->utf8_n < d->utf8_need)
+ return 0;
+ int n = d->utf8_n;
+ d->utf8_n = 0;
+ u32 cp =
+ d->utf8[0] & (n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u);
+ for (int i = 1; i < n; i++)
+ cp = (cp << 6) | (d->utf8[i] & 0x3Fu);
+ k->kind = CLIOK_CHAR;
+ k->cp = cp;
+ return 1;
+ }
+ d->utf8_n = 0;
+ k->kind = CLIOK_UNKNOWN;
+ k->cp = b;
+ return 1;
+}
+
+static inline int clio_kd_escape(clio_keydec *d, unsigned char b, clio_key *k) {
+ d->state = 0;
+ if (b == '[') {
+ d->state = 2;
+ d->csi_n = 0;
+ return 0;
+ }
+ if (b == 'O') {
+ d->state = 3;
+ return 0;
+ }
+ if (b >= 0x20 && b <= 0x7E) {
+ k->kind = CLIOK_CHAR;
+ k->cp = b;
+ return 1;
+ }
+ k->kind = CLIOK_ESC;
+ k->cp = 0;
+ return 1;
+}
+
+static inline int clio_kd_csi(clio_keydec *d, unsigned char b, clio_key *k) {
+ if (d->csi_n < (int) sizeof d->csi)
+ d->csi[d->csi_n++] = b;
+ if ((b >= '0' && b <= '9') || b == ';' || b == '?' || b == '<' || b == '=' ||
+ b == '>')
+ return 0;
+ d->state = 0;
+ unsigned long num = 0;
+ int any = 0;
+ for (int i = 0; i < d->csi_n - 1; i++) {
+ unsigned char p = d->csi[i];
+ if (p < '0' || p > '9')
+ break;
+ if (any)
+ num = num * 10 + (unsigned long)(p - '0');
+ else
+ num = (unsigned long)(p - '0');
+ any = 1;
+ if (num > 0xFFFFu)
+ break;
+ }
+ d->csi_n = 0;
+ switch (b) {
+ case 'A':
+ k->kind = CLIOK_UP;
+ k->cp = 0;
+ return 1;
+ case 'B':
+ k->kind = CLIOK_DOWN;
+ k->cp = 0;
+ return 1;
+ case 'C':
+ k->kind = CLIOK_RIGHT;
+ k->cp = 0;
+ return 1;
+ case 'D':
+ k->kind = CLIOK_LEFT;
+ k->cp = 0;
+ return 1;
+ case 'H':
+ k->kind = CLIOK_HOME;
+ k->cp = 0;
+ return 1;
+ case 'F':
+ k->kind = CLIOK_END;
+ k->cp = 0;
+ return 1;
+ case 'Z':
+ k->kind = CLIOK_BACKTAB;
+ k->cp = 0;
+ return 1;
+ case '~':
+ switch (num) {
+ case 1u:
+ case 7u:
+ k->kind = CLIOK_HOME;
+ break;
+ case 3u:
+ k->kind = CLIOK_DELETE;
+ break;
+ case 4u:
+ case 8u:
+ k->kind = CLIOK_END;
+ break;
+ case 5u:
+ k->kind = CLIOK_PAGEUP;
+ break;
+ case 6u:
+ k->kind = CLIOK_PAGEDOWN;
+ break;
+ case 200u:
+ d->pasting = 1;
+ return 0;
+ case 201u:
+ d->pasting = 0;
+ return 0;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+ case 'u': /* kitty keyboard protocol */
+ switch (num) {
+ case 9u:
+ k->kind = CLIOK_TAB;
+ break;
+ case 12u:
+ k->kind = CLIOK_BACKSPACE;
+ break;
+ case 13u:
+ k->kind = CLIOK_ENTER;
+ break;
+ case 27u:
+ k->kind = CLIOK_ESC;
+ break;
+ case 127u:
+ k->kind = CLIOK_DELETE;
+ break;
+ case 0x2190u:
+ k->kind = CLIOK_LEFT;
+ break;
+ case 0x2191u:
+ k->kind = CLIOK_UP;
+ break;
+ case 0x2192u:
+ k->kind = CLIOK_RIGHT;
+ break;
+ case 0x2193u:
+ k->kind = CLIOK_DOWN;
+ break;
+ default:
+ k->cp = 0;
+ return 0;
+ }
+ k->cp = 0;
+ return 1;
+ case 'R': /* cursor position report: swallow */
+ case 'M':
+ case 'h':
+ case 'l':
+ case 'm':
+ return 0;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ k->cp = 0;
+ return 1;
+ }
+}
+
+static inline int clio_kd_ss3(clio_keydec *d, unsigned char b, clio_key *k) {
+ d->state = 0;
+ switch (b) {
+ case 'A':
+ k->kind = CLIOK_UP;
+ break;
+ case 'B':
+ k->kind = CLIOK_DOWN;
+ break;
+ case 'C':
+ k->kind = CLIOK_RIGHT;
+ break;
+ case 'D':
+ k->kind = CLIOK_LEFT;
+ break;
+ case 'H':
+ k->kind = CLIOK_HOME;
+ break;
+ case 'F':
+ k->kind = CLIOK_END;
+ break;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+}
+
+static inline int clio_kd_step(clio_keydec *d, unsigned char b, clio_key *k) {
+ switch (d->state) {
+ case 1:
+ return clio_kd_escape(d, b, k);
+ case 2:
+ return clio_kd_csi(d, b, k);
+ case 3:
+ return clio_kd_ss3(d, b, k);
+ default:
+ return clio_kd_ground(d, b, k);
+ }
+}
+
+/* Feed raw bytes; emits up to cap decoded keys into out; returns the
+ * number emitted. */
+static inline size_t clio_keydec_feed(clio_keydec *d, const unsigned char *in,
+ size_t n, clio_key *out, size_t cap) {
+ size_t emitted = 0;
+ for (size_t i = 0; i < n; i++) {
+ clio_key k;
+ if (clio_kd_step(d, in[i], &k) && emitted < cap)
+ out[emitted++] = k;
+ }
+ return emitted;
+}
+
+/* Emit a trailing bare ESC (state 1) as a key. 1 if emitted. */
+static inline int clio_keydec_flush(clio_keydec *d, clio_key *k) {
+ if (d->state != 1)
+ return 0;
+ d->state = 0;
+ k->kind = CLIOK_ESC;
+ k->cp = 0;
+ return 1;
+}
+
+/* ------------------------------------------------------------------ */
+/* Terminal state */
+/* ------------------------------------------------------------------ */
+
+#define CLIO_KEYQ 32
+
+typedef struct clio_term {
+ int is_tty;
+ int color; /* clio_color_policy() result */
+ size_t rows;
+ size_t cols;
+ int raw;
+ struct termios saved;
+ int alt;
+ int cursor_hidden;
+ int paste;
+ int kitty;
+ clio_keydec dec;
+ clio_key q[CLIO_KEYQ];
+ size_t qh;
+ size_t qt;
+ clio_buf last;
+ int has_last;
+} clio_term;
+
+/* CLICOLOR_FORCE (non-empty, not "0") wins; otherwise color only on a tty
+ * without NO_COLOR, TERM=dumb, or CI. */
+static inline int clio_color_policy(int tty) {
+ const char *force = getenv("CLICOLOR_FORCE");
+ if (force && force[0]) {
+ if (strcmp(force, "0") == 0)
+ return 0;
+ return 1;
+ }
+ if (!tty)
+ return 0;
+ if (getenv("NO_COLOR"))
+ return 0;
+ const char *term = getenv("TERM");
+ if (term && strcmp(term, "dumb") == 0)
+ return 0;
+ if (getenv("CI"))
+ return 0;
+ return 1;
+}
+
+static inline int clio_term_init(clio_term *t) {
+ memset(t, 0, sizeof *t);
+ t->is_tty = isatty(STDIN_FILENO) != 0 && isatty(STDOUT_FILENO) != 0;
+ t->color = clio_color_policy(t->is_tty);
+ t->rows = 24;
+ t->cols = 80;
+ struct winsize ws;
+ if (t->is_tty && ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 &&
+ ws.ws_row > 0 && ws.ws_col > 0) {
+ t->rows = (size_t) ws.ws_row;
+ t->cols = (size_t) ws.ws_col;
+ }
+ clio_keydec_init(&t->dec);
+ return 0;
+}
+
+/* Re-read the window size; invalidates the flushed-frame cache. -1 on
+ * failure (previous size kept). */
+static inline int clio_term_resize(clio_term *t) {
+ struct winsize ws;
+ if (!t->is_tty)
+ return -1;
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) != 0)
+ return -1;
+ if (ws.ws_row == 0 || ws.ws_col == 0)
+ return -1;
+ t->rows = (size_t) ws.ws_row;
+ t->cols = (size_t) ws.ws_col;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+ return 0;
+}
+
+static inline int clio_term_raw_on(clio_term *t) {
+ if (t->raw || !t->is_tty)
+ return -1;
+ if (tcgetattr(STDIN_FILENO, &t->saved) != 0)
+ return -1;
+ struct termios raw = t->saved;
+ raw.c_iflag &= ~((tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON));
+ raw.c_lflag &= ~((tcflag_t)(ECHO | ICANON | IEXTEN | ISIG));
+ raw.c_oflag &= ~((tcflag_t) OPOST);
+ raw.c_cc[VMIN] = 1;
+ raw.c_cc[VTIME] = 0;
+ if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0)
+ return -1;
+ t->raw = 1;
+ return 0;
+}
+
+static inline void clio_term_raw_off(clio_term *t) {
+ if (!t->raw)
+ return;
+ tcsetattr(STDIN_FILENO, TCSANOW, &t->saved);
+ t->raw = 0;
+}
+
+static inline void clio_term_alt_on(clio_term *t) {
+ if (t->alt)
+ return;
+ esc_falt_screen_on(stdout);
+ fflush(stdout);
+ t->alt = 1;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+}
+
+static inline void clio_term_alt_off(clio_term *t) {
+ if (!t->alt)
+ return;
+ esc_falt_screen_off(stdout);
+ fflush(stdout);
+ t->alt = 0;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+}
+
+static inline void clio_term_cursor_hide(clio_term *t) {
+ if (t->cursor_hidden)
+ return;
+ esc_fcursor_hide(stdout);
+ fflush(stdout);
+ t->cursor_hidden = 1;
+}
+
+static inline void clio_term_cursor_show(clio_term *t) {
+ if (!t->cursor_hidden)
+ return;
+ esc_fcursor_show(stdout);
+ fflush(stdout);
+ t->cursor_hidden = 0;
+}
+
+static inline void clio_term_paste_on(clio_term *t) {
+ if (t->paste)
+ return;
+ esc_fbracketed_paste_on(stdout);
+ fflush(stdout);
+ t->paste = 1;
+ t->dec.pasting = 1;
+}
+
+static inline void clio_term_paste_off(clio_term *t) {
+ if (!t->paste)
+ return;
+ esc_fbracketed_paste_off(stdout);
+ fflush(stdout);
+ t->paste = 0;
+ t->dec.pasting = 0;
+}
+
+static inline void clio_term_kitty_on(clio_term *t) {
+ if (t->kitty)
+ return;
+ esc_fkitty_on(stdout);
+ fflush(stdout);
+ t->kitty = 1;
+}
+
+static inline void clio_term_kitty_off(clio_term *t) {
+ if (!t->kitty)
+ return;
+ esc_fkitty_off(stdout);
+ fflush(stdout);
+ t->kitty = 0;
+}
+
+static inline void clio_term_bell(clio_term *t) {
+ (void) t;
+ esc_fbell(stdout);
+ fflush(stdout);
+}
+
+/* Restore everything clio_term turned on (idempotent). */
+static inline void clio_term_shutdown(clio_term *t) {
+ if (t->kitty)
+ clio_term_kitty_off(t);
+ if (t->paste)
+ clio_term_paste_off(t);
+ if (t->cursor_hidden)
+ clio_term_cursor_show(t);
+ if (t->alt)
+ clio_term_alt_off(t);
+ clio_term_raw_off(t);
+}
+
+static inline void clio_term_free(clio_term *t) {
+ clio_term_shutdown(t);
+ clio_buf_free(&t->last);
+ memset(t, 0, sizeof *t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Output (diff flush) */
+/* ------------------------------------------------------------------ */
+
+/* Map a style onto the stream: reset, then attributes, then 256-color
+ * fg/bg. escape.h emits the codes; clio.h only decides what goes. */
+static inline void clio_fstyle(FILE *f, clio_style st) {
+ if (!f)
+ return;
+ fputs(ESC_RESET, f);
+ if (st.attr & CLIO_BOLD)
+ fputs(ESC_BOLD, f);
+ if (st.attr & CLIO_DIM)
+ fputs(ESC_DIM, f);
+ if (st.attr & CLIO_ITALIC)
+ fputs(ESC_ITALIC, f);
+ if (st.attr & CLIO_UNDERLINE)
+ fputs(ESC_UNDERLINE, f);
+ if (st.attr & CLIO_REVERSE)
+ fputs(ESC_REVERSE, f);
+ if (st.attr & CLIO_STRIKE)
+ fputs(ESC_STRIKETHROUGH, f);
+ if (st.fg >= 0)
+ esc_fcolor_fg_256(f, (u32)(st.fg & 0xFF));
+ if (st.bg >= 0)
+ esc_fcolor_bg_256(f, (u32)(st.bg & 0xFF));
+}
+
+/* Render one row: style runs + text. Blank cells print as spaces. */
+static inline void clio_emit_row(FILE *f, const clio_buf *b, size_t row) {
+ clio_style cur = clio_style_default();
+ for (size_t c = 0; c < b->cols; c++) {
+ const clio_cell *cell = &b->cells[row * b->cols + c];
+ clio_style st = clio_cell_style(cell);
+ if (!clio_style_eq(&st, &cur)) {
+ clio_fstyle(f, st);
+ cur = st;
+ }
+ if (cell->ch == 0) {
+ fputc(' ', f);
+ } else if (cell->ch < 0x80) {
+ fputc((int)(cell->ch & 0xFF), f);
+ } else {
+ char tmp[8];
+ if (clio_utf8_enc(cell->ch, tmp) > 0)
+ fputs(tmp, f);
+ else
+ fputc(' ', f);
+ }
+ }
+}
+
+/* Non-tty fallback: plain text lines, trailing blanks trimmed. */
+static inline void clio_flush_plain(const clio_buf *b) {
+ size_t cap = b->cols * 4 + 1;
+ char *row = (char *) malloc(cap);
+ if (!row)
+ return;
+ for (size_t r = 0; r < b->rows; r++) {
+ size_t n = 0;
+ for (size_t c = 0; c < b->cols; c++) {
+ const clio_cell *cell = &b->cells[r * b->cols + c];
+ if (cell->ch == 0) {
+ row[n++] = ' ';
+ } else if (cell->ch < 0x80) {
+ row[n++] = (char)(cell->ch & 0xFF);
+ } else {
+ int e = clio_utf8_enc(cell->ch, row + n);
+ if (e > 0)
+ n += (size_t) e;
+ else
+ row[n++] = ' ';
+ }
+ }
+ while (n > 0 && row[n - 1] == ' ')
+ n--;
+ fwrite(row, 1, n, stdout);
+ fputc('\n', stdout);
+ }
+ free(row);
+ fflush(stdout);
+}
+
+/* Blit the canvas to the terminal. TTY: diff against the previous frame,
+ * clear screen + full repaint on first flush or size change, otherwise
+ * only changed rows. Non-tty: plain text lines (no escapes, safe to pipe). */
+static inline void clio_flush(clio_term *t, const clio_buf *b) {
+ if (!b || !b->cells)
+ return;
+ if (!t->is_tty) {
+ clio_flush_plain(b);
+ return;
+ }
+ int full = 1;
+ if (t->has_last && t->last.rows == b->rows && t->last.cols == b->cols)
+ full = 0;
+ if (full) {
+ clio_buf_free(&t->last);
+ if (clio_buf_init(&t->last, b->rows, b->cols) != 0) {
+ t->has_last = 0;
+ return;
+ }
+ t->has_last = 1;
+ esc_fclear_screen(stdout);
+ for (size_t r = 0; r < b->rows; r++) {
+ esc_fcursor_position(stdout, 1, (u32)(r + 1));
+ clio_emit_row(stdout, b, r);
+ }
+ } else {
+ for (size_t r = 0; r < b->rows; r++) {
+ int same = 1;
+ for (size_t c = 0; c < b->cols; c++) {
+ if (!clio_cell_eq(&t->last.cells[r * b->cols + c],
+ &b->cells[r * b->cols + c])) {
+ same = 0;
+ break;
+ }
+ }
+ if (same)
+ continue;
+ esc_fcursor_position(stdout, 1, (u32)(r + 1));
+ clio_emit_row(stdout, b, r);
+ }
+ }
+ memcpy(t->last.cells, b->cells, b->rows * b->cols * sizeof(clio_cell));
+ fflush(stdout);
+}
+
+/* Move the visible cursor (0-based row/col, clamped to the terminal). */
+static inline void clio_cursor(clio_term *t, size_t row, size_t col) {
+ if (!t->is_tty)
+ return;
+ if (row > t->rows)
+ row = t->rows;
+ if (col > t->cols)
+ col = t->cols;
+ esc_fcursor_position(stdout, (u32)(col + 1), (u32)(row + 1));
+ fflush(stdout);
+}
+
+/* ------------------------------------------------------------------ */
+/* Key input */
+/* ------------------------------------------------------------------ */
+
+/* Key source: 1 + *k set on success, 0 on timeout/EOF. */
+typedef int (*clio_next_key)(void *ud, clio_key *k, int timeout_ms);
+
+static inline int clio_read_key(clio_term *t, clio_key *out, int timeout_ms) {
+ for (;;) {
+ if (t->qt != t->qh) {
+ *out = t->q[t->qh];
+ t->qh++;
+ if (t->qh == CLIO_KEYQ)
+ t->qh = 0;
+ return 1;
+ }
+ if (timeout_ms == 0)
+ return clio_keydec_flush(&t->dec, out);
+ struct pollfd pfd;
+ pfd.fd = STDIN_FILENO;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ int pr = poll(&pfd, 1, timeout_ms);
+ if (pr <= 0)
+ return clio_keydec_flush(&t->dec, out);
+ unsigned char in[16];
+ ssize_t n = read(STDIN_FILENO, in, sizeof in);
+ if (n <= 0)
+ return clio_keydec_flush(&t->dec, out);
+ clio_key tmp[16];
+ size_t got = clio_keydec_feed(&t->dec, in, (size_t) n, tmp, 16);
+ for (size_t i = 0; i < got; i++) {
+ if (t->qt == t->qh)
+ return clio_keydec_flush(&t->dec, out);
+ size_t slot = t->qt;
+ if (slot == CLIO_KEYQ)
+ slot = 0;
+ if (slot == t->qh) {
+ t->qh++;
+ if (t->qh == CLIO_KEYQ)
+ t->qh = 0;
+ } /* queue full: drop the oldest */
+ t->q[slot] = tmp[i];
+ t->qt = (slot + 1 == CLIO_KEYQ) ? 0 : slot + 1;
+ }
+ }
+}
+
+static inline int clio_next_key_stdin(void *ud, clio_key *k, int timeout_ms) {
+ clio_term *t = (clio_term *) ud;
+ return clio_read_key(t, k, timeout_ms);
+}
+
+/* ------------------------------------------------------------------ */
+/* Single-line editor */
+/* ------------------------------------------------------------------ */
+
+static inline void *clio_xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "clio: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+typedef struct clio_str {
+ char *s;
+ size_t len;
+ size_t cap;
+} clio_str;
+
+static inline void clio_str_init(clio_str *x) {
+ x->s = NULL;
+ x->len = 0;
+ x->cap = 0;
+}
+
+static inline void clio_str_free(clio_str *x) {
+ free(x->s);
+ x->s = NULL;
+ x->len = x->cap = 0;
+}
+
+static inline void clio_str_ins(clio_str *x, size_t at, const char *s, size_t n) {
+ if (x->len + n + 1 > x->cap) {
+ size_t nc = x->cap ? x->cap : 16;
+ while (x->len + n + 1 > nc)
+ nc *= 2;
+ x->s = (char *) clio_xrealloc(x->s, nc);
+ x->cap = nc;
+ }
+ memmove(x->s + at + n, x->s + at, x->len - at);
+ memcpy(x->s + at, s, n);
+ x->len += n;
+ x->s[x->len] = '\0';
+}
+
+static inline void clio_str_del(clio_str *x, size_t at, size_t n) {
+ if (at > x->len)
+ at = x->len;
+ if (at + n > x->len)
+ n = x->len - at;
+ memmove(x->s + at, x->s + at + n, x->len - at - n);
+ x->len -= n;
+ x->s[x->len] = '\0';
+}
+
+/* Count visible (non-zero-width) code points in the first n bytes. */
+static inline size_t clio_visible_n(const char *s, size_t n) {
+ size_t c = 0;
+ const char *p = s;
+ const char *end = s + n;
+ while (p < end) {
+ u32 cp;
+ size_t len = clio_utf8_next_n(p, end, &cp);
+ if (cp != 0 && clio_cp_width(cp) > 0)
+ c++;
+ p += len;
+ }
+ return c;
+}
+
+/* Paint the prompt + text (echo, or one '*' per visible code point) into
+ * the canvas at row; returns the caret column.
+ *
+ * The input is ONE visual line: it never wraps. When the caret passes the
+ * right edge the window slides (bash/readline style) so the caret always
+ * stays visible, so backspace/left-arrow work across the "wrap". */
+static inline size_t clio_readline_paint(clio_buf *b, size_t row,
+ const char *prompt, clio_style pstyle,
+ const clio_str *txt, size_t caret,
+ clio_style tstyle, int echo) {
+ clio_buf_clear_region(b, row, 0, row, b->cols - 1);
+ size_t pw = prompt ? clio_text_width(prompt) : 0;
+ if (prompt && prompt[0])
+ clio_buf_put(b, row, 0, prompt, pstyle, 0);
+ size_t area = (pw < b->cols) ? b->cols - pw : 0;
+ if (txt->len == 0 || area == 0)
+ return pw;
+ if (echo) {
+ size_t cw = clio_text_width_n(txt->s, caret);
+ /* Window left edge (display cells): 0 unless the caret does not
+ * fit, in which case pin the caret to the last column. */
+ size_t L = (cw + 1 > area) ? cw - (area - 1) : 0;
+ /* Largest char boundary <= L (a wide char straddling L is kept
+ * whole, window starts at it). */
+ size_t off = 0, start = 0;
+ const char *p = txt->s;
+ const char *end = txt->s + txt->len;
+ while (p < end) {
+ u32 cp;
+ size_t n = clio_utf8_next_n(p, end, &cp);
+ size_t w = (cp != 0) ? clio_cp_width(cp) : 0;
+ if (off + w > L) {
+ start = (size_t)(p - txt->s); /* `off` is the last boundary <= L */
+ break;
+ }
+ off += w;
+ p += n;
+ }
+ clio_buf_put(b, row, pw, txt->s + start, tstyle, 0);
+ size_t c = pw + (cw - off);
+ if (c >= b->cols) /* wide char straddled the left edge */
+ c = b->cols - 1;
+ return c;
+ }
+ size_t total = clio_visible_n(txt->s, txt->len);
+ size_t cw = clio_visible_n(txt->s, caret);
+ size_t first = (cw + 1 > area) ? cw - (area - 1) : 0;
+ for (size_t i = first; i < total; i++)
+ clio_buf_put(b, row, pw + (i - first), "*", tstyle, 0);
+ return pw + (cw - first);
+}
+
+/* Single-line editor. Renders into ONE canvas row (no wrapping; long
+ * input scrolls to keep the caret visible); reads keys from nk (NULL
+ * = clio_next_key_stdin with ud = t). Editing: printable insert, Tab,
+ * backspace/delete, left/right/home/end, Ctrl+A/E/U/K/W, Ctrl+C and ESC
+ * cancel (NULL), Ctrl+D deletes (EOF/submit when empty), Ctrl+L repaints,
+ * Enter submits. Returns the input (caller frees) or NULL. */
+static inline char *clio_readline(clio_term *t, clio_buf *b, size_t row,
+ const char *prompt, clio_style pstyle,
+ clio_style tstyle, int echo,
+ clio_next_key nk, void *ud, int timeout_ms) {
+ if (!nk) {
+ nk = clio_next_key_stdin;
+ ud = t;
+ }
+ clio_str txt;
+ clio_str_init(&txt);
+ size_t caret = 0;
+
+ size_t col =
+ clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo);
+ if (t->is_tty)
+ clio_cursor(t, row, col);
+
+ int quit = 0;
+ for (;;) {
+ clio_key k;
+ if (!nk(ud, &k, timeout_ms))
+ break; /* timeout / EOF / no key */
+ int done = 0;
+ switch (k.kind) {
+ case CLIOK_CHAR: {
+ char tmp[8];
+ int e = clio_utf8_enc(k.cp, tmp);
+ if (e > 0) {
+ clio_str_ins(&txt, caret, tmp, (size_t) e);
+ caret += (size_t) e;
+ }
+ break;
+ }
+ case CLIOK_TAB:
+ clio_str_ins(&txt, caret, "\t", 1);
+ caret++;
+ break;
+ case CLIOK_ENTER:
+ done = 1;
+ break;
+ case CLIOK_BACKSPACE:
+ if (caret > 0) {
+ size_t s = clio_char_before(txt.s, caret);
+ clio_str_del(&txt, s, caret - s);
+ caret = s;
+ }
+ break;
+ case CLIOK_DELETE:
+ if (caret < txt.len)
+ clio_str_del(&txt, caret,
+ clio_char_end(txt.s, txt.len, caret) - caret);
+ break;
+ case CLIOK_LEFT:
+ if (caret > 0)
+ caret = clio_char_before(txt.s, caret);
+ break;
+ case CLIOK_RIGHT:
+ if (caret < txt.len)
+ caret = clio_char_end(txt.s, txt.len, caret);
+ break;
+ case CLIOK_HOME:
+ case CLIOK_CTRL_A:
+ caret = 0;
+ break;
+ case CLIOK_END:
+ case CLIOK_CTRL_E:
+ caret = txt.len;
+ break;
+ case CLIOK_CTRL_U:
+ clio_str_del(&txt, 0, caret);
+ caret = 0;
+ break;
+ case CLIOK_CTRL_K:
+ clio_str_del(&txt, caret, txt.len - caret);
+ break;
+ case CLIOK_CTRL_W: {
+ size_t s = clio_word_back(txt.s, caret);
+ clio_str_del(&txt, s, caret - s);
+ caret = s;
+ break;
+ }
+ case CLIOK_CTRL_C:
+ quit = 1;
+ break;
+ case CLIOK_CTRL_D:
+ if (txt.len == 0)
+ done = 1;
+ else if (caret < txt.len)
+ clio_str_del(&txt, caret,
+ clio_char_end(txt.s, txt.len, caret) - caret);
+ break;
+ case CLIOK_CTRL_L:
+ if (t->is_tty) {
+ esc_fclear_screen(stdout);
+ esc_fcursor_position(stdout, 1, 1);
+ fflush(stdout);
+ }
+ break;
+ case CLIOK_ESC:
+ quit = 1;
+ break;
+ default:
+ break;
+ }
+ col = clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo);
+ if (t->is_tty)
+ clio_cursor(t, row, col);
+ if (done || quit)
+ break;
+ }
+
+ if (quit || txt.len == 0) {
+ clio_str_free(&txt);
+ return NULL; /* cancelled, or submitted empty */
+ }
+ return txt.s; /* caller frees */
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CLIO_H */