Skip to content

Commit c095617

Browse files
hansmanssonabhinav
andauthored
Add multi-select mode (#166)
Adds a new usage mode: multi-select. Enter multi-select mode by pressing tab, do selections and then press tab again to exit and run action. In multi-select mode, - Pressing labels will select them, but not exit yet. - Pressing labels for selected matches will deselect them. - Pressing Tab or Enter will accept selections. On accept, the selected matches will be joined together with spaces between them and the action will be run as usual. --------- Co-authored-by: Abhinav Gupta <mail@abhinavg.net>
1 parent 69ed5f4 commit c095617

File tree

6 files changed

+262
-28
lines changed

6 files changed

+262
-28
lines changed

app.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ func (c *ctrl) Init() {
181181
SkippedMatch: base.Foreground(tcell.ColorGray),
182182
HintLabel: base.Foreground(tcell.ColorRed),
183183
HintLabelInput: base.Foreground(tcell.ColorYellow),
184+
SelectedMatch: base.Foreground(tcell.ColorYellow),
185+
DeselectLabel: base.Foreground(tcell.ColorDarkRed),
184186
},
185187
}).Build()
186188

integration/integration_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,87 @@ func TestIntegration_ActionEnv(t *testing.T) {
361361
"action pane ID does not match")
362362
}
363363

364+
func TestIntegration_MultiSelect(t *testing.T) {
365+
t.Parallel()
366+
367+
env := (&fakeEnvConfig{
368+
Action: "json-report",
369+
}).Build(t)
370+
371+
testFile := filepath.Join(env.Root, "give.txt")
372+
require.NoError(t,
373+
os.WriteFile(testFile, []byte(_giveText), 0o644),
374+
"write test file")
375+
376+
tmux := (&virtualTmuxConfig{
377+
Tmux: env.Tmux,
378+
Width: 80,
379+
Height: 40,
380+
Env: env.Environ(),
381+
}).Build(t)
382+
time.Sleep(250 * time.Millisecond)
383+
require.NoError(t, tmux.Command("set-buffer", "").Run(),
384+
"clear tmux buffer")
385+
386+
// Clear to ensure the "cat /path/to/whatever" isn't part of the
387+
// matched text.
388+
tmux.Clear()
389+
fmt.Fprintln(tmux, "clear && cat", testFile)
390+
if !assert.NoError(t, tmux.WaitUntilContains("--EOF--", 5*time.Second)) {
391+
t.Fatalf("could not find EOF in %q", tmux.Contents())
392+
}
393+
394+
tmux.Clear()
395+
_, err := tmux.Write([]byte{0x01, 'f'}) // ctrl-a f
396+
require.NoError(t, err, "send ctrl-a f")
397+
398+
time.Sleep(250 * time.Millisecond)
399+
400+
// Enter multi-select mode.
401+
_, err = tmux.Write([]byte{0x09})
402+
require.NoError(t, err, "send tab")
403+
404+
time.Sleep(200 * time.Millisecond)
405+
406+
hints := tmux.Hints()
407+
t.Logf("got hints %q", hints)
408+
if !assert.Len(t, hints, len(_wantMatches)) {
409+
t.Fatalf("expected %d hints in %q", len(_wantMatches), tmux.Contents())
410+
}
411+
412+
// Select all hints.
413+
for _, hint := range hints {
414+
_, err := io.WriteString(tmux, hint)
415+
require.NoError(t, err, "select hint %q", hint)
416+
time.Sleep(250 * time.Millisecond)
417+
}
418+
419+
// Accept output and exit.
420+
_, err = tmux.Write([]byte{0x0d}) // CR
421+
require.NoError(t, err, "send enter")
422+
423+
time.Sleep(250 * time.Millisecond)
424+
425+
got, err := tmux.Command("show-buffer").Output()
426+
require.NoError(t, err)
427+
428+
var state jsonReport
429+
require.NoError(t, json.Unmarshal(got, &state))
430+
431+
t.Logf("got %+v", state)
432+
433+
// The outputs are space-separated in some order.
434+
texts := strings.Split(state.Text, " ")
435+
assert.Len(t, texts, len(_wantMatches), "expected texts to match")
436+
437+
var wantTexts []string
438+
for i := range _wantMatches {
439+
wantTexts = append(wantTexts, _wantMatches[i].Text)
440+
}
441+
442+
assert.ElementsMatch(t, wantTexts, texts)
443+
}
444+
364445
type fakeEnv struct {
365446
Root string
366447
Home string

internal/fastcopy/hint.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,27 @@ import (
66

77
"github.com/abhinav/tmux-fastcopy/internal/huffman"
88
"github.com/abhinav/tmux-fastcopy/internal/ui"
9+
tcell "github.com/gdamore/tcell/v2"
910
)
1011

1112
type hint struct {
12-
Label string
13-
Text string
13+
// Label to select this hint.
14+
Label string
15+
16+
// Text that will be copied if this hint is selected.
17+
Text string
18+
19+
// List ot matches identified by this hint.
20+
//
21+
// Note that a hint may have multiple matches
22+
// if the same text appears on the screen multiple times,
23+
// or if the same text matches multiple regexes.
1424
Matches []Match
25+
26+
// Selected reports whether this hint is selected.
27+
//
28+
// This is only used in multi-selection mode.
29+
Selected bool
1530
}
1631

1732
// generateHints generates a list of hints for the given text. It uses alphabet
@@ -58,13 +73,28 @@ func generateHints(alphabet []rune, text string, matches []Match) []hint {
5873
return hints
5974
}
6075

61-
func (h *hint) Annotations(input string, style Style) (anns []ui.TextAnnotation) {
76+
// AnnotationStyle is the style of annotations for hints and matched text.
77+
type AnnotationStyle struct {
78+
// Matched text that is still a candidate for selection.
79+
Match tcell.Style
80+
81+
// Matched text that is no longer a candidate for selection.
82+
Skipped tcell.Style
83+
84+
// Label that the user must type to select the hint.
85+
Label tcell.Style
86+
87+
// Part of a multi-character label that the user has already typed.
88+
LabelTyped tcell.Style
89+
}
90+
91+
func (h *hint) Annotations(input string, style AnnotationStyle) (anns []ui.TextAnnotation) {
6292
matched := strings.HasPrefix(h.Label, input)
6393

6494
// If the hint matches the input, overlay the hint (both, typed
6595
// and non-typed portions) over the string. Otherwise, grey out
6696
// the match.
67-
matchStyle := style.SkippedMatch
97+
matchStyle := style.Skipped
6898
if matched {
6999
matchStyle = style.Match
70100
}
@@ -82,7 +112,7 @@ func (h *hint) Annotations(input string, style Style) (anns []ui.TextAnnotation)
82112
anns = append(anns, ui.OverlayTextAnnotation{
83113
Offset: pos.Start,
84114
Overlay: input,
85-
Style: style.HintLabelInput,
115+
Style: style.LabelTyped,
86116
})
87117
i += len(input)
88118
}
@@ -92,7 +122,7 @@ func (h *hint) Annotations(input string, style Style) (anns []ui.TextAnnotation)
92122
anns = append(anns, ui.OverlayTextAnnotation{
93123
Offset: pos.Start + len(input),
94124
Overlay: h.Label[i:],
95-
Style: style.HintLabel,
125+
Style: style.Label,
96126
})
97127
}
98128

internal/fastcopy/hint_test.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"testing"
55

66
"github.com/abhinav/tmux-fastcopy/internal/ui"
7+
tcell "github.com/gdamore/tcell/v2"
78
"github.com/stretchr/testify/assert"
89
)
910

