aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-17 14:02:54 +0200
committeruser <user@clank>2026-08-17 14:02:54 +0200
commitd90aadfc636cf6b655ed5e5488944b5385b451ec (patch)
tree0d5504febde4e13087610b2e81c2616c3fba5fa8 /tests
parentsl0ppty: double-click a word to select it (diff)
downloadslosh-d90aadfc636cf6b655ed5e5488944b5385b451ec.tar.gz
slosh-d90aadfc636cf6b655ed5e5488944b5385b451ec.tar.xz
slosh-d90aadfc636cf6b655ed5e5488944b5385b451ec.zip
sl0ppty: a wrapped line is one line when you copy it
Select a line too long for the pane and the clipboard came back with a `\n` in the middle of it, at whatever column the pane happened to end. Paste that into a shell and it runs half a path. `pane_selection_text` set `trim` and left `unwrap` at whatever a zeroed struct gives, which is false. lib-vt's header names the exact trio the clipboard wants -- plain output with `unwrap` and `trim` both true, matching Ghostty's own `Screen.selectionString()` -- and we had two of the three. A row boundary in the grid is not a newline in the text: a long line occupies two rows and is still one line, and where it breaks is a fact about how wide the pane is rather than about what was copied. Hard newlines are untouched, which is the half a fix for this could easily have broken: lib-vt joins only the rows that are continuations, so three separate lines still come back as three lines. Both directions are now asserted, because "copy joins everything" would be a worse bug than the one being fixed and would look identical from the inside. `emit` is now set explicitly as well, to `GHOSTTY_FORMATTER_FORMAT_PLAIN`. It is already zero, so this changes nothing today -- but this is the API where a zeroed field means "whatever the enum happens to start with" (DESIGN.md's note on `ghostty_terminal_set`), and a silently defaulted field is how the wrap flag came to be missing. There is one call site, checked. Verified: three checks in `tests/test_clipboard.py` -- a 50-character line printed into a 40-column pane, dragged across the row boundary, arrives as the string that was printed with no newline in it; three short lines keep their two newlines; and a double-click on a word in a wrapped line brings back no newline either. 46 checks in that file, 1795 overall, all green.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_clipboard.py55
1 files changed, 55 insertions, 0 deletions
diff --git a/tests/test_clipboard.py b/tests/test_clipboard.py
index b2180ba..b655cc7 100644
--- a/tests/test_clipboard.py
+++ b/tests/test_clipboard.py
@@ -307,6 +307,58 @@ def test_a_mouse_program_keeps_its_double_clicks():
repr(s.api("clipboard")["text"]))
+# A line longer than the pane, so the terminal lays it across two rows. The
+# string itself contains no newline: where it breaks is a fact about how wide the
+# pane is, and nothing a paste should carry.
+WRAPPED = "AAAAAAAAAABBBBBBBBBBCCCCCCCCCCDDDDDDDDDDEEEEEEEEEE"
+
+
+def test_a_wrapped_line_is_copied_as_one_line():
+ """A row boundary in the grid is not a newline in the text. Copying a wrapped
+ path or URL used to paste a `\\n` into the middle of it, which is the kind of
+ wrong that is invisible until it reaches a shell."""
+ with Session(["/bin/sh", "-c", 'printf "%%s\\n" "%s"; read x' % WRAPPED],
+ cols=40, rows=10) as s:
+ s.settle(200)
+ p = s.pane()
+ cx, cy = content(s, p)
+ check("the pane really did wrap it",
+ s.snapshot().pane_line(p, 1).strip().startswith("DDDD"),
+ repr(s.snapshot().pane_line(p, 1)))
+
+ select(s, cx, cy, cx + 15, cy + 1)
+ got = s.api("clipboard")["text"]
+ check("the copy has no newline in it", "\n" not in got, repr(got))
+ check("and it is the line that was printed", got == WRAPPED, repr(got))
+
+
+def test_real_newlines_are_still_newlines():
+ """The other half, and the one a fix for the first could easily break: rows
+ that are separate *lines* keep the boundaries between them."""
+ with Session(["/bin/sh", "-c", 'printf "one\\ntwo\\nthree\\n"; read x'],
+ cols=40, rows=10) as s:
+ s.settle(200)
+ cx, cy = content(s)
+ select(s, cx, cy, cx + 4, cy + 2)
+ got = s.api("clipboard")["text"]
+ check("three lines come back as three lines", got == "one\ntwo\nthree",
+ repr(got))
+
+
+def test_a_word_that_straddles_the_wrap_is_still_one_word():
+ """Double-clicking the tail of a wrapped word selects the row's run, because a
+ word selection is a run of cells on one row. What it must not do is paste a
+ newline, which is the same bug in the other gesture."""
+ with Session(["/bin/sh", "-c", 'printf "%%s\\n" "%s"; read x' % WRAPPED],
+ cols=40, rows=10) as s:
+ s.settle(200)
+ cx, cy = content(s)
+ dbl_click(s, cx + 2, cy)
+ got = s.api("clipboard")["text"]
+ check("no newline arrives with it", "\n" not in got, repr(got))
+ check("and it copied something", got != "", repr(got))
+
+
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
@@ -394,6 +446,9 @@ def test_selection_does_not_fight_a_mouse_program():
if __name__ == "__main__":
test_select_to_copy()
test_selection_is_visible()
+ test_a_wrapped_line_is_copied_as_one_line()
+ test_real_newlines_are_still_newlines()
+ test_a_word_that_straddles_the_wrap_is_still_one_word()
test_middle_click_pastes()
test_a_program_can_copy()
test_toasts()