commit 9a9cb3b0fefe2093cd34035349db0fcb8e569ba6 from: Isaac Meerleo date: Fri Jul 17 16:55:18 2026 UTC Harden terminal visual rendering commit - b10a8c42ca51a9080cc5df95ecd26eee613eaf87 commit + 9a9cb3b0fefe2093cd34035349db0fcb8e569ba6 blob - ab234efe25ba7c9fe57c68c6dd9950036920e21a blob + 789d2e7baac27673436064c8a4bb921bb95b87c4 --- docs/design/m7-curses.md +++ docs/design/m7-curses.md @@ -34,17 +34,20 @@ dependency, each with its own regress suite, before an *styled display lines* (attribute runs over UTF-8 cells), with wide/combining-character width, fenced code blocks, headings, bullets, inline code, bold/italic, unmatched markers literal, and an - `ascii` fold (typographic punctuation to ASCII, unknown glyphs to + shared `ascii.c` fold (typographic punctuation to ASCII, unknown glyphs to `?`, markdown structure preserved). Pure; `regress/markdown`. - **editor.c** — the modal vi-like line editor over a byte buffer: insert/normal modes, the motion/delete verbs, history recall, the paste placeholder, input sanitation (NUL rejected, ESC stripped, - 64 KiB cap), and slash-completion cycling. Pure; `regress/editor` - drives key-op sequences and asserts buffer, cursor, and submission. -- **ui_layout.c** — plain transcript rows as UTF-8 spans plus an optional - fill attribute and pane width. Pure; `regress/ui_layout` pins User prefixes, - continuation indentation, word and wide/combining-character wrapping, - narrow panes, drawer clipping, and resize reflow before curses paints them. + 64 KiB cap), slash-completion cycling, and presentation-only ASCII folding + with matching cursor geometry. Pure; `regress/editor` drives key-op + sequences and asserts buffer, cursor, display, and submission. +- **ui_layout.c** — transcript rows as attributed UTF-8 spans, an optional + fill attribute and pane width, and normalization of malformed/control bytes + to visible Unicode cells. Pure; `regress/ui_layout` pins User prefixes, + continuation indentation, hostile bytes, ASCII expansion, word and + wide/combining-character wrapping, narrow panes and picker overlays, drawer + clipping, and resize reflow before the wide curses interface paints them. - **welcome.c** — the empty-transcript welcome's one-way dismissal state, exact copy, viewport layout, ASCII fold, and status-caption collision policy. Pure; `regress/welcome` proves that resize and temporary transcript blob - 857a9356989627994510c409c83cc2fb50b15f83 blob + b483a31281f5a4eb246f31778a66d7c4a277086e --- handoff/verification.md +++ handoff/verification.md @@ -154,16 +154,20 @@ states the property, not just examples: - **Owner-row treatment:** the pure UI layout suite pins the shared color and monochrome attributes, User prefix and continuation geometry, explicit full-pane fill, empty multiline rows, word wrapping, wide/combining UTF-8, - narrow-pane fallback, drawer clipping, and resize reflow. The curses pty - path exercises the styled input and transcript while retaining clean - terminal-mode startup and teardown. + visible normalization of NUL/control/malformed bytes, ASCII transliteration + and matching input-cursor width, narrow-pane fallback, drawer clipping, and + resize reflow. The curses pty path exercises the styled input and transcript + through wide curses while retaining clean terminal-mode startup and teardown. - **Empty-transcript welcome:** a pure suite pins the exact copy, centering in full and drawer-narrowed viewports, ASCII punctuation, whole-row omission on short/narrow terminals, nonoverlapping status captions, and one-way - dismissal. Staged curses pty snapshots prove startup visibility and removal - on the first nonempty edit, as well as absence from the Provider request and - Journal and suppression of the default and named Claude-profile warning only - in curses mode. Line/print regressions retain the stderr warning. + dismissal. Pure geometry checks keep tiny picker overlays above the status + row. Staged curses pty repaint captures prove startup visibility, restoration + after a presentation-only model selection, and, after a forced physical + clear, removal on the first nonempty edit. They also prove absence from the + Provider request and Journal and suppression of the default and named + Claude-profile warning only in curses mode. Line/print regressions retain the + stderr warning. - **Prompt caching:** request-builder assertions across consecutive requests — breakpoints on the final tool definition, the block-form system prompt, and the advancing tail; cold after blob - ec86d3a9f8a8a8b6fec32b089294ebededae6004 blob + feef42d0e897dca0bb6064c1bcbf51cef1fd1623 --- regress/README +++ regress/README @@ -143,7 +143,9 @@ line mode (behavior.md 2.2) turn (multi-turn piped se slash commands, /model refused) User transcript/input treatment ui_layout (attributes, prefix and continuation geometry, full-pane fill, - UTF-8 and reflow); turn (monochrome pty + hostile-byte normalization, ASCII fold, + UTF-8 and reflow); editor (folded input + cursor); turn (monochrome pty startup and teardown) empty-transcript welcome (2.1) welcome (copy, layout, ASCII, caption collision, one-way dismissal); turn (pty, blob - 1b7816836acb53a114befc361938f11f5d6551df blob + d4f8554a7ecabc3f5e3c9109a06ddc5e9c486367 --- regress/editor/Makefile +++ regress/editor/Makefile @@ -1,5 +1,5 @@ PROG= editor_test -SRCS= editor_test.c editor.c utf8.c buf.c log.c xmalloc.c +SRCS= editor_test.c editor.c ascii.c utf8.c buf.c log.c xmalloc.c NOMAN= yes CFLAGS+= -I${.CURDIR}/../../src/fugu-tty blob - aca4847ad372cc198d61a8a0a60045a7772aff96 blob + 61a0c8af47da23239630abbd89df054cdd2959d8 --- regress/editor/editor_test.c +++ regress/editor/editor_test.c @@ -58,7 +58,7 @@ disp_is(struct editor *e, const char *want) int ok; buf_init(&b); - ed_display(e, &b); + ed_display(e, &b, 0); buf_addc(&b, '\0'); ok = strcmp((const char *)b.data, want) == 0; buf_free(&b); @@ -306,8 +306,39 @@ main(void) CHECK(text_is(&e, "ab")); /* ESC stripped */ ed_free(&e); + /* Controls draw as one visible replacement cell; the cursor agrees. */ ed_init(&e); + ed_feed(&e, K_PASTE, 0, "A\tB\xc2\x85" "C", 6); + CHECK(ed_cursor_col(&e, 0) == 5); { + struct buf b; + + buf_init(&b); + ed_display(&e, &b, 1); + CHECK(b.len == 5 && memcmp(b.data, "A B?C", 5) == 0); + buf_free(&b); + } + ed_free(&e); + + /* ASCII mode folds display only and counts expansion at the cursor. */ + ed_init(&e); + ed_feed(&e, K_PASTE, 0, + "A\xe2\x80\xa6\xe2\x80\x94\xe4\xb8\xad", 10); + { + struct buf b; + + buf_init(&b); + ed_display(&e, &b, 1); + CHECK(b.len == 7 && memcmp(b.data, "A...--?", 7) == 0); + buf_free(&b); + } + CHECK(ed_cursor_col(&e, 1) == 7); + CHECK(text_is(&e, + "A\xe2\x80\xa6\xe2\x80\x94\xe4\xb8\xad")); + ed_free(&e); + + ed_init(&e); + { static char big[70000]; memset(big, 'x', sizeof(big)); blob - a06f5d6422d99ef3616b6a456c6658c5a837b351 blob + 46205337201d56ce7feedcbc414d01d73b0d76bc --- regress/markdown/Makefile +++ regress/markdown/Makefile @@ -1,5 +1,5 @@ PROG= markdown_test -SRCS= markdown_test.c markdown.c utf8.c buf.c log.c xmalloc.c +SRCS= markdown_test.c markdown.c ascii.c utf8.c buf.c log.c xmalloc.c NOMAN= yes CFLAGS+= -I${.CURDIR}/../../src/fugu-tty blob - 16f347790f6041328417b70fb5cef7787808e526 blob + 610dddc751b30b0639fcad85e0bb5243fe807f14 --- regress/turn/run.sh +++ regress/turn/run.sh @@ -991,7 +991,7 @@ if [ -x "$obj/ptydrive" ]; then rm -rf "$HOME/.fugu" start_stub "CONTEXT" write_conf "$stubport" - printf '\000\033o\000\033\014\000m\000\000\033\014\000iO\000WNER-BLOCK-MARKER\r/quit\r' | \ + printf '\000\033o\000\033\014\000m\000\000\r\014\000m\000\000\033\014\000iOWNER-BLOCK-MARKER\014\000\r/quit\r' | \ TERM=vt100 HOME=$HOME \ FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \ FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \ @@ -1033,14 +1033,29 @@ if [ -x "$obj/ptydrive" ]; then test -n "$picker_stage" ok "curses: model picker overlays the undismissed welcome" $? grep -a -Fq "I like Fugu" "$dir/welcome-stage.5" && - grep -a -Fq "context used / limit" "$dir/welcome-stage.5" - ok "curses: closing the model picker restores the welcome" $? - test -s "$dir/welcome-stage.6" && - ! grep -a -Fq "I like Fugu" "$dir/welcome-stage.6" && - ! grep -a -Fq "agent state" "$dir/welcome-stage.6" && - ! grep -a -Fq "context used / limit" "$dir/welcome-stage.6" - ok "curses: first nonempty edit removes welcome and captions" $? - grep -a -Fq "Context 46k/200k 23%" "$dir/cursesout" + grep -a -Fq "context used / limit" "$dir/welcome-stage.5" && + ! grep -a -Fq "model: stub-model-a" "$dir/welcome-stage.5" + ok "curses: selecting from m restores welcome without scrollback" $? + picker_stage='' + for stage in 6 7; do + if grep -a -Fq "stub-model-b" "$dir/welcome-stage.$stage"; then + picker_stage=$stage + fi + done + test -n "$picker_stage" + ok "curses: model picker can reopen before Owner input" $? + grep -a -Fq "I like Fugu" "$dir/welcome-stage.8" && + grep -a -Fq "context used / limit" "$dir/welcome-stage.8" + ok "curses: cancelling the model picker restores the welcome" $? + test -s "$dir/welcome-stage.9" && + grep -a -Fq "OWNER-BLOCK-MARKER" "$dir/welcome-stage.9" && + ! grep -a -Fq "I like Fugu" "$dir/welcome-stage.9" && + ! grep -a -Fq "agent state" "$dir/welcome-stage.9" && + ! grep -a -Fq "context used / limit" "$dir/welcome-stage.9" + ok "curses: forced repaint after first edit omits welcome and captions" $? + grep -q '"model":"stub-model-a"' "$dir/reqout" 2>/dev/null + ok "curses: pre-input picker selection drives the request" $? + grep -a -Fq "Context 46k/111k 41%" "$dir/cursesout" ok "curses: status shows Lead cache plus session context" $? grep -q "I like Fugu" "$dir/reqout" 2>/dev/null ok "curses: welcome is absent from the Provider request" \ blob - d5e67a6bd303a05123ca0616e4956ac8995b29a3 blob + d2c19e6eaf4259c491ac735405b37fcf6553b371 --- regress/ui_layout/Makefile +++ regress/ui_layout/Makefile @@ -1,6 +1,6 @@ PROG= ui_layout_test -SRCS= ui_layout_test.c ui_layout.c ui_palette.c utf8.c buf.c log.c \ - xmalloc.c +SRCS= ui_layout_test.c ui_layout.c ui_palette.c ascii.c utf8.c buf.c \ + log.c xmalloc.c NOMAN= yes CFLAGS+= -I${.CURDIR}/../../src/fugu-tty blob - 5d8fdb1ce2fe947b2e679eaf92e1ee92cc7b8952 blob + cef615c14a41843149a5070594aec7c507885bd8 --- regress/ui_layout/ui_layout_test.c +++ regress/ui_layout/ui_layout_test.c @@ -101,13 +101,14 @@ main(void) "A\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd" "\xef\xbf\xbd(\xef\xbf\xbd(B"; struct ui_layout layout = { 0 }; + struct ui_picker_layout picker; int attr = 0x1234; /* One named treatment serves color and monochrome surfaces. */ CHECK(ui_owner_attr(0) == (A_BOLD | A_REVERSE)); CHECK(ui_owner_attr(1) == (A_BOLD | COLOR_PAIR(P_OWNER))); - ui_layout_plain(&layout, "hello", 5, 10, attr, "> ", " ", 1); + ui_layout_plain(&layout, "hello", 5, 10, attr, "> ", " ", 1, 0); CHECK(layout.nlines == 1); CHECK(line_is(&layout.lines[0], "> hello")); CHECK(layout.lines[0].fill_width == 10); @@ -117,9 +118,26 @@ main(void) CHECK(ui_layout_visible_fill(&layout.lines[0], 8, 3) == 5); CHECK(ui_layout_visible_fill(&layout.lines[0], 3, 3) == 0); + /* Tiny picker overlays never reach the input/status rows below them. */ + ui_layout_picker(&picker, 3, 20); + CHECK(picker.top == 0 && picker.height == 1 && + picker.model_rows == 0 && picker.top + picker.height <= 3 - 2); + ui_layout_picker(&picker, 4, 20); + CHECK(picker.top == 0 && picker.height == 2 && + picker.model_rows == 0 && picker.top + picker.height <= 4 - 2); + ui_layout_picker(&picker, 5, 20); + CHECK(picker.top == 0 && picker.height == 3 && + picker.model_rows == 0 && picker.top + picker.height <= 5 - 2); + ui_layout_picker(&picker, 6, 20); + CHECK(picker.top == 0 && picker.height == 4 && + picker.model_rows == 1 && picker.top + picker.height <= 6 - 2); + ui_layout_picker(&picker, 40, 2); + CHECK(picker.top == 17 && picker.height == 5 && + picker.model_rows == 2 && picker.top + picker.height <= 40 - 2); + /* Every wrapped row has the prefix indent and the same full-width fill. */ ui_layout_reset(&layout); - ui_layout_plain(&layout, "abcdefghi", 9, 6, attr, "> ", " ", 1); + ui_layout_plain(&layout, "abcdefghi", 9, 6, attr, "> ", " ", 1, 0); CHECK(layout.nlines == 3); CHECK(line_is(&layout.lines[0], "> abcd")); CHECK(line_is(&layout.lines[1], " efgh")); @@ -133,7 +151,7 @@ main(void) /* Hard blank lines remain part of one coherent User block. */ ui_layout_reset(&layout); - ui_layout_plain(&layout, "a\n\nb", 4, 8, attr, "> ", " ", 1); + ui_layout_plain(&layout, "a\n\nb", 4, 8, attr, "> ", " ", 1, 0); CHECK(layout.nlines == 3); CHECK(line_is(&layout.lines[0], "> a")); CHECK(line_is(&layout.lines[1], " ")); @@ -143,7 +161,7 @@ main(void) /* Word wrapping trims the break and reports the retained cell width. */ ui_layout_reset(&layout); - ui_layout_plain(&layout, "one two", 7, 8, attr, "> ", " ", 1); + ui_layout_plain(&layout, "one two", 7, 8, attr, "> ", " ", 1, 0); CHECK(layout.nlines == 2); CHECK(line_is(&layout.lines[0], "> one")); CHECK(layout.lines[0].nspans == 2 && @@ -153,20 +171,30 @@ main(void) /* Combining and wide cells use display width, not byte length. */ ui_layout_reset(&layout); ui_layout_plain(&layout, wide_combining, sizeof(wide_combining) - 1, - 6, attr, "> ", " ", 1); + 6, attr, "> ", " ", 1, 0); CHECK(layout.nlines == 1); CHECK(layout.lines[0].nspans == 2 && layout.lines[0].spans[1].len == sizeof(wide_combining) - 1 && layout.lines[0].spans[1].width == 4); CHECK(layout.lines[0].fill_width == 6); + /* ASCII folding precedes wrapping and applies to Owner rows. */ + ui_layout_reset(&layout); + ui_layout_plain(&layout, "\xe2\x80\x94\xe4\xb8\xad", 6, 4, + attr, "> ", " ", 1, 1); + CHECK(layout.nlines == 2); + CHECK(line_is(&layout.lines[0], "> --")); + CHECK(line_is(&layout.lines[1], " ?")); + CHECK(line_width(&layout.lines[0]) == 4 && + line_width(&layout.lines[1]) == 3); + /* * Hostile transcript bytes become visible UTF-8 cells before storage: * embedded NUL, C0/C1 controls, and each malformed byte are U+FFFD. */ ui_layout_reset(&layout); ui_layout_plain(&layout, hostile, sizeof(hostile), 40, attr, NULL, - NULL, 0); + NULL, 0, 0); CHECK(layout.nlines == 1); CHECK(line_bytes_are(&layout.lines[0], normalized, sizeof(normalized) - 1)); @@ -188,16 +216,16 @@ main(void) /* Re-rendering at a narrower pane pins resize and drawer reflow. */ ui_layout_reset(&layout); - ui_layout_plain(&layout, "abcdef", 6, 5, attr, "> ", " ", 1); + ui_layout_plain(&layout, "abcdef", 6, 5, attr, "> ", " ", 1, 0); CHECK(layout.nlines == 2 && layout.lines[0].fill_width == 5); ui_layout_reset(&layout); - ui_layout_plain(&layout, "abcdef", 6, 4, attr, "> ", " ", 1); + ui_layout_plain(&layout, "abcdef", 6, 4, attr, "> ", " ", 1, 0); CHECK(layout.nlines == 3 && layout.lines[0].fill_width == 4 && layout.lines[2].fill_width == 4); /* Prefixes yield to content at widths one and two; nothing overdraws. */ ui_layout_reset(&layout); - ui_layout_plain(&layout, "ab", 2, 1, attr, "> ", " ", 1); + ui_layout_plain(&layout, "ab", 2, 1, attr, "> ", " ", 1, 0); CHECK(layout.nlines == 3); CHECK(line_is(&layout.lines[0], ">")); CHECK(line_is(&layout.lines[1], "a")); @@ -207,7 +235,7 @@ main(void) line_width(&layout.lines[2]) <= 1); ui_layout_reset(&layout); - ui_layout_plain(&layout, "ab", 2, 2, attr, "> ", " ", 1); + ui_layout_plain(&layout, "ab", 2, 2, attr, "> ", " ", 1, 0); CHECK(layout.nlines == 3); CHECK(line_is(&layout.lines[0], "> ")); CHECK(line_is(&layout.lines[1], " a")); @@ -218,7 +246,8 @@ main(void) /* A glyph wider than the whole pane becomes one visible placeholder. */ ui_layout_reset(&layout); - ui_layout_plain(&layout, "\xe4\xb8\xad", 3, 1, attr, "> ", " ", 1); + ui_layout_plain(&layout, "\xe4\xb8\xad", 3, 1, attr, "> ", " ", 1, + 0); CHECK(layout.nlines == 2 && line_is(&layout.lines[0], ">") && line_is(&layout.lines[1], "?")); CHECK(line_width(&layout.lines[0]) == 1 && @@ -226,14 +255,14 @@ main(void) /* Other transcript kinds retain ordinary, unfilled lines. */ ui_layout_reset(&layout); - ui_layout_plain(&layout, "assistant", 9, 20, 7, NULL, NULL, 0); + ui_layout_plain(&layout, "assistant", 9, 20, 7, NULL, NULL, 0, 0); CHECK(layout.nlines == 1 && layout.lines[0].fill_width == 0); CHECK(ui_layout_visible_fill(&layout.lines[0], 80, 31) == 0); CHECK(line_attrs_are(&layout.lines[0], 7)); /* An empty User message still owns its prefix and padded row. */ ui_layout_reset(&layout); - ui_layout_plain(&layout, NULL, 0, 12, attr, "> ", " ", 1); + ui_layout_plain(&layout, NULL, 0, 12, attr, "> ", " ", 1, 0); CHECK(layout.nlines == 1 && line_is(&layout.lines[0], "> ")); CHECK(layout.lines[0].fill_width == 12); blob - 9a92356bbf6c44202ee32acb8d09452d52504700 blob + 1bb802445df759a57bc8d38fc2c0dfa2fa850466 --- src/fugu/coord.c +++ src/fugu/coord.c @@ -176,6 +176,7 @@ struct coord { struct listed_model *listed_models; /* validates picker selections */ size_t nlisted_models; int models_collecting; + int models_announce; /* slash-command feedback */ int default_provider_type; /* to restore on slot 0 */ int active_provider_slot; int64_t provider_window; /* selected listing metadata */ @@ -1591,7 +1592,7 @@ configured_provider_name(const struct coord *c, int sl return (NULL); } -/* Start one live model listing; normal-mode `m` keeps it presentation-only. */ +/* Start one live model listing; normal-mode "m" stays presentation-only. */ static void model_list_begin(struct coord *c, int announce) { @@ -1604,6 +1605,7 @@ model_list_begin(struct coord *c, int announce) c->listed_models = NULL; c->nlisted_models = 0; c->models_collecting = 1; + c->models_announce = announce; imsgev_send(&c->iev[ROLE_API], FUGU_IMSG_LIST_MODELS, 0, NULL, 0); if (announce) emit_note(c, "fetching the model list..."); @@ -1750,7 +1752,9 @@ ui_event(struct coord *c, struct imsg *imsg) turn_txn_cache_reset(c->turn); /* the cache starts cold (8) */ context_invalidate(c); /* tokenization/provider changed */ send_ui_config(c); /* the status bar shows it */ - emit_note(c, "model: %s", id); + if (c->models_announce) + emit_note(c, "model: %s", id); + c->models_announce = 0; break; } } blob - 3d338f1558d354235e859836b6c17d91327b78e2 blob + 653d4cb2e00b5fcbb159c201000a25766325e20f --- src/fugu-tty/Makefile +++ src/fugu-tty/Makefile @@ -1,7 +1,7 @@ PROG= fugu-tty SRCS= main.c ui.c ui_config_rx.c ui_layout.c ui_palette.c welcome.c \ - markdown.c editor.c term_input.c utf8.c worker.c imsgev.c buf.c log.c \ - xmalloc.c + markdown.c editor.c ascii.c term_input.c utf8.c worker.c imsgev.c \ + buf.c log.c xmalloc.c BINDIR= ${LIBEXECDIR}/fugu MAN= blob - 7ceca5556b0eebac5415e0f3df17f4faba18826d blob + 21d6e3e8a798360c3f4b51b95c9fd51bba948c8c --- src/fugu-tty/editor.c +++ src/fugu-tty/editor.c @@ -18,6 +18,7 @@ #include #include +#include "ascii.h" #include "xmalloc.h" #include "buf.h" #include "utf8.h" @@ -245,8 +246,36 @@ ed_text(struct editor *e, size_t *len) return ((const char *)textbuf.data); } +/* Append one display code point (or its ASCII fold), returning its width. */ +static size_t +display_cp(struct buf *out, uint32_t cp, int ascii) +{ + unsigned char enc[4]; + uint32_t folded[4]; + size_t enclen, width = 0; + int i, n, w; + + if (ascii) + n = ascii_fold_cp(cp, folded); + else { + folded[0] = cp; + n = 1; + } + for (i = 0; i < n; i++) { + w = utf8_width(folded[i]); + if (folded[i] == 0 || w < 0) + w = 1; /* controls render as one visible cell */ + width += (size_t)w; + if (out != NULL) { + enclen = utf8_encode(folded[i], enc); + buf_add(out, enc, enclen); + } + } + return (width); +} + void -ed_display(const struct editor *e, struct buf *out) +ed_display(const struct editor *e, struct buf *out, int ascii) { size_t i; @@ -254,7 +283,7 @@ ed_display(const struct editor *e, struct buf *out) if (e->items[i].paste) buf_addf(out, "[paste: %d lines]", e->items[i].lines); else - buf_add(out, e->items[i].bytes, e->items[i].nbytes); + (void)display_cp(out, e->items[i].cp, ascii); } } @@ -273,7 +302,7 @@ size_t ed_cursor(const struct editor *e) { return (e- size_t ed_nitems(const struct editor *e) { return (e->n); } size_t -ed_cursor_col(const struct editor *e) +ed_cursor_col(const struct editor *e, int ascii) { size_t i, col = 0; @@ -285,11 +314,8 @@ ed_cursor_col(const struct editor *e) k = snprintf(tmp, sizeof(tmp), "[paste: %d lines]", e->items[i].lines); col += (k > 0 ? (size_t)k : 0); - } else { - int w = utf8_width(e->items[i].cp); - - col += (w < 0 ? 0 : (size_t)w); - } + } else + col += display_cp(NULL, e->items[i].cp, ascii); } return (col); } blob - /dev/null blob + 83dac1d1edf876b6e119211549cafbe56bb32023 (mode 644) --- /dev/null +++ src/fugu-tty/ascii.c @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include +#include + +#include "ascii.h" + +int +ascii_fold_cp(uint32_t cp, uint32_t out[4]) +{ + const char *rep = NULL; + int i, n; + + if (cp == '\n') { + out[0] = cp; /* structural line break */ + return (1); + } + if (cp == '\t') { + out[0] = ' '; + return (1); + } + if (cp < 0x20 || cp == 0x7f) { + out[0] = '?'; + return (1); + } + if (cp < 0x80) { + out[0] = cp; + return (1); + } + switch (cp) { + case 0x2018: case 0x2019: case 0x2032: out[0] = '\''; return (1); + case 0x201C: case 0x201D: case 0x2033: out[0] = '"'; return (1); + case 0x2013: out[0] = '-'; return (1); + case 0x00A0: out[0] = ' '; return (1); + case 0x2022: case 0x00B7: case 0x2023: out[0] = '*'; return (1); + case 0x2014: rep = "--"; break; + case 0x2026: rep = "..."; break; + case 0x00A9: rep = "(c)"; break; + case 0x00AE: rep = "(r)"; break; + case 0x2122: rep = "(tm)"; break; + case 0x2192: rep = "->"; break; + case 0x2190: rep = "<-"; break; + default: out[0] = '?'; return (1); + } + for (i = 0, n = 0; rep[i] != '\0' && n < 4; i++) + out[n++] = (unsigned char)rep[i]; + return (n); +} blob - c053afb616e32c0c496b53e6e50b136e60a78c14 blob + ca7d21a4814bda6c99255dbf3542ff88740295b2 --- src/fugu-tty/editor.h +++ src/fugu-tty/editor.h @@ -129,15 +129,15 @@ int ed_empty(const struct editor *); int ed_mode(const struct editor *); size_t ed_cursor(const struct editor *); size_t ed_nitems(const struct editor *); -/* Display column of the cursor in the rendered line (paste placeholders - * and wide characters counted), for the curses layer to place it. */ -size_t ed_cursor_col(const struct editor *); +/* Display column of the cursor in the rendered line (paste placeholders, + * ASCII folds, and wide characters counted), for curses to place it. */ +size_t ed_cursor_col(const struct editor *, int ascii); void ed_history_add(struct editor *, const char *, size_t); void ed_set_completions(struct editor *, char **names, size_t n); -/* The display string with paste placeholders; for the driver and tests. */ -void ed_display(const struct editor *, struct buf *); +/* The display string with paste placeholders and optional ASCII folding. */ +void ed_display(const struct editor *, struct buf *, int ascii); /* A one-shot notice (oversize paste, &c.); NULL if none pending. */ const char *ed_take_notice(struct editor *); blob - /dev/null blob + aca64e010fcfcd99a51593168e8e188dbb8d6e84 (mode 644) --- /dev/null +++ src/fugu-tty/ascii.h @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#ifndef ASCII_H +#define ASCII_H + +#include + +/* + * Map one code point to a readable ASCII stand-in of at most four cells. + * Printable ASCII and newlines pass through, tabs become spaces, and unknown + * or nonprinting glyphs become '?'. + */ +int ascii_fold_cp(uint32_t, uint32_t [4]); + +#endif /* ASCII_H */ blob - 0a218495e86efd4ffbb6c4a34691a14ced2164e5 blob + 5a2ac48675c6d5851fb83e62bb908d17770ad739 --- src/fugu-tty/markdown.c +++ src/fugu-tty/markdown.c @@ -18,6 +18,7 @@ #include #include +#include "ascii.h" #include "xmalloc.h" #include "buf.h" #include "utf8.h" @@ -118,43 +119,6 @@ lb_finish(struct lb *b) return (ln); } -/* ---- ascii fold ----------------------------------------------------- */ - -/* - * Under `ascii yes` map a code point to a short ASCII stand-in sequence - * (out holds up to 4), preserving readability; any other non-ASCII glyph - * becomes '?'. Returns the count. ASCII passes through unchanged. - */ -static int -ascii_fold(uint32_t cp, uint32_t out[4]) -{ - const char *rep = NULL; - int i, n; - - if (cp < 0x80) { - out[0] = cp; - return (1); - } - switch (cp) { - case 0x2018: case 0x2019: case 0x2032: out[0] = '\''; return (1); - case 0x201C: case 0x201D: case 0x2033: out[0] = '"'; return (1); - case 0x2013: out[0] = '-'; return (1); - case 0x00A0: out[0] = ' '; return (1); - case 0x2022: case 0x00B7: case 0x2023: out[0] = '*'; return (1); - case 0x2014: rep = "--"; break; - case 0x2026: rep = "..."; break; - case 0x00A9: rep = "(c)"; break; - case 0x00AE: rep = "(r)"; break; - case 0x2122: rep = "(tm)"; break; - case 0x2192: rep = "->"; break; - case 0x2190: rep = "<-"; break; - default: out[0] = '?'; return (1); - } - for (i = 0, n = 0; rep[i] != '\0' && n < 4; i++) - out[n++] = (unsigned char)rep[i]; - return (n); -} - /* ---- decode a logical line into cells ------------------------------- */ static void @@ -200,7 +164,7 @@ decode_line(const char *s, size_t len, int ascii, size uint32_t out[4]; int k, m; - m = ascii_fold(cp, out); + m = ascii_fold_cp(cp, out); for (k = 0; k < m; k++) push_cp(&cells, &n, &cap, out[k]); } else blob - bac1fe598e0384069d9d1cf6244497a9fab59110 blob + c3a532af8c47fa980b25808e997af51c1e3d9e80 --- src/fugu-tty/ui.c +++ src/fugu-tty/ui.c @@ -241,7 +241,7 @@ render_diff(struct ui *u, const char *text, size_t len A_BOLD | A_UNDERLINE; } ui_layout_plain(&u->layout, text + i, j - i, width, attr, - NULL, NULL, 0); + NULL, NULL, 0, u->ascii); i = (j < len) ? j + 1 : j; } } @@ -283,7 +283,7 @@ rebuild(struct ui *u) switch (it->kind) { case IT_USER: ui_layout_plain(&u->layout, t, n, w, - ui_owner_attr(u->color), "> ", " ", 1); + ui_owner_attr(u->color), "> ", " ", 1, u->ascii); break; case IT_ASSIST: render_assist(u, it, w); @@ -291,19 +291,20 @@ rebuild(struct ui *u) case IT_TOOL: ui_layout_plain(&u->layout, t, n, w, u->color ? COLOR_PAIR(P_TOOL) : A_DIM, - " tool: ", " ", 0); + " tool: ", " ", 0, u->ascii); break; case IT_DIFF: render_diff(u, t, n, w); break; case IT_NOTE: ui_layout_plain(&u->layout, t, n, w, - u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL, NULL, 0); + u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL, NULL, 0, + u->ascii); break; case IT_ERROR: ui_layout_plain(&u->layout, t, n, w, u->color ? COLOR_PAIR(P_ERR) : A_BOLD, "! ", NULL, - 0); + 0, u->ascii); break; case IT_CLEAR: u->clear_line = (int)u->layout.nlines; @@ -437,12 +438,12 @@ static void draw_input(struct ui *u, int row) { struct buf disp; - size_t ccol = ed_cursor_col(&u->ed); + size_t ccol = ed_cursor_col(&u->ed, u->ascii); size_t start = 0, i; int attr, curscol; buf_init(&disp); - ed_display(&u->ed, &disp); + ed_display(&u->ed, &disp, u->ascii); /* horizontal scroll so the cursor stays on screen */ if ((int)ccol > u->cols - 1) @@ -700,7 +701,8 @@ draw_welcome(const struct ui *u, int xoff, int height) break; } attrset(attr); - mvaddnstr(row->row, row->col, row->text, (int)row->len); + move(row->row, row->col); + (void)draw_utf8_cells(row->text, row->len, row->width); } attrset(A_NORMAL); } @@ -846,6 +848,7 @@ pick_matches(const struct ui *u, size_t *out, size_t c static void draw_picker(struct ui *u) { + struct ui_picker_layout layout; size_t match[256]; size_t n = pick_matches(u, match, 256); int w = u->cols - 4 < 60 ? u->cols - 4 : 60; @@ -853,26 +856,29 @@ draw_picker(struct ui *u) if (w < 20) w = u->cols < 20 ? u->cols : 20; - h = (int)n + 3; - if (h > u->rows - 2) - h = u->rows - 2; + ui_layout_picker(&layout, u->rows, n); + h = layout.height; + rows = layout.model_rows; + if (h == 0) + return; x = (u->cols - w) / 2; - y = (u->rows - h) / 2; + y = layout.top; if (u->pick_sel >= n) u->pick_sel = n > 0 ? n - 1 : 0; /* scroll the visible window to keep the selection on screen (2.1): * a real /v1/models listing has far more entries than rows */ - rows = h - 3; - if (rows < 1) - rows = 1; - if (u->pick_sel < u->pick_top) - u->pick_top = u->pick_sel; - else if (u->pick_sel >= u->pick_top + (size_t)rows) - u->pick_top = u->pick_sel - rows + 1; - if (u->pick_top > n) + if (rows == 0) u->pick_top = 0; + else { + if (u->pick_sel < u->pick_top) + u->pick_top = u->pick_sel; + else if (u->pick_sel >= u->pick_top + (size_t)rows) + u->pick_top = u->pick_sel - rows + 1; + if (u->pick_top > n) + u->pick_top = 0; + } for (r = 0; r < h; r++) { move(y + r, x); @@ -881,8 +887,10 @@ draw_picker(struct ui *u) } attrset(A_REVERSE | A_BOLD); mvaddnstr(y, x + 1, "model", w - 2); - attrset(A_REVERSE); - mvprintw(y + 1, x + 1, "> %.*s", w - 4, u->filter); + if (h > 1) { + attrset(A_REVERSE); + mvprintw(y + 1, x + 1, "> %.*s", w - 4, u->filter); + } for (r = 0; r < rows && u->pick_top + (size_t)r < n; r++) { size_t mi = u->pick_top + (size_t)r; struct pick *m = &u->models[match[mi]]; blob - e930cf754a8196847eda80497a1253fa5efae8c6 blob + cc5cdd88b5362488a68cf9051d3cd5574fc24c80 --- src/fugu-tty/ui_layout.c +++ src/fugu-tty/ui_layout.c @@ -16,10 +16,12 @@ #include +#include #include #include #include +#include "ascii.h" #include "buf.h" #include "utf8.h" #include "xmalloc.h" @@ -120,18 +122,44 @@ ui_layout_span(struct ui_line *line, int attr, const v buf_free(&normalized); } +/* Fold text before wrapping so expansions participate in row geometry. */ +static void +fold_ascii(struct buf *out, const unsigned char *text, size_t len) +{ + unsigned char enc[4]; + uint32_t cp, folded[4]; + size_t adv, enclen, i; + int j, n; + + for (i = 0; i < len; i += adv) { + adv = utf8_decode(text + i, len - i, &cp); + n = ascii_fold_cp(cp, folded); + for (j = 0; j < n; j++) { + enclen = utf8_encode(folded[j], enc); + buf_add(out, enc, enclen); + } + } +} + /* Wrap plain UTF-8 text to width columns, one attribute, into new lines. */ void ui_layout_plain(struct ui_layout *layout, const void *vtext, size_t len, int width, int attr, const char *prefix, const char *continuation, - int fill) + int fill, int ascii) { const unsigned char *text = vtext; struct ui_line *line; + struct buf folded; size_t i = 0; int col, first = 1; const char *lead; + buf_init(&folded); + if (ascii && len > 0) { + fold_ascii(&folded, text, len); + text = folded.data; + len = folded.len; + } if (width < 1) width = 1; do { @@ -221,6 +249,7 @@ ui_layout_plain(struct ui_layout *layout, const void * ui_layout_span(line, attr, seg.data, seg.len); buf_free(&seg); } while (i < len); + buf_free(&folded); } int @@ -236,3 +265,18 @@ ui_layout_visible_fill(const struct ui_line *line, int fill = available; return (fill); } + +void +ui_layout_picker(struct ui_picker_layout *layout, int screen_rows, + size_t matches) +{ + int available, desired, usable; + + available = screen_rows > 2 ? screen_rows - 2 : 0; + usable = screen_rows > 0 ? screen_rows - 1 : 0; + desired = matches > (size_t)INT_MAX - 3 ? INT_MAX : + (int)matches + 3; /* title, filter, bottom padding */ + layout->height = desired < available ? desired : available; + layout->top = (usable - layout->height) / 2; + layout->model_rows = layout->height > 3 ? layout->height - 3 : 0; +} blob - 2ca324091c9504b1a0b68bf62b5670cca635284d blob + fcefa1bfce29974926fa660ea36202ef6c5ac19a --- src/fugu-tty/ui_layout.h +++ src/fugu-tty/ui_layout.h @@ -39,12 +39,20 @@ struct ui_layout { size_t cap; }; +/* Rows available to the picker without touching the persistent status row. */ +struct ui_picker_layout { + int top; + int height; + int model_rows; +}; + void ui_layout_reset(struct ui_layout *); void ui_layout_free(struct ui_layout *); struct ui_line *ui_layout_line(struct ui_layout *); void ui_layout_span(struct ui_line *, int, const void *, size_t); void ui_layout_plain(struct ui_layout *, const void *, size_t, int, - int, const char *, const char *, int); + int, const char *, const char *, int, int); int ui_layout_visible_fill(const struct ui_line *, int, int); +void ui_layout_picker(struct ui_picker_layout *, int, size_t); #endif /* UI_LAYOUT_H */