TUI: mouse drag selection with OSC 52 copy; Markdown emphasis follows CommonMark; v0.5.0

- Drag, double-click (word) and triple-click (line) selection in the log,
  auto-scroll at the edges, soft-wrapped lines joined on copy.
- sashidown ANSI renderer: delimiter runs with flanking rules, so
  intraword underscores (test_apply_patch.c) stay literal; unmatched
  * _ and backticks are shown as-is; * and _ do not close each other.
- tests: test_textsel, test_markdown.
This commit is contained in:
WHC
2026-10-06 14:24:42 +08:00
parent b28bca2cfc
commit 1c26cc6e30
11 changed files with 875 additions and 111 deletions
+11 -3
View File
@@ -80,7 +80,7 @@ set(CST_CORE_SOURCES
set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c) set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c)
if(CST_BUILD_TUI) if(CST_BUILD_TUI)
list(APPEND CST_SOURCES src/tui.c) list(APPEND CST_SOURCES src/textsel.c src/tui.c)
else() else()
list(APPEND CST_SOURCES src/tui_stub.c) list(APPEND CST_SOURCES src/tui_stub.c)
endif() endif()
@@ -151,9 +151,17 @@ endif()
# --- tests --- # --- tests ---
enable_testing() enable_testing()
set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline 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}) 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) add_executable(${t} tests/${t}.c src/ctl_parse.c)
elseif(t STREQUAL test_json_baseline) elseif(t STREQUAL test_json_baseline)
add_executable(${t} tests/${t}.c src/json_baseline.c vendor/cjson/cJSON.c) add_executable(${t} tests/${t}.c src/json_baseline.c vendor/cjson/cJSON.c)
+9 -1
View File
@@ -80,7 +80,7 @@ CORE_SRC = \
src/models.c \ src/models.c \
src/metrics.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) OBJ = $(SRC:.c=.o)
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h
@@ -104,6 +104,8 @@ TESTS = \
tests/test_models \ tests/test_models \
tests/test_sandbox \ tests/test_sandbox \
tests/test_keymap \ tests/test_keymap \
tests/test_textsel \
tests/test_markdown \
tests/test_shell_compact \ tests/test_shell_compact \
tests/test_skills \ tests/test_skills \
tests/test_plugins \ 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 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 $(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) tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS) $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS)
+2 -1
View File
@@ -336,7 +336,8 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
| Ctrl+X t / d / s | 全部 Thought 默认展开或折叠 / 全部 tool 输出 / 侧栏 | | Ctrl+X t / d / s | 全部 Thought 默认展开或折叠 / 全部 tool 输出 / 侧栏 |
| Ctrl+X a / k / g | 子 agent 面板 / skills / 插件 | | Ctrl+X a / k / g | 子 agent 面板 / skills / 插件 |
| Ctrl+X h、F1 / Ctrl+X q | 快捷键列表 / 退出 | | Ctrl+X h、F1 / Ctrl+X q | 快捷键列表 / 退出 |
| 鼠标点击 | 展开/折叠 tool 卡片和 Thought、在输入框里定位光标、点模式行切换 Build/Plan、在选择框里选中(`/mouse` 关闭后可用终端原生选择复制) | | 鼠标点击 | 展开/折叠 tool 卡片和 Thought、在输入框里定位光标、点模式行切换 Build/Plan、在选择框里选中 |
| 鼠标拖动 / 双击 / 三击 | 在对话区选中文字(按字符 / 词 / 行),松开即通过 OSC 52 复制到剪贴板;拖到上下边缘会自动滚动。Shift+拖动或 `/mouse` 关闭后用终端原生选择 |
| Esc | 取消当前 turn(不会退出) | | Esc | 取消当前 turn(不会退出) |
| Ctrl+C 两次 / 空输入 Ctrl+D | 退出 | | Ctrl+C 两次 / 空输入 Ctrl+D | 退出 |
+12 -3
View File
@@ -81,9 +81,15 @@ take the agent down.
- UTF-8 locale gives the full UI. Under a non-UTF-8 locale, or with - 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. `CST_ASCII=1`, box and icon glyphs fall back to ASCII.
- `NO_COLOR=1` turns colours off. - `NO_COLOR=1` turns colours off.
- `/copy` uses OSC 52. iTerm2 needs "Applications in terminal may access - `/copy` and mouse selection use OSC 52. iTerm2 needs "Applications in
clipboard"; tmux needs `set -g set-clipboard on`; Windows Terminal (WSL) terminal may access clipboard"; tmux needs `set -g set-clipboard on`;
supports it out of the box. 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). - Bracketed paste needs ncurses (all platforms above).
- macOS Terminal.app sends Option+Enter as Esc+Enter only with "Use Option as - macOS Terminal.app sends Option+Enter as Esc+Enter only with "Use Option as
Meta key"; Ctrl+N always inserts a newline. Meta key"; Ctrl+N always inserts a newline.
@@ -128,6 +134,9 @@ TUI:
- [ ] `/diff`, `/export`, `/status`, `/tools off grep`, `/yolo` work - [ ] `/diff`, `/export`, `/status`, `/tools off grep`, `/yolo` work
- [ ] Mouse: click a tool card to expand it, click in the input to move the - [ ] Mouse: click a tool card to expand it, click in the input to move the
cursor, click a picker row; `/mouse` turns it off 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 - [ ] 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 - [ ] `/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 - [ ] `auto` mode: `echo x > ~/outside` in run_shell asks "Rerun outside the
+41
View File
@@ -0,0 +1,41 @@
#ifndef CST_TEXTSEL_H
#define CST_TEXTSEL_H
#include <stddef.h>
#include <wchar.h>
/* 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
+1 -1
View File
@@ -1,6 +1,6 @@
#ifndef CST_VERSION_H #ifndef CST_VERSION_H
#define CST_VERSION_H #define CST_VERSION_H
#define CST_VERSION "0.4.0" #define CST_VERSION "0.5.0"
#endif #endif
+194
View File
@@ -0,0 +1,194 @@
#ifndef _GNU_SOURCE
#define _GNU_SOURCE /* wcwidth */
#endif
#include "cst_textsel.h"
#include <stdlib.h>
#include <string.h>
#include <wctype.h>
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;
}
}
}
+265 -85
View File
@@ -10,6 +10,7 @@
#include "cst_plugins.h" #include "cst_plugins.h"
#include "cst_skills.h" #include "cst_skills.h"
#include "cst_subagent.h" #include "cst_subagent.h"
#include "cst_textsel.h"
#include "cst_perms.h" #include "cst_perms.h"
#include "cst_sandbox.h" #include "cst_sandbox.h"
#include "cst_tools.h" #include "cst_tools.h"
@@ -61,23 +62,7 @@ static int g_utf8 = 1; /* locale can show non-ASCII text */
#ifndef KEY_RESIZE #ifndef KEY_RESIZE
#define KEY_RESIZE 0632 #define KEY_RESIZE 0632
#endif #endif
/* MinGW often lacks wcwidth; simple East-Asian / ASCII heuristic. */ #define wcwidth cst_wcwidth /* MinGW often lacks wcwidth */
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
#else #else
#include <ncurses.h> #include <ncurses.h>
#endif #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_REPLY (KEY_OWN_BASE + 3)
#define KEY_KITTY_BASE (KEY_OWN_BASE + 16) #define KEY_KITTY_BASE (KEY_OWN_BASE + 16)
#define KITTY_CP_END 128 #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 #ifdef NCURSES_VERSION
static int g_kitty_on; static int g_kitty_on;
@@ -301,6 +292,15 @@ typedef struct {
int row_blk_cap; int row_blk_cap;
int cmd_x0, cmd_x1; /* "ctrl+p commands" in the status row */ int cmd_x0, cmd_x1; /* "ctrl+p commands" in the status row */
int mouse_on; 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 *tip;
const char *sandbox_name; /* "landlock" / "seatbelt" / "rules" */ const char *sandbox_name; /* "landlock" / "seatbelt" / "rules" */
char where[300]; /* ~/path:branch */ 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) { static int utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) {
if (avail == 0) return cst_utf8_decode(s, avail, wc, eaten);
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;
} }
static int utf8_width(wchar_t wc) { static int utf8_width(wchar_t wc) { return cst_utf8_width(wc); }
int w = wcwidth(wc);
if (w < 0)
return 1;
return w;
}
static int utf8_cols(const char *s, size_t n) { static int utf8_cols(const char *s, size_t n) {
int cols = 0; int cols = 0;
@@ -454,20 +438,7 @@ static size_t utf8_fit_cols(const char *s, size_t n, int max_cols) {
return i; return i;
} }
static size_t ansi_skip(const char *s, size_t n) { static size_t ansi_skip(const char *s, size_t n) { return cst_ansi_skip(s, 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 char *xstrdup(const char *s) { static char *xstrdup(const char *s) {
if (!s) if (!s)
@@ -1272,6 +1243,8 @@ typedef struct {
int fill; /* pair painted across the whole row (0 = none) */ int fill; /* pair painted across the whole row (0 = none) */
int bar; /* pair for a "┃" in column 0 (0 = none) */ int bar; /* pair for a "┃" in column 0 (0 = none) */
int blk; /* hist block index, -1 for live / 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; } dline_t;
static int g_dl_blk = -1; /* block being flattened, stamped into 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->fill = 0;
L->bar = 0; L->bar = 0;
L->blk = g_dl_blk; L->blk = g_dl_blk;
L->cont = 0;
L->cont_pad = 0;
return 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. */ /* Walk body; for ANSI text, wrap on visible columns skipping SGR. */
size_t n = strlen(body); size_t n = strlen(body);
size_t i = 0; size_t i = 0;
int first = 1; int first = 1, soft = 0;
while (i < n && dl->n < MAX_RENDER_LINES) { while (i < n && dl->n < MAX_RENDER_LINES) {
size_t start = i; size_t start = i;
int col = 0; int col = 0, pad = 0;
while (i < n) { while (i < n) {
size_t sg = 0; size_t sg = 0;
if ((unsigned char)body[i] == 0x1b) { 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; first = 0;
} else { } else {
/* continuation indent matching prefix visual width */ /* continuation indent matching prefix visual width */
int pad = pref_cols; pad = pref_cols;
if (pad < 0) if (pad < 0)
pad = 0; pad = 0;
if (pad > 40) if (pad > 40)
@@ -1400,10 +1375,11 @@ static void push_wrapped(dlines_t *dl, const char *prefix, const char *body,
line[pad] = 0; line[pad] = 0;
} }
} }
dlines_push(dl, line, pair, bold, dim); if (dlines_push(dl, line, pair, bold, dim) == 0 && soft) {
if (i < n && body[i - 1] != '\n' && col >= body_w) { dl->lines[dl->n - 1].cont = 1;
/* soft wrap — already advanced */ 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", "/undo reverts the file changes of the last turn",
"/provider switches provider; keys are saved per provider", "/provider switches provider; keys are saved per provider",
"Click a tool card or Thought to expand it", "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) { static void draw_home(tui_t *t) {
@@ -2548,6 +2524,57 @@ static void draw_side(tui_t *t) {
wrefresh(s); 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) { static void draw_log(tui_t *t) {
if (!t->logw) if (!t->logw)
return; return;
@@ -2581,6 +2608,7 @@ static void draw_log(tui_t *t) {
int start = total - h - t->log_scroll; int start = total - h - t->log_scroll;
if (start < 0) if (start < 0)
start = 0; start = 0;
t->log_start = start;
if (t->row_blk_cap < h) { if (t->row_blk_cap < h) {
int *nb = realloc(t->row_blk, (size_t)h * sizeof(int)); 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) if (L->bar)
put_wc(t->logw, row, 0, L'┃', HAS_COLOR() ? (int)COLOR_PAIR(L->bar) : 0); 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) { if (t->log_scroll > 0 && h > 2) {
char more[128]; char more[128];
snprintf(more, sizeof(more), 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) { 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); 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) { 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 " push_system_line(t, " Enter send · Esc cancel turn · Ctrl+C twice or Ctrl+D "
"quit · Ctrl+P command palette"); "quit · Ctrl+P command palette");
push_system_line(t, " Mouse: click cards / Thought to expand, wheel scrolls, " 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, " Rebind in keybinds.json next to settings.json");
push_system_line(t, ""); push_system_line(t, "");
} }
@@ -4346,7 +4384,9 @@ static void cmd_mouse(tui_t *t, char *arg, int *quit) {
(void)quit; (void)quit;
t->mouse_on = !t->mouse_on; t->mouse_on = !t->mouse_on;
apply_mouse(t); apply_mouse(t);
push_system_line(t, t->mouse_on ? "(mouse on · Shift+drag 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)"); : "(mouse off · the terminal selects text)");
} }
@@ -4533,25 +4573,16 @@ static void cmd_export(tui_t *t, char *arg, int *quit) {
push_system_line(t, "(exported to %s)", path); push_system_line(t, "(exported to %s)", path);
} }
static void cmd_copy(tui_t *t, char *arg, int *quit) { /* Puts text on the terminal's clipboard; returns the bytes sent, 0 on
(void)arg; * failure. */
(void)quit; static size_t osc52_copy(const char *text, size_t n) {
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;
}
static const char b64[] = static const char b64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t n = strlen(text);
if (n > 75000) if (n > 75000)
n = 75000; /* many terminals cap OSC 52 near 100 KB */ n = 75000; /* many terminals cap OSC 52 near 100 KB */
char *enc = malloc((n + 2) / 3 * 4 + 1); char *enc = malloc((n + 2) / 3 * 4 + 1);
if (!enc) if (!enc)
return; return 0;
size_t o = 0; size_t o = 0;
for (size_t i = 0; i < n; i += 3) { for (size_t i = 0; i < n; i += 3) {
unsigned v = (unsigned char)text[i] << 16; unsigned v = (unsigned char)text[i] << 16;
@@ -4571,6 +4602,22 @@ static void cmd_copy(tui_t *t, char *arg, int *quit) {
printf("\033]52;c;%s\a", enc); printf("\033]52;c;%s\a", enc);
fflush(stdout); fflush(stdout);
free(enc); free(enc);
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 " push_system_line(t, "(copied %zu bytes of the last reply via OSC 52 — the "
"terminal must allow clipboard access)", n); "terminal must allow clipboard access)", n);
} }
@@ -4906,7 +4953,7 @@ static const slash_cmd_t SLASH[] = {
1, cmd_approval}, 1, cmd_approval},
{"yolo", "", "", "toggle approving everything", 1, cmd_yolo}, {"yolo", "", "", "toggle approving everything", 1, cmd_yolo},
{"sidebar", "", "", "show or hide the session sidebar", 1, cmd_sidebar}, {"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}, cmd_mouse},
{"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps}, {"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps},
{"status", "", "", "session, model, context and settings", 1, cmd_status}, {"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; 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. */ /* Tab completes after "/" at the start or an "@word" under the cursor. */
static int wants_completion(const tui_t *t) { static int wants_completion(const tui_t *t) {
if (t->in_len && t->inbuf[0] == '/') 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); int rc = tui_wget_wch(t.input, &wch);
if (rc == ERR) if (rc == ERR)
continue; continue;
if (t.sel_state == SEL_SHOWN &&
!(rc == KEY_CODE_YES && (wch == KEY_MOUSE || wch == KEY_RESIZE)))
sel_clear(&t);
if (!t.in_paste && if (!t.in_paste &&
!(rc == KEY_CODE_YES && !(rc == KEY_CODE_YES &&
@@ -6263,6 +6452,7 @@ int cst_tui_run(cst_agent_t *agent) {
continue; continue;
} }
if (wch == KEY_RESIZE) { if (wch == KEY_RESIZE) {
t.sel_state = SEL_NONE; /* display lines rewrap */
layout(&t); layout(&t);
wtimeout(t.input, 80); wtimeout(t.input, 80);
redraw_all(&t); redraw_all(&t);
@@ -6271,18 +6461,8 @@ int cst_tui_run(cst_agent_t *agent) {
if (wch == KEY_MOUSE) { if (wch == KEY_MOUSE) {
MEVENT ev; MEVENT ev;
if (getmouse(&ev) == OK) { if (getmouse(&ev) == OK) {
if (ev.bstate & BUTTON4_PRESSED) { if (on_mouse(&t, &ev, busy) == -2)
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; goto out;
}
draw_input(&t); /* keep the terminal cursor in the box */ draw_input(&t); /* keep the terminal cursor in the box */
} }
continue; continue;
@@ -6495,7 +6675,7 @@ out:
tui_free(&t); tui_free(&t);
#ifdef NCURSES_VERSION #ifdef NCURSES_VERSION
kitty_restore(); kitty_restore();
printf("\033[?2004l"); printf("\033[?1002l\033[?2004l");
fflush(stdout); fflush(stdout);
#endif #endif
endwin(); endwin();
+109
View File
@@ -0,0 +1,109 @@
#include "sashidown.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}
+112
View File
@@ -0,0 +1,112 @@
#include "cst_textsel.h"
#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}
+115 -13
View File
@@ -12,8 +12,88 @@ typedef struct {
uint8_t code; uint8_t code;
uint8_t latex; uint8_t latex;
uint8_t strike; uint8_t strike;
char bold_ch; /* '*' or '_' that opened bold / italic */
char italic_ch;
} ansi_inline_t; } 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 { typedef struct {
uint8_t ul; uint8_t ul;
uint8_t ol; 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, const char *input, size_t len,
size_t start, size_t end) 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; size_t i = start;
while (i < end) { 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) { if (ch == '*' || ch == '_') {
ansi_s(ctx, st.bold ? th->strong_off : th->strong_on); /* run of 1 = italic, 2 = bold, 3 = both; others are literal */
st.bold = !st.bold; size_t n = md_run(input, end, i);
i += 2; 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; continue;
} }
@@ -208,14 +317,7 @@ static void ansi_render_inline(sashidown_ctx_t *ctx, const ansi_theme_t *th,
continue; continue;
} }
if (ch == '*' || ch == '_') { if (ch == '`' && block_scan_until_byte(input, end, i + 1, '`') < end) {
ansi_s(ctx, st.italic ? th->emph_off : th->emph_on);
st.italic = !st.italic;
i++;
continue;
}
if (ch == '`') {
ansi_s(ctx, th->code_on); ansi_s(ctx, th->code_on);
st.code = 1; st.code = 1;
i++; i++;