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