docs(copy): describe precise selection controls
- commit
- 5173b95c9eedc9c2eb72cfe909dea04523410942
- author
- brice · · 2026-07-15 09:33 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- 3026b44
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +31 -7
M docs/configuration.md +14 -0
| @@ -70,6 +70,20 @@ Invalid values log a warning and keep the last valid value. | |||
| 70 | 70 | ||
| 71 | 71 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | |
| 72 | 72 | ||
| 73 | + | ## Copy mode | |
| 74 | + | ||
| 75 | + | Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy. | |
| 76 | + | ||
| 77 | + | Mouse drag selects a text range. Double-click selects the word under the pointer; dragging after a double-click extends by complete words. | |
| 78 | + | ||
| 79 | + | ```ini | |
| 80 | + | # Unicode whitespace always separates words. | |
| 81 | + | # The default is " -_@". | |
| 82 | + | copy.word-separators = " -_@/" | |
| 83 | + | ``` | |
| 84 | + | ||
| 85 | + | Set `copy.word-separators = ""` to use only Unicode whitespace as a separator. | |
| 86 | + | ||
| 73 | 87 | ## Palette anchor | |
| 74 | 88 | ||
| 75 | 89 | `auto` uses a bottom shelf on narrow terminals and a bottom-right rail from 96 columns up. Reload moves an open palette without losing your query. | |
| @@ -70,6 +70,20 @@ Invalid values log a warning and keep the last valid value. | |||||
| 70 | 70 | ||||
| 71 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | 71 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | ||
| 72 | 72 | ||||
| 73 | + | ## Copy mode | |||
| 74 | + | ||||
| 75 | + | Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy. | |||
| 76 | + | ||||
| 77 | + | Mouse drag selects a text range. Double-click selects the word under the pointer; dragging after a double-click extends by complete words. | |||
| 78 | + | ||||
| 79 | + | ```ini | |||
| 80 | + | # Unicode whitespace always separates words. | |||
| 81 | + | # The default is " -_@". | |||
| 82 | + | copy.word-separators = " -_@/" | |||
| 83 | + | ``` | |||
| 84 | + | ||||
| 85 | + | Set `copy.word-separators = ""` to use only Unicode whitespace as a separator. | |||
| 86 | + | ||||
| 73 | ## Palette anchor | 87 | ## Palette anchor | ||
| 74 | 88 | ||||
| 75 | `auto` uses a bottom shelf on narrow terminals and a bottom-right rail from 96 columns up. Reload moves an open palette without losing your query. | 89 | `auto` uses a bottom shelf on narrow terminals and a bottom-right rail from 96 columns up. Reload moves an open palette without losing your query. | ||
M internal/usecase/daemon/copymode_mouse_test.go +17 -7
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -149,10 +150,12 @@ func TestCopyWordDragAndOSC52(t *testing.T) { | |||
| 149 | 150 | mode := ac.overlays.copyMode | |
| 150 | 151 | selection := mode.Selection() | |
| 151 | 152 | text := mode.SelectedText() | |
| 153 | + | width := mode.Document().Width() | |
| 152 | 154 | ac.overlays.copyMu.Unlock() | |
| 155 | + | want := "alpha beta" + strings.Repeat(" ", width-len("alpha beta")) + "\ngamma" | |
| 153 | 156 | require.Equal(t, scopy.Word, selection.Granularity) | |
| 154 | - | require.Equal(t, "alpha beta\ngamma", text) | |
| 155 | - | require.Equal(t, "\x1b]52;c;YWxwaGEgYmV0YQpnYW1tYQ=\a", string(scopy.OSC52(text)[0])) | |
| 157 | + | require.Equal(t, want, text) | |
| 158 | + | require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0]) | |
| 156 | 159 | ||
| 157 | 160 | copyMouseInput(d, sess, ac, "y") | |
| 158 | 161 | require.False(t, ac.overlays.copyActive()) | |
| @@ -167,11 +170,12 @@ func TestCopyPointerResetClearsClick(t *testing.T) { | |||
| 167 | 170 | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | |
| 168 | 171 | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators) | |
| 169 | 172 | for _, tc := range []struct { | |
| 170 | - | name string | |
| 171 | - | reset func(*overlayRuntime) | |
| 173 | + | name string | |
| 174 | + | reset func(*overlayRuntime) | |
| 175 | + | holdsCopyMu bool | |
| 172 | 176 | }{ | |
| 173 | - | {name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }}, | |
| 174 | - | {name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }}, | |
| 177 | + | {name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }, holdsCopyMu: true}, | |
| 178 | + | {name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }, holdsCopyMu: true}, | |
| 175 | 179 | {name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }}, | |
| 176 | 180 | } { | |
| 177 | 181 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -179,7 +183,13 @@ func TestCopyPointerResetClearsClick(t *testing.T) { | |||
| 179 | 183 | rt.copyMu.Lock() | |
| 180 | 184 | rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc) | |
| 181 | 185 | seedCopyInteractionLocked(rt, p, doc) | |
| 182 | - | tc.reset(rt) | |
| 186 | + | if !tc.holdsCopyMu { | |
| 187 | + | rt.copyMu.Unlock() | |
| 188 | + | tc.reset(rt) | |
| 189 | + | rt.copyMu.Lock() | |
| 190 | + | } else { | |
| 191 | + | tc.reset(rt) | |
| 192 | + | } | |
| 183 | 193 | require.False(t, rt.copyPointer.valid) | |
| 184 | 194 | require.False(t, rt.copyClick.valid) | |
| 185 | 195 | rt.copyMu.Unlock() | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| @@ -149,10 +150,12 @@ func TestCopyWordDragAndOSC52(t *testing.T) { | |||||
| 149 | mode := ac.overlays.copyMode | 150 | mode := ac.overlays.copyMode | ||
| 150 | selection := mode.Selection() | 151 | selection := mode.Selection() | ||
| 151 | text := mode.SelectedText() | 152 | text := mode.SelectedText() | ||
| 153 | + | width := mode.Document().Width() | |||
| 152 | ac.overlays.copyMu.Unlock() | 154 | ac.overlays.copyMu.Unlock() | ||
| 155 | + | want := "alpha beta" + strings.Repeat(" ", width-len("alpha beta")) + "\ngamma" | |||
| 153 | require.Equal(t, scopy.Word, selection.Granularity) | 156 | require.Equal(t, scopy.Word, selection.Granularity) | ||
| 154 | - | require.Equal(t, "alpha beta\ngamma", text) | 157 | + | require.Equal(t, want, text) |
| 155 | - | require.Equal(t, "\x1b]52;c;YWxwaGEgYmV0YQpnYW1tYQ=\a", string(scopy.OSC52(text)[0])) | 158 | + | require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0]) |
| 156 | 159 | ||||
| 157 | copyMouseInput(d, sess, ac, "y") | 160 | copyMouseInput(d, sess, ac, "y") | ||
| 158 | require.False(t, ac.overlays.copyActive()) | 161 | require.False(t, ac.overlays.copyActive()) | ||
| @@ -167,11 +170,12 @@ func TestCopyPointerResetClearsClick(t *testing.T) { | |||||
| 167 | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | 170 | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | ||
| 168 | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators) | 171 | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators) | ||
| 169 | for _, tc := range []struct { | 172 | for _, tc := range []struct { | ||
| 170 | - | name string | 173 | + | name string |
| 171 | - | reset func(*overlayRuntime) | 174 | + | reset func(*overlayRuntime) |
| 175 | + | holdsCopyMu bool | |||
| 172 | }{ | 176 | }{ | ||
| 173 | - | {name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }}, | 177 | + | {name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }, holdsCopyMu: true}, |
| 174 | - | {name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }}, | 178 | + | {name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }, holdsCopyMu: true}, |
| 175 | {name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }}, | 179 | {name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }}, | ||
| 176 | } { | 180 | } { | ||
| 177 | t.Run(tc.name, func(t *testing.T) { | 181 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -179,7 +183,13 @@ func TestCopyPointerResetClearsClick(t *testing.T) { | |||||
| 179 | rt.copyMu.Lock() | 183 | rt.copyMu.Lock() | ||
| 180 | rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc) | 184 | rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc) | ||
| 181 | seedCopyInteractionLocked(rt, p, doc) | 185 | seedCopyInteractionLocked(rt, p, doc) | ||
| 182 | - | tc.reset(rt) | 186 | + | if !tc.holdsCopyMu { |
| 187 | + | rt.copyMu.Unlock() | |||
| 188 | + | tc.reset(rt) | |||
| 189 | + | rt.copyMu.Lock() | |||
| 190 | + | } else { | |||
| 191 | + | tc.reset(rt) | |||
| 192 | + | } | |||
| 183 | require.False(t, rt.copyPointer.valid) | 193 | require.False(t, rt.copyPointer.valid) | ||
| 184 | require.False(t, rt.copyClick.valid) | 194 | require.False(t, rt.copyClick.valid) | ||
| 185 | rt.copyMu.Unlock() | 195 | rt.copyMu.Unlock() | ||