v0.5.1 657f658

controls.go RawHistoryFolder

1package ui
2
3import (
4	"fmt"
5	"github.com/bnema/nefergui/internal/edit"
6	"strconv"
7	"strings"
8)
9
10// Control event values are immutable snapshots; querying them never consumes events.
11type ButtonEvent struct{ activated bool }
12
13func (e ButtonEvent) Activated() bool { return e.activated }
14
15type EditEvent struct{ changed, submitted bool }
16
17func (e EditEvent) Changed() bool   { return e.changed }
18func (e EditEvent) Submitted() bool { return e.submitted }
19
20type ChangeEvent struct{ changed bool }
21
22func (e ChangeEvent) Changed() bool { return e.changed }
23func (n Node) Button(label string, options ...ButtonOption) ButtonEvent {
24	child := n.buttonLike("button", "button", label, options)
25	return ButtonEvent{activated: activated(child)}
26}
27
28func (n Node) buttonLike(typ, role, label string, options []ButtonOption) Node {
29	opts := make([]ContainerOption, 0, len(options))
30	for _, o := range options {
31		if c, ok := o.(ContainerOption); ok {
32			opts = append(opts, c)
33		}
34	}
35	child := n.child(typ, role, opts)
36	if !child.valid() {
37		return child
38	}
39	child.element.text = label
40	for _, o := range options {
41		if _, common := o.(ContainerOption); !common {
42			o.button(child.element)
43		}
44	}
45	child.frame.compute(child.element) // disabled is set by control-only options
46	return child
47}
48
49func activated(child Node) bool {
50	if !child.valid() || child.element.disabled {
51		return false
52	}
53	for _, input := range child.element.events {
54		if input.Kind == "activate" {
55			return true
56		}
57	}
58	return false
59}
60func (n Node) Input(label string, value *string, options ...EditOption) EditEvent {
61	return n.editControl("input", label, value, options)
62}
63func (n Node) Textarea(label string, value *string, options ...EditOption) EditEvent {
64	return n.editControl("textarea", label, value, options)
65}
66func (n Node) editControl(typ, label string, value *string, options []EditOption) EditEvent {
67	opts := make([]ContainerOption, 0, len(options))
68	for _, o := range options {
69		if c, ok := o.(ContainerOption); ok {
70			opts = append(opts, c)
71		}
72	}
73	child := n.child(typ, "textbox", opts)
74	if !child.valid() {
75		return EditEvent{}
76	}
77	child.element.text = label
78	for _, o := range options {
79		if _, common := o.(ContainerOption); !common {
80			o.edit(child.element)
81		}
82	}
83	var ev EditEvent
84	if value == nil {
85		return ev
86	}
87	id := idKey(child.element.identity)
88	editor := child.frame.edits[id]
89	if editor == nil {
90		editor = &edit.State{}
91		editor.Sync(*value)
92		editor.Move(len(editor.Value), false)
93		child.frame.edits[id] = editor
94	}
95	if editor.Value != *value {
96		editor.Sync(*value)
97	}
98	if !child.element.disabled {
99		for _, input := range child.element.events {
100			switch input.Kind {
101			case "edit-pointer", "edit-word":
102				editor.PreferredXValid = false
103				place := newEditorGeometry(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, child.element.password).nearest(input.X, input.Y)
104				if input.Kind == "edit-word" {
105					start, end := editor.WordAt(place)
106					editor.Select(start, end)
107				} else {
108					editor.Move(place, input.Shift)
109				}
110			case "text":
111				editor.PreferredXValid = false
112				ev.changed = editor.Insert(input.Text) || ev.changed
113			case "edit-key":
114				if (input.Text == "up" || input.Text == "down") && typ == "textarea" {
115					moveVisualLine(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, child.element.password, input.Text == "down", input.Shift)
116				} else {
117					editor.PreferredXValid = false
118					if input.Ctrl && input.Text == "v" && child.frame.owner != nil {
119						if async, ok := child.frame.clipboard.(edit.AsyncClipboard); ok {
120							if err := child.frame.owner.startPaste(async, child.element.identity); err != nil && debugDiagnostics {
121								child.frame.diagnostics = append(child.frame.diagnostics, fmt.Sprintf("paste: %v", err))
122							}
123							break
124						}
125					}
126					ev.changed = editKey(editor, input, child.frame.clipboard, child.element.password) || ev.changed
127				}
128			case "paste-result":
129				changed, err := editor.PasteAsync([]byte(input.Text), input.Err)
130				if err != nil && debugDiagnostics {
131					child.frame.diagnostics = append(child.frame.diagnostics, fmt.Sprintf("paste: %v", err))
132				}
133				ev.changed = changed || ev.changed
134			case "ime-done":
135				ev.changed = editor.Done(input.IME) || ev.changed
136			case "submit":
137				ev.submitted = true
138			}
139		}
140	}
141	*value = editor.Value
142	if child.element.password {
143		child.element.text = editor.Mask()
144	} else {
145		child.element.text = editor.Value
146	}
147	// The composition is projected into the display list at the cursor;
148	// it never enters the bound model before IME commit.
149	if ev.changed {
150		editor.Surround(child.frame.ime, child.element.password)
151	}
152	return ev
153}
154func editKey(s *edit.State, ev inputEvent, clipboard edit.Clipboard, password bool) bool {
155	name := strings.ToLower(ev.Text)
156	if ev.Ctrl {
157		switch name {
158		case "a":
159			s.Select(0, len(s.Value))
160			return false
161		case "c":
162			_ = s.Copy(clipboard, password)
163			return false
164		case "x":
165			changed, _ := s.Cut(clipboard, password)
166			return changed
167		case "v":
168			changed, _ := s.Paste(clipboard)
169			return changed
170		case "left":
171			s.Move(s.WordLeft(), ev.Shift)
172			return false
173		case "right":
174			s.Move(s.WordRight(), ev.Shift)
175			return false
176		case "backspace":
177			return s.Delete(true, true)
178		case "delete":
179			return s.Delete(false, true)
180		}
181	}
182	switch name {
183	case "left":
184		s.Left(ev.Shift)
185	case "right":
186		s.Right(ev.Shift)
187	case "home":
188		s.Move(0, ev.Shift)
189	case "end":
190		s.Move(len(s.Value), ev.Shift)
191	case "backspace":
192		return s.Delete(true, false)
193	case "delete":
194		return s.Delete(false, false)
195	}
196	return false
197}
198func (n Node) Checkbox(label string, value *bool, options ...ButtonOption) ChangeEvent {
199	child := n.buttonLike("checkbox", "checkbox", label, options)
200	if child.valid() && value != nil {
201		child.element.checked = *value
202	}
203	if activated(child) && value != nil {
204		*value = !*value
205		child.element.checked = *value
206		return ChangeEvent{true}
207	}
208	return ChangeEvent{}
209}
210func (n Node) Text(text string, options ...ContainerOption) {
211	child := n.child("text", "", options)
212	if child.valid() {
213		child.element.text = text
214	}
215}
216func (n Node) Heading(text string, options ...HeadingOption) {
217	opts := make([]ContainerOption, 0, len(options))
218	for _, o := range options {
219		if c, ok := o.(ContainerOption); ok {
220			opts = append(opts, c)
221		}
222	}
223	child := n.child("heading", "heading", opts)
224	if !child.valid() {
225		return
226	}
227	child.element.text = text
228	child.element.level = 2
229	for _, o := range options {
230		if _, common := o.(ContainerOption); !common {
231			o.heading(child.element)
232		}
233	}
234}
235
236// TextInt shows prefix followed by v in decimal without fmt boxing. A prefix
237// up to 76 bytes is built in a stack buffer, so the only allocation is the
238// final string; a longer prefix costs one more.
239func (n Node) TextInt(prefix string, v int64, options ...ContainerOption) {
240	child := n.child("text", "", options)
241	if !child.valid() {
242		return
243	}
244	var stack [96]byte
245	buf := append(stack[:0], prefix...) // grows on the heap for a long prefix
246	buf = strconv.AppendInt(buf, v, 10)
247	child.element.text = string(buf)
248}
249
250// maskBullets backs Masked for common counts without allocating.
251var maskBullets = strings.Repeat("•", 256)
252
253// Masked shows count bullet glyphs and stores no value, for a secret held
254// outside the view (a password field fed from an external buffer). A negative
255// count shows nothing.
256func (n Node) Masked(count int, options ...ContainerOption) {
257	child := n.child("text", "", options)
258	if !child.valid() || count <= 0 {
259		return
260	}
261	if count <= 256 {
262		child.element.text = maskBullets[:count*len("•")]
263		return
264	}
265	child.element.text = strings.Repeat("•", count)
266}

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 1

Tags 12

↑↓ moveenter openesc close