diff options
| author | user <user@clank> | 2026-08-17 14:02:54 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-17 14:02:54 +0200 |
| commit | d90aadfc636cf6b655ed5e5488944b5385b451ec (patch) | |
| tree | 0d5504febde4e13087610b2e81c2616c3fba5fa8 /tests | |
| parent | sl0ppty: double-click a word to select it (diff) | |
| download | slosh-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.py | 55 |
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() |
