v0.3.0 99dc266

model.go RawHistoryFolder

1package palette
2
3import (
4	"strings"
5	"unicode"
6	"unicode/utf8"
7
8	renderer "github.com/bnema/vev-vt"
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/usecase/command"
11	"github.com/bnema/vev/internal/usecase/ui"
12)
13
14type RenderStyles struct {
15	// Base fills unused modal interior cells; Row is the ordinary inactive
16	// result surface. They are separate to retain the chrome hierarchy.
17	Base                 renderer.Style
18	Row                  renderer.Style
19	Selection            renderer.Style
20	Description          renderer.Style
21	SelectionDescription renderer.Style
22}
23
24// RenderOptions contains presentation supplied by the daemon for one render.
25// Guidance is substituted only into the exact contextual command row; it never
26// adds rows or changes fuzzy matching geometry.
27type RenderOptions struct {
28	Styles   RenderStyles
29	Guidance string
30	// Preview is the current exact command's action description. It replaces
31	// the selected row's static description unless Feedback is present.
32	Preview string
33	// Feedback is interaction-scoped daemon feedback. It replaces the selected
34	// row's contextual detail without adding a searchable result or row.
35	Feedback string
36}
37
38type Model struct {
39	results         []Result
40	argumentResults []Result
41	input           ui.TextInput
42	matches         []Match
43	selected        int
44	scroll          int
45}
46
47// New accepts typed immutable palette results.
48func New(results []Result) *Model {
49	copied := append([]Result(nil), results...)
50	m := &Model{
51		results:         copied,
52		argumentResults: requiredArgumentResults(copied),
53	}
54	m.refresh()
55	return m
56}
57
58func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) }
59
60func DefaultRenderStyles() RenderStyles {
61	selection := renderer.DefaultStyle()
62	selection.Inverse = true
63	description := renderer.DefaultStyle()
64	description.Italic = true
65	selectionDescription := selection
66	selectionDescription.Italic = true
67	base := renderer.DefaultStyle()
68	return RenderStyles{Base: base, Row: base, Selection: selection, Description: description, SelectionDescription: selectionDescription}
69}
70
71func (m *Model) Insert(r rune) {
72	if m != nil {
73		m.input.Insert(r)
74		m.selected, m.scroll = 0, 0
75		m.refresh()
76	}
77}
78func (m *Model) Backspace() {
79	if m != nil && m.input.Value() != "" {
80		m.input.Backspace()
81		m.selected, m.scroll = 0, 0
82		m.refresh()
83	}
84}
85
86func (m *Model) Query() string {
87	if m == nil {
88		return ""
89	}
90	return m.input.Value()
91}
92func (m *Model) Up() {
93	if m != nil && m.selected > 0 {
94		m.selected--
95		m.clamp()
96	}
97}
98func (m *Model) Down() {
99	if m != nil && m.selected+1 < len(m.matches) {
100		m.selected++
101		m.clamp()
102	}
103}
104
105// Selected returns the typed immutable target, not an executable command.
106func (m *Model) Selected() (Result, bool) {
107	if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
108		return Result{}, false
109	}
110	return m.matches[m.selected].Result, true
111}
112
113// CompleteSelected only completes static command results. Session targets can
114// never be parsed, completed, or otherwise treated as commands.
115func (m *Model) CompleteSelected() bool {
116	selected, ok := m.Selected()
117	if !ok {
118		return false
119	}
120	cmd, ok := selected.Command()
121	if !ok {
122		return false
123	}
124
125	query := m.Query()
126	completed := cmd.Code
127	if cmd.Arguments != command.ArgumentsNone {
128		query = strings.TrimLeftFunc(query, unicode.IsSpace)
129		token, args, hasSeparator := completionParts(query)
130		if token == cmd.Code && hasSeparator && args != "" {
131			return false
132		}
133		completed += " "
134		if args != "" {
135			completed += args
136		}
137	}
138	if completed == query {
139		return false
140	}
141
142	m.input.SetValue(completed)
143	m.selected, m.scroll = 0, 0
144	m.refresh()
145	return true
146}
147
148// completionParts splits the first token at Unicode whitespace and returns
149// the argument beginning at its first non-whitespace byte.
150func completionParts(query string) (token, args string, hasSeparator bool) {
151	for i, r := range query {
152		if !unicode.IsSpace(r) {
153			continue
154		}
155		for j, argumentRune := range query[i:] {
156			if !unicode.IsSpace(argumentRune) {
157				return query[:i], query[i+j:], true
158			}
159		}
160		return query[:i], "", true
161	}
162	return query, "", false
163}
164
165// ArgumentCommand returns the exact argument-taking static command being entered.
166func (m *Model) ArgumentCommand() (command.Command, bool) {
167	if m == nil {
168		return command.Command{}, false
169	}
170	return ArgumentCommand(m.results, m.Query())
171}
172
173func (m *Model) Matches() []Match {
174	if m == nil {
175		return nil
176	}
177	out := make([]Match, len(m.matches))
178	for i, match := range m.matches {
179		out[i] = match
180		out[i].Positions = append([]int(nil), match.Positions...)
181	}
182	return out
183}
184
185func (m *Model) refresh() {
186	query := m.input.Value()
187	m.matches = Fuzzy(m.results, query)
188	// Keep an exact command row selected over fuzzy session matches.
189	if result, ok := ExactCommandResult(m.results, query); ok {
190		m.prependMatch(result)
191		// Once an argument-taking static token is exact, retain its row while its
192		// arguments make ordinary fuzzy matching inapplicable.
193	} else if result, _, ok := argumentResult(m.results, query); ok {
194		m.prependMatch(result)
195	} else if len(m.matches) == 0 {
196		// Arguments are not part of static command matching. When they would
197		// otherwise clear the list, keep argument-capable candidates matched by
198		// the partial first token so Tab can complete them.
199		token, _, hasSeparator := completionParts(query)
200		if hasSeparator {
201			m.matches = Fuzzy(m.argumentResults, token)
202		}
203	}
204	m.clamp()
205}
206
207func requiredArgumentResults(results []Result) []Result {
208	commands := make([]Result, 0, len(results))
209	for _, result := range results {
210		cmd, ok := result.Command()
211		if ok && cmd.Arguments != command.ArgumentsNone {
212			commands = append(commands, result)
213		}
214	}
215	return commands
216}
217
218func (m *Model) prependMatch(result Result) {
219	cmd, _ := result.Command()
220	for i, match := range m.matches {
221		candidate, ok := match.Result.Command()
222		if !ok || candidate.Code != cmd.Code {
223			continue
224		}
225		if i > 0 {
226			copy(m.matches[1:i+1], m.matches[:i])
227			m.matches[0] = match
228		}
229		return
230	}
231	m.matches = append([]Match{newMatch(result, 0)}, m.matches...)
232}
233func (m *Model) clamp() {
234	if len(m.matches) == 0 {
235		m.selected, m.scroll = -1, 0
236		return
237	}
238	if m.selected < 0 {
239		m.selected = 0
240	}
241	if m.selected >= len(m.matches) {
242		m.selected = len(m.matches) - 1
243	}
244	if m.scroll < 0 {
245		m.scroll = 0
246	}
247	if m.scroll > m.selected {
248		m.scroll = m.selected
249	}
250}
251
252func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
253	styles := opts.Styles
254	if styles == (RenderStyles{}) {
255		styles = DefaultRenderStyles()
256	}
257	frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
258	if frame.Width == 0 || frame.Height == 0 {
259		return frame
260	}
261	base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description
262	selectionDesc := styles.SelectionDescription
263	if selectionDesc == (renderer.Style{}) {
264		selectionDesc = mergePaletteDescStyle(selection, desc)
265	}
266	ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
267	if m == nil {
268		ui.DrawInputLine(frame, 0, "> ", "", base, selection)
269		return frame
270	}
271	ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
272	start, visible := 1, frame.Height-1
273	if visible <= 0 || len(m.matches) == 0 {
274		return frame
275	}
276	if m.selected < m.scroll {
277		m.scroll = m.selected
278	}
279	if m.selected >= m.scroll+visible {
280		m.scroll = m.selected - visible + 1
281	}
282	codeWidth := 0
283	for _, match := range m.matches {
284		if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
285			codeWidth = utf8.RuneCountInString(cmd.Code)
286		}
287	}
288	activeCmd, activeOK := m.ArgumentCommand()
289	for y := range visible {
290		idx := m.scroll + y
291		if idx >= len(m.matches) {
292			break
293		}
294		match := m.matches[idx]
295		style := row
296		if idx == m.selected {
297			style = selection
298		}
299		ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
300		if cmd, ok := match.Result.Command(); ok {
301			m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, idx == m.selected)
302			continue
303		}
304		m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)
305	}
306	return frame
307}
308
309func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc, selectionDesc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, preview, feedback string, selected bool) {
310	x, nextHighlight := 0, 0
311	for _, r := range cmd.Code {
312		cellStyle := style
313		cellStyle.Bold = true
314		if nextHighlight < len(positions) && positions[nextHighlight] == x {
315			cellStyle = selection
316			cellStyle.Bold = true
317			nextHighlight++
318		}
319		if x < frame.Width {
320			frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
321		}
322		x++
323	}
324	for x < codeWidth+1 && x < frame.Width {
325		frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
326		x++
327	}
328	description := cmd.Desc
329	if feedback != "" && selected {
330		description = feedback
331	} else if preview != "" && selected {
332		description = preview
333	} else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
334		description = guidance
335	}
336	descriptionStyle := mergePaletteDescStyle(style, desc)
337	if selected {
338		descriptionStyle = selectionDesc
339	}
340	ui.DrawText(frame, x, y, frame.Width, description, descriptionStyle)
341}
342
343func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
344	x, nextHighlight := 0, 0
345	for _, r := range match.Result.DisplayText() {
346		cellStyle := style
347		if nextHighlight < len(match.Positions) && match.Positions[nextHighlight] == x {
348			cellStyle = selection
349			cellStyle.Bold = true
350			nextHighlight++
351		}
352		if x < frame.Width {
353			frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
354		}
355		x++
356	}
357	if feedback == "" || !selected {
358		return
359	}
360	if x < frame.Width {
361		frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
362	}
363	x++
364	for _, r := range feedback {
365		if x < frame.Width {
366			frame.Set(x, y, renderer.Cell{Rune: r, Style: style})
367		}
368		x++
369	}
370}
371
372func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {
373	out := line
374	out.Italic = desc.Italic
375	if desc.HasForegroundRGB {
376		out.HasForegroundRGB = true
377		out.ForegroundRGB = desc.ForegroundRGB
378		out.Foreground = -1
379	} else if desc.Foreground >= 0 {
380		out.HasForegroundRGB = false
381		out.Foreground = desc.Foreground
382	}
383	return out
384}

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