1package visualsearch
2
3import (
4 "strconv"
5
6 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"
8 scopy "github.com/bnema/vev/internal/usecase/copy"
9 "github.com/bnema/vev/internal/usecase/ui"
10)
11
12type Model struct {
13 snapshot scopy.Snapshot
14 input ui.TextInput
15 matches []scopy.SearchMatch
16 selected int
17 scroll int
18}
19
20func New(snapshot scopy.Snapshot) *Model {
21 m := &Model{snapshot: snapshot, selected: -1}
22 m.refresh()
23 return m
24}
25
26func (m *Model) Clone() *Model {
27 if m == nil {
28 return nil
29 }
30 clone := *m
31 clone.input.SetValue(m.input.Value())
32 clone.matches = append([]scopy.SearchMatch(nil), m.matches...)
33 return &clone
34}
35
36func (m *Model) Snapshot() scopy.Snapshot {
37 if m == nil {
38 return scopy.NewSnapshotFromRows(nil, 0, 0)
39 }
40 return m.snapshot
41}
42
43func (m *Model) Insert(r rune) {
44 if m == nil {
45 return
46 }
47 m.input.Insert(r)
48 m.selected = 0
49 m.scroll = 0
50 m.refresh()
51}
52
53func (m *Model) Backspace() {
54 if m == nil || m.input.Value() == "" {
55 return
56 }
57 m.input.Backspace()
58 m.selected = 0
59 m.scroll = 0
60 m.refresh()
61}
62
63func (m *Model) Query() string {
64 if m == nil {
65 return ""
66 }
67 return m.input.Value()
68}
69
70// searchInputPrefix leads the query line on row 0 of the rendered frame.
71const searchInputPrefix = "/"
72
73// Caret is the query caret column on row 0 of a Render frame of size inner
74// (see ui.InputCaret).
75func (m *Model) Caret(inner domain.Size) (int, bool) {
76 return ui.InputCaret(inner, searchInputPrefix, m.Query())
77}
78
79func (m *Model) Up() {
80 if m != nil && m.selected > 0 {
81 m.selected--
82 m.clamp()
83 m.ensureVisible(defaultVisibleRows)
84 }
85}
86
87func (m *Model) Down() {
88 if m != nil && m.selected+1 < len(m.matches) {
89 m.selected++
90 m.clamp()
91 m.ensureVisible(defaultVisibleRows)
92 }
93}
94
95func (m *Model) Selected() (scopy.SearchMatch, bool) {
96 idx := m.SelectedIndex()
97 if idx < 0 {
98 return scopy.SearchMatch{}, false
99 }
100 return m.matches[idx], true
101}
102
103func (m *Model) SelectedIndex() int {
104 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
105 return -1
106 }
107 return m.selected
108}
109
110func (m *Model) Matches() []scopy.SearchMatch {
111 if m == nil {
112 return nil
113 }
114 return append([]scopy.SearchMatch(nil), m.matches...)
115}
116
117func (m *Model) refresh() {
118 m.matches = scopy.FindMatches(scopy.NewDocument(m.snapshot, ""), m.input.Value())
119 m.clamp()
120 m.ensureVisible(defaultVisibleRows)
121}
122
123func (m *Model) clamp() {
124 if len(m.matches) == 0 {
125 m.selected = -1
126 m.scroll = 0
127 return
128 }
129 if m.selected < 0 {
130 m.selected = 0
131 }
132 if m.selected >= len(m.matches) {
133 m.selected = len(m.matches) - 1
134 }
135 if m.scroll < 0 {
136 m.scroll = 0
137 }
138 if m.scroll > m.selected {
139 m.scroll = m.selected
140 }
141}
142
143const defaultVisibleRows = 10
144
145// RenderStyles contains the base chrome surface and active selection role.
146type RenderStyles struct {
147 Base renderer.Style
148 Selection renderer.Style
149}
150
151func (m *Model) ensureVisible(visible int) {
152 if m == nil || visible <= 0 || len(m.matches) == 0 {
153 return
154 }
155 if m.selected < m.scroll {
156 m.scroll = m.selected
157 }
158 if m.selected >= m.scroll+visible {
159 m.scroll = m.selected - visible + 1
160 }
161}
162
163func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
164 base := renderer.DefaultStyle()
165 selection := base
166 selection.Inverse = true
167 if len(selectedStyle) > 0 {
168 selection = selectedStyle[0]
169 }
170 return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection})
171}
172
173// RenderStyled renders with explicit chrome roles captured by the daemon.
174func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame {
175 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
176 if frame.Width == 0 || frame.Height == 0 {
177 return frame
178 }
179 base, selection := styles.Base, styles.Selection
180 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
181 ui.DrawInputLine(frame, 0, searchInputPrefix, m.Query(), base)
182 visible := frame.Height - 1
183 if visible <= 0 || len(m.matches) == 0 {
184 return frame
185 }
186 scroll := m.scroll
187 if m.selected < scroll {
188 scroll = m.selected
189 }
190 if m.selected >= scroll+visible {
191 scroll = m.selected - visible + 1
192 }
193 for y := range visible {
194 idx := scroll + y
195 if idx >= len(m.matches) {
196 break
197 }
198 match := m.matches[idx]
199 style := base
200 if idx == m.selected {
201 style = selection
202 }
203 ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
204 label := strconv.Itoa(match.Row+1) + ":" + strconv.Itoa(match.Start+1) + " " + match.Text
205 ui.DrawText(frame, 0, y+1, frame.Width, label, style)
206 }
207 return frame
208}