v0.4.0 2de4e1d

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/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}

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