diff options
| author | user <user@clank> | 2026-08-17 13:53:57 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-17 13:53:57 +0200 |
| commit | 2f9135d2810cfb38ccdd4f07e895c66430d066fe (patch) | |
| tree | 2816a6c2addf8c8181dddc51faa308188beccb10 /src | |
| parent | sl0ppty: `set-name target="pane"`, so a script can overrule a program's title (diff) | |
| download | slosh-2f9135d2810cfb38ccdd4f07e895c66430d066fe.tar.gz slosh-2f9135d2810cfb38ccdd4f07e895c66430d066fe.tar.xz slosh-2f9135d2810cfb38ccdd4f07e895c66430d066fe.zip | |
sl0ppty: double-click a word to select it
`pane_select_word()` was written and never wired to anything: no declaration, no
caller, no way to reach it. This is the rest of it -- the gesture, the setting, and
the tests.
**The word is defined by what it stops at, not by what it is made of.** Anything
that is not blank and not a listed separator belongs to a word, so CJK, accented
letters and emoji need no table and come out whole; whitespace always separates,
listed or not, because a double-click that selected the line would be a different
gesture arrived at by surprise. Wide glyphs continue the run they are in rather than
each being their own word -- otherwise every CJK character is a word.
`word_separators` defaults to `"'`()[]{}<>;,|`, and what it leaves *out* is the
decision: `/ . - _ : = @ # % ? & + ~ $` stay inside words, so a path, a dotted
name, a snake_case identifier, a key=value pair, a URL and `src/app.c:1234` are
each one thing you double-clicked to copy whole. `:` is the difference between
copying an error location and copying half of one, which is why kitty keeps it too.
The gesture reuses what selecting already has: the press starts a selection at the
cell -- which anchors a drag out of the double-click -- then widens it to the word,
and the release path that already copies a drag copies this. So the word *is* the
copy, in the one place that rule lives, and the highlight is the copied span rather
than an approximation of it. Tracked per cell as well as per pane, so clicking two
different words quickly selects the second rather than everything between them.
A program that asked for the mouse still keeps its clicks, with shift as the escape
hatch, exactly as the drag has it.
**One thing this needed on the way: quoted values were dumped unescaped.** The
default separator set opens with a quote, and `cb_add(&b, "key \"%s\"\n", ...)`
wrote it raw -- so `--dump-config` produced a file that no longer parsed, breaking
the property the dump exists for. The escaping loop already existed inside
`cb_chord`, for the same reason and with the reasoning written down; it is now
`cb_qval`/`cb_qstr` and every string setting goes through it: the marks, `shell`,
`editor`, `shader_dir`, `project_roots`, `project_layout`. Latent for all of those,
load-bearing for this one.
**And `--dump-config` does not do what four comments and the docs said it did.** It
calls `config_dump_defaults()`: every setting with its *default*, never the value
your config gives it. The function name was the honest one. Corrected in
`docs/config.md`, `config/config.kdl`, `main.c`, `config.c`, `app.c` and the test's
own docstring, rather than changing what the flag does -- starting a config from it
is the documented use, and `--check` is what reads yours. There is no way to ask for
the effective config; that is a gap, not a lie, and it can be a flag when somebody
wants it.
Verified: 22 checks in `tests/test_clipboard.py`. An error location, an identifier
stopping at a bracket, a URL, a word inside quotes and a run of CJK; the highlight
being exactly as wide as the copy; a separator, a blank cell and a single click each
leaving the clipboard alone; two clicks on different cells not being a double; two
slow clicks not being one; the separator set honoured from a config, including a
quote and a backslash carried through KDL escaping; a word in a scrolled pane coming
from the row on screen; and a mouse-tracking program keeping its double-clicks until
shift takes them back. Three of those failed first because the test asked for the
answer twice -- a second `worded()` call is a second pair of clicks -- and one
because it mapped string indices to columns without counting wide glyphs as two.
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.c | 27 | ||||
| -rw-r--r-- | src/config.c | 82 | ||||
| -rw-r--r-- | src/config.h | 5 | ||||
| -rw-r--r-- | src/main.c | 8 | ||||
| -rw-r--r-- | src/pane.c | 116 | ||||
| -rw-r--r-- | src/sl0ppty.h | 4 |
6 files changed, 215 insertions, 27 deletions
@@ -354,6 +354,14 @@ struct app { * on the other reads as a double-click on neither. */ int64_t name_click_ms; uint32_t name_click_id; + + /* The same idea for a pane's contents: a second click on the *same cell* of + * the same pane selects the word there. Per cell as well as per pane, so + * clicking two different words quickly selects the second one rather than + * everything between them. */ + int64_t cell_click_ms; + uint32_t cell_click_id; + uint16_t cell_click_x, cell_click_y; int name_click_kind; /* Set while a layout is being built, by a `focus=true` pane, and consumed * by the tab that contains it: focus belongs to a tab, and the tab does not @@ -2184,8 +2192,8 @@ bool app_edit_config(app_t *a) { /* An editor opened on a file that is not there is a blank buffer, and a * blank buffer does not tell you what you can set. So write the defaults - * out first: every knob with the value it currently has, generated from the - * code rather than from a copy of it. Only when there is nothing there -- + * out first: every knob with its default, generated from the code rather + * than from a copy of it. Only when there is nothing there -- * this must never touch a config somebody has written. */ if (access(path, F_OK) != 0) { char dir[1024]; @@ -5832,7 +5840,22 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) { bool select_press = ev->maction == MOUSE_PRESS && ev->button == MBTN_LEFT && (!pane_wants_mouse(n->pane) || (ev->mods & MOD_SHIFT)); if (select_press) { + int64_t now = now_ms_(); + bool again = a->cell_click_id == n->id && a->cell_click_x == local.mx && + a->cell_click_y == local.my && + now - a->cell_click_ms <= (int64_t)CFG.double_click_ms; + a->cell_click_id = n->id; + a->cell_click_x = local.mx; + a->cell_click_y = local.my; + a->cell_click_ms = now; + + /* Start first, then widen to the word: the anchor start() leaves behind is + * what a drag out of the double-click extends from, and it means the + * release path below copies this selection like any other. The word *is* + * the copy, in one place, rather than a second copy site here. */ pane_select_start(n->pane, local.mx, local.my); + if (again) + pane_select_word(n->pane, local.mx, local.my, CFG.word_separators); a->drag.kind = DRAG_SELECT; a->drag.src = n->id; return; diff --git a/src/config.c b/src/config.c index dba211d..556f7f8 100644 --- a/src/config.c +++ b/src/config.c @@ -702,6 +702,16 @@ void config_defaults(config_t *c) { c->toast_ms = 2500; c->hover_delay_ms = 250; c->double_click_ms = 400; + /* Quotes, brackets, and the punctuation that ends a clause or a list. + * + * Deliberately *not* `/ . - _ : = @ # % ? & + ~ $`: a path, a dotted name, a + * snake_case identifier, a key=value pair, a URL and -- the one that decides + * it in a terminal -- `src/app.c:1234` are each one thing somebody + * double-clicked to copy whole. A separator set that breaks those costs a + * second gesture every time, and `:` is the difference between copying an + * error location and copying half of one. */ + snprintf(c->word_separators, sizeof c->word_separators, "%s", + "\"'`()[]{}<>;,|"); /* 20fps. Fast enough that a pulse or a sweep reads as movement, slow enough * that an idle session with an animated shader is not a busy loop -- and it * only costs anything at all while such a shader is actually on screen. */ @@ -1108,10 +1118,12 @@ void config_chord_name(int key, uint16_t mods, char *out, size_t cap) { /* ---- rendering a config back out ---------------------------------------- * - * Every knob with the value it currently has, as a file you could have - * written. Generated rather than kept as a copy on disk: a checked-in - * "defaults" file is a second source of truth, and it drifts -- ours had - * already lost four colours by the time anyone noticed. + * Every knob with its default, as a file you could have written -- the name + * `config_dump_defaults` is the honest one, and the flag is documented to + * match: this is the file to *start* from, not a report on the one in effect. + * Generated rather than kept as a copy on disk: a checked-in "defaults" file is + * a second source of truth, and it drifts -- ours had already lost four colours + * by the time anyone noticed. * * The comments here are one line each and say what a setting *is*. The long * form -- why a setting exists, what it cost to get right -- lives in @@ -1146,6 +1158,30 @@ static const char *yesno(bool v) { return v ? "true" : "false"; } /* A chord as a KDL string: `\` and `"` are both keys somebody may have bound * and both end or escape a string, so they have to be written escaped. The * dump has to *parse back*, and a bare backslash there does not. */ +/* A value as a KDL string. `\\` and `"` both end or escape a string, so every + * value that reaches a dump goes through here: the dump is documented as a file + * you could have written, and a file that does not parse back is not one. Any + * setting whose value a person chooses can contain either character -- a shell + * command with a quoted argument, a word separator set that includes the quote + * you most want double-click to stop at. */ +static void cb_qval(cfgbuf_t *b, const char *val) { + cb_add(b, "\""); + for (const char *q = val; q && *q; q++) { + if (*q == '"' || *q == '\\') cb_add(b, "\\%c", *q); + else cb_add(b, "%c", *q); + } + cb_add(b, "\""); +} + +/* `key "value"` on a line of its own, with an optional trailing comment. */ +static void cb_qstr(cfgbuf_t *b, const char *key, const char *val, + const char *comment) { + cb_add(b, "%s ", key); + cb_qval(b, val); + if (comment) cb_add(b, " %s", comment); + cb_add(b, "\n"); +} + /* A chord as a *config* writes it, which is not how the cheatsheet prints it. * * The dump used to use config_chord_name(), the display form -- so @@ -1196,12 +1232,7 @@ static void cb_chord(cfgbuf_t *b, int key, uint16_t mods) { snprintf(chord + n, sizeof chord - n, "%c", ch); } - cb_add(b, "\""); - for (const char *q = chord; *q; q++) { - if (*q == '"' || *q == '\\') cb_add(b, "\\%c", *q); - else cb_add(b, "%c", *q); - } - cb_add(b, "\""); + cb_qval(b, chord); } static void cb_color(cfgbuf_t *b, const char *name, color_t c) { @@ -1274,12 +1305,12 @@ char *config_render(const config_t *c) { cb_add(&b, "pane_buttons %s // the marks in a frame's top-right\n", yesno(c->pane_buttons)); cb_add(&b, "bell_indicator %s\n", yesno(c->bell_indicator)); - cb_add(&b, "zoom_mark \"%s\"\n", c->zoom_mark); - cb_add(&b, "zoom_on_mark \"%s\"\n", c->zoom_on_mark); - cb_add(&b, "close_mark \"%s\"\n", c->close_mark); - cb_add(&b, "min_mark \"%s\"\n", c->min_mark); - cb_add(&b, "newtab_mark \"%s\"\n", c->newtab_mark); - cb_add(&b, "bell_mark \"%s\"\n", c->bell_mark); + cb_qstr(&b, "zoom_mark", c->zoom_mark, NULL); + cb_qstr(&b, "zoom_on_mark", c->zoom_on_mark, NULL); + cb_qstr(&b, "close_mark", c->close_mark, NULL); + cb_qstr(&b, "min_mark", c->min_mark, NULL); + cb_qstr(&b, "newtab_mark", c->newtab_mark, NULL); + cb_qstr(&b, "bell_mark", c->bell_mark, NULL); cb_add(&b, "\n// ---- behaviour ----\n"); cb_add(&b, "focus_follows_mouse %s\n", yesno(c->focus_follows_mouse)); @@ -1292,6 +1323,8 @@ char *config_render(const config_t *c) { cb_add(&b, "toast_ms %u\n", c->toast_ms); cb_add(&b, "hover_delay_ms %u\n", c->hover_delay_ms); cb_add(&b, "double_click_ms %u\n", c->double_click_ms); + cb_qstr(&b, "word_separators", c->word_separators, + "// what a double-click's word stops at"); cb_add(&b, "anim_ms %u // frame clock while a shader animates\n", c->anim_ms); cb_add(&b, "modal_scrim %u // how far a modal pushes the rest back\n", @@ -1301,22 +1334,24 @@ char *config_render(const config_t *c) { cb_add(&b, "keep_dead \"%s\" // which dead panes stay: commands, all, none\n", c->keep_dead == KEEP_DEAD_ALL ? "all" : c->keep_dead == KEEP_DEAD_NONE ? "none" : "commands"); - if (c->shell) cb_add(&b, "shell \"%s\"\n", c->shell); + if (c->shell) cb_qstr(&b, "shell", c->shell, NULL); else cb_add(&b, "// shell \"/bin/zsh\" // unset: $SHELL\n"); - if (c->editor) cb_add(&b, "editor \"%s\"\n", c->editor); + if (c->editor) cb_qstr(&b, "editor", c->editor, NULL); else cb_add(&b, "// editor \"nvim\" // unset: $EDITOR, then vi\n"); - if (c->shader_dir) cb_add(&b, "shader_dir \"%s\"\n", c->shader_dir); + if (c->shader_dir) cb_qstr(&b, "shader_dir", c->shader_dir, NULL); else cb_add(&b, "// shader_dir \"~/.config/sl0ppty/shaders\"\n"); if (c->nproject_roots) { cb_add(&b, "project_roots"); - for (size_t i = 0; i < c->nproject_roots; i++) - cb_add(&b, " \"%s\"", c->project_roots[i]); + for (size_t i = 0; i < c->nproject_roots; i++) { + cb_add(&b, " "); + cb_qval(&b, c->project_roots[i]); + } cb_add(&b, " depth=%d\n", c->project_depth); } else { cb_add(&b, "// project_roots \"~/dev\" \"~/work\" depth=%d\n", c->project_depth); } - if (c->project_layout) cb_add(&b, "project_layout \"%s\"\n", c->project_layout); + if (c->project_layout) cb_qstr(&b, "project_layout", c->project_layout, NULL); else cb_add(&b, "// project_layout \"~/.config/sl0ppty/project.layout.kdl\"\n"); cb_add(&b, "\n// ---- colour ----\ntheme {\n"); @@ -1464,6 +1499,7 @@ static const char *const KNOWN_TOP[] = { "close_mark", "dim_unfocused", "double_click_ms", + "word_separators", "editor", "focus_follows_mouse", "gap", @@ -1784,6 +1820,8 @@ static bool load_into(config_t *c, const char *path, int depth, char *err, c->hover_delay_ms); c->double_click_ms = (uint16_t)kdl_arg_int(kdl_child(root, "double_click_ms"), 0, c->double_click_ms); + const char *ws = kdl_arg(kdl_child(root, "word_separators"), 0, NULL); + if (ws) snprintf(c->word_separators, sizeof c->word_separators, "%s", ws); c->anim_ms = (uint16_t)kdl_arg_int(kdl_child(root, "anim_ms"), 0, c->anim_ms); { diff --git a/src/config.h b/src/config.h index 0376790..00ea5cc 100644 --- a/src/config.h +++ b/src/config.h @@ -197,6 +197,11 @@ typedef struct { uint16_t toast_ms; uint16_t hover_delay_ms; /* how long the pointer must rest to arm a guide */ /* how long an announcement stays up */ /* rows per wheel notch */ uint16_t double_click_ms; /* how close two clicks must be to be a double */ + /* What a double-click's word stops at, listed as *separators* rather than as + * word characters: anything unlisted belongs to a word, so text nobody + * enumerated -- CJK, accents, emoji -- needs no table. Whitespace always + * separates, listed or not. */ + char word_separators[64]; /* How far everything behind a modal is pushed back, 0..255, 0 being off. * A modal that floats over a screen as bright as it is reads as another * pane rather than as something in front. */ @@ -165,9 +165,11 @@ int main(int argc, char **argv) { : check_config(path); } else if (strcmp(a, "--dump-config") == 0) { - /* Every setting with the value it currently has, as a file you could - * have written. `sl0ppty --dump-config > ~/.config/sl0ppty/config.kdl` - * is a supported way to start one. */ + /* Every setting with its *default*, as a file you could have written: + * `sl0ppty --dump-config > ~/.config/sl0ppty/config.kdl` is a supported + * way to start one. Deliberately not the values a config in effect gives + * them -- this is the file to begin from, and `--check` is the one that + * reads yours and says what it understood. */ char *text = config_dump_defaults(); fputs(text, stdout); free(text); @@ -906,6 +906,122 @@ void pane_select_clear(pane_t *p) { bool pane_selecting(const pane_t *p) { return p->selecting; } void pane_select_done(pane_t *p) { p->selecting = false; } +/* Is this cell part of a word? + * + * Defined by *separators* rather than by a set of word characters, which is what + * makes it work for text nobody enumerated: anything that is neither blank nor a + * listed separator belongs to a word, so CJK, accented letters and emoji need no + * table and get the right answer by default. + * + * Whitespace always separates, whether it is listed or not. A space that could be + * configured into a word character would make a double-click select the line, + * which is a different gesture and a surprising way to arrive at it. */ +static bool word_cell(const char *utf8, size_t len, const char *seps) { + if (!len) return false; /* an empty cell: past the end of the text */ + if (len == 1) { + char c = utf8[0]; + if (c == 0 || c == ' ' || c == '\t') return false; + /* Guarded against NUL above: strchr would otherwise match the terminator. */ + if (seps && *seps && strchr(seps, c)) return false; + } + return true; +} + +/* Select the word under a viewport cell. False when there is no word there -- a + * space, a separator, or past the end of the row -- and then the selection is + * left alone rather than being set to something nobody pointed at. + * + * The row is walked once, from column zero, because the cell iterator only goes + * forward. Which is enough: the run containing `x` is bounded by the separator + * before it and the one after it, and both are met on that single pass. */ +bool pane_select_word(pane_t *p, uint16_t x, uint16_t y, const char *seps) { + if (!p || !p->term || !p->rstate || x >= p->cols || y >= p->rows_n) return false; + if (ghostty_render_state_update(p->rstate, p->term) != GHOSTTY_SUCCESS) + return false; + if (ghostty_render_state_get(p->rstate, + GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, + &p->rows) != GHOSTTY_SUCCESS) + return false; + + uint16_t row = 0; + bool at_row = false; + while (ghostty_render_state_row_iterator_next(p->rows)) { + if (row == y) { + at_row = true; + break; + } + row++; + } + if (!at_row) return false; + if (ghostty_render_state_row_get(p->rows, GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, + &p->cells) != GHOSTTY_SUCCESS) + return false; + + uint16_t col = 0, run_start = 0, left = 0, right = 0; + bool in_run = false, hit = false, done = false; + while (ghostty_render_state_row_cells_next(p->cells)) { + if (col >= p->cols) break; + + GhosttyCell raw = {0}; + GhosttyCellWide wide = GHOSTTY_CELL_WIDE_NARROW; + if (ghostty_render_state_row_cells_get( + p->cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, &raw) == + GHOSTTY_SUCCESS) + ghostty_cell_get(raw, GHOSTTY_CELL_DATA_WIDE, &wide); + + bool w; + if (wide == GHOSTTY_CELL_WIDE_SPACER_TAIL) { + /* The second cell of a wide glyph carries no text of its own. It belongs + * to the glyph in front of it, so it continues whatever that was -- + * otherwise every CJK character would be its own word. */ + w = in_run; + } else { + char utf8[16] = {0}; + GhosttyBuffer gb = {.ptr = (uint8_t *)utf8, .cap = sizeof utf8, .len = 0}; + if (ghostty_render_state_row_cells_get( + p->cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8, + &gb) != GHOSTTY_SUCCESS) + gb.len = 0; + w = word_cell(utf8, gb.len, seps); + } + + if (w) { + if (!in_run) { + in_run = true; + run_start = col; + } + if (col == x) hit = true; + } else { + if (in_run && hit) { /* the separator that ends the word we wanted */ + left = run_start; + right = (uint16_t)(col - 1); + done = true; + break; + } + in_run = false; + if (col == x) return false; /* pointed straight at a separator */ + } + col++; + } + /* A word that runs to the end of the row has no separator to close it. */ + if (!done && hit && in_run) { + left = run_start; + right = (uint16_t)(col - 1); + done = true; + } + if (!done) return false; + + GhosttyGridRef a, b; + if (!grid_ref_at(p, left, y, &a) || !grid_ref_at(p, right, y, &b)) return false; + GhosttySelection sel = GHOSTTY_INIT_SIZED(GhosttySelection); + sel.start = a; + sel.end = b; + sel.rectangle = false; + ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_SELECTION, &sel); + p->dirty = true; + return true; +} + /* The selected text, or NULL. Caller frees. */ char *pane_selection_text(pane_t *p) { GhosttyTerminalSelectionFormatOptions opts = diff --git a/src/sl0ppty.h b/src/sl0ppty.h index a6cdf6a..7e9e11d 100644 --- a/src/sl0ppty.h +++ b/src/sl0ppty.h @@ -265,6 +265,10 @@ size_t pane_graphics(pane_t *p, pane_gfx_fn cb, void *ud); void pane_select_start(pane_t *p, uint16_t x, uint16_t y); void pane_select_extend(pane_t *p, uint16_t x, uint16_t y); void pane_select_clear(pane_t *p); +/* Select the word under a viewport cell, with `seps` naming what a word stops + * at. False when there is no word there -- blank, a separator, past the end of + * the row -- and then the selection is left alone. */ +bool pane_select_word(pane_t *p, uint16_t x, uint16_t y, const char *seps); void pane_select_done(pane_t *p); bool pane_selecting(const pane_t *p); char *pane_selection_text(pane_t *p); |
