commit 41aa0eed73347fe812858fd61ac90ba378a02500 from: Isaac Meerleo date: Fri Jul 17 15:15:33 2026 UTC Style Owner rows in the terminal UI commit - 17d56da6b3808a59155d2afbc938cd8484cc43b7 commit + 41aa0eed73347fe812858fd61ac90ba378a02500 blob - a2d0f85c129b3981c63a794afb366ca4adefbf63 blob + ba26133faea629c2563dd9fddba3ce0a0abd6c87 --- docs/design/m7-curses.md +++ docs/design/m7-curses.md @@ -41,6 +41,10 @@ dependency, each with its own regress suite, before an 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. - **diff.c** — a unified-diff formatter, run in the tool executor role (below); pure line diff to hunk text; `regress/diff`. @@ -106,6 +110,11 @@ bracketed paste (`ESC[200~` … `ESC[201~`) collected event (NUL rejected, other ESC stripped, 64 KiB cap, overflow discarded with a notice). Decoded events feed the pure editor/UI model; nothing about input depends on `wgetch`, so it is testable. +The input row and the Owner's User transcript rows use one named palette +treatment: high-contrast shaded rows on color terminals and bold reverse +video otherwise. Every visual row of a User message is explicitly filled to +the current transcript-pane edge, including its prefix and continuation +indent; resize and drawer changes rebuild that edge from the pure layout. `Ctrl-L` records a reflow-stable display boundary and forces a physical clear. The tail view starts after that boundary, while scrolling up can still reach earlier lines. It sends no coordinator message and does not alter conversation blob - 4a797f13fee9166dbbba6ce13a27ab6b59a9a652 blob + 5c60dfb315d71a46f754cc8efd17bc2e4597688b --- handoff/behavior.md +++ handoff/behavior.md @@ -86,7 +86,11 @@ Transcript behavior: bullet lists, inline code, bold/italic — via curses attributes. Unmatched markers render literally. Ordinary assistant text is light grey when color is available; markdown-specific colors take precedence. - User text renders plain. + The Owner's User messages share a shaded, high-contrast treatment with + the input row. The `> ` prefix, wrapped or hard-break continuation rows, + and padding through the transcript pane's right edge all carry that + treatment; an open conversation drawer narrows the edge. Monochrome + terminals use bold reverse video. Line and print modes remain plain. - Tool calls appear as they run, indented and visually distinct from prose. Each `tool:`-prefixed line includes a bounded, control-safe allowlist of useful arguments: for example, a read's path and line window or a grep's blob - f53727c56c505a66fcf3934d6fc5b9b0af9c46c4 blob + 6279752052e03e05a5c783672834e84a75cbde05 --- handoff/verification.md +++ handoff/verification.md @@ -151,6 +151,12 @@ states the property, not just examples: hostile diff bytes are length-checked at the role boundary (I13); and a resume/projection check proves diff output appears in neither the provider projection nor the replayed journal (I14). +- **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. - **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 - c3f2ea6f4cc01df501a740da95851e17c54bc553 blob + 402a81eb1c7aaf5958cf491f6a98afa86a3fcc1c --- regress/Makefile +++ regress/Makefile @@ -1,5 +1,5 @@ SUBDIR= agentcfg anthropic buf claude_subscription compaction conf deploy diff editor generation http imsgev journal json local_port log markdown \ model_window openai output print sandbox skills sse term tool_display tools turn \ - turn_mechanics turn_txn ui_config web xmalloc + turn_mechanics turn_txn ui_config ui_layout web xmalloc .include blob - 3c5f8488d910e615a68f0ea2110ab7eac0b1f7bf blob + ed15645241409d09070e027ca8035f9eaf16f64e --- regress/README +++ regress/README @@ -141,6 +141,10 @@ headless turn (behavior.md 1) turn, print (real -p tu codes, key flow, transactional cancel) line mode (behavior.md 2.2) turn (multi-turn piped session, 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 + startup and teardown) slash commands (behavior.md 3) turn, print (/clear, /context, /compact, /quit and Skill dispatch); journal (clear replay) @@ -199,6 +203,8 @@ src/fugu/journal.c journal, turn src/fugu/turn_txn.c turn_txn (pure ordering plus real-Journal integration); turn (cache/SIGINT path) src/fugu-tty/ui_config_rx.c ui_config; turn (full-palette PTY path) +src/fugu-tty/ui_layout.c, + ui_palette.c ui_layout; turn (curses PTY path) src/common/imsgev.c imsgev (whole-payload views, checked normal/teardown sends, bounded chunk emission, event failures); blob - 1d654133634f2852306ac68ef95b3652f76f93b3 blob + d04352ab3fb3080232ecdb2227fb07527239e0e9 --- 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 'hello\r/quit\r' | TERM=vt100 HOME=$HOME \ + printf 'OWNER-BLOCK-MARKER\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 \ "$obj/ptydrive" "$fugu" >"$dir/cursesout" 2>&1 @@ -1003,8 +1003,10 @@ if [ -x "$obj/ptydrive" ]; then grep -a -Fq "${esc}[?1006l" "$dir/cursesout" && grep -a -Fq "${esc}[?1000l" "$dir/cursesout" ok "curses: mouse reporting is enabled and restored" $? - grep -q "hello" "$dir/reqout" 2>/dev/null + grep -q "OWNER-BLOCK-MARKER" "$dir/reqout" 2>/dev/null ok "curses: the typed prompt reached the provider" $? + grep -a -Fq "OWNER-BLOCK-MARKER" "$dir/cursesout" + ok "curses: Owner marker renders over a monochrome pty" $? grep -a -Fq "Context 46k/200k 23%" "$dir/cursesout" ok "curses: status shows Lead cache plus session context" $? n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null || echo 0) blob - /dev/null blob + d5e67a6bd303a05123ca0616e4956ac8995b29a3 (mode 644) --- /dev/null +++ regress/ui_layout/Makefile @@ -0,0 +1,11 @@ +PROG= ui_layout_test +SRCS= ui_layout_test.c ui_layout.c ui_palette.c utf8.c buf.c log.c \ + xmalloc.c +NOMAN= yes + +CFLAGS+= -I${.CURDIR}/../../src/fugu-tty +LDADD= -lcurses +DPADD= ${LIBCURSES} +.PATH: ${.CURDIR}/../../src/fugu-tty + +.include blob - /dev/null blob + 2ff3486fbd247746fb4e110747f2f0b72b2092d0 (mode 644) --- /dev/null +++ regress/ui_layout/ui_layout_test.c @@ -0,0 +1,183 @@ +/* + * 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 "buf.h" +#include "ui_layout.h" +#include "ui_palette.h" +#include "regress.h" + +static int +line_is(const struct ui_line *line, const char *want) +{ + struct buf buf; + size_t i; + int ok; + + buf_init(&buf); + for (i = 0; i < line->nspans; i++) + buf_add(&buf, line->spans[i].text, line->spans[i].len); + buf_addc(&buf, '\0'); + ok = strcmp((const char *)buf.data, want) == 0; + buf_free(&buf); + return (ok); +} + +static int +line_attrs_are(const struct ui_line *line, int attr) +{ + size_t i; + + for (i = 0; i < line->nspans; i++) + if (line->spans[i].attr != attr) + return (0); + return (1); +} + +static int +line_width(const struct ui_line *line) +{ + size_t i; + int width = 0; + + for (i = 0; i < line->nspans; i++) + width += line->spans[i].width; + return (width); +} + +int +main(void) +{ + static const char wide_combining[] = + "A\xcc\x81\xe4\xb8\xad" "B"; + struct ui_layout layout = { 0 }; + 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); + CHECK(layout.nlines == 1); + CHECK(line_is(&layout.lines[0], "> hello")); + CHECK(layout.lines[0].fill_width == 10); + CHECK(layout.lines[0].fill_attr == attr); + CHECK(line_attrs_are(&layout.lines[0], attr)); + CHECK(ui_layout_visible_fill(&layout.lines[0], 10, 0) == 10); + CHECK(ui_layout_visible_fill(&layout.lines[0], 8, 3) == 5); + CHECK(ui_layout_visible_fill(&layout.lines[0], 3, 3) == 0); + + /* 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); + CHECK(layout.nlines == 3); + CHECK(line_is(&layout.lines[0], "> abcd")); + CHECK(line_is(&layout.lines[1], " efgh")); + CHECK(line_is(&layout.lines[2], " i")); + CHECK(layout.lines[0].fill_width == 6 && + layout.lines[1].fill_width == 6 && + layout.lines[2].fill_width == 6); + CHECK(line_attrs_are(&layout.lines[0], attr) && + line_attrs_are(&layout.lines[1], attr) && + line_attrs_are(&layout.lines[2], attr)); + + /* 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); + CHECK(layout.nlines == 3); + CHECK(line_is(&layout.lines[0], "> a")); + CHECK(line_is(&layout.lines[1], " ")); + CHECK(line_is(&layout.lines[2], " b")); + CHECK(layout.lines[1].fill_width == 8 && + layout.lines[1].fill_attr == attr); + + /* 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); + CHECK(layout.nlines == 2); + CHECK(line_is(&layout.lines[0], "> one")); + CHECK(layout.lines[0].nspans == 2 && + layout.lines[0].spans[1].width == 3); + CHECK(line_is(&layout.lines[1], " two")); + + /* 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); + 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); + + /* Re-rendering at a narrower pane pins resize and drawer reflow. */ + ui_layout_reset(&layout); + ui_layout_plain(&layout, "abcdef", 6, 5, attr, "> ", " ", 1); + CHECK(layout.nlines == 2 && layout.lines[0].fill_width == 5); + ui_layout_reset(&layout); + ui_layout_plain(&layout, "abcdef", 6, 4, attr, "> ", " ", 1); + 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); + CHECK(layout.nlines == 3); + CHECK(line_is(&layout.lines[0], ">")); + CHECK(line_is(&layout.lines[1], "a")); + CHECK(line_is(&layout.lines[2], "b")); + CHECK(line_width(&layout.lines[0]) <= 1 && + line_width(&layout.lines[1]) <= 1 && + line_width(&layout.lines[2]) <= 1); + + ui_layout_reset(&layout); + ui_layout_plain(&layout, "ab", 2, 2, attr, "> ", " ", 1); + CHECK(layout.nlines == 3); + CHECK(line_is(&layout.lines[0], "> ")); + CHECK(line_is(&layout.lines[1], " a")); + CHECK(line_is(&layout.lines[2], " b")); + CHECK(line_width(&layout.lines[0]) <= 2 && + line_width(&layout.lines[1]) <= 2 && + line_width(&layout.lines[2]) <= 2); + + /* 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); + CHECK(layout.nlines == 2 && line_is(&layout.lines[0], ">") && + line_is(&layout.lines[1], "?")); + CHECK(line_width(&layout.lines[0]) == 1 && + line_width(&layout.lines[1]) == 1); + + /* Other transcript kinds retain ordinary, unfilled lines. */ + ui_layout_reset(&layout); + ui_layout_plain(&layout, "assistant", 9, 20, 7, NULL, NULL, 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); + CHECK(layout.nlines == 1 && line_is(&layout.lines[0], "> ")); + CHECK(layout.lines[0].fill_width == 12); + + ui_layout_free(&layout); + CHECK(layout.lines == NULL && layout.nlines == 0 && layout.cap == 0); + REGRESS_END(); +} blob - f7c35cbc03654903dfa84d3caded4baf0ebccbea blob + ac80260ee463e3dcd4a63f24b029ef2cae430ac0 --- src/fugu/fugu.1 +++ src/fugu/fugu.1 @@ -139,7 +139,10 @@ May be repeated for debug-level logging. The curses interface displays a scrolling transcript, a one-line modal editor, and a status bar. Assistant markdown headings, lists, fenced code, emphasis, and inline code -are rendered with terminal attributes; user messages remain plain text. +are rendered with terminal attributes. +The Owner's User messages share a shaded, high-contrast treatment with the +input row; every wrapped row fills the current transcript pane. +Monochrome terminals use bold reverse video. Tool calls, errors, and edit diffs are visually distinct. The status bar identifies idle, active-turn, summarization, and retry states, and shows blob - c85c3f89ffa51c2863e5d736f1543051bb983324 blob + 31c9a5089db29c5e37b47f4841a1ed13e884411b --- src/fugu-tty/Makefile +++ src/fugu-tty/Makefile @@ -1,6 +1,6 @@ PROG= fugu-tty -SRCS= main.c ui.c ui_config_rx.c markdown.c editor.c term_input.c utf8.c \ - worker.c imsgev.c buf.c log.c xmalloc.c +SRCS= main.c ui.c ui_config_rx.c ui_layout.c ui_palette.c markdown.c \ + editor.c term_input.c utf8.c worker.c imsgev.c buf.c log.c xmalloc.c BINDIR= ${LIBEXECDIR}/fugu MAN= blob - 9c20233d383357a221d7316c0cccb7b58dfad897 blob + b20c911fc66c960252025317d69aa62cca89493c --- src/fugu-tty/ui.c +++ src/fugu-tty/ui.c @@ -37,6 +37,8 @@ #include "editor.h" #include "term_input.h" #include "ui_config_rx.h" +#include "ui_layout.h" +#include "ui_palette.h" #include "ui.h" /* transcript item kinds */ @@ -47,28 +49,6 @@ struct titem { struct buf raw; }; -/* a rendered display line: coalesced spans with resolved curses attrs */ -struct span { - int attr; - char *text; - size_t len; - int width; -}; -struct dline { - struct span *spans; - size_t n; -}; - -/* curses color pairs (when the terminal has color) */ -#define P_HEAD 1 -#define P_CODE 2 -#define P_ADD 3 -#define P_DEL 4 -#define P_TOOL 5 -#define P_ERR 6 -#define P_DIM 7 -#define P_TEXT 8 - /* the conversation drawer (behavior.md 2.1): reference width, and the * narrowest terminal it will occupy without starving the transcript */ #define DRAWER_W 30 @@ -111,8 +91,7 @@ struct ui { /* transcript and its rendered scrollback */ struct titem *items; size_t nitems, icap; - struct dline *lines; - size_t nlines, lcap; + struct ui_layout layout; int dirty; int scroll; /* top line; -1 follows the tail */ int clear_line; /* latest Ctrl-L display boundary */ @@ -208,124 +187,6 @@ md_attr(const struct ui *u, int md) return (a); } -/* ---- rendered-line construction ------------------------------------- */ - -static void -lines_reset(struct ui *u) -{ - size_t i, j; - - for (i = 0; i < u->nlines; i++) { - for (j = 0; j < u->lines[i].n; j++) - free(u->lines[i].spans[j].text); - free(u->lines[i].spans); - } - u->nlines = 0; -} - -static struct dline * -line_new(struct ui *u) -{ - if (u->nlines == u->lcap) { - u->lcap = u->lcap ? u->lcap * 2 : 128; - u->lines = xreallocarray(u->lines, u->lcap, sizeof(*u->lines)); - } - memset(&u->lines[u->nlines], 0, sizeof(u->lines[u->nlines])); - return (&u->lines[u->nlines++]); -} - -static void -line_add(struct dline *l, int attr, const char *text, size_t len, int width) -{ - struct span *s; - - l->spans = xreallocarray(l->spans, l->n + 1, sizeof(*l->spans)); - s = &l->spans[l->n++]; - s->attr = attr; - s->width = width; - s->len = len; - s->text = xmalloc(len + 1); - memcpy(s->text, text, len); - s->text[len] = '\0'; -} - -/* Wrap plain UTF-8 text to width columns, one attr, into new lines. */ -static void -render_plain(struct ui *u, const char *text, size_t len, int width, int attr, - const char *prefix, const char *continuation) -{ - struct dline *l; - size_t i = 0; - int col; - size_t last_break, seg_start; - const char *lead; - - if (width < 1) - width = 1; - do { - l = line_new(u); - col = 0; - lead = i == 0 ? prefix : continuation; - if (lead != NULL) { - int pw = 0; - size_t k; - - for (k = 0; lead[k] != '\0'; k++) - pw++; - line_add(l, attr, lead, strlen(lead), pw); - col = pw; - } - seg_start = i; - last_break = (size_t)-1; - { - struct buf seg; - int segw = 0; - - buf_init(&seg); - while (i < len) { - uint32_t cp; - size_t adv; - int w; - - adv = utf8_decode((const unsigned char *)text + i, - len - i, &cp); - if (cp == '\n') { /* hard break */ - i += adv; - break; - } - w = utf8_width(cp); - if (w < 0) - w = 0; - if (col + w > width && seg.len > 0) { - /* wrap: prefer the last space */ - if (last_break != (size_t)-1 && - last_break > seg_start) { - size_t back = i - last_break; - - seg.len -= back; /* trim */ - i = last_break; - /* skip the space */ - i += utf8_decode( - (const unsigned char *)text + - i, len - i, &cp); - } - break; - } - buf_add(&seg, text + i, adv); - segw += w; - if (cp == ' ') - last_break = i; - i += adv; - col += w; - } - if (seg.len > 0) - line_add(l, attr, (char *)seg.data, seg.len, - segw); - buf_free(&seg); - } - } while (i < len); -} - /* The transcript wrap width: an open drawer narrows it (2.1). */ static int wrap_width(const struct ui *u) @@ -358,7 +219,8 @@ render_diff(struct ui *u, const char *text, size_t len attr = u->color ? COLOR_PAIR(P_TOOL) : A_BOLD | A_UNDERLINE; } - render_plain(u, text + i, j - i, width, attr, NULL, NULL); + ui_layout_plain(&u->layout, text + i, j - i, width, attr, + NULL, NULL, 0); i = (j < len) ? j + 1 : j; } } @@ -372,12 +234,12 @@ render_assist(struct ui *u, const struct titem *it, in md_render((const char *)it->raw.data, it->raw.len, width, u->ascii, &d); for (i = 0; i < d.nlines; i++) { - struct dline *l = line_new(u); + struct ui_line *l = ui_layout_line(&u->layout); for (j = 0; j < d.lines[i].nspans; j++) { struct md_span *s = &d.lines[i].spans[j]; - line_add(l, md_attr(u, s->attr), s->text, s->len, + ui_layout_span(l, md_attr(u, s->attr), s->text, s->len, s->width); } } @@ -391,7 +253,7 @@ rebuild(struct ui *u) size_t i; int w = wrap_width(u); - lines_reset(u); + ui_layout_reset(&u->layout); u->clear_line = 0; for (i = 0; i < u->nitems; i++) { struct titem *it = &u->items[i]; @@ -400,30 +262,31 @@ rebuild(struct ui *u) switch (it->kind) { case IT_USER: - render_plain(u, t, n, w, - A_BOLD, "> ", NULL); + ui_layout_plain(&u->layout, t, n, w, + ui_owner_attr(u->color), "> ", " ", 1); break; case IT_ASSIST: render_assist(u, it, w); break; case IT_TOOL: - render_plain(u, t, n, w, + ui_layout_plain(&u->layout, t, n, w, u->color ? COLOR_PAIR(P_TOOL) : A_DIM, - " tool: ", " "); + " tool: ", " ", 0); break; case IT_DIFF: render_diff(u, t, n, w); break; case IT_NOTE: - render_plain(u, t, n, w, - u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL, NULL); + ui_layout_plain(&u->layout, t, n, w, + u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL, NULL, 0); break; case IT_ERROR: - render_plain(u, t, n, w, - u->color ? COLOR_PAIR(P_ERR) : A_BOLD, "! ", NULL); + ui_layout_plain(&u->layout, t, n, w, + u->color ? COLOR_PAIR(P_ERR) : A_BOLD, "! ", NULL, + 0); break; case IT_CLEAR: - u->clear_line = (int)u->nlines; + u->clear_line = (int)u->layout.nlines; break; } } @@ -502,13 +365,19 @@ transcript_height(const struct ui *u) } static void -draw_line(const struct ui *u, int row, const struct dline *l, int xoff) +draw_line(const struct ui *u, int row, const struct ui_line *l, int xoff) { size_t i; - int col = xoff; + int col = xoff, fill; - for (i = 0; i < l->n && col < u->cols; i++) { - struct span *s = &l->spans[i]; + fill = ui_layout_visible_fill(l, u->cols, xoff); + if (fill > 0) { + move(row, xoff); + attrset(l->fill_attr); + hline(' ', fill); + } + for (i = 0; i < l->nspans && col < u->cols; i++) { + struct ui_span *s = &l->spans[i]; move(row, col); attrset(s->attr); @@ -524,7 +393,7 @@ draw_input(struct ui *u, int row) struct buf disp; size_t ccol = ed_cursor_col(&u->ed); size_t start = 0, i; - int curscol; + int attr, curscol; buf_init(&disp); ed_display(&u->ed, &disp); @@ -534,8 +403,10 @@ draw_input(struct ui *u, int row) start = ccol - (u->cols - 1); curscol = (int)(ccol - start); + attr = ui_owner_attr(u->color); move(row, 0); - clrtoeol(); + attrset(attr); + hline(' ', u->cols); i = 0; { size_t scanned = 0; @@ -572,6 +443,7 @@ draw_input(struct ui *u, int row) curscol = 0; if (curscol > u->cols - 1) curscol = u->cols - 1; + attrset(A_NORMAL); move(row, curscol); /* leave the hardware cursor here */ } @@ -869,7 +741,7 @@ draw_picker(struct ui *u) static int tail_top(const struct ui *u, int height) { - int top = (int)u->nlines - height; + int top = (int)u->layout.nlines - height; if (top < u->clear_line) top = u->clear_line; @@ -899,15 +771,15 @@ draw(struct ui *u) top = u->scroll; if (top < 0) top = 0; - if (top > (int)u->nlines) - top = (int)u->nlines; + if (top > (int)u->layout.nlines) + top = (int)u->layout.nlines; erase(); for (r = 0; r < th; r++) { int li = top + r; - if (li >= 0 && li < (int)u->nlines) - draw_line(u, r, &u->lines[li], xoff); + if (li >= 0 && li < (int)u->layout.nlines) + draw_line(u, r, &u->layout.lines[li], xoff); } draw_input(u, th); draw_queue(u, th + 1); @@ -1720,6 +1592,7 @@ ui_stop(void) { if (UI == NULL) return; + ui_layout_free(&UI->layout); terminal_modes(0); endwin(); UI = NULL; @@ -1738,18 +1611,8 @@ ui_start(struct worker *w, int cols, int rows) noecho(); nonl(); curs_set(1); - if (has_colors()) { - start_color(); - use_default_colors(); - init_pair(P_HEAD, COLOR_YELLOW, -1); - init_pair(P_CODE, COLOR_CYAN, -1); - init_pair(P_ADD, COLOR_GREEN, -1); - init_pair(P_DEL, COLOR_RED, -1); - init_pair(P_TOOL, COLOR_CYAN, -1); - init_pair(P_ERR, COLOR_RED, -1); - init_pair(P_DIM, COLOR_BLUE, -1); - init_pair(P_TEXT, COLOR_WHITE, -1); - } + if (has_colors()) + ui_palette_init(); terminal_modes(1); u = xcalloc(1, sizeof(*u)); blob - /dev/null blob + 7e7e63c09138ee4872a6f61b1188b07b44368e93 (mode 644) --- /dev/null +++ src/fugu-tty/ui_layout.c @@ -0,0 +1,213 @@ +/* + * 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 +#include + +#include "buf.h" +#include "utf8.h" +#include "xmalloc.h" +#include "ui_layout.h" + +/* Width of the next visible cell, skipping leading combining characters. */ +static int +next_cell_width(const unsigned char *text, size_t len) +{ + uint32_t cp; + size_t adv, i = 0; + int w; + + while (i < len) { + adv = utf8_decode(text + i, len - i, &cp); + if (cp == '\n') + return (0); + w = utf8_width(cp); + if (w > 0) + return (w); + i += adv; + } + return (0); +} + +void +ui_layout_reset(struct ui_layout *layout) +{ + size_t i, j; + + for (i = 0; i < layout->nlines; i++) { + for (j = 0; j < layout->lines[i].nspans; j++) + free(layout->lines[i].spans[j].text); + free(layout->lines[i].spans); + } + layout->nlines = 0; +} + +void +ui_layout_free(struct ui_layout *layout) +{ + ui_layout_reset(layout); + free(layout->lines); + memset(layout, 0, sizeof(*layout)); +} + +struct ui_line * +ui_layout_line(struct ui_layout *layout) +{ + struct ui_line *line; + + if (layout->nlines == layout->cap) { + layout->cap = layout->cap ? layout->cap * 2 : 128; + layout->lines = xreallocarray(layout->lines, layout->cap, + sizeof(*layout->lines)); + } + line = &layout->lines[layout->nlines++]; + memset(line, 0, sizeof(*line)); + return (line); +} + +void +ui_layout_span(struct ui_line *line, int attr, const void *text, size_t len, + int width) +{ + struct ui_span *span; + + line->spans = xreallocarray(line->spans, line->nspans + 1, + sizeof(*line->spans)); + span = &line->spans[line->nspans++]; + span->attr = attr; + span->width = width; + span->len = len; + span->text = xmalloc(len + 1); + memcpy(span->text, text, len); + span->text[len] = '\0'; +} + +/* 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) +{ + const unsigned char *text = vtext; + struct ui_line *line; + size_t i = 0; + int col, first = 1; + const char *lead; + + if (width < 1) + width = 1; + do { + struct buf seg; + size_t last_break, break_len, leadlen; + int segw, break_width, lead_limit, need; + + line = ui_layout_line(layout); + if (fill) { + line->fill_attr = attr; + line->fill_width = width; + } + col = 0; + lead = first ? prefix : continuation; + if (lead != NULL) { + leadlen = strlen(lead); + lead_limit = width; + if (!first && i < len) { + need = next_cell_width(text + i, len - i); + if (need > width) + need = width; + if (need > 0) + lead_limit = width - need; + } + if (leadlen > (size_t)lead_limit) + leadlen = (size_t)lead_limit; + + if (leadlen > 0) { + ui_layout_span(line, attr, lead, leadlen, + (int)leadlen); + col = (int)leadlen; + } + } + first = 0; + + buf_init(&seg); + segw = 0; + last_break = (size_t)-1; + break_len = 0; + break_width = 0; + while (i < len) { + uint32_t cp; + size_t adv; + int w; + + adv = utf8_decode(text + i, len - i, &cp); + if (cp == '\n') { + i += adv; + break; + } + w = utf8_width(cp); + if (w < 0) + w = 0; + if (col + w > width) { + if (segw > 0) { + if (last_break != (size_t)-1) { + seg.len = break_len; + segw = break_width; + i = last_break; + i += utf8_decode(text + i, + len - i, &cp); + } + break; + } + if (col > 0) + break; + /* One glyph cannot fit this pane at all. */ + buf_addc(&seg, '?'); + segw = col = 1; + i += adv; + continue; + } + buf_add(&seg, text + i, adv); + segw += w; + col += w; + if (cp == ' ' && seg.len > adv) { + last_break = i; + break_len = seg.len - adv; + break_width = segw - w; + } + i += adv; + } + if (seg.len > 0) + ui_layout_span(line, attr, seg.data, seg.len, segw); + buf_free(&seg); + } while (i < len); +} + +int +ui_layout_visible_fill(const struct ui_line *line, int cols, int xoff) +{ + int available, fill; + + available = cols - xoff; + if (available <= 0 || line->fill_width <= 0) + return (0); + fill = line->fill_width; + if (fill > available) + fill = available; + return (fill); +} blob - /dev/null blob + 85c490f435a7c16cfa3993077783f5fb53324520 (mode 644) --- /dev/null +++ src/fugu-tty/ui_layout.h @@ -0,0 +1,51 @@ +/* + * 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 UI_LAYOUT_H +#define UI_LAYOUT_H + +#include + +struct ui_span { + int attr; + char *text; + size_t len; + int width; +}; + +struct ui_line { + struct ui_span *spans; + size_t nspans; + int fill_attr; + int fill_width; +}; + +struct ui_layout { + struct ui_line *lines; + size_t nlines; + size_t cap; +}; + +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, + int); +void ui_layout_plain(struct ui_layout *, const void *, size_t, int, + int, const char *, const char *, int); +int ui_layout_visible_fill(const struct ui_line *, int, int); + +#endif /* UI_LAYOUT_H */ blob - /dev/null blob + e7d9863b6f3d1952791f34d8555e14c3e2822a62 (mode 644) --- /dev/null +++ src/fugu-tty/ui_palette.c @@ -0,0 +1,41 @@ +/* + * 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 "ui_palette.h" + +void +ui_palette_init(void) +{ + start_color(); + use_default_colors(); + init_pair(P_HEAD, COLOR_YELLOW, -1); + init_pair(P_CODE, COLOR_CYAN, -1); + init_pair(P_ADD, COLOR_GREEN, -1); + init_pair(P_DEL, COLOR_RED, -1); + init_pair(P_TOOL, COLOR_CYAN, -1); + init_pair(P_ERR, COLOR_RED, -1); + init_pair(P_DIM, COLOR_BLUE, -1); + init_pair(P_TEXT, COLOR_WHITE, -1); + init_pair(P_OWNER, COLOR_BLACK, COLOR_WHITE); +} + +int +ui_owner_attr(int color) +{ + return (A_BOLD | (color ? COLOR_PAIR(P_OWNER) : A_REVERSE)); +} blob - /dev/null blob + e49ac5a61b752f0d1b47c30dd6c30d32e7aede06 (mode 644) --- /dev/null +++ src/fugu-tty/ui_palette.h @@ -0,0 +1,35 @@ +/* + * 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 UI_PALETTE_H +#define UI_PALETTE_H + +enum ui_pair { + P_HEAD = 1, + P_CODE, + P_ADD, + P_DEL, + P_TOOL, + P_ERR, + P_DIM, + P_TEXT, + P_OWNER +}; + +void ui_palette_init(void); +int ui_owner_attr(int); + +#endif /* UI_PALETTE_H */