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}