1package ui
2
3import (
4 "math"
5 "strconv"
6 "strings"
7 "time"
8
9 "github.com/bnema/nefergui/internal/css"
10 "github.com/bnema/nefergui/internal/edit"
11 "github.com/bnema/nefergui/internal/keyboard"
12 "github.com/bnema/nefergui/internal/layout"
13 "github.com/bnema/nefergui/internal/text"
14)
15
16// platformInput is the UI-loop port. Coordinates and viewport dimensions are
17// logical pixels; the platform converts physical pointer coordinates once.
18type platformInput struct {
19 Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize
20 X, Y, DX, DY float64
21 Width, Height, Scale float64
22 Key keyboard.Key
23 Button uint32 // evdev button code (BTN_LEFT is primary)
24 Shift, Ctrl bool
25 IME edit.IMEBatch
26 Time time.Time // optional event timestamp; zero uses receipt time
27}
28
29const pointerPrimary uint32 = 0x110 // Linux BTN_LEFT
30
31type interactionState struct {
32 hover, capture, focus, space *identity
33 button uint32
34 down, visible, inside bool
35 x, y float64
36 lastTextPress time.Time
37 lastTextX, lastTextY float64
38 lastTextTarget *identity
39 scroll map[string]layout.Size
40}
41
42func (s interactionState) flags(e *element) css.StateFlags {
43 var flags css.StateFlags
44 if e.identity.same(s.hover) {
45 flags |= css.Hover
46 }
47 if (e.identity.same(s.capture) && s.down && s.button == pointerPrimary) || (s.space != nil && e.identity.same(s.space)) {
48 flags |= css.Active
49 }
50 if e.identity.same(s.focus) {
51 flags |= css.Focus
52 if s.visible {
53 flags |= css.FocusVisible
54 }
55 }
56 if e.disabled {
57 flags |= css.Disabled
58 }
59 if e.checked {
60 flags |= css.Checked
61 }
62 return flags
63}
64
65// idKey is the stable string form of an identity path, computed once when
66// the identity is built so identities stay immutable and safe to share.
67func idKey(i *identity) string {
68 if i == nil {
69 return ""
70 }
71 if i.path == "" {
72 return identityPath(i)
73 }
74 return i.path
75}
76func identityPath(i *identity) string {
77 switch {
78 case i.parent == nil:
79 return "root"
80 case i.explicit:
81 return idKey(i.parent) + "/k" + strconv.Itoa(len(i.key)) + ":" + i.key
82 default:
83 return idKey(i.parent) + "/p" + strconv.Itoa(i.position) + ":" + strconv.Itoa(len(i.typ)) + ":" + i.typ
84 }
85}
86func layoutKind(e *element) layout.Kind {
87 switch e.typ {
88 case "row":
89 return layout.Row
90 case "column":
91 return layout.Column
92 case "stack":
93 return layout.Stack
94 case "scroll":
95 return layout.Scroll
96 // Labelled controls measure and paint their label like text.
97 case "text", "heading", "input", "textarea", "button", "checkbox", "radio", "slider":
98 return layout.Text
99 case "image":
100 return layout.Image
101 default:
102 return layout.Box
103 }
104}
105func (r *runtime) layoutTree(e *element) *layout.Node {
106 n := &layout.Node{ID: idKey(e.identity), Kind: layoutKind(e), Content: e.text, ImageSize: e.imageSize, Image: e.image, NoWrap: e.typ == "input"}
107 if e.computed != nil {
108 n.Style = e.computed.Style
109 }
110 n.Rect, n.HasRect = e.rect, e.hasRect
111 if offset, ok := r.state.scroll[n.ID]; ok {
112 n.ScrollX, n.ScrollY = offset.W, offset.H
113 }
114 if e.typ == "input" || e.typ == "textarea" {
115 // Editor clips are intrinsic even if the authored CSS leaves overflow visible.
116 n.EditorScroll = true
117 }
118 for _, child := range e.children {
119 n.Children = append(n.Children, r.layoutTree(child))
120 }
121 return n
122}
123func (r *runtime) lookup(id string) *element {
124 var walk func(*element) *element
125 walk = func(e *element) *element {
126 if e == nil {
127 return nil
128 }
129 if idKey(e.identity) == id {
130 return e
131 }
132 for _, child := range e.children {
133 if found := walk(child); found != nil {
134 return found
135 }
136 }
137 return nil
138 }
139 return walk(r.committed)
140}
141func (r *runtime) hit(x, y float64) *element { return r.lookup(layout.Hit(r.output.Tree, x, y)) }
142
143// cursor is the CSS cursor of the element under the pointer, from the last
144// committed styles. While a primary button is captured, the capturing element
145// keeps its cursor even if the pointer leaves it (as when dragging a slider).
146// auto and unknown values map to default.
147func (r *runtime) cursor() css.Keyword {
148 r.mu.Lock()
149 defer r.mu.Unlock()
150 id := r.state.hover
151 if r.state.down && r.state.capture != nil {
152 id = r.state.capture
153 }
154 if !r.state.inside || id == nil {
155 return css.KeywordDefault
156 }
157 if e := r.lookup(idKey(id)); e != nil && e.computed != nil {
158 return e.computed.Style.Cursor
159 }
160 return css.KeywordDefault
161}
162func focusable(e *element) bool {
163 if e == nil || e.disabled {
164 return false
165 }
166 switch e.typ {
167 case "button", "checkbox", "input", "radio", "slider", "textarea":
168 return true
169 }
170 return false
171}
172func (r *runtime) focusOrder() []*element {
173 var result []*element
174 var walk func(*layout.Result)
175 walk = func(n *layout.Result) {
176 if n == nil {
177 return
178 }
179 e := r.lookup(n.ID)
180 if focusable(e) {
181 result = append(result, e)
182 }
183 for _, child := range n.Children {
184 walk(child)
185 }
186 }
187 walk(r.output.Tree)
188 return result
189}
190func (r *runtime) setFocus(e *element, visible bool) bool {
191 var id *identity
192 if focusable(e) {
193 id = e.identity
194 }
195 if r.state.focus.same(id) && (id == nil || r.state.visible == visible) {
196 return false
197 }
198 if !r.state.focus.same(id) {
199 r.state.space = nil // changing focus cancels a held Space
200 }
201 r.state.focus, r.state.visible = id, id != nil && visible
202 return true
203}
204func (r *runtime) tab(reverse bool) bool {
205 order := r.focusOrder()
206 if len(order) == 0 {
207 return r.setFocus(nil, false)
208 }
209 idx := -1
210 for i, e := range order {
211 if e.identity.same(r.state.focus) {
212 idx = i
213 break
214 }
215 }
216 if reverse {
217 if idx < 0 {
218 idx = 0
219 }
220 idx = (idx - 1 + len(order)) % len(order)
221 } else {
222 idx = (idx + 1) % len(order)
223 }
224 return r.setFocus(order[idx], true)
225}
226func scrollable(e *element, n *layout.Result) bool {
227 if e == nil || n == nil || !n.Clipped || e.computed == nil {
228 return false
229 }
230 st := e.computed.Style
231 return e.typ == "scroll" || st.OverflowX == css.KeywordScroll || st.OverflowX == css.KeywordAuto || st.OverflowY == css.KeywordScroll || st.OverflowY == css.KeywordAuto
232}
233
234// physicalToLogical is used only at the platform boundary, never for layout or Hit.
235func physicalToLogical(x, y, scale float64) (float64, float64, bool) {
236 if !validPoint(x, y) || !validPoint(scale, 0) || scale <= 0 {
237 return 0, 0, false
238 }
239 return x / scale, y / scale, true
240}
241func validPoint(x, y float64) bool {
242 return !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0)
243}
244func (r *runtime) route(in platformInput) {
245 r.mu.Lock()
246 changed := false
247 s := &r.state
248 queue := func(e *element, kind string) {
249 if e != nil {
250 r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: kind})
251 changed = true
252 }
253 }
254 switch in.Kind {
255 case "resize":
256 if validPoint(in.Width, in.Height) && in.Width >= 0 && in.Height >= 0 && (r.width != in.Width || r.height != in.Height) {
257 r.width, r.height = in.Width, in.Height
258 changed = true
259 }
260 if in.Scale > 0 && !math.IsInf(in.Scale, 0) && !math.IsNaN(in.Scale) && r.scale != in.Scale {
261 r.scale = in.Scale
262 changed = true
263 }
264 case "focus-out":
265 if s.inside || s.hover != nil || s.down || s.capture != nil || s.space != nil {
266 s.inside = false
267 s.hover = nil
268 s.capture = nil
269 s.down = false
270 s.space = nil
271 changed = true
272 }
273 if r.setFocus(nil, false) {
274 changed = true
275 }
276 case "focus-in": // keyboard focus returns only after an explicit traversal/click
277 case "ime-done":
278 if e := r.lookup(idKey(s.focus)); focusable(e) && (e.typ == "input" || e.typ == "textarea") {
279 r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "ime-done", IME: in.IME})
280 changed = true
281 }
282 case "leave":
283 // A pointer leave is not a button release. Keep capture for the
284 // matching release, but do not hit-test its stale coordinates.
285 if s.inside || s.hover != nil {
286 s.inside = false
287 s.hover = nil
288 changed = true
289 }
290 case "motion", "press", "release", "axis":
291 if !validPoint(in.X, in.Y) {
292 break
293 }
294 // A release after leave must not turn stale in-surface coordinates
295 // into a new hover or an activation.
296 if in.Kind != "release" || s.inside {
297 s.x, s.y = in.X, in.Y
298 s.inside = true
299 }
300 var target *element
301 if s.inside {
302 target = r.hit(in.X, in.Y)
303 }
304 var hover *identity
305 if target != nil {
306 hover = target.identity
307 }
308 if !s.hover.same(hover) {
309 s.hover = hover
310 changed = true
311 }
312 if s.down && s.button == pointerPrimary && in.Kind == "motion" {
313 if captured := r.lookup(idKey(s.capture)); focusable(captured) && (captured.typ == "input" || captured.typ == "textarea") {
314 r.pending = append(r.pending, inputEvent{Target: captured.identity, Kind: "edit-pointer", X: in.X, Y: in.Y, Shift: true})
315 changed = true
316 }
317 if captured := r.lookup(idKey(s.capture)); focusable(captured) && captured.typ == "slider" {
318 r.pending = append(r.pending, inputEvent{Target: captured.identity, Kind: "slider-pointer", X: in.X, Track: resultByID(r.output.Tree, idKey(captured.identity)).Content})
319 changed = true
320 }
321 }
322 switch in.Kind {
323 case "press":
324 if !s.down {
325 if in.Button != pointerPrimary || target == nil || (target.typ != "input" && target.typ != "textarea") {
326 s.lastTextPress = time.Time{}
327 }
328 s.down = true
329 s.button = in.Button
330 changed = true
331 if target != nil {
332 s.capture = target.identity
333 }
334 if in.Button == pointerPrimary && r.setFocus(target, false) {
335 changed = true
336 }
337 if in.Button == pointerPrimary && focusable(target) && (target.typ == "input" || target.typ == "textarea") {
338 at := in.Time
339 if at.IsZero() {
340 at = time.Now()
341 }
342 double := target.identity.same(s.lastTextTarget) && !s.lastTextPress.IsZero() && at.Sub(s.lastTextPress) >= 0 && at.Sub(s.lastTextPress) <= 500*time.Millisecond && math.Hypot(in.X-s.lastTextX, in.Y-s.lastTextY) <= 4
343 kind := "edit-pointer"
344 if double {
345 kind = "edit-word"
346 }
347 r.pending = append(r.pending, inputEvent{Target: target.identity, Kind: kind, X: in.X, Y: in.Y, Shift: in.Shift})
348 s.lastTextPress, s.lastTextX, s.lastTextY, s.lastTextTarget = at, in.X, in.Y, target.identity
349 changed = true
350 }
351 if in.Button == pointerPrimary && focusable(target) && target.typ == "slider" {
352 r.pending = append(r.pending, inputEvent{Target: target.identity, Kind: "slider-pointer", X: in.X, Track: resultByID(r.output.Tree, idKey(target.identity)).Content})
353 changed = true
354 }
355 }
356 case "release":
357 if s.down && in.Button == s.button {
358 if s.button == pointerPrimary {
359 if captured := r.lookup(idKey(s.capture)); focusable(captured) && captured.typ == "slider" && s.inside {
360 r.pending = append(r.pending, inputEvent{Target: captured.identity, Kind: "slider-pointer", X: in.X, Track: resultByID(r.output.Tree, idKey(captured.identity)).Content})
361 changed = true
362 }
363 if captured := r.lookup(idKey(s.capture)); captured != nil && captured == target && resultByID(r.output.Tree, idKey(s.capture)) != nil && focusable(captured) {
364 if captured.typ == "button" || captured.typ == "checkbox" || captured.typ == "radio" {
365 queue(captured, "activate")
366 }
367 }
368 }
369 s.down = false
370 s.capture = nil
371 changed = true
372 }
373 case "axis":
374 if !validPoint(in.DX, in.DY) {
375 break
376 }
377 for e := target; e != nil; e = e.parent {
378 n := resultByID(r.output.Tree, idKey(e.identity))
379 if !scrollable(e, n) {
380 continue
381 }
382 old := s.scroll[n.ID]
383 next := layout.Size{W: math.Max(0, math.Min(n.ContentSize.W-n.Content.W, old.W+in.DX)), H: math.Max(0, math.Min(n.ContentSize.H-n.Content.H, old.H+in.DY))}
384 if next != old {
385 if s.scroll == nil {
386 s.scroll = make(map[string]layout.Size)
387 }
388 s.scroll[n.ID] = next
389 changed = true
390 }
391 break
392 }
393 }
394 case "key":
395 name := strings.ToLower(in.Key.Name)
396 if name == "space" && !in.Key.Pressed {
397 if s.space != nil {
398 if e := r.lookup(idKey(s.space)); e != nil && e.identity.same(s.focus) && focusable(e) && resultByID(r.output.Tree, idKey(s.space)) != nil {
399 queue(e, "activate")
400 }
401 s.space = nil
402 changed = true
403 }
404 break
405 }
406 if !in.Key.Pressed {
407 break
408 }
409 if e := r.lookup(idKey(s.focus)); focusable(e) && (e.typ == "input" || e.typ == "textarea" || e.typ == "slider") {
410 if name == "tab" || name == "iso_left_tab" {
411 if !in.Key.Repeat && r.tab(in.Shift || name == "iso_left_tab") {
412 changed = true
413 }
414 break
415 }
416 if e.typ == "slider" {
417 switch name {
418 case "left", "right", "up", "down", "home", "end", "page_up", "page_down":
419 queue(e, "slider-key")
420 r.pending[len(r.pending)-1].Text = name
421 }
422 } else {
423 switch name {
424 case "left", "right", "up", "down", "home", "end", "backspace", "delete":
425 r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "edit-key", Text: name, Ctrl: in.Ctrl, Shift: in.Shift})
426 changed = true
427 case "a", "c", "x", "v":
428 if in.Ctrl && !in.Key.Repeat {
429 r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "edit-key", Text: name, Ctrl: true, Shift: in.Shift})
430 changed = true
431 } else if !in.Ctrl && in.Key.Text != "" && !r.composing(e.identity) {
432 r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "text", Text: in.Key.Text})
433 changed = true
434 }
435 case "return", "kp_enter":
436 if e.typ == "textarea" {
437 if !r.composing(e.identity) {
438 r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "text", Text: "\n"})
439 changed = true
440 }
441 } else {
442 queue(e, "submit")
443 }
444 default:
445 if in.Key.Text != "" && !in.Ctrl && !r.composing(e.identity) {
446 r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "text", Text: in.Key.Text})
447 changed = true
448 }
449 }
450 }
451 break
452 }
453 if in.Key.Repeat {
454 break
455 }
456 switch name {
457 case "escape":
458 if s.space != nil {
459 s.space = nil
460 changed = true
461 }
462 case "tab", "iso_left_tab":
463 if r.tab(in.Shift || name == "iso_left_tab") {
464 changed = true
465 }
466 case "return", "kp_enter":
467 if e := r.lookup(idKey(s.focus)); focusable(e) && resultByID(r.output.Tree, idKey(s.focus)) != nil && (e.typ == "button" || e.typ == "checkbox" || e.typ == "radio") {
468 queue(e, "activate")
469 }
470 case "space":
471 if e := r.lookup(idKey(s.focus)); s.space == nil && focusable(e) && resultByID(r.output.Tree, idKey(s.focus)) != nil && (e.typ == "button" || e.typ == "checkbox" || e.typ == "radio") {
472 s.space = e.identity
473 changed = true
474 }
475 default:
476 }
477 }
478 if changed {
479 r.redraw = true
480 }
481 r.mu.Unlock()
482 if changed {
483 select {
484 case r.wake <- struct{}{}:
485 default:
486 }
487 }
488}
489func resultByID(n *layout.Result, id string) *layout.Result {
490 if n == nil {
491 return nil
492 }
493 if n.ID == id {
494 return n
495 }
496 for _, child := range n.Children {
497 if found := resultByID(child, id); found != nil {
498 return found
499 }
500 }
501 return nil
502}
503
504// viewport and text engine are UI-loop inputs, never physical layout units.
505func (r *runtime) setTextEngine(engine *text.Engine) {
506 r.mu.Lock()
507 r.textEngine = engine
508 r.mu.Unlock()
509 r.Redraw()
510}
511
512// composing reports an input-method composition in progress on the editor.
513// Keys reaching wl_keyboard were not grabbed by the input method and are typed,
514// except while a preedit is shown: a non-grabbing input method owns that text
515// until it commits, so plain keys would interleave with the composition.
516func (r *runtime) composing(id *identity) bool {
517 editor := r.edits[idKey(id)]
518 return editor != nil && editor.IMEActive() && editor.Preedit != ""
519}