aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-17 13:53:57 +0200
committeruser <user@clank>2026-08-17 13:53:57 +0200
commit2f9135d2810cfb38ccdd4f07e895c66430d066fe (patch)
tree2816a6c2addf8c8181dddc51faa308188beccb10 /tests
parentsl0ppty: `set-name target="pane"`, so a script can overrule a program's title (diff)
downloadslosh-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 'tests')
-rw-r--r--tests/test_clipboard.py236
-rw-r--r--tests/test_dump_config.py20
2 files changed, 255 insertions, 1 deletions
diff --git a/tests/test_clipboard.py b/tests/test_clipboard.py
index 356d775..b2180ba 100644
--- a/tests/test_clipboard.py
+++ b/tests/test_clipboard.py
@@ -7,6 +7,7 @@ middle click pastes.
"""
import sys
import time
+import unicodedata
from harness import Session, check, report
@@ -41,6 +42,44 @@ def select(s, x0, y0, x1, y1):
s.send(rf"\e[<0;{x1 + 1};{y1 + 1}m")
+def dbl_click(s, x, y, times=2):
+ """`times` presses on the same cell, close enough together to be a double."""
+ for _ in range(times):
+ s.send(rf"\e[<0;{x + 1};{y + 1}M")
+ s.send(rf"\e[<0;{x + 1};{y + 1}m")
+
+
+LINE = ('src/app.c:1234:5: error in foo_bar(x); see https://a.b/c?d=1 '
+ 'or "quoted" \u65e5\u672c\u8a9e\u3067\u3059 end')
+
+
+def column_of(line, index):
+ """The screen column a string index lands on. A wide glyph takes two cells, so
+ every position after one is shifted -- the arithmetic the terminal is doing, and
+ the reason a test that skips it clicks somewhere else entirely."""
+ return sum(2 if unicodedata.east_asian_width(c) in ("W", "F") else 1
+ for c in line[:index])
+
+
+def worded(s, needle, offset=0, times=2, line=LINE):
+ """Double-click inside `needle` and return what ended up on the clipboard."""
+ p = s.pane()
+ x = p["content_x"] + column_of(line, line.index(needle) + offset)
+ dbl_click(s, x, p["content_y"], times)
+ s.settle(80)
+ return s.api("clipboard")["text"]
+
+
+def a_line(text=LINE):
+ """A pane showing one line of text, from a file: a printf of this would hand
+ the shell backticks and parentheses to interpret."""
+ import tempfile
+ f = tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False)
+ f.write(text + "\n")
+ f.close()
+ return echoing("cat " + f.name)
+
+
def test_select_to_copy():
with Session(SH, cols=46, rows=10) as s:
s.settle()
@@ -79,6 +118,195 @@ def test_selection_is_visible():
s.send(rf"\e[<0;{cx + 9};{row + 1}m")
+def test_double_click_selects_a_word():
+ """The gesture people already have: two clicks, one word, on the clipboard.
+
+ The cases are the ones a terminal actually shows. `src/app.c:1234:5` is why
+ `:` is not a separator -- an error location is one thing you meant to copy --
+ and the CJK run is why a wide glyph's second cell continues the word rather
+ than being a word of its own.
+
+ Each case clicks *once*, because a second call here would be another pair of
+ clicks: the assertion has to hold the answer, not ask for it twice."""
+ with Session(a_line(), cols=110, rows=12) as s:
+ s.until_text("error")
+ for label, needle, offset, want in (
+ ("an error location comes whole", "app.c", 2, "src/app.c:1234:5:"),
+ ("an identifier stops at the bracket", "foo_bar", 3, "foo_bar"),
+ ("a URL is one word", "https", 4, "https://a.b/c?d=1"),
+ ("quotes separate, so the word inside them is the word",
+ "quoted", 2, "quoted"),
+ ("a run of wide glyphs is one word", "\u65e5\u672c\u8a9e", 1,
+ "\u65e5\u672c\u8a9e\u3067\u3059")):
+ got = worded(s, needle, offset)
+ check(label, got == want, repr(got))
+
+
+def test_the_highlight_is_the_copy():
+ """Whatever is on the clipboard is what the screen says is selected -- the
+ same rule the drag has, since it is the same selection."""
+ with Session(a_line(), cols=110, rows=12) as s:
+ s.until_text("error")
+ clip = worded(s, "foo_bar", 3)
+ snap = s.snapshot()
+ runs = [r for r in snap.styles if "inverse" in r["attrs"]]
+ check("exactly one highlighted run", len(runs) == 1, str(snap.styles))
+ if runs:
+ p = s.pane()
+ check("on the row that was clicked", runs[0]["y"] == p["content_y"],
+ str(runs[0]))
+ check("as wide as the word that was copied", runs[0]["w"] == len(clip),
+ "%s vs %r" % (runs[0], clip))
+
+
+def test_a_click_that_finds_no_word_leaves_the_clipboard_alone():
+ """A separator, a blank cell, or one click rather than two: nothing was named,
+ so nothing is copied. Wiping a clipboard is not something a stray click should
+ be able to do."""
+ with Session(a_line(), cols=110, rows=12) as s:
+ s.until_text("error")
+ first = worded(s, "foo_bar", 3)
+ check("something is on the clipboard to lose", first == "foo_bar", repr(first))
+
+ on_separator = worded(s, "(x)")
+ check("double-clicking a separator copies nothing new",
+ on_separator == first, repr(on_separator))
+
+ on_blank = worded(s, " end")
+ check("nor does double-clicking a blank cell", on_blank == first,
+ repr(on_blank))
+
+ single = worded(s, "end", 1, times=1)
+ check("nor does a single click", single == first, repr(single))
+
+ runs = [r for r in s.snapshot().styles if "inverse" in r["attrs"]]
+ check("and the pane shows no selection either", runs == [], str(runs))
+
+
+def test_two_clicks_on_different_words_are_not_a_double_click():
+ """Per cell, not per pane: clicking one word and then another quickly must not
+ select everything between them."""
+ with Session(a_line(), cols=110, rows=12) as s:
+ s.until_text("error")
+ p = s.pane()
+ x1 = p["content_x"] + column_of(LINE, LINE.index("foo_bar") + 3)
+ x2 = p["content_x"] + column_of(LINE, LINE.index("quoted") + 2)
+ row = p["content_y"]
+ dbl_click(s, x1, row, 1)
+ dbl_click(s, x2, row, 1)
+ s.settle(80)
+ check("neither click selected anything on its own",
+ s.api("clipboard")["text"] in (None, ""), repr(s.api("clipboard")["text"]))
+ dbl_click(s, x2, row, 1) # ...and the second cell's pair completes
+ s.settle(80)
+ check("the pair that lands on one cell selects that word",
+ s.api("clipboard")["text"] == "quoted", repr(s.api("clipboard")["text"]))
+
+
+def test_slow_clicks_are_two_clicks():
+ with Session(a_line(), cols=110, rows=12) as s:
+ s.until_text("error")
+ p = s.pane()
+ x = p["content_x"] + column_of(LINE, LINE.index("foo_bar") + 3)
+ dbl_click(s, x, p["content_y"], 1)
+ time.sleep(0.5) # longer than double_click_ms (400)
+ dbl_click(s, x, p["content_y"], 1)
+ s.settle(80)
+ check("two slow clicks select no word",
+ s.api("clipboard")["text"] in (None, ""), repr(s.api("clipboard")["text"]))
+
+
+def test_the_separators_are_configurable():
+ """`word_separators` lists separators, so anything unlisted is part of a word:
+ emptying it makes punctuation part of the word, up to the blanks that always
+ separate whether they are listed or not."""
+ import tempfile
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write('word_separators ""\n')
+ f.close()
+ with Session(a_line(), cols=110, rows=12, config=f.name) as s:
+ s.until_text("error")
+ got = worded(s, "foo_bar", 3)
+ check("with no separators, punctuation is part of the word",
+ got == "foo_bar(x);", repr(got))
+ check("but a space still ends it", " " not in got, repr(got))
+
+ g = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ g.write('word_separators "_"\n') # the opposite: split an identifier
+ g.close()
+ with Session(a_line(), cols=110, rows=12, config=g.name) as s:
+ s.until_text("error")
+ got = worded(s, "foo_bar", 1)
+ check("and a listed character splits what it is listed in", got == "foo",
+ repr(got))
+
+ # The two characters that end or escape a KDL string are also the two a
+ # double-click most wants to stop at, so the config path has to carry them:
+ # `"quoted"` and `C:\\path` are the shapes this is for.
+ q, bs = '"', chr(92)
+ h = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ h.write("word_separators " + q + bs + q + bs + bs + q + chr(10))
+ h.close()
+ text = 'say ' + q + 'hi' + q + ' and ' + bs + 'esc' + bs + ' ok'
+ with Session(a_line(text), cols=80, rows=10, config=h.name) as s:
+ s.until_text("say")
+ got = worded(s, "hi", 0, line=text)
+ check("a quote can be a separator, escaped through the config",
+ got == "hi", repr(got))
+ got = worded(s, "esc", 0, line=text)
+ check("and so can a backslash", got == "esc", repr(got))
+
+
+def test_a_word_in_scrollback_is_the_word_on_screen():
+ """The word is found by walking the row the pointer is on, and while a pane is
+ scrolled that row is not the row the terminal calls `y`. Same coordinates the
+ drag selection uses -- viewport, not screen history -- so this is the check that
+ they stay the same."""
+ body = "; ".join("printf 'line%02d unique_%02d\\r\\n'" % (i, i) for i in range(1, 41))
+ with Session(echoing(body), cols=60, rows=12) as s:
+ s.until_text("line40")
+ p = s.pane()
+ for _ in range(6): # wheel up into the scrollback
+ s.send(rf"\e[<64;{p['content_x'] + 2};{p['content_y'] + 2}M")
+ s.settle(60)
+
+ row = s.snapshot().line(p["content_y"])
+ word = row.split()[1]
+ check("the top row is a scrollback row", word.startswith("unique_"), repr(row))
+ dbl_click(s, row.index(word), p["content_y"])
+ s.settle(80)
+ got = s.api("clipboard")["text"]
+ check("and the word copied is the one on screen", got == word,
+ "%r from %r" % (got, row.strip()))
+
+
+def test_a_mouse_program_keeps_its_double_clicks():
+ """The same rule the drag has: a program that asked for the mouse gets the
+ clicks, and shift is the way to select over one anyway. A word selection that
+ ignored this would take double-clicks away from every program in every pane."""
+ tracking = ["/bin/sh", "-c",
+ "printf '\\033[?1002h'; printf 'tracked_word here\\r\\n'; "
+ "stty raw -echo; cat"]
+ with Session(tracking, cols=60, rows=10) as s:
+ s.until_text("tracked_word")
+ p = s.pane()
+ row = p["content_y"]
+ col = s.snapshot().line(row).index("tracked_word")
+
+ dbl_click(s, col, row)
+ s.settle(80)
+ check("a plain double-click is the program's", s.api("clipboard")["text"] in (None, ""),
+ repr(s.api("clipboard")["text"]))
+
+ for _ in range(2): # the same pair with shift held
+ s.send(rf"\e[<4;{col + 1};{row + 1}M")
+ s.send(rf"\e[<4;{col + 1};{row + 1}m")
+ s.settle(80)
+ check("shift takes it back and selects the word",
+ s.api("clipboard")["text"] == "tracked_word",
+ repr(s.api("clipboard")["text"]))
+
+
def test_middle_click_pastes():
# a pane that echoes, so a paste is visible; the eval-loop pane would try
# to *run* what was pasted, which is a different (and worse) demonstration
@@ -171,4 +399,12 @@ if __name__ == "__main__":
test_toasts()
test_pane_notifications_become_toasts()
test_selection_does_not_fight_a_mouse_program()
+ test_double_click_selects_a_word()
+ test_the_highlight_is_the_copy()
+ test_a_click_that_finds_no_word_leaves_the_clipboard_alone()
+ test_two_clicks_on_different_words_are_not_a_double_click()
+ test_slow_clicks_are_two_clicks()
+ test_the_separators_are_configurable()
+ test_a_word_in_scrollback_is_the_word_on_screen()
+ test_a_mouse_program_keeps_its_double_clicks()
sys.exit(report())
diff --git a/tests/test_dump_config.py b/tests/test_dump_config.py
index e5bf263..b263523 100644
--- a/tests/test_dump_config.py
+++ b/tests/test_dump_config.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""`sl0ppty --dump-config`: every setting, with the value it currently has.
+"""`sl0ppty --dump-config`: every setting, with its default value.
Generated from the code rather than kept as a file on disk, because a
checked-in copy of the defaults is a second source of truth and drifts — ours
@@ -52,6 +52,24 @@ def test_it_parses_back_to_exactly_itself():
os.unlink(f.name)
+def test_a_value_with_a_quote_in_it_is_written_escaped():
+ """`\\` and `"` both end or escape a KDL string, so a value carrying either has
+ to be written escaped or the dump stops being a file that loads. Not
+ hypothetical: the default `word_separators` opens with a quote, because the
+ quote is the character a double-click most wants to stop at.
+
+ The round-trip tests above would catch a *default* that broke this. This one
+ says which line, so the failure names the setting instead of the whole file."""
+ dumped = dump()
+ line = [l for l in dumped.splitlines() if l.startswith("word_separators ")]
+ check("word_separators is rendered", len(line) == 1, str(line))
+ if line:
+ value = line[0].split(None, 1)[1]
+ check("its quote is escaped", value.startswith('"' + chr(92) + '"'), repr(value))
+ check("and the line is one KDL string, not two",
+ value.count('"') - value.count(chr(92) + '"') == 2, repr(value))
+
+
def test_every_key_the_parser_knows_is_in_it():
"""The drift check. `kdl_child(root, "x")` is how config.c reads a
top-level setting, so that list is the parser's own vocabulary."""