model.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close