diff --git a/CMakeLists.txt b/CMakeLists.txt index c192efd..2a83f8a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,7 +80,7 @@ set(CST_CORE_SOURCES set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c) if(CST_BUILD_TUI) - list(APPEND CST_SOURCES src/tui.c) + list(APPEND CST_SOURCES src/textsel.c src/tui.c) else() list(APPEND CST_SOURCES src/tui_stub.c) endif() @@ -151,9 +151,17 @@ endif() # --- tests --- enable_testing() set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline - test_replay_starvation test_replay_real test_mm_tool) + test_replay_starvation test_replay_real test_mm_tool test_textsel + test_markdown) foreach(t ${_CST_SMALL_TESTS}) - if(t STREQUAL test_ctl_frontier OR t STREQUAL test_ctl_heredoc) + if(t STREQUAL test_textsel) + add_executable(${t} tests/${t}.c src/textsel.c) + elseif(t STREQUAL test_markdown) + add_executable(${t} tests/${t}.c) + target_include_directories(${t} PRIVATE "${SD_DIR}/include") + target_link_libraries(${t} PRIVATE "${SASHIDOWN_A}") + add_dependencies(${t} sashidown) + elseif(t STREQUAL test_ctl_frontier OR t STREQUAL test_ctl_heredoc) add_executable(${t} tests/${t}.c src/ctl_parse.c) elseif(t STREQUAL test_json_baseline) add_executable(${t} tests/${t}.c src/json_baseline.c vendor/cjson/cJSON.c) diff --git a/Makefile b/Makefile index b572109..c9a2cdb 100644 --- a/Makefile +++ b/Makefile @@ -80,7 +80,7 @@ CORE_SRC = \ src/models.c \ src/metrics.c -SRC = $(CORE_SRC) src/tui.c src/main.c +SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c OBJ = $(SRC:.c=.o) HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h @@ -104,6 +104,8 @@ TESTS = \ tests/test_models \ tests/test_sandbox \ tests/test_keymap \ + tests/test_textsel \ + tests/test_markdown \ tests/test_shell_compact \ tests/test_skills \ tests/test_plugins \ @@ -161,6 +163,12 @@ tests/test_replay_real: tests/test_replay_real.c src/ctl_parse.c src/json_baseli tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c +tests/test_textsel: tests/test_textsel.c src/textsel.c include/cst_textsel.h + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_textsel.c src/textsel.c + +tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm + tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS) $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS) diff --git a/README.md b/README.md index 7d5a7f3..ea7c125 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,8 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。 | Ctrl+X t / d / s | 全部 Thought 默认展开或折叠 / 全部 tool 输出 / 侧栏 | | Ctrl+X a / k / g | 子 agent 面板 / skills / 插件 | | Ctrl+X h、F1 / Ctrl+X q | 快捷键列表 / 退出 | -| 鼠标点击 | 展开/折叠 tool 卡片和 Thought、在输入框里定位光标、点模式行切换 Build/Plan、在选择框里选中(`/mouse` 关闭后可用终端原生选择复制) | +| 鼠标点击 | 展开/折叠 tool 卡片和 Thought、在输入框里定位光标、点模式行切换 Build/Plan、在选择框里选中 | +| 鼠标拖动 / 双击 / 三击 | 在对话区选中文字(按字符 / 词 / 行),松开即通过 OSC 52 复制到剪贴板;拖到上下边缘会自动滚动。Shift+拖动或 `/mouse` 关闭后用终端原生选择 | | Esc | 取消当前 turn(不会退出) | | Ctrl+C 两次 / 空输入 Ctrl+D | 退出 | diff --git a/docs/compat.md b/docs/compat.md index 5b7a387..57baf24 100644 --- a/docs/compat.md +++ b/docs/compat.md @@ -81,9 +81,15 @@ take the agent down. - UTF-8 locale gives the full UI. Under a non-UTF-8 locale, or with `CST_ASCII=1`, box and icon glyphs fall back to ASCII. - `NO_COLOR=1` turns colours off. -- `/copy` uses OSC 52. iTerm2 needs "Applications in terminal may access - clipboard"; tmux needs `set -g set-clipboard on`; Windows Terminal (WSL) - supports it out of the box. +- `/copy` and mouse selection use OSC 52. iTerm2 needs "Applications in + terminal may access clipboard"; tmux needs `set -g set-clipboard on`; + Windows Terminal (WSL), kitty, WezTerm, foot and WHC Shell support it out of + the box. +- Mouse selection: the TUI turns on button-motion reporting (`?1002h`) on top + of what ncurses enables, so dragging over the transcript selects text + (double-click a word, triple-click a line) and releasing copies it. Terminals + without 1002 still get clicks and the wheel. Shift+drag bypasses the TUI and + uses the terminal's own selection. - Bracketed paste needs ncurses (all platforms above). - macOS Terminal.app sends Option+Enter as Esc+Enter only with "Use Option as Meta key"; Ctrl+N always inserts a newline. @@ -128,6 +134,9 @@ TUI: - [ ] `/diff`, `/export`, `/status`, `/tools off grep`, `/yolo` work - [ ] Mouse: click a tool card to expand it, click in the input to move the cursor, click a picker row; `/mouse` turns it off +- [ ] Drag over a wrapped reply: it highlights, the status row says "Copied", + and pasting gives the paragraph without extra line breaks; double-click + copies a word - [ ] Tab toggles Build/Plan, Shift+Tab cycles ask/auto/yolo, Ctrl+P opens the palette - [ ] `/model` lists the provider's `/models` (marked `(api)`); a wrong key is re-asked - [ ] `auto` mode: `echo x > ~/outside` in run_shell asks "Rerun outside the diff --git a/include/cst_textsel.h b/include/cst_textsel.h new file mode 100644 index 0000000..393b5df --- /dev/null +++ b/include/cst_textsel.h @@ -0,0 +1,41 @@ +#ifndef CST_TEXTSEL_H +#define CST_TEXTSEL_H + +#include +#include + +/* UTF-8 / ANSI helpers shared by the TUI renderer and mouse selection, so both + * agree on which character occupies each column. */ +int cst_utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten); +/* wcwidth(), with an East-Asian / ASCII heuristic on Windows. */ +int cst_wcwidth(wchar_t wc); +/* Columns of wc; 1 for non-printing characters. */ +int cst_utf8_width(wchar_t wc); +/* Length of the escape sequence at s (CSI or two-byte ESC), 0 if none. */ +size_t cst_ansi_skip(const char *s, size_t n); + +typedef struct { + char *s; /* NUL-terminated once anything was appended */ + size_t n, cap; +} cst_selbuf_t; + +void cst_selbuf_free(cst_selbuf_t *b); +int cst_selbuf_putc(cst_selbuf_t *b, char c); + +/* Appends the text drawn in columns [c1, c2] of a row whose content `line` + * (ANSI escapes allowed) starts at column `indent`; c2 < 0 means the end of + * the row. A wide character is taken whole when either of its cells is in + * range. With `trim`, trailing spaces of the appended part are dropped. + * Returns 0, or -1 when out of memory. */ +int cst_textsel_append(cst_selbuf_t *b, const char *line, int indent, int c1, + int c2, int trim); + +/* Column span [*c1, *c2] of the word under column `col`; just that cell (or + * both cells of a wide character) when it is not on a word character. */ +void cst_textsel_word(const char *line, int indent, int col, int *c1, int *c2); + +/* Widens [*c1, *c2] so that neither end splits a wide character; highlighting + * half of one desynchronizes curses from the terminal. *c2 < 0 is left as is. */ +void cst_textsel_snap(const char *line, int indent, int *c1, int *c2); + +#endif diff --git a/include/cst_version.h b/include/cst_version.h index 9c7e088..75d6277 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -1,6 +1,6 @@ #ifndef CST_VERSION_H #define CST_VERSION_H -#define CST_VERSION "0.4.0" +#define CST_VERSION "0.5.0" #endif diff --git a/src/textsel.c b/src/textsel.c new file mode 100644 index 0000000..0be8583 --- /dev/null +++ b/src/textsel.c @@ -0,0 +1,194 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE /* wcwidth */ +#endif + +#include "cst_textsel.h" + +#include +#include +#include + +int cst_utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) { + if (avail == 0) + return -1; + mbstate_t st; + memset(&st, 0, sizeof(st)); + size_t n = mbrtowc(wc, s, avail, &st); + if (n == (size_t)-1 || n == (size_t)-2 || n == 0) { + *wc = (unsigned char)s[0]; + *eaten = 1; + return 0; + } + *eaten = n; + return 0; +} + +int cst_wcwidth(wchar_t wc) { +#ifdef _WIN32 + unsigned long c = (unsigned long)wc; + if (c == 0) + return 0; + if (c < 32 || (c >= 0x7f && c < 0xa0)) + return -1; + if (c >= 0x1100 && + (c <= 0x115f || c == 0x2329 || c == 0x232a || + (c >= 0x2e80 && c <= 0xa4cf && c != 0x303f) || + (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || + (c >= 0xfe10 && c <= 0xfe19) || (c >= 0xfe30 && c <= 0xfe6f) || + (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6) || + (c >= 0x20000 && c <= 0x2fffd) || (c >= 0x30000 && c <= 0x3fffd))) + return 2; + return 1; +#else + return wcwidth(wc); +#endif +} + +int cst_utf8_width(wchar_t wc) { + int w = cst_wcwidth(wc); + if (w < 0) + return 1; + return w; +} + +size_t cst_ansi_skip(const char *s, size_t n) { + if (n < 2 || (unsigned char)s[0] != 0x1b) + return 0; + if (s[1] == '[') { + size_t i = 2; + while (i < n) { + unsigned char c = (unsigned char)s[i++]; + if (c >= 0x40 && c <= 0x7e) + return i; + } + return 0; + } + return 2; +} + +void cst_selbuf_free(cst_selbuf_t *b) { + free(b->s); + b->s = NULL; + b->n = b->cap = 0; +} + +static int selbuf_put(cst_selbuf_t *b, const char *p, size_t n) { + if (b->n + n + 1 > b->cap) { + size_t nc = b->cap ? b->cap * 2 : 256; + while (nc < b->n + n + 1) + nc *= 2; + char *ns = realloc(b->s, nc); + if (!ns) + return -1; + b->s = ns; + b->cap = nc; + } + memcpy(b->s + b->n, p, n); + b->n += n; + b->s[b->n] = 0; + return 0; +} + +int cst_selbuf_putc(cst_selbuf_t *b, char c) { return selbuf_put(b, &c, 1); } + +/* One drawn character: its bytes in the line and the columns it covers. */ +typedef struct { + size_t off, len; + int col, width; + wchar_t wc; +} cell_t; + +/* Advances to the next drawn character of line, skipping escapes. */ +static int next_cell(const char *line, size_t n, size_t *i, int *col, + cell_t *c) { + while (*i < n) { + size_t skip = cst_ansi_skip(line + *i, n - *i); + if (skip) { + *i += skip; + continue; + } + size_t eaten; + cst_utf8_decode(line + *i, n - *i, &c->wc, &eaten); + c->off = *i; + c->len = eaten; + c->col = *col; + c->width = cst_utf8_width(c->wc); + *i += eaten; + *col += c->width; + return 1; + } + return 0; +} + +int cst_textsel_append(cst_selbuf_t *b, const char *line, int indent, int c1, + int c2, int trim) { + size_t n = line ? strlen(line) : 0, i = 0; + size_t start = b->n; + int col = indent; + cell_t c; + while (next_cell(line, n, &i, &col, &c)) { + if (c2 >= 0 && c.col > c2) + break; + if (c.width == 0 || c.col + c.width - 1 < c1) + continue; + if (selbuf_put(b, line + c.off, c.len) < 0) + return -1; + } + if (trim) + while (b->n > start && b->s[b->n - 1] == ' ') + b->s[--b->n] = 0; + return 0; +} + +static int is_word(wchar_t wc) { + return iswalnum((wint_t)wc) || (wc < 128 && wc && strchr("_-./:@~%+=?&#$", (int)wc)); +} + +void cst_textsel_snap(const char *line, int indent, int *c1, int *c2) { + size_t n = line ? strlen(line) : 0, i = 0; + int x = indent; + cell_t c; + while (next_cell(line, n, &i, &x, &c)) { + if (c.width < 2) + continue; + int last = c.col + c.width - 1; + if (*c1 > c.col && *c1 <= last) + *c1 = c.col; + if (*c2 >= c.col && *c2 < last) + *c2 = last; + } +} + +void cst_textsel_word(const char *line, int indent, int col, int *c1, int *c2) { + *c1 = *c2 = col; + size_t n = line ? strlen(line) : 0, i = 0; + int x = indent; + cell_t c; + int run_start = -1, in_run = 0; + while (next_cell(line, n, &i, &x, &c)) { + if (c.width == 0) + continue; + int word = is_word(c.wc); + if (word && !in_run) + run_start = c.col; + in_run = word; + if (col >= c.col && col < c.col + c.width) { + if (!word) { + *c1 = c.col; + *c2 = c.col + c.width - 1; + return; + } + *c1 = run_start; + *c2 = c.col + c.width - 1; + cell_t d; + while (next_cell(line, n, &i, &x, &d)) { + if (d.width == 0) + continue; + if (!is_word(d.wc)) + break; + *c2 = d.col + d.width - 1; + } + return; + } + } +} diff --git a/src/tui.c b/src/tui.c index e2c8ad9..2a1866a 100644 --- a/src/tui.c +++ b/src/tui.c @@ -10,6 +10,7 @@ #include "cst_plugins.h" #include "cst_skills.h" #include "cst_subagent.h" +#include "cst_textsel.h" #include "cst_perms.h" #include "cst_sandbox.h" #include "cst_tools.h" @@ -61,23 +62,7 @@ static int g_utf8 = 1; /* locale can show non-ASCII text */ #ifndef KEY_RESIZE #define KEY_RESIZE 0632 #endif -/* MinGW often lacks wcwidth; simple East-Asian / ASCII heuristic. */ -static int cst_wcwidth(wchar_t wc) { - if (wc == 0) - return 0; - if (wc < 32 || (wc >= 0x7f && wc < 0xa0)) - return -1; - if (wc >= 0x1100 && - (wc <= 0x115f || wc == 0x2329 || wc == 0x232a || - (wc >= 0x2e80 && wc <= 0xa4cf && wc != 0x303f) || - (wc >= 0xac00 && wc <= 0xd7a3) || (wc >= 0xf900 && wc <= 0xfaff) || - (wc >= 0xfe10 && wc <= 0xfe19) || (wc >= 0xfe30 && wc <= 0xfe6f) || - (wc >= 0xff00 && wc <= 0xff60) || (wc >= 0xffe0 && wc <= 0xffe6) || - (wc >= 0x20000 && wc <= 0x2fffd) || (wc >= 0x30000 && wc <= 0x3fffd))) - return 2; - return 1; -} -#define wcwidth cst_wcwidth +#define wcwidth cst_wcwidth /* MinGW often lacks wcwidth */ #else #include #endif @@ -139,6 +124,12 @@ static int cst_clock_gettime_mono(struct timespec *ts) { #define KEY_KITTY_REPLY (KEY_OWN_BASE + 3) #define KEY_KITTY_BASE (KEY_OWN_BASE + 16) #define KITTY_CP_END 128 +/* Transcript selection states. */ +#define SEL_NONE 0 +#define SEL_PRESS 1 /* button down, not moved off the pressed cell yet */ +#define SEL_DRAG 2 +#define SEL_SHOWN 3 /* released; highlighted until the next input */ +#define MULTI_CLICK_MS 400 #ifdef NCURSES_VERSION static int g_kitty_on; @@ -301,6 +292,15 @@ typedef struct { int row_blk_cap; int cmd_x0, cmd_x1; /* "ctrl+p commands" in the status row */ int mouse_on; + int log_start; /* display line shown in logw row 0 */ + /* Mouse selection in the transcript: display line + logw column. */ + int sel_state; /* SEL_* */ + int sel_mode; /* 1 characters, 2 words, 3 lines */ + int sel_ay, sel_ax; /* anchor */ + int sel_y, sel_x; /* current end */ + int sel_press_y, sel_press_x; /* screen cell of the press */ + int sel_clicks, sel_click_y, sel_click_x; + struct timespec sel_click_at; const char *tip; const char *sandbox_name; /* "landlock" / "seatbelt" / "rules" */ char where[300]; /* ~/path:branch */ @@ -404,26 +404,10 @@ static size_t utf8_prev_len(const char *s, size_t len) { } static int utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) { - if (avail == 0) - return -1; - mbstate_t st; - memset(&st, 0, sizeof(st)); - size_t n = mbrtowc(wc, s, avail, &st); - if (n == (size_t)-1 || n == (size_t)-2 || n == 0) { - *wc = (unsigned char)s[0]; - *eaten = 1; - return 0; - } - *eaten = n; - return 0; + return cst_utf8_decode(s, avail, wc, eaten); } -static int utf8_width(wchar_t wc) { - int w = wcwidth(wc); - if (w < 0) - return 1; - return w; -} +static int utf8_width(wchar_t wc) { return cst_utf8_width(wc); } static int utf8_cols(const char *s, size_t n) { int cols = 0; @@ -454,20 +438,7 @@ static size_t utf8_fit_cols(const char *s, size_t n, int max_cols) { return i; } -static size_t ansi_skip(const char *s, size_t n) { - if (n < 2 || (unsigned char)s[0] != 0x1b) - return 0; - if (s[1] == '[') { - size_t i = 2; - while (i < n) { - unsigned char c = (unsigned char)s[i++]; - if (c >= 0x40 && c <= 0x7e) - return i; - } - return 0; - } - return 2; -} +static size_t ansi_skip(const char *s, size_t n) { return cst_ansi_skip(s, n); } static char *xstrdup(const char *s) { if (!s) @@ -1272,6 +1243,8 @@ typedef struct { int fill; /* pair painted across the whole row (0 = none) */ int bar; /* pair for a "┃" in column 0 (0 = none) */ int blk; /* hist block index, -1 for live / none */ + int cont; /* soft-wrapped continuation of the previous row */ + int cont_pad; /* leading columns of a continuation that only indent */ } dline_t; static int g_dl_blk = -1; /* block being flattened, stamped into dline_t */ @@ -1311,6 +1284,8 @@ static int dlines_push(dlines_t *dl, const char *text, int pair, int bold, L->fill = 0; L->bar = 0; L->blk = g_dl_blk; + L->cont = 0; + L->cont_pad = 0; return 0; } @@ -1341,10 +1316,10 @@ static void push_wrapped(dlines_t *dl, const char *prefix, const char *body, /* Walk body; for ANSI text, wrap on visible columns skipping SGR. */ size_t n = strlen(body); size_t i = 0; - int first = 1; + int first = 1, soft = 0; while (i < n && dl->n < MAX_RENDER_LINES) { size_t start = i; - int col = 0; + int col = 0, pad = 0; while (i < n) { size_t sg = 0; if ((unsigned char)body[i] == 0x1b) { @@ -1387,7 +1362,7 @@ static void push_wrapped(dlines_t *dl, const char *prefix, const char *body, first = 0; } else { /* continuation indent matching prefix visual width */ - int pad = pref_cols; + pad = pref_cols; if (pad < 0) pad = 0; if (pad > 40) @@ -1400,10 +1375,11 @@ static void push_wrapped(dlines_t *dl, const char *prefix, const char *body, line[pad] = 0; } } - dlines_push(dl, line, pair, bold, dim); - if (i < n && body[i - 1] != '\n' && col >= body_w) { - /* soft wrap — already advanced */ + if (dlines_push(dl, line, pair, bold, dim) == 0 && soft) { + dl->lines[dl->n - 1].cont = 1; + dl->lines[dl->n - 1].cont_pad = pad; } + soft = body[i - 1] != '\n'; } } @@ -2338,7 +2314,7 @@ static const char *const TIPS[] = { "/undo reverts the file changes of the last turn", "/provider switches provider; keys are saved per provider", "Click a tool card or Thought to expand it", - "Shift+drag selects text while mouse support is on", + "Drag over the transcript to copy it; double-click copies a word", }; static void draw_home(tui_t *t) { @@ -2548,6 +2524,57 @@ static void draw_side(tui_t *t) { wrefresh(s); } +/* Ordered selection [y1:x1, y2:x2] over display lines, snapped to words or + * whole lines by the click count; x2 < 0 means the end of the line. */ +static void sel_range(const tui_t *t, const dlines_t *dl, int *y1, int *x1, + int *y2, int *x2) { + int ay = t->sel_ay, ax = t->sel_ax, by = t->sel_y, bx = t->sel_x; + if (by < ay || (by == ay && bx < ax)) { + int ty = ay, tx = ax; + ay = by, ax = bx, by = ty, bx = tx; + } + if (t->sel_mode == 2) { + int c1, c2; + if (ay >= 0 && ay < dl->n) { + cst_textsel_word(dl->lines[ay].text, dl->lines[ay].indent, ax, &c1, &c2); + ax = c1; + } + if (by >= 0 && by < dl->n) { + cst_textsel_word(dl->lines[by].text, dl->lines[by].indent, bx, &c1, &c2); + bx = c2; + } + } else if (t->sel_mode == 3) { + while (ay > 0 && ay < dl->n && dl->lines[ay].cont) + ay--; + while (by + 1 < dl->n && dl->lines[by + 1].cont) + by++; + ax = 0; + bx = -1; + } + *y1 = ay, *x1 = ax, *y2 = by, *x2 = bx; +} + +static void draw_selection(tui_t *t, const dlines_t *dl, int h, int w) { + if (t->sel_state != SEL_DRAG && t->sel_state != SEL_SHOWN) + return; + int y1, x1, y2, x2; + sel_range(t, dl, &y1, &x1, &y2, &x2); + for (int row = 0; row < h; row++) { + int li = t->log_start + row; + if (li < y1 || li > y2) + continue; + int a = li == y1 ? x1 : 0; + int b = li == y2 && x2 >= 0 ? x2 : w - 1; + if (li < dl->n) + cst_textsel_snap(dl->lines[li].text, dl->lines[li].indent, &a, &b); + if (b >= w) + b = w - 1; + if (a <= b) + mvwchgat(t->logw, row, a, b - a + 1, A_REVERSE, + HAS_COLOR() ? CP_PANEL : 0, NULL); + } +} + static void draw_log(tui_t *t) { if (!t->logw) return; @@ -2581,6 +2608,7 @@ static void draw_log(tui_t *t) { int start = total - h - t->log_scroll; if (start < 0) start = 0; + t->log_start = start; if (t->row_blk_cap < h) { int *nb = realloc(t->row_blk, (size_t)h * sizeof(int)); @@ -2604,6 +2632,7 @@ static void draw_log(tui_t *t) { if (L->bar) put_wc(t->logw, row, 0, L'┃', HAS_COLOR() ? (int)COLOR_PAIR(L->bar) : 0); } + draw_selection(t, &dl, h, w); if (t->log_scroll > 0 && h > 2) { char more[128]; snprintf(more, sizeof(more), @@ -2777,8 +2806,17 @@ static int input_visual_rows(const tui_t *t) { } static void apply_mouse(const tui_t *t) { - mousemask(t->mouse_on ? (mmask_t)ALL_MOUSE_EVENTS : 0, NULL); + mousemask(t->mouse_on ? (mmask_t)(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION) + : 0, + NULL); mouseinterval(0); +#ifdef NCURSES_VERSION + /* ncurses only asks for presses and releases (1000). 1002 also reports + * motion while a button is held, for drag selection; terminals treat it as + * replacing 1000, so it goes after mousemask and is turned off explicitly. */ + printf(t->mouse_on ? "\033[?1002h" : "\033[?1002l"); + fflush(stdout); +#endif } static void free_windows(tui_t *t) { @@ -3919,7 +3957,7 @@ static void cmd_help(tui_t *t, char *arg, int *quit) { push_system_line(t, " Enter send · Esc cancel turn · Ctrl+C twice or Ctrl+D " "quit · Ctrl+P command palette"); push_system_line(t, " Mouse: click cards / Thought to expand, wheel scrolls, " - "Shift+drag selects · /mouse"); + "drag copies (double-click word, triple line) · /mouse"); push_system_line(t, " Rebind in keybinds.json next to settings.json"); push_system_line(t, ""); } @@ -4346,8 +4384,10 @@ static void cmd_mouse(tui_t *t, char *arg, int *quit) { (void)quit; t->mouse_on = !t->mouse_on; apply_mouse(t); - push_system_line(t, t->mouse_on ? "(mouse on · Shift+drag selects text)" - : "(mouse off · the terminal selects text)"); + push_system_line(t, t->mouse_on + ? "(mouse on · drag selects and copies (OSC 52) · " + "Shift+drag uses the terminal's own selection)" + : "(mouse off · the terminal selects text)"); } static void cmd_max_steps(tui_t *t, char *arg, int *quit) { @@ -4533,25 +4573,16 @@ static void cmd_export(tui_t *t, char *arg, int *quit) { push_system_line(t, "(exported to %s)", path); } -static void cmd_copy(tui_t *t, char *arg, int *quit) { - (void)arg; - (void)quit; - const char *text = NULL; - for (int i = t->hist.n - 1; i >= 0 && !text; i--) - if (t->hist.items[i].kind == BLK_ASSISTANT && t->hist.items[i].body) - text = t->hist.items[i].body; - if (!text || !text[0]) { - push_system_line(t, "Nothing to copy yet"); - return; - } +/* Puts text on the terminal's clipboard; returns the bytes sent, 0 on + * failure. */ +static size_t osc52_copy(const char *text, size_t n) { static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - size_t n = strlen(text); if (n > 75000) n = 75000; /* many terminals cap OSC 52 near 100 KB */ char *enc = malloc((n + 2) / 3 * 4 + 1); if (!enc) - return; + return 0; size_t o = 0; for (size_t i = 0; i < n; i += 3) { unsigned v = (unsigned char)text[i] << 16; @@ -4571,8 +4602,24 @@ static void cmd_copy(tui_t *t, char *arg, int *quit) { printf("\033]52;c;%s\a", enc); fflush(stdout); free(enc); - push_system_line(t, "(copied %zu bytes of the last reply via OSC 52 — the " - "terminal must allow clipboard access)", n); + return n; +} + +static void cmd_copy(tui_t *t, char *arg, int *quit) { + (void)arg; + (void)quit; + const char *text = NULL; + for (int i = t->hist.n - 1; i >= 0 && !text; i--) + if (t->hist.items[i].kind == BLK_ASSISTANT && t->hist.items[i].body) + text = t->hist.items[i].body; + if (!text || !text[0]) { + push_system_line(t, "Nothing to copy yet"); + return; + } + size_t n = osc52_copy(text, strlen(text)); + if (n) + push_system_line(t, "(copied %zu bytes of the last reply via OSC 52 — the " + "terminal must allow clipboard access)", n); } static void cmd_init(tui_t *t, char *arg, int *quit) { @@ -4906,7 +4953,7 @@ static const slash_cmd_t SLASH[] = { 1, cmd_approval}, {"yolo", "", "", "toggle approving everything", 1, cmd_yolo}, {"sidebar", "", "", "show or hide the session sidebar", 1, cmd_sidebar}, - {"mouse", "", "", "toggle mouse support (Shift+drag selects)", 1, + {"mouse", "", "", "toggle mouse support (drag copies)", 1, cmd_mouse}, {"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps}, {"status", "", "", "session, model, context and settings", 1, cmd_status}, @@ -5437,6 +5484,145 @@ static int handle_click(tui_t *t, int y, int x, int busy) { return 0; } +/* Transcript cell under screen (y, x) as display line + column. Outside the + * transcript returns 0, unless `clamp` pulls the point to its nearest edge. */ +static int log_cell(const tui_t *t, int y, int x, int clamp, int *li, + int *col) { + if (t->home || !t->logw) + return 0; + int lh, lw; + getmaxyx(t->logw, lh, lw); + int ry = y - t->log_y, rx = x - t->log_x; + if (!clamp && (ry < 0 || ry >= lh || rx < 0 || rx >= lw)) + return 0; + *li = t->log_start + clampi(ry, 0, lh - 1); + *col = clampi(rx, 0, lw - 1); + return 1; +} + +static void sel_clear(tui_t *t) { + int shown = t->sel_state == SEL_DRAG || t->sel_state == SEL_SHOWN; + t->sel_state = SEL_NONE; + if (shown) + draw_log(t); +} + +static void sel_copy(tui_t *t) { + int h, w; + getmaxyx(t->logw, h, w); + (void)h; + dlines_t dl = {0}; + build_display(t, &dl, w); + int y1, x1, y2, x2; + sel_range(t, &dl, &y1, &x1, &y2, &x2); + cst_selbuf_t b = {0}; + for (int k = y1 < 0 ? 0 : y1; k <= y2 && k < dl.n; k++) { + const dline_t *L = &dl.lines[k]; + int c1 = k == y1 ? x1 : 0, c2 = k == y2 ? x2 : -1; + if (k > y1) { + if (!L->cont) + cst_selbuf_putc(&b, '\n'); + else if (c1 < L->indent + L->cont_pad) + c1 = L->indent + L->cont_pad; + } + int joined = k < y2 && k + 1 < dl.n && dl.lines[k + 1].cont; + cst_textsel_append(&b, L->text, L->indent, c1, c2, !joined); + } + dlines_free(&dl); + if (b.n && osc52_copy(b.s, b.n)) { + size_t chars = 0; + for (size_t i = 0; i < b.n; i++) + chars += ((unsigned char)b.s[i] & 0xC0) != 0x80; + char msg[96]; + snprintf(msg, sizeof(msg), "Copied %zu characters (OSC 52)", chars); + set_status(t, msg); + draw_status(t); + } + cst_selbuf_free(&b); +} + +/* Drag while the button is held: extend the selection, scrolling the + * transcript when the pointer is above or below it. */ +static void sel_motion(tui_t *t, int y, int x) { + int lh, lw; + getmaxyx(t->logw, lh, lw); + (void)lw; + int ry = y - t->log_y; + if (ry < 0 || ry >= lh) { + int before = t->log_scroll; + t->log_scroll += ry < 0 ? 1 : -1; + if (t->log_scroll < 0) + t->log_scroll = 0; + if (t->log_scroll != before) + draw_log(t); /* updates log_start */ + } + int li, col; + if (!log_cell(t, y, x, 1, &li, &col)) + return; + if (t->sel_state == SEL_PRESS && li == t->sel_ay && col == t->sel_ax) + return; + t->sel_state = SEL_DRAG; + t->sel_y = li; + t->sel_x = col; + draw_log(t); +} + +/* Wheel, clicks and drag selection; -2 means quit. */ +static int on_mouse(tui_t *t, const MEVENT *ev, int busy) { + mmask_t b = ev->bstate; + if (b & BUTTON4_PRESSED) { + t->log_scroll += SCROLL_SPEED; + draw_log(t); + return 0; + } + if (b & BUTTON5_PRESSED) { + t->log_scroll -= SCROLL_SPEED; + if (t->log_scroll < 0) + t->log_scroll = 0; + draw_log(t); + return 0; + } + if (b & REPORT_MOUSE_POSITION) { + if (t->sel_state == SEL_PRESS || t->sel_state == SEL_DRAG) + sel_motion(t, ev->y, ev->x); + return 0; + } + if (b & (BUTTON1_PRESSED | BUTTON1_CLICKED)) { + sel_clear(t); + int li, col; + if (!(b & BUTTON1_PRESSED) || !log_cell(t, ev->y, ev->x, 0, &li, &col)) + return handle_click(t, ev->y, ev->x, busy); + if (t->sel_clicks && since_ms(&t->sel_click_at) < MULTI_CLICK_MS && + li == t->sel_click_y && abs(col - t->sel_click_x) <= 1) + t->sel_clicks = t->sel_clicks % 3 + 1; + else + t->sel_clicks = 1; + clock_gettime(CLOCK_MONOTONIC, &t->sel_click_at); + t->sel_click_y = li; + t->sel_click_x = col; + t->sel_mode = t->sel_clicks; + t->sel_ay = t->sel_y = li; + t->sel_ax = t->sel_x = col; + t->sel_press_y = ev->y; + t->sel_press_x = ev->x; + t->sel_state = t->sel_clicks > 1 ? SEL_DRAG : SEL_PRESS; + if (t->sel_state == SEL_DRAG) + draw_log(t); + return 0; + } + if (b & BUTTON1_RELEASED) { + if (t->sel_state == SEL_PRESS) { + t->sel_state = SEL_NONE; + return handle_click(t, t->sel_press_y, t->sel_press_x, busy); + } + if (t->sel_state == SEL_DRAG) { + sel_copy(t); + t->sel_state = SEL_SHOWN; + } + } + return 0; +} + /* Tab completes after "/" at the start or an "@word" under the cursor. */ static int wants_completion(const tui_t *t) { if (t->in_len && t->inbuf[0] == '/') @@ -6192,6 +6378,9 @@ int cst_tui_run(cst_agent_t *agent) { int rc = tui_wget_wch(t.input, &wch); if (rc == ERR) continue; + if (t.sel_state == SEL_SHOWN && + !(rc == KEY_CODE_YES && (wch == KEY_MOUSE || wch == KEY_RESIZE))) + sel_clear(&t); if (!t.in_paste && !(rc == KEY_CODE_YES && @@ -6263,6 +6452,7 @@ int cst_tui_run(cst_agent_t *agent) { continue; } if (wch == KEY_RESIZE) { + t.sel_state = SEL_NONE; /* display lines rewrap */ layout(&t); wtimeout(t.input, 80); redraw_all(&t); @@ -6271,18 +6461,8 @@ int cst_tui_run(cst_agent_t *agent) { if (wch == KEY_MOUSE) { MEVENT ev; if (getmouse(&ev) == OK) { - if (ev.bstate & BUTTON4_PRESSED) { - t.log_scroll += SCROLL_SPEED; - draw_log(&t); - } else if (ev.bstate & BUTTON5_PRESSED) { - t.log_scroll -= SCROLL_SPEED; - if (t.log_scroll < 0) - t.log_scroll = 0; - draw_log(&t); - } else if (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) { - if (handle_click(&t, ev.y, ev.x, busy) == -2) - goto out; - } + if (on_mouse(&t, &ev, busy) == -2) + goto out; draw_input(&t); /* keep the terminal cursor in the box */ } continue; @@ -6495,7 +6675,7 @@ out: tui_free(&t); #ifdef NCURSES_VERSION kitty_restore(); - printf("\033[?2004l"); + printf("\033[?1002l\033[?2004l"); fflush(stdout); #endif endwin(); diff --git a/tests/test_markdown.c b/tests/test_markdown.c new file mode 100644 index 0000000..0fe8351 --- /dev/null +++ b/tests/test_markdown.c @@ -0,0 +1,109 @@ +#include "sashidown.h" + +#include +#include +#include + +#define CHECK(c) \ + do { \ + if (!(c)) { \ + fprintf(stderr, "%s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #c); \ + exit(1); \ + } \ + } while (0) + +/* SASHIDOWN_FMT_ANSI_PLAIN theme */ +#define ITALIC "\033[3m" +#define ITALIC_OFF "\033[23m" +#define BOLD "\033[1m" +#define BOLD_OFF "\033[22m" +#define CODE "\033[7m" + +static char out[4096], plain[4096]; + +/* Renders md; `out` keeps the escapes, `plain` has them stripped. */ +static void render(const char *md) { + size_t n = 0; + CHECK(sashidown_convert(md, strlen(md), out, sizeof(out) - 1, &n, + SASHIDOWN_FMT_ANSI_PLAIN) == 0); + out[n] = 0; + size_t o = 0; + for (size_t i = 0; i < n;) { + if (out[i] == '\033' && out[i + 1] == '[') { + i += 2; + while (i < n && !(out[i] >= 0x40 && out[i] <= 0x7e)) + i++; + i++; + continue; + } + plain[o++] = out[i++]; + } + plain[o] = 0; +} + +static int has(const char *s, const char *needle) { + return strstr(s, needle) != NULL; +} + +static void test_intraword(void) { + render("see test_apply_patch.c now"); + CHECK(has(plain, "test_apply_patch.c")); + CHECK(!has(out, ITALIC)); + render("snake_case_x and 中文_变量_名 and a__b__c"); + CHECK(has(plain, "snake_case_x and 中文_变量_名 and a__b__c")); + CHECK(!has(out, ITALIC) && !has(out, BOLD)); + render("foo_bar_ baz"); + CHECK(has(plain, "foo_bar_ baz") && !has(out, ITALIC)); +} + +static void test_pairs(void) { + render("*it* and _it_"); + CHECK(has(out, ITALIC "it" ITALIC_OFF " and " ITALIC "it" ITALIC_OFF)); + render("**b** __b__"); + CHECK(has(out, BOLD "b" BOLD_OFF " " BOLD "b" BOLD_OFF)); + render("***x***"); + CHECK(has(out, BOLD ITALIC "x" ITALIC_OFF BOLD_OFF)); + render("*a_b* end"); + CHECK(has(out, ITALIC "a_b" ITALIC_OFF " end")); + /* CommonMark: intraword '*' pairs, a standalone __init__ is bold */ + render("2*3*4"); + CHECK(has(out, "2" ITALIC "3" ITALIC_OFF "4")); + render("call __init__ now"); + CHECK(has(out, BOLD "init" BOLD_OFF " now")); +} + +static void test_unmatched(void) { + render("a * b and 2 * 3 * 4"); + CHECK(has(plain, "a * b and 2 * 3 * 4") && !has(out, ITALIC)); + render("trailing star*"); + CHECK(has(plain, "trailing star*") && !has(out, ITALIC)); + render("*mix_ words"); + CHECK(has(plain, "*mix_ words") && !has(out, ITALIC)); + render("**open bold only"); + CHECK(has(plain, "**open bold only") && !has(out, BOLD)); + render("a `b"); + CHECK(has(plain, "a `b") && !has(out, CODE)); +} + +static void test_code(void) { + render("run `a_b_c` and `*x*`"); + CHECK(has(plain, "run a_b_c and *x*")); + CHECK(has(out, CODE) && !has(out, ITALIC)); +} + +static void test_cjk(void) { + render("**粗体**,后文"); + CHECK(has(out, BOLD "粗体" BOLD_OFF ",后文")); + render("看**“引号”**这里"); + CHECK(has(out, BOLD "“引号”" BOLD_OFF "这里")); +} + +int main(void) { + test_intraword(); + test_pairs(); + test_unmatched(); + test_code(); + test_cjk(); + printf("test_markdown: ok\n"); + return 0; +} diff --git a/tests/test_textsel.c b/tests/test_textsel.c new file mode 100644 index 0000000..e10f5f6 --- /dev/null +++ b/tests/test_textsel.c @@ -0,0 +1,112 @@ +#include "cst_textsel.h" + +#include +#include +#include +#include + +#define CHECK(c) \ + do { \ + if (!(c)) { \ + fprintf(stderr, "%s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #c); \ + exit(1); \ + } \ + } while (0) + +static char got[512]; + +static const char *slice(const char *line, int indent, int c1, int c2, + int trim) { + cst_selbuf_t b = {0}; + CHECK(cst_textsel_append(&b, line, indent, c1, c2, trim) == 0); + snprintf(got, sizeof(got), "%s", b.s ? b.s : ""); + cst_selbuf_free(&b); + return got; +} + +static void test_ascii(void) { + CHECK(strcmp(slice("hello world", 0, 0, 4, 1), "hello") == 0); + CHECK(strcmp(slice("hello world", 0, 6, -1, 1), "world") == 0); + CHECK(strcmp(slice("hello world", 0, 3, 7, 1), "lo wo") == 0); + /* indent: columns before the content are blank */ + CHECK(strcmp(slice("abc", 2, 0, 2, 1), "a") == 0); + CHECK(strcmp(slice("abc", 2, 0, 1, 1), "") == 0); + /* trailing spaces */ + CHECK(strcmp(slice("ab ", 0, 0, -1, 1), "ab") == 0); + CHECK(strcmp(slice("ab ", 0, 0, -1, 0), "ab ") == 0); + /* beyond the end of the content */ + CHECK(strcmp(slice("ab", 0, 5, 9, 1), "") == 0); +} + +static void test_ansi(void) { + const char *s = "\033[1;31mred\033[0m and \033[32mgreen\033[0m"; + CHECK(strcmp(slice(s, 0, 0, -1, 1), "red and green") == 0); + CHECK(strcmp(slice(s, 0, 4, 6, 1), "and") == 0); + CHECK(strcmp(slice(s, 0, 8, 12, 1), "green") == 0); + int c1, c2; + cst_textsel_word(s, 0, 10, &c1, &c2); + CHECK(c1 == 8 && c2 == 12); +} + +static void test_words(void) { + const char *s = " open src/tui.c:42 now"; + int c1, c2; + cst_textsel_word(s, 0, 9, &c1, &c2); + CHECK(c1 == 7 && c2 == 18); + CHECK(strcmp(slice(s, 0, c1, c2, 1), "src/tui.c:42") == 0); + cst_textsel_word(s, 0, 6, &c1, &c2); /* on a space */ + CHECK(c1 == 6 && c2 == 6); + cst_textsel_word(s, 0, 40, &c1, &c2); /* past the end */ + CHECK(c1 == 40 && c2 == 40); + cst_textsel_word(s, 1, 3, &c1, &c2); /* indent shifts columns */ + CHECK(c1 == 3 && c2 == 6); +} + +static void test_wide(void) { + /* 中文 occupies columns 0-1 and 2-3 */ + const char *s = "中文 ok"; + CHECK(strcmp(slice(s, 0, 1, 1, 1), "中") == 0); + CHECK(strcmp(slice(s, 0, 1, 2, 1), "中文") == 0); + CHECK(strcmp(slice(s, 0, 3, 6, 1), "文 ok") == 0); + int c1, c2; + cst_textsel_word(s, 0, 3, &c1, &c2); + CHECK(c1 == 0 && c2 == 3); + cst_textsel_word("「x」", 0, 1, &c1, &c2); /* wide punctuation */ + CHECK(c1 == 0 && c2 == 1); + /* highlight edges never split a wide character */ + c1 = 1, c2 = 2; + cst_textsel_snap(s, 0, &c1, &c2); + CHECK(c1 == 0 && c2 == 3); + c1 = 2, c2 = 5; + cst_textsel_snap(s, 0, &c1, &c2); + CHECK(c1 == 2 && c2 == 5); + c1 = 3, c2 = -1; + cst_textsel_snap(s, 1, &c1, &c2); /* indent 1: 中 at 1-2, 文 at 3-4 */ + CHECK(c1 == 3 && c2 == -1); + c1 = 4, c2 = 1; + cst_textsel_snap(s, 1, &c1, &c2); + CHECK(c1 == 3 && c2 == 2); +} + +static void test_join(void) { + cst_selbuf_t b = {0}; + CHECK(cst_textsel_append(&b, "first line ", 0, 6, -1, 1) == 0); + CHECK(cst_selbuf_putc(&b, '\n') == 0); + CHECK(cst_textsel_append(&b, "second", 2, 0, 4, 1) == 0); + CHECK(strcmp(b.s, "line\nsec") == 0); + cst_selbuf_free(&b); + CHECK(b.s == NULL && b.n == 0); +} + +int main(void) { + test_ascii(); + test_ansi(); + test_words(); + if (setlocale(LC_CTYPE, "C.UTF-8") || setlocale(LC_CTYPE, "en_US.UTF-8")) + test_wide(); + else + printf("test_textsel: no UTF-8 locale, wide-character checks skipped\n"); + test_join(); + printf("test_textsel: ok\n"); + return 0; +} diff --git a/vendor/sashidown/src/render_ansi.c b/vendor/sashidown/src/render_ansi.c index 37cf413..72cc147 100644 --- a/vendor/sashidown/src/render_ansi.c +++ b/vendor/sashidown/src/render_ansi.c @@ -12,8 +12,88 @@ typedef struct { uint8_t code; uint8_t latex; uint8_t strike; + char bold_ch; /* '*' or '_' that opened bold / italic */ + char italic_ch; } ansi_inline_t; +/* --- emphasis delimiters (CommonMark flanking rules, simplified) ---------- */ + +/* Neighbour of a delimiter run: whitespace (also line start/end), ASCII + * punctuation, or anything else. Non-ASCII counts as a letter so that CJK + * text and its full-width punctuation behave like words. */ +enum { MD_SPACE, MD_PUNCT, MD_OTHER }; + +static int md_class(unsigned char c) +{ + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') + return MD_SPACE; + if ((c >= 0x21 && c <= 0x2F) || (c >= 0x3A && c <= 0x40) || + (c >= 0x5B && c <= 0x60) || (c >= 0x7B && c <= 0x7E)) + return MD_PUNCT; + return MD_OTHER; +} + +static size_t md_run(const char *input, size_t end, size_t i) +{ + size_t j = i; + while (j < end && input[j] == input[i]) + j++; + return j - i; +} + +/* Flanking of the run [i, i+n) within [start, end). */ +static void md_flank(const char *input, size_t start, size_t end, size_t i, + size_t n, int *left, int *right, int *pc, int *nc) +{ + *pc = md_class(i > start ? (unsigned char)input[i - 1] : ' '); + *nc = md_class(i + n < end ? (unsigned char)input[i + n] : ' '); + *left = *nc != MD_SPACE && (*nc != MD_PUNCT || *pc != MD_OTHER); + *right = *pc != MD_SPACE && (*pc != MD_PUNCT || *nc != MD_OTHER); +} + +/* '_' must not open or close inside a word (snake_case, __init__). */ +static int md_can_open(const char *input, size_t start, size_t end, size_t i, + size_t n) +{ + int l, r, pc, nc; + md_flank(input, start, end, i, n, &l, &r, &pc, &nc); + return input[i] == '*' ? l : l && (!r || pc == MD_PUNCT); +} + +static int md_can_close(const char *input, size_t start, size_t end, size_t i, + size_t n) +{ + int l, r, pc, nc; + md_flank(input, start, end, i, n, &l, &r, &pc, &nc); + return input[i] == '*' ? r : r && (!l || nc == MD_PUNCT); +} + +/* Whether a run of n delimiters like input[i] that can close follows on this + * line, outside code spans. */ +static int md_has_closer(const char *input, size_t start, size_t end, + size_t i, size_t n) +{ + char d = input[i]; + size_t k = i + n; + while (k < end) { + char c = input[k]; + if (c == '\\' && k + 1 < end) { + k += 2; + } else if (c == '`') { + size_t j = block_scan_until_byte(input, end, k + 1, '`'); + k = j < end ? j + 1 : k + 1; + } else if (c == d) { + size_t m = md_run(input, end, k); + if (m == n && md_can_close(input, start, end, k, m)) + return 1; + k += m; + } else { + k++; + } + } + return 0; +} + typedef struct { uint8_t ul; uint8_t ol; @@ -100,7 +180,7 @@ static void ansi_render_inline(sashidown_ctx_t *ctx, const ansi_theme_t *th, const char *input, size_t len, size_t start, size_t end) { - ansi_inline_t st = {0, 0, 0, 0, 0}; + ansi_inline_t st = {0, 0, 0, 0, 0, 0, 0}; size_t i = start; while (i < end) { @@ -194,10 +274,39 @@ static void ansi_render_inline(sashidown_ctx_t *ctx, const ansi_theme_t *th, } } - if ((ch == '*' || ch == '_') && i + 1 < end && input[i + 1] == (char)ch) { - ansi_s(ctx, st.bold ? th->strong_off : th->strong_on); - st.bold = !st.bold; - i += 2; + if (ch == '*' || ch == '_') { + /* run of 1 = italic, 2 = bold, 3 = both; others are literal */ + size_t n = md_run(input, end, i); + int b = n >= 2 && n <= 3, it = n == 1 || n == 3; + int open_b = st.bold && st.bold_ch == (char)ch; + int open_i = st.italic && st.italic_ch == (char)ch; + if (n <= 3 && (!b || open_b) && (!it || open_i) && + md_can_close(input, start, end, i, n)) { + if (it) { + ansi_s(ctx, th->emph_off); + st.italic = 0; + } + if (b) { + ansi_s(ctx, th->strong_off); + st.bold = 0; + } + } else if (n <= 3 && (!b || !st.bold) && (!it || !st.italic) && + md_can_open(input, start, end, i, n) && + md_has_closer(input, start, end, i, n)) { + if (b) { + ansi_s(ctx, th->strong_on); + st.bold = 1; + st.bold_ch = (char)ch; + } + if (it) { + ansi_s(ctx, th->emph_on); + st.italic = 1; + st.italic_ch = (char)ch; + } + } else { + fsm_append_len(ctx, input + i, n); + } + i += n; continue; } @@ -208,14 +317,7 @@ static void ansi_render_inline(sashidown_ctx_t *ctx, const ansi_theme_t *th, continue; } - if (ch == '*' || ch == '_') { - ansi_s(ctx, st.italic ? th->emph_off : th->emph_on); - st.italic = !st.italic; - i++; - continue; - } - - if (ch == '`') { + if (ch == '`' && block_scan_until_byte(input, end, i + 1, '`') < end) { ansi_s(ctx, th->code_on); st.code = 1; i++;