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:
+11
-3
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 | 退出 |
|
||||
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
#ifndef CST_VERSION_H
|
||||
#define CST_VERSION_H
|
||||
|
||||
#define CST_VERSION "0.4.0"
|
||||
#define CST_VERSION "0.5.0"
|
||||
|
||||
#endif
|
||||
|
||||
+194
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <ncurses.h>
|
||||
#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,7 +4384,9 @@ 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)"
|
||||
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)");
|
||||
}
|
||||
|
||||
@@ -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,6 +4602,22 @@ static void cmd_copy(tui_t *t, char *arg, int *quit) {
|
||||
printf("\033]52;c;%s\a", enc);
|
||||
fflush(stdout);
|
||||
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 "
|
||||
"terminal must allow clipboard access)", n);
|
||||
}
|
||||
@@ -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)
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+115
-13
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user