detached 5173b95

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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.
7070
7171 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.
7272
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+
7387 ## Palette anchor
7488
7589 `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 anchor87 ## 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 @@
11 package daemon
22
33 import (
4+ "strings"
45 "testing"
56 "time"
67
@@ -149,10 +150,12 @@ func TestCopyWordDragAndOSC52(t *testing.T) {
149150 mode := ac.overlays.copyMode
150151 selection := mode.Selection()
151152 text := mode.SelectedText()
153+ width := mode.Document().Width()
152154 ac.overlays.copyMu.Unlock()
155+ want := "alpha beta" + strings.Repeat(" ", width-len("alpha beta")) + "\ngamma"
153156 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])
156159
157160 copyMouseInput(d, sess, ac, "y")
158161 require.False(t, ac.overlays.copyActive())
@@ -167,11 +170,12 @@ func TestCopyPointerResetClearsClick(t *testing.T) {
167170 p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1})
168171 doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators)
169172 for _, tc := range []struct {
170- name string
171- reset func(*overlayRuntime)
173+ name string
174+ reset func(*overlayRuntime)
175+ holdsCopyMu bool
172176 }{
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},
175179 {name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }},
176180 } {
177181 t.Run(tc.name, func(t *testing.T) {
@@ -179,7 +183,13 @@ func TestCopyPointerResetClearsClick(t *testing.T) {
179183 rt.copyMu.Lock()
180184 rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc)
181185 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+ }
183193 require.False(t, rt.copyPointer.valid)
184194 require.False(t, rt.copyClick.valid)
185195 rt.copyMu.Unlock()
@@ -1,6 +1,7 @@
1 package daemon1 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.copyMode150 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 string173+ 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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close