v0.1.0 47aaa0f

controls.go RawHistoryFolder

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

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