v0.7.3 bcc9a45

interaction.go RawHistoryFolder

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}

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