Merge pull request #19 from bnema/feature/visual-search
feat(daemon): add visual mode search
16 files · +813 -38
M internal/usecase/copy/mode.go +133 -0
| @@ -3,6 +3,7 @@ package copy | |||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/base64" | |
| 5 | 5 | "strings" | |
| 6 | + | "unicode" | |
| 6 | 7 | ||
| 7 | 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 | 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 | 51 | type Mode struct { | |
| 44 | 52 | ViewportTop int | |
| 45 | 53 | Cursor int | |
| 46 | 54 | Anchor int | |
| 47 | 55 | Selecting bool | |
| 56 | + | SearchQuery string | |
| 57 | + | Searches []SearchMatch | |
| 58 | + | SearchIndex int | |
| 48 | 59 | } | |
| 49 | 60 | ||
| 50 | 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 | 71 | func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) } | |
| 61 | 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 | 177 | // SetCursor moves the visual cursor to row, clamping it to the snapshot and | |
| 64 | 178 | // scrolling the viewport just enough to keep it visible. | |
| 65 | 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 | 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 | 281 | if src == m.Cursor && !lineSelected && len(row) > 0 { | |
| 163 | 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 | 290 | 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 | 301 | func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) { | |
| 175 | 302 | if idx >= len(styles) { | |
| 176 | 303 | return renderer.Style{}, false | |
| @@ -220,6 +347,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl | |||
| 220 | 347 | } else { | |
| 221 | 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 | 356 | for i, r := range text { | |
| 224 | 357 | if i >= len(row) { | |
| 225 | 358 | 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 int | 52 | ViewportTop int | ||
| 45 | Cursor int | 53 | Cursor int | ||
| 46 | Anchor int | 54 | Anchor int | ||
| 47 | Selecting bool | 55 | 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 and | 177 | // 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 frame | 290 | 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{}, false | 303 | 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 | break | 358 | break | ||
M internal/usecase/copy/mode_test.go +86 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | func snapshot(lines []string, height int) Snapshot { | |
| @@ -310,6 +311,91 @@ func frameText(row []renderer.Cell) string { | |||
| 310 | 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 | 399 | func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) { | |
| 314 | 400 | s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3) | |
| 315 | 401 | 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 ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | 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 { | |
| 12 | 13 | cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |
| 13 | 14 | } | |
| 14 | 15 | 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 cells | 15 | return cells | ||
M internal/usecase/daemon/copymode.go +85 -4
| @@ -33,9 +33,13 @@ import ( | |||
| 33 | 33 | "github.com/bnema/vev/internal/usecase/keys" | |
| 34 | 34 | "github.com/bnema/vev/internal/usecase/layout" | |
| 35 | 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 | 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 | 43 | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |
| 40 | 44 | rt := ac.overlays | |
| 41 | 45 | tb := sess.activeTab() | |
| @@ -58,7 +62,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 58 | 62 | } | |
| 59 | 63 | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 60 | 64 | if delta > 0 && rt.copyMode.AtBottom(snap) { | |
| 61 | - | rt.copyMode = nil | |
| 65 | + | rt.clearCopyModeLocked() | |
| 62 | 66 | rt.copyMu.Unlock() | |
| 63 | 67 | p.mu.Unlock() | |
| 64 | 68 | d.paint(sess, ac, true) | |
| @@ -67,7 +71,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 67 | 71 | rt.copyMode.Move(snap, delta) | |
| 68 | 72 | exit := delta > 0 && rt.copyMode.AtBottom(snap) | |
| 69 | 73 | if exit { | |
| 70 | - | rt.copyMode = nil | |
| 74 | + | rt.clearCopyModeLocked() | |
| 71 | 75 | } | |
| 72 | 76 | rt.copyMu.Unlock() | |
| 73 | 77 | p.mu.Unlock() | |
| @@ -92,6 +96,8 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 92 | 96 | p.mu.Unlock() | |
| 93 | 97 | rt.copyMu.Lock() | |
| 94 | 98 | rt.copyMode = scopy.NewMode(snap) | |
| 99 | + | rt.copySearch = nil | |
| 100 | + | rt.copySearchPending = nil | |
| 95 | 101 | rt.copyPressRowValid = false | |
| 96 | 102 | rt.copyDragging = false | |
| 97 | 103 | rt.normalMousePressValid = false | |
| @@ -198,6 +204,15 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 198 | 204 | rt.copyPending = nil | |
| 199 | 205 | } | |
| 200 | 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 | 216 | changed := false | |
| 202 | 217 | copyOut := false | |
| 203 | 218 | exit := false | |
| @@ -218,6 +233,19 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 218 | 233 | case ' ', 'v': | |
| 219 | 234 | rt.copyMode.ToggleSelection() | |
| 220 | 235 | 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 | 249 | case '\r', '\n', 'y': | |
| 222 | 250 | copyOut = true | |
| 223 | 251 | exit = true | |
| @@ -248,7 +276,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 248 | 276 | } | |
| 249 | 277 | if exit { | |
| 250 | 278 | d.stopCopyPendingTimerLocked(ac) | |
| 251 | - | rt.copyMode = nil | |
| 279 | + | rt.clearCopyModeLocked() | |
| 252 | 280 | } | |
| 253 | 281 | rt.copyMu.Unlock() | |
| 254 | 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 | 358 | func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |
| 283 | 359 | rt := ac.overlays | |
| 284 | 360 | mode := rt.copyMode | |
| @@ -296,7 +372,7 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |||
| 296 | 372 | rt.copyMu.Unlock() | |
| 297 | 373 | return | |
| 298 | 374 | } | |
| 299 | - | rt.copyMode = nil | |
| 375 | + | rt.clearCopyModeLocked() | |
| 300 | 376 | rt.copyMu.Unlock() | |
| 301 | 377 | ||
| 302 | 378 | if sess := ac.currentSession(); sess != nil { | |
| @@ -339,6 +415,11 @@ func isCopyEscapePrefix(data []byte) bool { | |||
| 339 | 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 | 423 | func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) { | |
| 343 | 424 | styles := newThemeStyles(bars.theme) | |
| 344 | 425 | 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.overlays | 44 | 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 = nil | 65 | + | 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 = nil | 74 | + | 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 = false | 101 | rt.copyPressRowValid = false | ||
| 96 | rt.copyDragging = false | 102 | rt.copyDragging = false | ||
| 97 | rt.normalMousePressValid = false | 103 | rt.normalMousePressValid = false | ||
| @@ -198,6 +204,15 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 198 | rt.copyPending = nil | 204 | 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 := false | 216 | changed := false | ||
| 202 | copyOut := false | 217 | copyOut := false | ||
| 203 | exit := false | 218 | 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 = true | 235 | 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 = true | 250 | copyOut = true | ||
| 223 | exit = true | 251 | 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 = nil | 279 | + | 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.overlays | 359 | rt := ac.overlays | ||
| 284 | mode := rt.copyMode | 360 | mode := rt.copyMode | ||
| @@ -296,7 +372,7 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |||||
| 296 | rt.copyMu.Unlock() | 372 | rt.copyMu.Unlock() | ||
| 297 | return | 373 | return | ||
| 298 | } | 374 | } | ||
| 299 | - | rt.copyMode = nil | 375 | + | 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) { | |||
| 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 | 268 | func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |
| 148 | 269 | writes := make(chan []byte, 1) | |
| 149 | 270 | 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) { | |||
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | 73 | if rt.copyActive() { | |
| 74 | + | if rt.copySearchActive() { | |
| 75 | + | return | |
| 76 | + | } | |
| 74 | 77 | if ev.Button == mouse.Left { | |
| 75 | 78 | contentRow := ev.Row - 1 | |
| 76 | 79 | 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 - 1 | 78 | 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) { | |||
| 144 | 144 | } | |
| 145 | 145 | rt := ac.overlays | |
| 146 | 146 | rt.copyMu.Lock() | |
| 147 | - | rt.copyMode = nil | |
| 147 | + | rt.clearCopyModeLocked() | |
| 148 | 148 | rt.copyPressRowValid = false | |
| 149 | 149 | rt.copyDragging = false | |
| 150 | 150 | rt.normalMousePressValid = false | |
| @@ -144,7 +144,7 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) { | |||||
| 144 | } | 144 | } | ||
| 145 | rt := ac.overlays | 145 | rt := ac.overlays | ||
| 146 | rt.copyMu.Lock() | 146 | rt.copyMu.Lock() | ||
| 147 | - | rt.copyMode = nil | 147 | + | rt.clearCopyModeLocked() |
| 148 | rt.copyPressRowValid = false | 148 | rt.copyPressRowValid = false | ||
| 149 | rt.copyDragging = false | 149 | rt.copyDragging = false | ||
| 150 | rt.normalMousePressValid = false | 150 | rt.normalMousePressValid = false | ||
M internal/usecase/daemon/overlay_runtime.go +24 -3
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "github.com/bnema/vev/internal/usecase/palette" | |
| 8 | 8 | "github.com/bnema/vev/internal/usecase/picker" | |
| 9 | 9 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | type overlayRuntime struct { | |
| @@ -31,6 +32,8 @@ type overlayRuntime struct { | |||
| 31 | 32 | copyMode *scopy.Mode | |
| 32 | 33 | copyPending []byte | |
| 33 | 34 | copyESC pendingByteTimer | |
| 35 | + | copySearch *visualsearch.Model | |
| 36 | + | copySearchPending []byte | |
| 34 | 37 | copyFeedback string | |
| 35 | 38 | copyPressRow int | |
| 36 | 39 | copyPressRowValid bool | |
| @@ -87,6 +90,21 @@ func (rt *overlayRuntime) copyActive() bool { | |||
| 87 | 90 | 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 | 108 | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |
| 91 | 109 | if rt == nil || rt.ac == nil { | |
| 92 | 110 | return false | |
| @@ -114,9 +132,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |||
| 114 | 132 | type overlayRenderSnapshot struct { | |
| 115 | 133 | rt *overlayRuntime | |
| 116 | 134 | ||
| 117 | - | copyActive bool | |
| 118 | - | copyMode *scopy.Mode | |
| 119 | - | copyFeedback string | |
| 135 | + | copyActive bool | |
| 136 | + | copyMode *scopy.Mode | |
| 137 | + | copySearchModel *visualsearch.Model | |
| 138 | + | copyFeedback string | |
| 120 | 139 | ||
| 121 | 140 | pickerActive bool | |
| 122 | 141 | pickerModel *picker.Model | |
| @@ -147,8 +166,10 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 147 | 166 | snap.copyActive = rt.copyMode != nil | |
| 148 | 167 | if rt.copyMode != nil { | |
| 149 | 168 | copyModeValue := *rt.copyMode | |
| 169 | + | copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...) | |
| 150 | 170 | snap.copyMode = ©ModeValue | |
| 151 | 171 | } | |
| 172 | + | snap.copySearchModel = rt.copySearch.Clone() | |
| 152 | 173 | snap.copyFeedback = rt.copyFeedback | |
| 153 | 174 | if snap.copyFeedback != "" && !snap.copyActive { | |
| 154 | 175 | 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.Mode | 32 | copyMode *scopy.Mode | ||
| 32 | copyPending []byte | 33 | copyPending []byte | ||
| 33 | copyESC pendingByteTimer | 34 | copyESC pendingByteTimer | ||
| 35 | + | copySearch *visualsearch.Model | |||
| 36 | + | copySearchPending []byte | |||
| 34 | copyFeedback string | 37 | copyFeedback string | ||
| 35 | copyPressRow int | 38 | copyPressRow int | ||
| 36 | copyPressRowValid bool | 39 | copyPressRowValid bool | ||
| @@ -87,6 +90,21 @@ func (rt *overlayRuntime) copyActive() bool { | |||||
| 87 | return rt.copyMode != nil | 90 | 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 false | 110 | 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 *overlayRuntime | 133 | rt *overlayRuntime | ||
| 116 | 134 | ||||
| 117 | - | copyActive bool | 135 | + | copyActive bool |
| 118 | - | copyMode *scopy.Mode | 136 | + | copyMode *scopy.Mode |
| 119 | - | copyFeedback string | 137 | + | copySearchModel *visualsearch.Model |
| 138 | + | copyFeedback string | |||
| 120 | 139 | ||||
| 121 | pickerActive bool | 140 | pickerActive bool | ||
| 122 | pickerModel *picker.Model | 141 | pickerModel *picker.Model | ||
| @@ -147,8 +166,10 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 147 | snap.copyActive = rt.copyMode != nil | 166 | snap.copyActive = rt.copyMode != nil | ||
| 148 | if rt.copyMode != nil { | 167 | if rt.copyMode != nil { | ||
| 149 | copyModeValue := *rt.copyMode | 168 | copyModeValue := *rt.copyMode | ||
| 169 | + | copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...) | |||
| 150 | snap.copyMode = ©ModeValue | 170 | snap.copyMode = ©ModeValue | ||
| 151 | } | 171 | } | ||
| 172 | + | snap.copySearchModel = rt.copySearch.Clone() | |||
| 152 | snap.copyFeedback = rt.copyFeedback | 173 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 7 | 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 | 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 | 140 | func TestOverlayRuntimeHandleInputInactive(t *testing.T) { | |
| 109 | 141 | ac := &attachedClient{} | |
| 110 | 142 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "github.com/bnema/vev/internal/domain" | |
| 5 | 4 | "github.com/bnema/vev/internal/usecase/command" | |
| 6 | 5 | "github.com/bnema/vev/internal/usecase/layout" | |
| 7 | 6 | "github.com/bnema/vev/internal/usecase/palette" | |
| @@ -243,12 +242,5 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 243 | 242 | ||
| 244 | 243 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 245 | 244 | 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) | |
| 254 | 246 | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package daemon | 1 | 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) { | |||
| 481 | 481 | ||
| 482 | 482 | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 483 | 483 | 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 | + | }) | |
| 492 | 487 | } | |
| 493 | 488 | ||
| 494 | 489 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/internal/domain" | |
| 7 | 6 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 8 | 7 | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | 8 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -98,12 +97,5 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||
| 98 | 97 | ||
| 99 | 98 | func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 100 | 99 | 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) | |
| 109 | 101 | } | |
| @@ -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) { | |||
| 323 | 323 | if overlays.copyActive { | |
| 324 | 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 | 329 | if overlays.pickerActive { | |
| 327 | 330 | if overlays.previewTab == tb { | |
| 328 | 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 | 346 | overlays.Unlock() | |
| 344 | 347 | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | |
| 345 | 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 | 350 | p.mu.Unlock() | |
| 348 | 351 | data, err := ac.rend.Draw(frame, damage) | |
| 349 | 352 | 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 []byte | 352 | 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 | + | } | |||