@@ -107,7 +108,12 @@ func TestGenerateHints(t *testing.T) {
107108
func TestHintAnnotations(t *testing.T) {
108109
t.Parallel()
109110

110-
style := sampleStyle()
111+
style := AnnotationStyle{
112+
Match: tcell.StyleDefault.Foreground(tcell.ColorGreen),
113+
Skipped: tcell.StyleDefault.Foreground(tcell.ColorGray),
114+
Label: tcell.StyleDefault.Foreground(tcell.ColorRed),
115+
LabelTyped: tcell.StyleDefault.Foreground(tcell.ColorYellow),
116+
}
111117

112118
tests := []struct {
113119
desc string
@@ -130,7 +136,7 @@ func TestHintAnnotations(t *testing.T) {
130136
ui.OverlayTextAnnotation{
131137
Offset: 0,
132138
Overlay: "a",
133-
Style: style.HintLabel,
139+
Style: style.Label,
134140
},
135141
ui.StyleTextAnnotation{
136142
Offset: 1,
@@ -140,7 +146,7 @@ func TestHintAnnotations(t *testing.T) {
140146
ui.OverlayTextAnnotation{
141147
Offset: 7,
142148
Overlay: "a",
143-
Style: style.HintLabel,
149+
Style: style.Label,
144150
},
145151
ui.StyleTextAnnotation{
146152
Offset: 8,
@@ -163,7 +169,7 @@ func TestHintAnnotations(t *testing.T) {
163169
ui.OverlayTextAnnotation{
164170
Offset: 0,
165171
Overlay: "a",
166-
Style: style.HintLabelInput,
172+
Style: style.LabelTyped,
167173
},
168174
ui.StyleTextAnnotation{
169175
Offset: 1,
@@ -185,7 +191,7 @@ func TestHintAnnotations(t *testing.T) {
185191
ui.OverlayTextAnnotation{
186192
Offset: 1,
187193
Overlay: "ab",
188-
Style: style.HintLabel,
194+
Style: style.Label,
189195
},
190196
ui.StyleTextAnnotation{
191197
Offset: 3,
@@ -208,12 +214,12 @@ func TestHintAnnotations(t *testing.T) {
208214
ui.OverlayTextAnnotation{
209215
Offset: 1,
210216
Overlay: "a",
211-
Style: style.HintLabelInput,
217+
Style: style.LabelTyped,
212218
},
213219
ui.OverlayTextAnnotation{
214220
Offset: 2,
215221
Overlay: "b",
216-
Style: style.HintLabel,
222+
Style: style.Label,
217223
},
218224
ui.StyleTextAnnotation{
219225
Offset: 3,
@@ -236,7 +242,7 @@ func TestHintAnnotations(t *testing.T) {
236242
ui.StyleTextAnnotation{
237243
Offset: 1,
238244
Length: 6,
239-
Style: style.SkippedMatch,
245+
Style: style.Skipped,
240246
},
241247
},
242248
},
@@ -253,7 +259,7 @@ func TestHintAnnotations(t *testing.T) {
253259
ui.OverlayTextAnnotation{
254260
Offset: 0,
255261
Overlay: "abcd",
256-
Style: style.HintLabel,
262+
Style: style.Label,
257263
},
258264
},
259265
},

0 commit comments

Comments
 (0)