diff options
Diffstat (limited to 'include/clio.h')
| -rw-r--r-- | include/clio.h | 1493 |
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 */ |