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
70func (m *Model) Up() {
71 if m != nil && m.selected > 0 {
72 m.selected--
73 m.clamp()
74 m.ensureVisible(defaultVisibleRows)
75 }
76}
77
78func (m *Model) Down() {
79 if m != nil && m.selected+1 < len(m.matches) {
80 m.selected++
81 m.clamp()
82 m.ensureVisible(defaultVisibleRows)
83 }
84}
85
86func (m *Model) Selected() (scopy.SearchMatch, bool) {
87 idx := m.SelectedIndex()
88 if idx < 0 {
89 return scopy.SearchMatch{}, false
90 }
91 return m.matches[idx], true
92}
93
94func (m *Model) SelectedIndex() int {
95 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
96 return -1
97 }
98 return m.selected
99}
100
101func (m *Model) Matches() []scopy.SearchMatch {
102 if m == nil {
103 return nil
104 }
105 return append([]scopy.SearchMatch(nil), m.matches...)
106}
107
108func (m *Model) refresh() {
109 m.matches = scopy.FindMatches(scopy.NewDocument(m.snapshot, ""), m.input.Value())
110 m.clamp()
111 m.ensureVisible(defaultVisibleRows)
112}
113
114func (m *Model) clamp() {
115 if len(m.matches) == 0 {
116 m.selected = -1
117 m.scroll = 0
118 return
119 }
120 if m.selected < 0 {
121 m.selected = 0
122 }
123 if m.selected >= len(m.matches) {
124 m.selected = len(m.matches) - 1
125 }
126 if m.scroll < 0 {
127 m.scroll = 0
128 }
129 if m.scroll > m.selected {
130 m.scroll = m.selected
131 }
132}
133
134const defaultVisibleRows = 10
135
136// RenderStyles contains the base chrome surface and active selection role.
137type RenderStyles struct {
138 Base renderer.Style
139 Selection renderer.Style
140}
141
142func (m *Model) ensureVisible(visible int) {
143 if m == nil || visible <= 0 || len(m.matches) == 0 {
144 return
145 }
146 if m.selected < m.scroll {
147 m.scroll = m.selected
148 }
149 if m.selected >= m.scroll+visible {
150 m.scroll = m.selected - visible + 1
151 }
152}
153
154func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
155 base := renderer.DefaultStyle()
156 selection := base
157 selection.Inverse = true
158 if len(selectedStyle) > 0 {
159 selection = selectedStyle[0]
160 }
161 return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection})
162}
163
164// RenderStyled renders with explicit chrome roles captured by the daemon.
165func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame {
166 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
167 if frame.Width == 0 || frame.Height == 0 {
168 return frame
169 }
170 base, selection := styles.Base, styles.Selection
171 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
172 ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection)
173 visible := frame.Height - 1
174 if visible <= 0 || len(m.matches) == 0 {
175 return frame
176 }
177 scroll := m.scroll
178 if m.selected < scroll {
179 scroll = m.selected
180 }
181 if m.selected >= scroll+visible {
182 scroll = m.selected - visible + 1
183 }
184 for y := range visible {
185 idx := scroll + y
186 if idx >= len(m.matches) {
187 break
188 }
189 match := m.matches[idx]
190 style := base
191 if idx == m.selected {
192 style = selection
193 }
194 ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
195 label := strconv.Itoa(match.Row+1) + ":" + strconv.Itoa(match.Start+1) + " " + match.Text
196 ui.DrawText(frame, 0, y+1, frame.Width, label, style)
197 }
198 return frame
199}