detached e87efc2

Merge pull request #19 from bnema/feature/visual-search

feat(daemon): add visual mode search
commit
e87efc202455d8fed20fc47a6f21ca3618a2e402
author
bnema · · 2026-07-05 05:43 UTC
committer
GitHub · · 2026-07-05 05:43 UTC
merge of
f1ffcd8 + d60f14b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
16 files · +813 -38
M internal/usecase/copy/mode.go +133 -0
@@ -3,6 +3,7 @@ package copy
33 import (
44 "encoding/base64"
55 "strings"
6+ "unicode"
67
78 "github.com/bnema/vev/pkg/renderer"
89 )
@@ -40,11 +41,21 @@ func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
4041 }
4142
4243 // Mode stores per-client scrollback viewport and line-selection state.
44+type SearchMatch struct {
45+ Row int
46+ Start int
47+ End int
48+ Text string
49+}
50+
4351 type Mode struct {
4452 ViewportTop int
4553 Cursor int
4654 Anchor int
4755 Selecting bool
56+ SearchQuery string
57+ Searches []SearchMatch
58+ SearchIndex int
4859 }
4960
5061 func NewMode(s Snapshot) *Mode {
@@ -60,6 +71,109 @@ func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s
6071 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }
6172 func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) }
6273
74+func FindMatches(s Snapshot, query string) []SearchMatch {
75+ query = strings.TrimSpace(query)
76+ if query == "" {
77+ return nil
78+ }
79+ needle := lowerRunes(query)
80+ matches := make([]SearchMatch, 0)
81+ for row, cells := range s.Rows {
82+ haystack, cellIndexes := searchableCells(cells)
83+ var text string
84+ for start := 0; start+len(needle) <= len(haystack); {
85+ if !sameRunes(haystack[start:start+len(needle)], needle) {
86+ start++
87+ continue
88+ }
89+ if text == "" {
90+ text = rowString(cells)
91+ }
92+ end := start + len(needle)
93+ cellEnd := len(cells)
94+ if end < len(cellIndexes) {
95+ cellEnd = cellIndexes[end]
96+ }
97+ matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text})
98+ start += len(needle)
99+ }
100+ }
101+ return matches
102+}
103+
104+func searchableCells(cells []renderer.Cell) ([]rune, []int) {
105+ runes := make([]rune, 0, len(cells))
106+ cellIndexes := make([]int, 0, len(cells))
107+ for x, cell := range cells {
108+ if cell.Continuation {
109+ continue
110+ }
111+ r := cell.Rune
112+ if r == 0 {
113+ r = ' '
114+ }
115+ runes = append(runes, unicode.ToLower(r))
116+ cellIndexes = append(cellIndexes, x)
117+ }
118+ return runes, cellIndexes
119+}
120+
121+func lowerRunes(s string) []rune {
122+ runes := []rune(s)
123+ for i, r := range runes {
124+ runes[i] = unicode.ToLower(r)
125+ }
126+ return runes
127+}
128+
129+func sameRunes(a, b []rune) bool {
130+ if len(a) != len(b) {
131+ return false
132+ }
133+ for i := range a {
134+ if a[i] != b[i] {
135+ return false
136+ }
137+ }
138+ return true
139+}
140+
141+func (m *Mode) Search(s Snapshot, query string) bool {
142+ return m.SetSearchMatches(s, query, FindMatches(s, query), 0)
143+}
144+
145+func (m *Mode) SetSearchMatches(s Snapshot, query string, matches []SearchMatch, index int) bool {
146+ if len(matches) == 0 {
147+ m.SearchQuery = strings.TrimSpace(query)
148+ m.Searches = nil
149+ m.SearchIndex = -1
150+ return false
151+ }
152+ m.SearchQuery = strings.TrimSpace(query)
153+ m.Searches = append(m.Searches[:0], matches...)
154+ if index < 0 {
155+ index = 0
156+ }
157+ if index >= len(matches) {
158+ index = len(matches) - 1
159+ }
160+ m.SearchIndex = index
161+ m.SetCursor(s, matches[index].Row)
162+ return true
163+}
164+
165+func (m *Mode) NextSearchMatch(s Snapshot, delta int) bool {
166+ if len(m.Searches) == 0 || delta == 0 {
167+ return false
168+ }
169+ m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)
170+ if m.SearchIndex < 0 {
171+ m.SearchIndex += len(m.Searches)
172+ }
173+ m.SetCursor(s, m.Searches[m.SearchIndex].Row)
174+ return true
175+}
176+
63177 // SetCursor moves the visual cursor to row, clamping it to the snapshot and
64178 // scrolling the viewport just enough to keep it visible.
65179 func (m *Mode) SetCursor(s Snapshot, row int) { m.setCursor(s, row) }
@@ -159,6 +273,11 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
159273 applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)
160274 }
161275 }
276+ if match, ok := m.currentSearchMatchForRow(src); ok {
277+ for x := match.Start; x < match.End && x < len(row); x++ {
278+ applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)
279+ }
280+ }
162281 if src == m.Cursor && !lineSelected && len(row) > 0 {
163282 applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle)
164283 }
@@ -171,6 +290,14 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
171290 return frame
172291 }
173292
293+func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
294+ if m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
295+ return SearchMatch{}, false
296+ }
297+ match := m.Searches[m.SearchIndex]
298+ return match, match.Row == row
299+}
300+
174301 func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
175302 if idx >= len(styles) {
176303 return renderer.Style{}, false
@@ -220,6 +347,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
220347 } else {
221348 text += "0/0 "
222349 }
350+ if m.SearchQuery != "" {
351+ if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {
352+ text += strconvItoa(m.SearchIndex+1) + "/" + strconvItoa(len(m.Searches)) + " "
353+ }
354+ text += "/" + m.SearchQuery + " "
355+ }
223356 for i, r := range text {
224357 if i >= len(row) {
225358 break
@@ -3,6 +3,7 @@ package copy
3 import (3 import (
4 "encoding/base64"4 "encoding/base64"
5 "strings"5 "strings"
6+ "unicode"
6 7
7 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
8 )9 )
@@ -40,11 +41,21 @@ func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
40 }41 }
41 42
42 // Mode stores per-client scrollback viewport and line-selection state.43 // Mode stores per-client scrollback viewport and line-selection state.
44+type SearchMatch struct {
45+ Row int
46+ Start int
47+ End int
48+ Text string
49+}
50+
43 type Mode struct {51 type Mode struct {
44 ViewportTop int52 ViewportTop int
45 Cursor int53 Cursor int
46 Anchor int54 Anchor int
47 Selecting bool55 Selecting bool
56+ SearchQuery string
57+ Searches []SearchMatch
58+ SearchIndex int
48 }59 }
49 60
50 func NewMode(s Snapshot) *Mode {61 func NewMode(s Snapshot) *Mode {
@@ -60,6 +71,109 @@ func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s
60 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }71 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }
61 func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) }72 func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) }
62 73
74+func FindMatches(s Snapshot, query string) []SearchMatch {
75+ query = strings.TrimSpace(query)
76+ if query == "" {
77+ return nil
78+ }
79+ needle := lowerRunes(query)
80+ matches := make([]SearchMatch, 0)
81+ for row, cells := range s.Rows {
82+ haystack, cellIndexes := searchableCells(cells)
83+ var text string
84+ for start := 0; start+len(needle) <= len(haystack); {
85+ if !sameRunes(haystack[start:start+len(needle)], needle) {
86+ start++
87+ continue
88+ }
89+ if text == "" {
90+ text = rowString(cells)
91+ }
92+ end := start + len(needle)
93+ cellEnd := len(cells)
94+ if end < len(cellIndexes) {
95+ cellEnd = cellIndexes[end]
96+ }
97+ matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text})
98+ start += len(needle)
99+ }
100+ }
101+ return matches
102+}
103+
104+func searchableCells(cells []renderer.Cell) ([]rune, []int) {
105+ runes := make([]rune, 0, len(cells))
106+ cellIndexes := make([]int, 0, len(cells))
107+ for x, cell := range cells {
108+ if cell.Continuation {
109+ continue
110+ }
111+ r := cell.Rune
112+ if r == 0 {
113+ r = ' '
114+ }
115+ runes = append(runes, unicode.ToLower(r))
116+ cellIndexes = append(cellIndexes, x)
117+ }
118+ return runes, cellIndexes
119+}
120+
121+func lowerRunes(s string) []rune {
122+ runes := []rune(s)
123+ for i, r := range runes {
124+ runes[i] = unicode.ToLower(r)
125+ }
126+ return runes
127+}
128+
129+func sameRunes(a, b []rune) bool {
130+ if len(a) != len(b) {
131+ return false
132+ }
133+ for i := range a {
134+ if a[i] != b[i] {
135+ return false
136+ }
137+ }
138+ return true
139+}
140+
141+func (m *Mode) Search(s Snapshot, query string) bool {
142+ return m.SetSearchMatches(s, query, FindMatches(s, query), 0)
143+}
144+
145+func (m *Mode) SetSearchMatches(s Snapshot, query string, matches []SearchMatch, index int) bool {
146+ if len(matches) == 0 {
147+ m.SearchQuery = strings.TrimSpace(query)
148+ m.Searches = nil
149+ m.SearchIndex = -1
150+ return false
151+ }
152+ m.SearchQuery = strings.TrimSpace(query)
153+ m.Searches = append(m.Searches[:0], matches...)
154+ if index < 0 {
155+ index = 0
156+ }
157+ if index >= len(matches) {
158+ index = len(matches) - 1
159+ }
160+ m.SearchIndex = index
161+ m.SetCursor(s, matches[index].Row)
162+ return true
163+}
164+
165+func (m *Mode) NextSearchMatch(s Snapshot, delta int) bool {
166+ if len(m.Searches) == 0 || delta == 0 {
167+ return false
168+ }
169+ m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)
170+ if m.SearchIndex < 0 {
171+ m.SearchIndex += len(m.Searches)
172+ }
173+ m.SetCursor(s, m.Searches[m.SearchIndex].Row)
174+ return true
175+}
176+
63 // SetCursor moves the visual cursor to row, clamping it to the snapshot and177 // SetCursor moves the visual cursor to row, clamping it to the snapshot and
64 // scrolling the viewport just enough to keep it visible.178 // scrolling the viewport just enough to keep it visible.
65 func (m *Mode) SetCursor(s Snapshot, row int) { m.setCursor(s, row) }179 func (m *Mode) SetCursor(s Snapshot, row int) { m.setCursor(s, row) }
@@ -159,6 +273,11 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
159 applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)273 applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)
160 }274 }
161 }275 }
276+ if match, ok := m.currentSearchMatchForRow(src); ok {
277+ for x := match.Start; x < match.End && x < len(row); x++ {
278+ applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)
279+ }
280+ }
162 if src == m.Cursor && !lineSelected && len(row) > 0 {281 if src == m.Cursor && !lineSelected && len(row) > 0 {
163 applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle)282 applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle)
164 }283 }
@@ -171,6 +290,14 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
171 return frame290 return frame
172 }291 }
173 292
293+func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
294+ if m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
295+ return SearchMatch{}, false
296+ }
297+ match := m.Searches[m.SearchIndex]
298+ return match, match.Row == row
299+}
300+
174 func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {301 func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
175 if idx >= len(styles) {302 if idx >= len(styles) {
176 return renderer.Style{}, false303 return renderer.Style{}, false
@@ -220,6 +347,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
220 } else {347 } else {
221 text += "0/0 "348 text += "0/0 "
222 }349 }
350+ if m.SearchQuery != "" {
351+ if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {
352+ text += strconvItoa(m.SearchIndex+1) + "/" + strconvItoa(len(m.Searches)) + " "
353+ }
354+ text += "/" + m.SearchQuery + " "
355+ }
223 for i, r := range text {356 for i, r := range text {
224 if i >= len(row) {357 if i >= len(row) {
225 break358 break
M internal/usecase/copy/mode_test.go +86 -0
@@ -7,6 +7,7 @@ import (
77 "testing"
88
99 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/stretchr/testify/require"
1011 )
1112
1213 func snapshot(lines []string, height int) Snapshot {
@@ -310,6 +311,91 @@ func frameText(row []renderer.Cell) string {
310311 return b.String()
311312 }
312313
314+func TestCopyModeSearchMovesAndCyclesMatches(t *testing.T) {
315+ s := snapshot([]string{"alpha", "beta alpha", "gamma", "alpha omega"}, 2)
316+ m := NewMode(s)
317+
318+ require.True(t, m.Search(s, "alpha"))
319+ require.Equal(t, 0, m.Cursor)
320+ require.Equal(t, 0, m.ViewportTop)
321+
322+ require.True(t, m.NextSearchMatch(s, 1))
323+ require.Equal(t, 1, m.Cursor)
324+ require.Equal(t, 0, m.ViewportTop)
325+
326+ require.True(t, m.NextSearchMatch(s, 1))
327+ require.Equal(t, 3, m.Cursor)
328+ require.Equal(t, 2, m.ViewportTop)
329+
330+ require.True(t, m.NextSearchMatch(s, 1))
331+ require.Equal(t, 0, m.Cursor)
332+
333+ require.True(t, m.NextSearchMatch(s, -1))
334+ require.Equal(t, 3, m.Cursor)
335+}
336+
337+func TestFindMatchesReturnsNonOverlappingRepeatedMatches(t *testing.T) {
338+ s := snapshot([]string{"aaa"}, 1)
339+
340+ matches := FindMatches(s, "aa")
341+
342+ require.Len(t, matches, 1)
343+ require.Equal(t, 0, matches[0].Start)
344+ require.Equal(t, 2, matches[0].End)
345+}
346+
347+func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T) {
348+ s := snapshot([]string{"İa"}, 1)
349+
350+ matches := FindMatches(s, "İa")
351+
352+ require.Len(t, matches, 1)
353+ require.Equal(t, 0, matches[0].Start)
354+ require.Equal(t, 2, matches[0].End)
355+}
356+
357+func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
358+ row := []renderer.Cell{
359+ {Rune: '界'},
360+ {Continuation: true},
361+ {Rune: ' '},
362+ {Rune: 'a'},
363+ {Rune: 'l'},
364+ {Rune: 'p'},
365+ {Rune: 'h'},
366+ {Rune: 'a'},
367+ }
368+ s := Snapshot{Rows: [][]renderer.Cell{row}, Width: 8, Height: 1}
369+
370+ wideMatches := FindMatches(s, "界")
371+ require.Len(t, wideMatches, 1)
372+ require.Equal(t, 0, wideMatches[0].Start)
373+ require.Equal(t, 2, wideMatches[0].End)
374+
375+ matches := FindMatches(s, "alpha")
376+ require.Len(t, matches, 1)
377+ require.Equal(t, 3, matches[0].Start)
378+ require.Equal(t, 8, matches[0].End)
379+}
380+
381+func TestCopyModeRenderHighlightsCurrentSearchMatch(t *testing.T) {
382+ s := snapshot([]string{"alpha", "beta alpha"}, 2)
383+ s.Width = 32
384+ m := NewMode(s)
385+ require.True(t, m.Search(s, "alpha"))
386+
387+ frame := m.Render(s)
388+
389+ for x := range len("alpha") {
390+ if !frame.At(x, 0).Style.Inverse {
391+ t.Fatalf("search match cell %d inverse = false, want highlighted", x)
392+ }
393+ }
394+ if !strings.Contains(frameText(frame.Row(s.Height)), "/alpha") {
395+ t.Fatalf("status row = %q, want search query", frameText(frame.Row(s.Height)))
396+ }
397+}
398+
313399 func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) {
314400 s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3)
315401 m := NewMode(s)
@@ -7,6 +7,7 @@ import (
7 "testing"7 "testing"
8 8
9 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/stretchr/testify/require"
10 )11 )
11 12
12 func snapshot(lines []string, height int) Snapshot {13 func snapshot(lines []string, height int) Snapshot {
@@ -310,6 +311,91 @@ func frameText(row []renderer.Cell) string {
310 return b.String()311 return b.String()
311 }312 }
312 313
314+func TestCopyModeSearchMovesAndCyclesMatches(t *testing.T) {
315+ s := snapshot([]string{"alpha", "beta alpha", "gamma", "alpha omega"}, 2)
316+ m := NewMode(s)
317+
318+ require.True(t, m.Search(s, "alpha"))
319+ require.Equal(t, 0, m.Cursor)
320+ require.Equal(t, 0, m.ViewportTop)
321+
322+ require.True(t, m.NextSearchMatch(s, 1))
323+ require.Equal(t, 1, m.Cursor)
324+ require.Equal(t, 0, m.ViewportTop)
325+
326+ require.True(t, m.NextSearchMatch(s, 1))
327+ require.Equal(t, 3, m.Cursor)
328+ require.Equal(t, 2, m.ViewportTop)
329+
330+ require.True(t, m.NextSearchMatch(s, 1))
331+ require.Equal(t, 0, m.Cursor)
332+
333+ require.True(t, m.NextSearchMatch(s, -1))
334+ require.Equal(t, 3, m.Cursor)
335+}
336+
337+func TestFindMatchesReturnsNonOverlappingRepeatedMatches(t *testing.T) {
338+ s := snapshot([]string{"aaa"}, 1)
339+
340+ matches := FindMatches(s, "aa")
341+
342+ require.Len(t, matches, 1)
343+ require.Equal(t, 0, matches[0].Start)
344+ require.Equal(t, 2, matches[0].End)
345+}
346+
347+func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T) {
348+ s := snapshot([]string{"İa"}, 1)
349+
350+ matches := FindMatches(s, "İa")
351+
352+ require.Len(t, matches, 1)
353+ require.Equal(t, 0, matches[0].Start)
354+ require.Equal(t, 2, matches[0].End)
355+}
356+
357+func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
358+ row := []renderer.Cell{
359+ {Rune: '界'},
360+ {Continuation: true},
361+ {Rune: ' '},
362+ {Rune: 'a'},
363+ {Rune: 'l'},
364+ {Rune: 'p'},
365+ {Rune: 'h'},
366+ {Rune: 'a'},
367+ }
368+ s := Snapshot{Rows: [][]renderer.Cell{row}, Width: 8, Height: 1}
369+
370+ wideMatches := FindMatches(s, "界")
371+ require.Len(t, wideMatches, 1)
372+ require.Equal(t, 0, wideMatches[0].Start)
373+ require.Equal(t, 2, wideMatches[0].End)
374+
375+ matches := FindMatches(s, "alpha")
376+ require.Len(t, matches, 1)
377+ require.Equal(t, 3, matches[0].Start)
378+ require.Equal(t, 8, matches[0].End)
379+}
380+
381+func TestCopyModeRenderHighlightsCurrentSearchMatch(t *testing.T) {
382+ s := snapshot([]string{"alpha", "beta alpha"}, 2)
383+ s.Width = 32
384+ m := NewMode(s)
385+ require.True(t, m.Search(s, "alpha"))
386+
387+ frame := m.Render(s)
388+
389+ for x := range len("alpha") {
390+ if !frame.At(x, 0).Style.Inverse {
391+ t.Fatalf("search match cell %d inverse = false, want highlighted", x)
392+ }
393+ }
394+ if !strings.Contains(frameText(frame.Row(s.Height)), "/alpha") {
395+ t.Fatalf("status row = %q, want search query", frameText(frame.Row(s.Height)))
396+ }
397+}
398+
313 func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) {399 func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) {
314 s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3)400 s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3)
315 m := NewMode(s)401 m := NewMode(s)
M internal/usecase/copy/scrollback_test.go +3 -2
@@ -7,8 +7,9 @@ import (
77 )
88
99 func row(text string) []renderer.Cell {
10- cells := make([]renderer.Cell, len(text))
11- for i, r := range text {
10+ runes := []rune(text)
11+ cells := make([]renderer.Cell, len(runes))
12+ for i, r := range runes {
1213 cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
1314 }
1415 return cells
@@ -7,8 +7,9 @@ import (
7 )7 )
8 8
9 func row(text string) []renderer.Cell {9 func row(text string) []renderer.Cell {
10- cells := make([]renderer.Cell, len(text))10+ runes := []rune(text)
11- for i, r := range text {11+ cells := make([]renderer.Cell, len(runes))
12+ for i, r := range runes {
12 cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}13 cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
13 }14 }
14 return cells15 return cells
M internal/usecase/daemon/copymode.go +85 -4
@@ -33,9 +33,13 @@ import (
3333 "github.com/bnema/vev/internal/usecase/keys"
3434 "github.com/bnema/vev/internal/usecase/layout"
3535 "github.com/bnema/vev/internal/usecase/mouse"
36+ "github.com/bnema/vev/internal/usecase/ui"
37+ "github.com/bnema/vev/internal/usecase/visualsearch"
3638 "github.com/bnema/vev/pkg/renderer"
3739 )
3840
41+var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}
42+
3943 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
4044 rt := ac.overlays
4145 tb := sess.activeTab()
@@ -58,7 +62,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
5862 }
5963 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
6064 if delta > 0 && rt.copyMode.AtBottom(snap) {
61- rt.copyMode = nil
65+ rt.clearCopyModeLocked()
6266 rt.copyMu.Unlock()
6367 p.mu.Unlock()
6468 d.paint(sess, ac, true)
@@ -67,7 +71,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
6771 rt.copyMode.Move(snap, delta)
6872 exit := delta > 0 && rt.copyMode.AtBottom(snap)
6973 if exit {
70- rt.copyMode = nil
74+ rt.clearCopyModeLocked()
7175 }
7276 rt.copyMu.Unlock()
7377 p.mu.Unlock()
@@ -92,6 +96,8 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
9296 p.mu.Unlock()
9397 rt.copyMu.Lock()
9498 rt.copyMode = scopy.NewMode(snap)
99+ rt.copySearch = nil
100+ rt.copySearchPending = nil
95101 rt.copyPressRowValid = false
96102 rt.copyDragging = false
97103 rt.normalMousePressValid = false
@@ -198,6 +204,15 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
198204 rt.copyPending = nil
199205 }
200206 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
207+ if rt.copySearch != nil {
208+ changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, snap, data)
209+ rt.copyMu.Unlock()
210+ p.mu.Unlock()
211+ if changed || closeSearch || accepted {
212+ d.paint(sess, ac, true)
213+ }
214+ return
215+ }
201216 changed := false
202217 copyOut := false
203218 exit := false
@@ -218,6 +233,19 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
218233 case ' ', 'v':
219234 rt.copyMode.ToggleSelection()
220235 changed = true
236+ case '/':
237+ rt.copySearch = visualsearch.New(snap)
238+ rt.copySearchPending = nil
239+ changed = true
240+ if i+1 < len(data) {
241+ searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, snap, data[i+1:])
242+ changed = changed || searchChanged || accepted
243+ }
244+ i = len(data)
245+ case 'n':
246+ changed = rt.copyMode.NextSearchMatch(snap, 1) || changed
247+ case 'N':
248+ changed = rt.copyMode.NextSearchMatch(snap, -1) || changed
221249 case '\r', '\n', 'y':
222250 copyOut = true
223251 exit = true
@@ -248,7 +276,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
248276 }
249277 if exit {
250278 d.stopCopyPendingTimerLocked(ac)
251- rt.copyMode = nil
279+ rt.clearCopyModeLocked()
252280 }
253281 rt.copyMu.Unlock()
254282 p.mu.Unlock()
@@ -279,6 +307,54 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
279307 }
280308 }
281309
310+func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, snap scopy.Snapshot, data []byte) (changed bool, closeSearch bool, accepted bool) {
311+ routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{
312+ rune: func(r rune) {
313+ rt.copySearch.Insert(r)
314+ changed = true
315+ },
316+ backspace: func() {
317+ rt.copySearch.Backspace()
318+ changed = true
319+ },
320+ enter: func() {
321+ if _, ok := rt.copySearch.Selected(); ok {
322+ searchSnap := rt.copySearch.Snapshot()
323+ accepted = rt.copyMode.SetSearchMatches(searchSnap, rt.copySearch.Query(), rt.copySearch.Matches(), rt.copySearch.SelectedIndex())
324+ closeSearch = accepted
325+ }
326+ },
327+ cancel: func() { closeSearch = true },
328+ up: func() {
329+ rt.copySearch.Up()
330+ changed = true
331+ },
332+ down: func() {
333+ rt.copySearch.Down()
334+ changed = true
335+ },
336+ })
337+ if changed && !closeSearch {
338+ d.previewCopySearchSelectionLocked(rt, snap)
339+ }
340+ if closeSearch {
341+ rt.copySearch = nil
342+ rt.copySearchPending = nil
343+ }
344+ return changed, closeSearch, accepted
345+}
346+
347+func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime, snap scopy.Snapshot) {
348+ if rt == nil || rt.copyMode == nil || rt.copySearch == nil {
349+ return
350+ }
351+ match, ok := rt.copySearch.Selected()
352+ if !ok {
353+ return
354+ }
355+ rt.copyMode.SetCursor(snap, match.Row)
356+}
357+
282358 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
283359 rt := ac.overlays
284360 mode := rt.copyMode
@@ -296,7 +372,7 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
296372 rt.copyMu.Unlock()
297373 return
298374 }
299- rt.copyMode = nil
375+ rt.clearCopyModeLocked()
300376 rt.copyMu.Unlock()
301377
302378 if sess := ac.currentSession(); sess != nil {
@@ -339,6 +415,11 @@ func isCopyEscapePrefix(data []byte) bool {
339415 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
340416 }
341417
418+func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
419+ styleSet := resolveThemeStyles(styles)
420+ return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
421+}
422+
342423 func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
343424 styles := newThemeStyles(bars.theme)
344425 p := tb.focusedPane()
@@ -33,9 +33,13 @@ import (
33 "github.com/bnema/vev/internal/usecase/keys"33 "github.com/bnema/vev/internal/usecase/keys"
34 "github.com/bnema/vev/internal/usecase/layout"34 "github.com/bnema/vev/internal/usecase/layout"
35 "github.com/bnema/vev/internal/usecase/mouse"35 "github.com/bnema/vev/internal/usecase/mouse"
36+ "github.com/bnema/vev/internal/usecase/ui"
37+ "github.com/bnema/vev/internal/usecase/visualsearch"
36 "github.com/bnema/vev/pkg/renderer"38 "github.com/bnema/vev/pkg/renderer"
37 )39 )
38 40
41+var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}
42+
39 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {43 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
40 rt := ac.overlays44 rt := ac.overlays
41 tb := sess.activeTab()45 tb := sess.activeTab()
@@ -58,7 +62,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
58 }62 }
59 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)63 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
60 if delta > 0 && rt.copyMode.AtBottom(snap) {64 if delta > 0 && rt.copyMode.AtBottom(snap) {
61- rt.copyMode = nil65+ rt.clearCopyModeLocked()
62 rt.copyMu.Unlock()66 rt.copyMu.Unlock()
63 p.mu.Unlock()67 p.mu.Unlock()
64 d.paint(sess, ac, true)68 d.paint(sess, ac, true)
@@ -67,7 +71,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
67 rt.copyMode.Move(snap, delta)71 rt.copyMode.Move(snap, delta)
68 exit := delta > 0 && rt.copyMode.AtBottom(snap)72 exit := delta > 0 && rt.copyMode.AtBottom(snap)
69 if exit {73 if exit {
70- rt.copyMode = nil74+ rt.clearCopyModeLocked()
71 }75 }
72 rt.copyMu.Unlock()76 rt.copyMu.Unlock()
73 p.mu.Unlock()77 p.mu.Unlock()
@@ -92,6 +96,8 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
92 p.mu.Unlock()96 p.mu.Unlock()
93 rt.copyMu.Lock()97 rt.copyMu.Lock()
94 rt.copyMode = scopy.NewMode(snap)98 rt.copyMode = scopy.NewMode(snap)
99+ rt.copySearch = nil
100+ rt.copySearchPending = nil
95 rt.copyPressRowValid = false101 rt.copyPressRowValid = false
96 rt.copyDragging = false102 rt.copyDragging = false
97 rt.normalMousePressValid = false103 rt.normalMousePressValid = false
@@ -198,6 +204,15 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
198 rt.copyPending = nil204 rt.copyPending = nil
199 }205 }
200 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)206 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
207+ if rt.copySearch != nil {
208+ changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, snap, data)
209+ rt.copyMu.Unlock()
210+ p.mu.Unlock()
211+ if changed || closeSearch || accepted {
212+ d.paint(sess, ac, true)
213+ }
214+ return
215+ }
201 changed := false216 changed := false
202 copyOut := false217 copyOut := false
203 exit := false218 exit := false
@@ -218,6 +233,19 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
218 case ' ', 'v':233 case ' ', 'v':
219 rt.copyMode.ToggleSelection()234 rt.copyMode.ToggleSelection()
220 changed = true235 changed = true
236+ case '/':
237+ rt.copySearch = visualsearch.New(snap)
238+ rt.copySearchPending = nil
239+ changed = true
240+ if i+1 < len(data) {
241+ searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, snap, data[i+1:])
242+ changed = changed || searchChanged || accepted
243+ }
244+ i = len(data)
245+ case 'n':
246+ changed = rt.copyMode.NextSearchMatch(snap, 1) || changed
247+ case 'N':
248+ changed = rt.copyMode.NextSearchMatch(snap, -1) || changed
221 case '\r', '\n', 'y':249 case '\r', '\n', 'y':
222 copyOut = true250 copyOut = true
223 exit = true251 exit = true
@@ -248,7 +276,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
248 }276 }
249 if exit {277 if exit {
250 d.stopCopyPendingTimerLocked(ac)278 d.stopCopyPendingTimerLocked(ac)
251- rt.copyMode = nil279+ rt.clearCopyModeLocked()
252 }280 }
253 rt.copyMu.Unlock()281 rt.copyMu.Unlock()
254 p.mu.Unlock()282 p.mu.Unlock()
@@ -279,6 +307,54 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
279 }307 }
280 }308 }
281 309
310+func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, snap scopy.Snapshot, data []byte) (changed bool, closeSearch bool, accepted bool) {
311+ routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{
312+ rune: func(r rune) {
313+ rt.copySearch.Insert(r)
314+ changed = true
315+ },
316+ backspace: func() {
317+ rt.copySearch.Backspace()
318+ changed = true
319+ },
320+ enter: func() {
321+ if _, ok := rt.copySearch.Selected(); ok {
322+ searchSnap := rt.copySearch.Snapshot()
323+ accepted = rt.copyMode.SetSearchMatches(searchSnap, rt.copySearch.Query(), rt.copySearch.Matches(), rt.copySearch.SelectedIndex())
324+ closeSearch = accepted
325+ }
326+ },
327+ cancel: func() { closeSearch = true },
328+ up: func() {
329+ rt.copySearch.Up()
330+ changed = true
331+ },
332+ down: func() {
333+ rt.copySearch.Down()
334+ changed = true
335+ },
336+ })
337+ if changed && !closeSearch {
338+ d.previewCopySearchSelectionLocked(rt, snap)
339+ }
340+ if closeSearch {
341+ rt.copySearch = nil
342+ rt.copySearchPending = nil
343+ }
344+ return changed, closeSearch, accepted
345+}
346+
347+func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime, snap scopy.Snapshot) {
348+ if rt == nil || rt.copyMode == nil || rt.copySearch == nil {
349+ return
350+ }
351+ match, ok := rt.copySearch.Selected()
352+ if !ok {
353+ return
354+ }
355+ rt.copyMode.SetCursor(snap, match.Row)
356+}
357+
282 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {358 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
283 rt := ac.overlays359 rt := ac.overlays
284 mode := rt.copyMode360 mode := rt.copyMode
@@ -296,7 +372,7 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
296 rt.copyMu.Unlock()372 rt.copyMu.Unlock()
297 return373 return
298 }374 }
299- rt.copyMode = nil375+ rt.clearCopyModeLocked()
300 rt.copyMu.Unlock()376 rt.copyMu.Unlock()
301 377
302 if sess := ac.currentSession(); sess != nil {378 if sess := ac.currentSession(); sess != nil {
@@ -339,6 +415,11 @@ func isCopyEscapePrefix(data []byte) bool {
339 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')415 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
340 }416 }
341 417
418+func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
419+ styleSet := resolveThemeStyles(styles)
420+ return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
421+}
422+
342 func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {423 func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
343 styles := newThemeStyles(bars.theme)424 styles := newThemeStyles(bars.theme)
344 p := tb.focusedPane()425 p := tb.focusedPane()
M internal/usecase/daemon/copymode_test.go +121 -0
@@ -144,6 +144,127 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
144144 }
145145 }
146146
147+func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) {
148+ writes := make(chan []byte, 1)
149+ p, _ := newBlockingPTYWithWrites(t, writes)
150+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
151+ pane := sess.tabs[0].focusedPane()
152+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
153+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
154+
155+ d.enterCopyMode(sess, ac)
156+ awaitFrame(t, sends, ports.MsgOutput)
157+ d.handleInput(sess, ac, []byte("/alpha\r"))
158+ awaitFrame(t, sends, ports.MsgOutput)
159+
160+ require.Nil(t, ac.overlays.copySearch)
161+ require.NotNil(t, ac.overlays.copyMode)
162+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
163+ select {
164+ case got := <-writes:
165+ t.Fatalf("batched visual search input forwarded to PTY: %q", got)
166+ default:
167+ }
168+}
169+
170+func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) {
171+ writes := make(chan []byte, 1)
172+ p, _ := newBlockingPTYWithWrites(t, writes)
173+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
174+ pane := sess.tabs[0].focusedPane()
175+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
176+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
177+ copy(pane.screen.Frame.Row(2), testRow("gamma"))
178+
179+ d.enterCopyMode(sess, ac)
180+ awaitFrame(t, sends, ports.MsgOutput)
181+ d.handleInput(sess, ac, []byte("/"))
182+ out := awaitFrame(t, sends, ports.MsgOutput)
183+ msg, err := ports.UnmarshalOutput(out.Payload)
184+ require.NoError(t, err)
185+ require.Contains(t, string(msg.Data), "Search")
186+ require.NotNil(t, ac.overlays.copySearch)
187+
188+ d.handleInput(sess, ac, []byte("alpha"))
189+ out = awaitFrame(t, sends, ports.MsgOutput)
190+ msg, err = ports.UnmarshalOutput(out.Payload)
191+ require.NoError(t, err)
192+ require.Contains(t, string(msg.Data), "/alpha")
193+ require.Contains(t, string(msg.Data), "1:1 alpha")
194+ require.Contains(t, string(msg.Data), "2:6 beta alpha")
195+
196+ d.handleInput(sess, ac, []byte("\r"))
197+ awaitFrame(t, sends, ports.MsgOutput)
198+ require.Nil(t, ac.overlays.copySearch)
199+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
200+
201+ d.handleInput(sess, ac, []byte("n"))
202+ awaitFrame(t, sends, ports.MsgOutput)
203+ require.Equal(t, 1, ac.overlays.copyMode.Cursor)
204+ d.handleInput(sess, ac, []byte("N"))
205+ awaitFrame(t, sends, ports.MsgOutput)
206+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
207+
208+ select {
209+ case got := <-writes:
210+ t.Fatalf("visual search input forwarded to PTY: %q", got)
211+ default:
212+ }
213+}
214+
215+func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) {
216+ p, _ := newBlockingPTY(t)
217+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
218+ pane := sess.tabs[0].focusedPane()
219+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
220+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
221+ copy(pane.screen.Frame.Row(2), testRow("gamma"))
222+
223+ d.enterCopyMode(sess, ac)
224+ awaitFrame(t, sends, ports.MsgOutput)
225+
226+ d.handleInput(sess, ac, []byte("/"))
227+ awaitFrame(t, sends, ports.MsgOutput)
228+ d.handleInput(sess, ac, []byte("alpha"))
229+ awaitFrame(t, sends, ports.MsgOutput)
230+ require.NotNil(t, ac.overlays.copySearch)
231+ require.Equal(t, 0, ac.overlays.copyMode.Cursor, "typing a query previews the selected first result behind the modal")
232+
233+ d.handleInput(sess, ac, []byte{0x0e})
234+ awaitFrame(t, sends, ports.MsgOutput)
235+ require.NotNil(t, ac.overlays.copySearch)
236+ require.Equal(t, 1, ac.overlays.copyMode.Cursor, "moving modal selection previews that result without Enter")
237+}
238+
239+func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
240+ p, _ := newBlockingPTY(t)
241+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
242+ pane := sess.tabs[0].focusedPane()
243+ pane.scrollback = scopy.NewScrollback(4)
244+ pane.scrollback.Append(testRow("old alpha"))
245+ copy(pane.screen.Frame.Row(0), testRow("live alpha"))
246+
247+ d.enterCopyMode(sess, ac)
248+ awaitFrame(t, sends, ports.MsgOutput)
249+ d.handleInput(sess, ac, []byte("/alpha"))
250+ awaitFrame(t, sends, ports.MsgOutput)
251+ require.NotNil(t, ac.overlays.copySearch)
252+ cursor := ac.overlays.copyMode.Cursor
253+
254+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
255+ require.NotNil(t, ac.overlays.copySearch)
256+ require.Equal(t, cursor, ac.overlays.copyMode.Cursor)
257+
258+ d.handleInput(sess, ac, []byte("\x1b"))
259+ awaitFrame(t, sends, ports.MsgOutput)
260+ require.Nil(t, ac.overlays.copySearch)
261+ require.NotNil(t, ac.overlays.copyMode)
262+ d.handleInput(sess, ac, []byte("q"))
263+ awaitFrame(t, sends, ports.MsgOutput)
264+ require.Nil(t, ac.overlays.copySearch)
265+ require.Nil(t, ac.overlays.copyMode)
266+}
267+
147268 func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
148269 writes := make(chan []byte, 1)
149270 p, _ := newBlockingPTYWithWrites(t, writes)
@@ -144,6 +144,127 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
144 }144 }
145 }145 }
146 146
147+func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) {
148+ writes := make(chan []byte, 1)
149+ p, _ := newBlockingPTYWithWrites(t, writes)
150+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
151+ pane := sess.tabs[0].focusedPane()
152+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
153+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
154+
155+ d.enterCopyMode(sess, ac)
156+ awaitFrame(t, sends, ports.MsgOutput)
157+ d.handleInput(sess, ac, []byte("/alpha\r"))
158+ awaitFrame(t, sends, ports.MsgOutput)
159+
160+ require.Nil(t, ac.overlays.copySearch)
161+ require.NotNil(t, ac.overlays.copyMode)
162+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
163+ select {
164+ case got := <-writes:
165+ t.Fatalf("batched visual search input forwarded to PTY: %q", got)
166+ default:
167+ }
168+}
169+
170+func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) {
171+ writes := make(chan []byte, 1)
172+ p, _ := newBlockingPTYWithWrites(t, writes)
173+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
174+ pane := sess.tabs[0].focusedPane()
175+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
176+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
177+ copy(pane.screen.Frame.Row(2), testRow("gamma"))
178+
179+ d.enterCopyMode(sess, ac)
180+ awaitFrame(t, sends, ports.MsgOutput)
181+ d.handleInput(sess, ac, []byte("/"))
182+ out := awaitFrame(t, sends, ports.MsgOutput)
183+ msg, err := ports.UnmarshalOutput(out.Payload)
184+ require.NoError(t, err)
185+ require.Contains(t, string(msg.Data), "Search")
186+ require.NotNil(t, ac.overlays.copySearch)
187+
188+ d.handleInput(sess, ac, []byte("alpha"))
189+ out = awaitFrame(t, sends, ports.MsgOutput)
190+ msg, err = ports.UnmarshalOutput(out.Payload)
191+ require.NoError(t, err)
192+ require.Contains(t, string(msg.Data), "/alpha")
193+ require.Contains(t, string(msg.Data), "1:1 alpha")
194+ require.Contains(t, string(msg.Data), "2:6 beta alpha")
195+
196+ d.handleInput(sess, ac, []byte("\r"))
197+ awaitFrame(t, sends, ports.MsgOutput)
198+ require.Nil(t, ac.overlays.copySearch)
199+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
200+
201+ d.handleInput(sess, ac, []byte("n"))
202+ awaitFrame(t, sends, ports.MsgOutput)
203+ require.Equal(t, 1, ac.overlays.copyMode.Cursor)
204+ d.handleInput(sess, ac, []byte("N"))
205+ awaitFrame(t, sends, ports.MsgOutput)
206+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
207+
208+ select {
209+ case got := <-writes:
210+ t.Fatalf("visual search input forwarded to PTY: %q", got)
211+ default:
212+ }
213+}
214+
215+func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) {
216+ p, _ := newBlockingPTY(t)
217+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
218+ pane := sess.tabs[0].focusedPane()
219+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
220+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
221+ copy(pane.screen.Frame.Row(2), testRow("gamma"))
222+
223+ d.enterCopyMode(sess, ac)
224+ awaitFrame(t, sends, ports.MsgOutput)
225+
226+ d.handleInput(sess, ac, []byte("/"))
227+ awaitFrame(t, sends, ports.MsgOutput)
228+ d.handleInput(sess, ac, []byte("alpha"))
229+ awaitFrame(t, sends, ports.MsgOutput)
230+ require.NotNil(t, ac.overlays.copySearch)
231+ require.Equal(t, 0, ac.overlays.copyMode.Cursor, "typing a query previews the selected first result behind the modal")
232+
233+ d.handleInput(sess, ac, []byte{0x0e})
234+ awaitFrame(t, sends, ports.MsgOutput)
235+ require.NotNil(t, ac.overlays.copySearch)
236+ require.Equal(t, 1, ac.overlays.copyMode.Cursor, "moving modal selection previews that result without Enter")
237+}
238+
239+func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
240+ p, _ := newBlockingPTY(t)
241+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
242+ pane := sess.tabs[0].focusedPane()
243+ pane.scrollback = scopy.NewScrollback(4)
244+ pane.scrollback.Append(testRow("old alpha"))
245+ copy(pane.screen.Frame.Row(0), testRow("live alpha"))
246+
247+ d.enterCopyMode(sess, ac)
248+ awaitFrame(t, sends, ports.MsgOutput)
249+ d.handleInput(sess, ac, []byte("/alpha"))
250+ awaitFrame(t, sends, ports.MsgOutput)
251+ require.NotNil(t, ac.overlays.copySearch)
252+ cursor := ac.overlays.copyMode.Cursor
253+
254+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
255+ require.NotNil(t, ac.overlays.copySearch)
256+ require.Equal(t, cursor, ac.overlays.copyMode.Cursor)
257+
258+ d.handleInput(sess, ac, []byte("\x1b"))
259+ awaitFrame(t, sends, ports.MsgOutput)
260+ require.Nil(t, ac.overlays.copySearch)
261+ require.NotNil(t, ac.overlays.copyMode)
262+ d.handleInput(sess, ac, []byte("q"))
263+ awaitFrame(t, sends, ports.MsgOutput)
264+ require.Nil(t, ac.overlays.copySearch)
265+ require.Nil(t, ac.overlays.copyMode)
266+}
267+
147 func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {268 func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
148 writes := make(chan []byte, 1)269 writes := make(chan []byte, 1)
149 p, _ := newBlockingPTYWithWrites(t, writes)270 p, _ := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/input.go +3 -0
@@ -71,6 +71,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
7171 }
7272
7373 if rt.copyActive() {
74+ if rt.copySearchActive() {
75+ return
76+ }
7477 if ev.Button == mouse.Left {
7578 contentRow := ev.Row - 1
7679 tb.mu.Lock()
@@ -71,6 +71,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
71 }71 }
72 72
73 if rt.copyActive() {73 if rt.copyActive() {
74+ if rt.copySearchActive() {
75+ return
76+ }
74 if ev.Button == mouse.Left {77 if ev.Button == mouse.Left {
75 contentRow := ev.Row - 178 contentRow := ev.Row - 1
76 tb.mu.Lock()79 tb.mu.Lock()
A internal/usecase/daemon/modal_compose.go +18 -0
@@ -0,0 +1,18 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/ui"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeStyles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) {
10+ inner := modal.Composite(base, styles.border)
11+ modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle)
12+ for y := range min(inner.Height, modalFrame.Height) {
13+ for x := range min(inner.Width, modalFrame.Width) {
14+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
15+ }
16+ }
17+ return base, []renderer.Damage{renderer.FullRedraw()}
18+}
@@ -0,0 +1,18 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/ui"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeStyles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) {
10+ inner := modal.Composite(base, styles.border)
11+ modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle)
12+ for y := range min(inner.Height, modalFrame.Height) {
13+ for x := range min(inner.Width, modalFrame.Width) {
14+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
15+ }
16+ }
17+ return base, []renderer.Damage{renderer.FullRedraw()}
18+}
M internal/usecase/daemon/mouse_panes.go +1 -1
@@ -144,7 +144,7 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) {
144144 }
145145 rt := ac.overlays
146146 rt.copyMu.Lock()
147- rt.copyMode = nil
147+ rt.clearCopyModeLocked()
148148 rt.copyPressRowValid = false
149149 rt.copyDragging = false
150150 rt.normalMousePressValid = false
@@ -144,7 +144,7 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) {
144 }144 }
145 rt := ac.overlays145 rt := ac.overlays
146 rt.copyMu.Lock()146 rt.copyMu.Lock()
147- rt.copyMode = nil147+ rt.clearCopyModeLocked()
148 rt.copyPressRowValid = false148 rt.copyPressRowValid = false
149 rt.copyDragging = false149 rt.copyDragging = false
150 rt.normalMousePressValid = false150 rt.normalMousePressValid = false
M internal/usecase/daemon/overlay_runtime.go +24 -3
@@ -7,6 +7,7 @@ import (
77 "github.com/bnema/vev/internal/usecase/palette"
88 "github.com/bnema/vev/internal/usecase/picker"
99 promptui "github.com/bnema/vev/internal/usecase/prompt"
10+ "github.com/bnema/vev/internal/usecase/visualsearch"
1011 )
1112
1213 type overlayRuntime struct {
@@ -31,6 +32,8 @@ type overlayRuntime struct {
3132 copyMode *scopy.Mode
3233 copyPending []byte
3334 copyESC pendingByteTimer
35+ copySearch *visualsearch.Model
36+ copySearchPending []byte
3437 copyFeedback string
3538 copyPressRow int
3639 copyPressRowValid bool
@@ -87,6 +90,21 @@ func (rt *overlayRuntime) copyActive() bool {
8790 return rt.copyMode != nil
8891 }
8992
93+func (rt *overlayRuntime) copySearchActive() bool {
94+ if rt == nil {
95+ return false
96+ }
97+ rt.copyMu.Lock()
98+ defer rt.copyMu.Unlock()
99+ return rt.copySearch != nil
100+}
101+
102+func (rt *overlayRuntime) clearCopyModeLocked() {
103+ rt.copyMode = nil
104+ rt.copySearch = nil
105+ rt.copySearchPending = nil
106+}
107+
90108 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
91109 if rt == nil || rt.ac == nil {
92110 return false
@@ -114,9 +132,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
114132 type overlayRenderSnapshot struct {
115133 rt *overlayRuntime
116134
117- copyActive bool
118- copyMode *scopy.Mode
119- copyFeedback string
135+ copyActive bool
136+ copyMode *scopy.Mode
137+ copySearchModel *visualsearch.Model
138+ copyFeedback string
120139
121140 pickerActive bool
122141 pickerModel *picker.Model
@@ -147,8 +166,10 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
147166 snap.copyActive = rt.copyMode != nil
148167 if rt.copyMode != nil {
149168 copyModeValue := *rt.copyMode
169+ copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
150170 snap.copyMode = &copyModeValue
151171 }
172+ snap.copySearchModel = rt.copySearch.Clone()
152173 snap.copyFeedback = rt.copyFeedback
153174 if snap.copyFeedback != "" && !snap.copyActive {
154175 rt.copyFeedback = ""
@@ -7,6 +7,7 @@ import (
7 "github.com/bnema/vev/internal/usecase/palette"7 "github.com/bnema/vev/internal/usecase/palette"
8 "github.com/bnema/vev/internal/usecase/picker"8 "github.com/bnema/vev/internal/usecase/picker"
9 promptui "github.com/bnema/vev/internal/usecase/prompt"9 promptui "github.com/bnema/vev/internal/usecase/prompt"
10+ "github.com/bnema/vev/internal/usecase/visualsearch"
10 )11 )
11 12
12 type overlayRuntime struct {13 type overlayRuntime struct {
@@ -31,6 +32,8 @@ type overlayRuntime struct {
31 copyMode *scopy.Mode32 copyMode *scopy.Mode
32 copyPending []byte33 copyPending []byte
33 copyESC pendingByteTimer34 copyESC pendingByteTimer
35+ copySearch *visualsearch.Model
36+ copySearchPending []byte
34 copyFeedback string37 copyFeedback string
35 copyPressRow int38 copyPressRow int
36 copyPressRowValid bool39 copyPressRowValid bool
@@ -87,6 +90,21 @@ func (rt *overlayRuntime) copyActive() bool {
87 return rt.copyMode != nil90 return rt.copyMode != nil
88 }91 }
89 92
93+func (rt *overlayRuntime) copySearchActive() bool {
94+ if rt == nil {
95+ return false
96+ }
97+ rt.copyMu.Lock()
98+ defer rt.copyMu.Unlock()
99+ return rt.copySearch != nil
100+}
101+
102+func (rt *overlayRuntime) clearCopyModeLocked() {
103+ rt.copyMode = nil
104+ rt.copySearch = nil
105+ rt.copySearchPending = nil
106+}
107+
90 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {108 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
91 if rt == nil || rt.ac == nil {109 if rt == nil || rt.ac == nil {
92 return false110 return false
@@ -114,9 +132,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
114 type overlayRenderSnapshot struct {132 type overlayRenderSnapshot struct {
115 rt *overlayRuntime133 rt *overlayRuntime
116 134
117- copyActive bool135+ copyActive bool
118- copyMode *scopy.Mode136+ copyMode *scopy.Mode
119- copyFeedback string137+ copySearchModel *visualsearch.Model
138+ copyFeedback string
120 139
121 pickerActive bool140 pickerActive bool
122 pickerModel *picker.Model141 pickerModel *picker.Model
@@ -147,8 +166,10 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
147 snap.copyActive = rt.copyMode != nil166 snap.copyActive = rt.copyMode != nil
148 if rt.copyMode != nil {167 if rt.copyMode != nil {
149 copyModeValue := *rt.copyMode168 copyModeValue := *rt.copyMode
169+ copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
150 snap.copyMode = &copyModeValue170 snap.copyMode = &copyModeValue
151 }171 }
172+ snap.copySearchModel = rt.copySearch.Clone()
152 snap.copyFeedback = rt.copyFeedback173 snap.copyFeedback = rt.copyFeedback
153 if snap.copyFeedback != "" && !snap.copyActive {174 if snap.copyFeedback != "" && !snap.copyActive {
154 rt.copyFeedback = ""175 rt.copyFeedback = ""
M internal/usecase/daemon/overlay_runtime_test.go +33 -1
@@ -3,8 +3,12 @@ package daemon
33 import (
44 "testing"
55
6- promptui "github.com/bnema/vev/internal/usecase/prompt"
76 "github.com/stretchr/testify/require"
7+
8+ scopy "github.com/bnema/vev/internal/usecase/copy"
9+ promptui "github.com/bnema/vev/internal/usecase/prompt"
10+ "github.com/bnema/vev/internal/usecase/visualsearch"
11+ "github.com/bnema/vev/pkg/renderer"
812 )
913
1014 func TestOverlayRuntimeActive(t *testing.T) {
@@ -105,6 +109,34 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
105109 }
106110 }
107111
112+func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) {
113+ ac := &attachedClient{}
114+ ac.initOverlays()
115+ snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, Width: 16, Height: 2}
116+ mode := scopy.NewMode(snap)
117+ require.True(t, mode.Search(snap, "alpha"))
118+ search := visualsearch.New(snap)
119+ for _, r := range "alpha" {
120+ search.Insert(r)
121+ }
122+
123+ ac.overlays.copyMu.Lock()
124+ ac.overlays.copyMode = mode
125+ ac.overlays.copySearch = search
126+ ac.overlays.copyMu.Unlock()
127+
128+ renderSnap := ac.overlays.SnapshotForRender()
129+ defer renderSnap.Unlock()
130+
131+ ac.overlays.copyMu.Lock()
132+ ac.overlays.copyMode.Searches[0].Row = 99
133+ ac.overlays.copySearch.Insert('z')
134+ ac.overlays.copyMu.Unlock()
135+
136+ require.Equal(t, 0, renderSnap.copyMode.Searches[0].Row)
137+ require.Equal(t, "alpha", renderSnap.copySearchModel.Query())
138+}
139+
108140 func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
109141 ac := &attachedClient{}
110142 ac.initOverlays()
@@ -3,8 +3,12 @@ package daemon
3 import (3 import (
4 "testing"4 "testing"
5 5
6- promptui "github.com/bnema/vev/internal/usecase/prompt"
7 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7+
8+ scopy "github.com/bnema/vev/internal/usecase/copy"
9+ promptui "github.com/bnema/vev/internal/usecase/prompt"
10+ "github.com/bnema/vev/internal/usecase/visualsearch"
11+ "github.com/bnema/vev/pkg/renderer"
8 )12 )
9 13
10 func TestOverlayRuntimeActive(t *testing.T) {14 func TestOverlayRuntimeActive(t *testing.T) {
@@ -105,6 +109,34 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
105 }109 }
106 }110 }
107 111
112+func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) {
113+ ac := &attachedClient{}
114+ ac.initOverlays()
115+ snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, Width: 16, Height: 2}
116+ mode := scopy.NewMode(snap)
117+ require.True(t, mode.Search(snap, "alpha"))
118+ search := visualsearch.New(snap)
119+ for _, r := range "alpha" {
120+ search.Insert(r)
121+ }
122+
123+ ac.overlays.copyMu.Lock()
124+ ac.overlays.copyMode = mode
125+ ac.overlays.copySearch = search
126+ ac.overlays.copyMu.Unlock()
127+
128+ renderSnap := ac.overlays.SnapshotForRender()
129+ defer renderSnap.Unlock()
130+
131+ ac.overlays.copyMu.Lock()
132+ ac.overlays.copyMode.Searches[0].Row = 99
133+ ac.overlays.copySearch.Insert('z')
134+ ac.overlays.copyMu.Unlock()
135+
136+ require.Equal(t, 0, renderSnap.copyMode.Searches[0].Row)
137+ require.Equal(t, "alpha", renderSnap.copySearchModel.Query())
138+}
139+
108 func TestOverlayRuntimeHandleInputInactive(t *testing.T) {140 func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
109 ac := &attachedClient{}141 ac := &attachedClient{}
110 ac.initOverlays()142 ac.initOverlays()
M internal/usecase/daemon/palette.go +1 -9
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "github.com/bnema/vev/internal/domain"
54 "github.com/bnema/vev/internal/usecase/command"
65 "github.com/bnema/vev/internal/usecase/layout"
76 "github.com/bnema/vev/internal/usecase/palette"
@@ -243,12 +242,5 @@ func (e paletteExec) OpenSessionPicker() error {
243242
244243 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
245244 styleSet := resolveThemeStyles(styles)
246- inner := paletteModal.Composite(base, styleSet.border)
247- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.selection)
248- for y := range min(inner.Height, modalFrame.Height) {
249- for x := range min(inner.Width, modalFrame.Width) {
250- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
251- }
252- }
253- return base, []renderer.Damage{renderer.FullRedraw()}
245+ return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, model.Render)
254246 }
@@ -1,7 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/usecase/command"4 "github.com/bnema/vev/internal/usecase/command"
6 "github.com/bnema/vev/internal/usecase/layout"5 "github.com/bnema/vev/internal/usecase/layout"
7 "github.com/bnema/vev/internal/usecase/palette"6 "github.com/bnema/vev/internal/usecase/palette"
@@ -243,12 +242,5 @@ func (e paletteExec) OpenSessionPicker() error {
243 242
244 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {243 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
245 styleSet := resolveThemeStyles(styles)244 styleSet := resolveThemeStyles(styles)
246- inner := paletteModal.Composite(base, styleSet.border)245+ return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, model.Render)
247- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.selection)
248- for y := range min(inner.Height, modalFrame.Height) {
249- for x := range min(inner.Width, modalFrame.Width) {
250- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
251- }
252- }
253- return base, []renderer.Damage{renderer.FullRedraw()}
254 }246 }
M internal/usecase/daemon/picker.go +3 -8
@@ -481,14 +481,9 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
481481
482482 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
483483 styleSet := resolveThemeStyles(styles)
484- inner := pickerModal.Composite(base, styleSet.border)
485- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview, styleSet.selection)
486- for y := range min(inner.Height, modalFrame.Height) {
487- for x := range min(inner.Width, modalFrame.Width) {
488- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
489- }
490- }
491- return base, []renderer.Damage{renderer.FullRedraw()}
484+ return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame {
485+ return model.Render(size, preview, styles...)
486+ })
492487 }
493488
494489 func snapshotPickerPreview(tb *tab) picker.Preview {
@@ -481,14 +481,9 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
481 481
482 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {482 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
483 styleSet := resolveThemeStyles(styles)483 styleSet := resolveThemeStyles(styles)
484- inner := pickerModal.Composite(base, styleSet.border)484+ return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, styles ...renderer.Style) renderer.Frame {
485- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview, styleSet.selection)485+ return model.Render(size, preview, styles...)
486- for y := range min(inner.Height, modalFrame.Height) {486+ })
487- for x := range min(inner.Width, modalFrame.Width) {
488- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
489- }
490- }
491- return base, []renderer.Damage{renderer.FullRedraw()}
492 }487 }
493 488
494 func snapshotPickerPreview(tb *tab) picker.Preview {489 func snapshotPickerPreview(tb *tab) picker.Preview {
M internal/usecase/daemon/prompt.go +1 -9
@@ -3,7 +3,6 @@ package daemon
33 import (
44 "strings"
55
6- "github.com/bnema/vev/internal/domain"
76 promptui "github.com/bnema/vev/internal/usecase/prompt"
87 "github.com/bnema/vev/internal/usecase/ui"
98 "github.com/bnema/vev/pkg/renderer"
@@ -98,12 +97,5 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
9897
9998 func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
10099 styleSet := resolveThemeStyles(styles)
101- inner := promptModalFor(model.Title()).Composite(base, styleSet.border)
102- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.accent)
103- for y := range min(inner.Height, modalFrame.Height) {
104- for x := range min(inner.Width, modalFrame.Width) {
105- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
106- }
107- }
108- return base, []renderer.Damage{renderer.FullRedraw()}
100+ return composeModalClientFrame(base, promptModalFor(model.Title()), styleSet, styleSet.accent, model.Render)
109101 }
@@ -3,7 +3,6 @@ package daemon
3 import (3 import (
4 "strings"4 "strings"
5 5
6- "github.com/bnema/vev/internal/domain"
7 promptui "github.com/bnema/vev/internal/usecase/prompt"6 promptui "github.com/bnema/vev/internal/usecase/prompt"
8 "github.com/bnema/vev/internal/usecase/ui"7 "github.com/bnema/vev/internal/usecase/ui"
9 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
@@ -98,12 +97,5 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
98 97
99 func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {98 func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
100 styleSet := resolveThemeStyles(styles)99 styleSet := resolveThemeStyles(styles)
101- inner := promptModalFor(model.Title()).Composite(base, styleSet.border)100+ return composeModalClientFrame(base, promptModalFor(model.Title()), styleSet, styleSet.accent, model.Render)
102- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.accent)
103- for y := range min(inner.Height, modalFrame.Height) {
104- for x := range min(inner.Width, modalFrame.Width) {
105- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
106- }
107- }
108- return base, []renderer.Damage{renderer.FullRedraw()}
109 }101 }
M internal/usecase/daemon/render.go +4 -1
@@ -323,6 +323,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
323323 if overlays.copyActive {
324324 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
325325 }
326+ if overlays.copySearchModel != nil {
327+ frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles)
328+ }
326329 if overlays.pickerActive {
327330 if overlays.previewTab == tb {
328331 if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf {
@@ -343,7 +346,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
343346 overlays.Unlock()
344347 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
345348 p.mu.Lock()
346- desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
349+ desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
347350 p.mu.Unlock()
348351 data, err := ac.rend.Draw(frame, damage)
349352 var cursorTail []byte
@@ -323,6 +323,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
323 if overlays.copyActive {323 if overlays.copyActive {
324 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)324 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
325 }325 }
326+ if overlays.copySearchModel != nil {
327+ frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles)
328+ }
326 if overlays.pickerActive {329 if overlays.pickerActive {
327 if overlays.previewTab == tb {330 if overlays.previewTab == tb {
328 if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf {331 if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf {
@@ -343,7 +346,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
343 overlays.Unlock()346 overlays.Unlock()
344 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)347 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
345 p.mu.Lock()348 p.mu.Lock()
346- desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)349+ desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
347 p.mu.Unlock()350 p.mu.Unlock()
348 data, err := ac.rend.Draw(frame, damage)351 data, err := ac.rend.Draw(frame, damage)
349 var cursorTail []byte352 var cursorTail []byte
A internal/usecase/visualsearch/model.go +197 -0
@@ -0,0 +1,197 @@
1+package visualsearch
2+
3+import (
4+ "strconv"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ scopy "github.com/bnema/vev/internal/usecase/copy"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+type Model struct {
13+ snapshot scopy.Snapshot
14+ input ui.TextInput
15+ matches []scopy.SearchMatch
16+ selected int
17+ scroll int
18+}
19+
20+func New(snapshot scopy.Snapshot) *Model {
21+ m := &Model{snapshot: cloneSnapshot(snapshot), selected: -1}
22+ m.refresh()
23+ return m
24+}
25+
26+func (m *Model) Clone() *Model {
27+ if m == nil {
28+ return nil
29+ }
30+ clone := *m
31+ clone.snapshot = cloneSnapshot(m.snapshot)
32+ clone.input.SetValue(m.input.Value())
33+ clone.matches = append([]scopy.SearchMatch(nil), m.matches...)
34+ return &clone
35+}
36+
37+func (m *Model) Snapshot() scopy.Snapshot {
38+ if m == nil {
39+ return scopy.Snapshot{}
40+ }
41+ return cloneSnapshot(m.snapshot)
42+}
43+
44+func (m *Model) Insert(r rune) {
45+ if m == nil {
46+ return
47+ }
48+ m.input.Insert(r)
49+ m.selected = 0
50+ m.scroll = 0
51+ m.refresh()
52+}
53+
54+func (m *Model) Backspace() {
55+ if m == nil || m.input.Value() == "" {
56+ return
57+ }
58+ m.input.Backspace()
59+ m.selected = 0
60+ m.scroll = 0
61+ m.refresh()
62+}
63+
64+func (m *Model) Query() string {
65+ if m == nil {
66+ return ""
67+ }
68+ return m.input.Value()
69+}
70+
71+func (m *Model) Up() {
72+ if m != nil && m.selected > 0 {
73+ m.selected--
74+ m.clamp()
75+ m.ensureVisible(defaultVisibleRows)
76+ }
77+}
78+
79+func (m *Model) Down() {
80+ if m != nil && m.selected+1 < len(m.matches) {
81+ m.selected++
82+ m.clamp()
83+ m.ensureVisible(defaultVisibleRows)
84+ }
85+}
86+
87+func (m *Model) Selected() (scopy.SearchMatch, bool) {
88+ idx := m.SelectedIndex()
89+ if idx < 0 {
90+ return scopy.SearchMatch{}, false
91+ }
92+ return m.matches[idx], true
93+}
94+
95+func (m *Model) SelectedIndex() int {
96+ if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
97+ return -1
98+ }
99+ return m.selected
100+}
101+
102+func (m *Model) Matches() []scopy.SearchMatch {
103+ if m == nil {
104+ return nil
105+ }
106+ return append([]scopy.SearchMatch(nil), m.matches...)
107+}
108+
109+func (m *Model) refresh() {
110+ m.matches = scopy.FindMatches(m.snapshot, m.input.Value())
111+ m.clamp()
112+ m.ensureVisible(defaultVisibleRows)
113+}
114+
115+func (m *Model) clamp() {
116+ if len(m.matches) == 0 {
117+ m.selected = -1
118+ m.scroll = 0
119+ return
120+ }
121+ if m.selected < 0 {
122+ m.selected = 0
123+ }
124+ if m.selected >= len(m.matches) {
125+ m.selected = len(m.matches) - 1
126+ }
127+ if m.scroll < 0 {
128+ m.scroll = 0
129+ }
130+ if m.scroll > m.selected {
131+ m.scroll = m.selected
132+ }
133+}
134+
135+const defaultVisibleRows = 10
136+
137+func (m *Model) ensureVisible(visible int) {
138+ if m == nil || visible <= 0 || len(m.matches) == 0 {
139+ return
140+ }
141+ if m.selected < m.scroll {
142+ m.scroll = m.selected
143+ }
144+ if m.selected >= m.scroll+visible {
145+ m.scroll = m.selected - visible + 1
146+ }
147+}
148+
149+func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot {
150+ clone := s
151+ clone.Rows = make([][]renderer.Cell, len(s.Rows))
152+ for i, row := range s.Rows {
153+ clone.Rows[i] = append([]renderer.Cell(nil), row...)
154+ }
155+ return clone
156+}
157+
158+func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
159+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
160+ if frame.Width == 0 || frame.Height == 0 {
161+ return frame
162+ }
163+ base := renderer.DefaultStyle()
164+ selection := base
165+ selection.Inverse = true
166+ if len(selectedStyle) > 0 {
167+ selection = selectedStyle[0]
168+ }
169+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
170+ ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection)
171+ visible := frame.Height - 1
172+ if visible <= 0 || len(m.matches) == 0 {
173+ return frame
174+ }
175+ scroll := m.scroll
176+ if m.selected < scroll {
177+ scroll = m.selected
178+ }
179+ if m.selected >= scroll+visible {
180+ scroll = m.selected - visible + 1
181+ }
182+ for y := range visible {
183+ idx := scroll + y
184+ if idx >= len(m.matches) {
185+ break
186+ }
187+ match := m.matches[idx]
188+ style := base
189+ if idx == m.selected {
190+ style = selection
191+ }
192+ ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
193+ label := strconv.Itoa(match.Row+1) + ":" + strconv.Itoa(match.Start+1) + " " + match.Text
194+ ui.DrawText(frame, 0, y+1, frame.Width, label, style)
195+ }
196+ return frame
197+}
@@ -0,0 +1,197 @@
1+package visualsearch
2+
3+import (
4+ "strconv"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ scopy "github.com/bnema/vev/internal/usecase/copy"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+type Model struct {
13+ snapshot scopy.Snapshot
14+ input ui.TextInput
15+ matches []scopy.SearchMatch
16+ selected int
17+ scroll int
18+}
19+
20+func New(snapshot scopy.Snapshot) *Model {
21+ m := &Model{snapshot: cloneSnapshot(snapshot), selected: -1}
22+ m.refresh()
23+ return m
24+}
25+
26+func (m *Model) Clone() *Model {
27+ if m == nil {
28+ return nil
29+ }
30+ clone := *m
31+ clone.snapshot = cloneSnapshot(m.snapshot)
32+ clone.input.SetValue(m.input.Value())
33+ clone.matches = append([]scopy.SearchMatch(nil), m.matches...)
34+ return &clone
35+}
36+
37+func (m *Model) Snapshot() scopy.Snapshot {
38+ if m == nil {
39+ return scopy.Snapshot{}
40+ }
41+ return cloneSnapshot(m.snapshot)
42+}
43+
44+func (m *Model) Insert(r rune) {
45+ if m == nil {
46+ return
47+ }
48+ m.input.Insert(r)
49+ m.selected = 0
50+ m.scroll = 0
51+ m.refresh()
52+}
53+
54+func (m *Model) Backspace() {
55+ if m == nil || m.input.Value() == "" {
56+ return
57+ }
58+ m.input.Backspace()
59+ m.selected = 0
60+ m.scroll = 0
61+ m.refresh()
62+}
63+
64+func (m *Model) Query() string {
65+ if m == nil {
66+ return ""
67+ }
68+ return m.input.Value()
69+}
70+
71+func (m *Model) Up() {
72+ if m != nil && m.selected > 0 {
73+ m.selected--
74+ m.clamp()
75+ m.ensureVisible(defaultVisibleRows)
76+ }
77+}
78+
79+func (m *Model) Down() {
80+ if m != nil && m.selected+1 < len(m.matches) {
81+ m.selected++
82+ m.clamp()
83+ m.ensureVisible(defaultVisibleRows)
84+ }
85+}
86+
87+func (m *Model) Selected() (scopy.SearchMatch, bool) {
88+ idx := m.SelectedIndex()
89+ if idx < 0 {
90+ return scopy.SearchMatch{}, false
91+ }
92+ return m.matches[idx], true
93+}
94+
95+func (m *Model) SelectedIndex() int {
96+ if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
97+ return -1
98+ }
99+ return m.selected
100+}
101+
102+func (m *Model) Matches() []scopy.SearchMatch {
103+ if m == nil {
104+ return nil
105+ }
106+ return append([]scopy.SearchMatch(nil), m.matches...)
107+}
108+
109+func (m *Model) refresh() {
110+ m.matches = scopy.FindMatches(m.snapshot, m.input.Value())
111+ m.clamp()
112+ m.ensureVisible(defaultVisibleRows)
113+}
114+
115+func (m *Model) clamp() {
116+ if len(m.matches) == 0 {
117+ m.selected = -1
118+ m.scroll = 0
119+ return
120+ }
121+ if m.selected < 0 {
122+ m.selected = 0
123+ }
124+ if m.selected >= len(m.matches) {
125+ m.selected = len(m.matches) - 1
126+ }
127+ if m.scroll < 0 {
128+ m.scroll = 0
129+ }
130+ if m.scroll > m.selected {
131+ m.scroll = m.selected
132+ }
133+}
134+
135+const defaultVisibleRows = 10
136+
137+func (m *Model) ensureVisible(visible int) {
138+ if m == nil || visible <= 0 || len(m.matches) == 0 {
139+ return
140+ }
141+ if m.selected < m.scroll {
142+ m.scroll = m.selected
143+ }
144+ if m.selected >= m.scroll+visible {
145+ m.scroll = m.selected - visible + 1
146+ }
147+}
148+
149+func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot {
150+ clone := s
151+ clone.Rows = make([][]renderer.Cell, len(s.Rows))
152+ for i, row := range s.Rows {
153+ clone.Rows[i] = append([]renderer.Cell(nil), row...)
154+ }
155+ return clone
156+}
157+
158+func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
159+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
160+ if frame.Width == 0 || frame.Height == 0 {
161+ return frame
162+ }
163+ base := renderer.DefaultStyle()
164+ selection := base
165+ selection.Inverse = true
166+ if len(selectedStyle) > 0 {
167+ selection = selectedStyle[0]
168+ }
169+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
170+ ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection)
171+ visible := frame.Height - 1
172+ if visible <= 0 || len(m.matches) == 0 {
173+ return frame
174+ }
175+ scroll := m.scroll
176+ if m.selected < scroll {
177+ scroll = m.selected
178+ }
179+ if m.selected >= scroll+visible {
180+ scroll = m.selected - visible + 1
181+ }
182+ for y := range visible {
183+ idx := scroll + y
184+ if idx >= len(m.matches) {
185+ break
186+ }
187+ match := m.matches[idx]
188+ style := base
189+ if idx == m.selected {
190+ style = selection
191+ }
192+ ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
193+ label := strconv.Itoa(match.Row+1) + ":" + strconv.Itoa(match.Start+1) + " " + match.Text
194+ ui.DrawText(frame, 0, y+1, frame.Width, label, style)
195+ }
196+ return frame
197+}
A internal/usecase/visualsearch/model_test.go +100 -0
@@ -0,0 +1,100 @@
1+package visualsearch
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+func testSnapshot(lines ...string) scopy.Snapshot {
15+ rows := make([][]renderer.Cell, len(lines))
16+ for i, line := range lines {
17+ runes := []rune(line)
18+ rows[i] = make([]renderer.Cell, len(runes))
19+ for x, r := range runes {
20+ rows[i][x] = renderer.Cell{Rune: r}
21+ }
22+ }
23+ return scopy.Snapshot{Rows: rows, Width: 40, Height: 4}
24+}
25+
26+func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
27+ m := New(testSnapshot("alpha", "beta alpha", "gamma"))
28+
29+ for _, r := range "alpha" {
30+ m.Insert(r)
31+ }
32+
33+ matches := m.Matches()
34+ require.Len(t, matches, 2)
35+ require.Equal(t, 0, matches[0].Row)
36+ require.Equal(t, "alpha", matches[0].Text)
37+ require.Equal(t, 1, matches[1].Row)
38+ selected, ok := m.Selected()
39+ require.True(t, ok)
40+ require.Equal(t, matches[0], selected)
41+
42+ m.Down()
43+ selected, ok = m.Selected()
44+ require.True(t, ok)
45+ require.Equal(t, matches[1], selected)
46+}
47+
48+func TestVisualSearchCloneDoesNotAliasInput(t *testing.T) {
49+ m := New(testSnapshot("alpha", "beta alpha"))
50+ for _, r := range "alpha" {
51+ m.Insert(r)
52+ }
53+ clone := m.Clone()
54+
55+ clone.Insert('z')
56+
57+ require.Equal(t, "alpha", m.Query())
58+ require.Equal(t, "alphaz", clone.Query())
59+ require.Len(t, m.Matches(), 2)
60+ require.Len(t, clone.Matches(), 0)
61+}
62+
63+func TestVisualSearchRenderDistinguishesMultipleMatchesOnSameLine(t *testing.T) {
64+ m := New(testSnapshot("alpha alpha"))
65+ for _, r := range "alpha" {
66+ m.Insert(r)
67+ }
68+
69+ frame := m.Render(domain.Size{Cols: 40, Rows: 4})
70+ text := rowText(frame.Row(1)) + "\n" + rowText(frame.Row(2))
71+
72+ require.Contains(t, text, "1:1")
73+ require.Contains(t, text, "1:7")
74+}
75+
76+func TestVisualSearchRenderShowsInputAndLineResults(t *testing.T) {
77+ m := New(testSnapshot("alpha", "beta alpha", "gamma"))
78+ for _, r := range "alpha" {
79+ m.Insert(r)
80+ }
81+
82+ frame := m.Render(domain.Size{Cols: 40, Rows: 4})
83+
84+ text := rowText(frame.Row(0)) + "\n" + rowText(frame.Row(1)) + "\n" + rowText(frame.Row(2))
85+ require.Contains(t, text, "/alpha")
86+ require.Contains(t, text, "1:1 alpha")
87+ require.Contains(t, text, "2:6 beta alpha")
88+}
89+
90+func rowText(row []renderer.Cell) string {
91+ var b strings.Builder
92+ for _, c := range row {
93+ if c.Rune == 0 {
94+ b.WriteRune(' ')
95+ continue
96+ }
97+ b.WriteRune(c.Rune)
98+ }
99+ return b.String()
100+}
@@ -0,0 +1,100 @@
1+package visualsearch
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+func testSnapshot(lines ...string) scopy.Snapshot {
15+ rows := make([][]renderer.Cell, len(lines))
16+ for i, line := range lines {
17+ runes := []rune(line)
18+ rows[i] = make([]renderer.Cell, len(runes))
19+ for x, r := range runes {
20+ rows[i][x] = renderer.Cell{Rune: r}
21+ }
22+ }
23+ return scopy.Snapshot{Rows: rows, Width: 40, Height: 4}
24+}
25+
26+func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
27+ m := New(testSnapshot("alpha", "beta alpha", "gamma"))
28+
29+ for _, r := range "alpha" {
30+ m.Insert(r)
31+ }
32+
33+ matches := m.Matches()
34+ require.Len(t, matches, 2)
35+ require.Equal(t, 0, matches[0].Row)
36+ require.Equal(t, "alpha", matches[0].Text)
37+ require.Equal(t, 1, matches[1].Row)
38+ selected, ok := m.Selected()
39+ require.True(t, ok)
40+ require.Equal(t, matches[0], selected)
41+
42+ m.Down()
43+ selected, ok = m.Selected()
44+ require.True(t, ok)
45+ require.Equal(t, matches[1], selected)
46+}
47+
48+func TestVisualSearchCloneDoesNotAliasInput(t *testing.T) {
49+ m := New(testSnapshot("alpha", "beta alpha"))
50+ for _, r := range "alpha" {
51+ m.Insert(r)
52+ }
53+ clone := m.Clone()
54+
55+ clone.Insert('z')
56+
57+ require.Equal(t, "alpha", m.Query())
58+ require.Equal(t, "alphaz", clone.Query())
59+ require.Len(t, m.Matches(), 2)
60+ require.Len(t, clone.Matches(), 0)
61+}
62+
63+func TestVisualSearchRenderDistinguishesMultipleMatchesOnSameLine(t *testing.T) {
64+ m := New(testSnapshot("alpha alpha"))
65+ for _, r := range "alpha" {
66+ m.Insert(r)
67+ }
68+
69+ frame := m.Render(domain.Size{Cols: 40, Rows: 4})
70+ text := rowText(frame.Row(1)) + "\n" + rowText(frame.Row(2))
71+
72+ require.Contains(t, text, "1:1")
73+ require.Contains(t, text, "1:7")
74+}
75+
76+func TestVisualSearchRenderShowsInputAndLineResults(t *testing.T) {
77+ m := New(testSnapshot("alpha", "beta alpha", "gamma"))
78+ for _, r := range "alpha" {
79+ m.Insert(r)
80+ }
81+
82+ frame := m.Render(domain.Size{Cols: 40, Rows: 4})
83+
84+ text := rowText(frame.Row(0)) + "\n" + rowText(frame.Row(1)) + "\n" + rowText(frame.Row(2))
85+ require.Contains(t, text, "/alpha")
86+ require.Contains(t, text, "1:1 alpha")
87+ require.Contains(t, text, "2:6 beta alpha")
88+}
89+
90+func rowText(row []renderer.Cell) string {
91+ var b strings.Builder
92+ for _, c := range row {
93+ if c.Rune == 0 {
94+ b.WriteRune(' ')
95+ continue
96+ }
97+ b.WriteRune(c.Rune)
98+ }
99+ return b.String()
100+}

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