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