main bcc9a45

runtime.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"sync"
6
7	"github.com/bnema/nefergui/internal/css"
8	"github.com/bnema/nefergui/internal/edit"
9	"github.com/bnema/nefergui/internal/layout"
10	"github.com/bnema/nefergui/internal/text"
11)
12
13// inputEvent is routed against the last committed layout. Target is opaque.
14type inputEvent struct {
15	Target      *identity
16	Kind        string
17	Text        string
18	Err         error
19	Shift, Ctrl bool
20	IME         edit.IMEBatch
21	X, Y        float64     // pointer position in logical pixels
22	Track       layout.Rect // committed slider content box
23}
24
25// runtime serializes frame construction; Queue and Redraw may be called concurrently.
26// Re-entrant or concurrent Build requests a follow-up redraw instead of waiting.
27type runtime struct {
28	buildMu               sync.Mutex
29	mu                    sync.Mutex
30	pending               []inputEvent
31	wake                  chan struct{}
32	generation            uint64
33	committed             *element
34	frame                 Frame           // reused every build; Node handles are fenced by generation
35	arenas                [2]elementArena // one holds the committed tree, the other builds the next
36	committedArena        int
37	styles                *css.Engine
38	output                layout.Output
39	layouts               [2]layout.Arena // one holds output, the other builds the next
40	outputArena           int
41	state                 interactionState
42	width, height, scale  float64
43	textEngine            *text.Engine
44	edits                 map[string]*edit.State
45	clipboard             edit.Clipboard
46	pasteCtx              context.Context
47	pasteCancel           context.CancelFunc
48	pasteID               uint64
49	ime                   edit.IME
50	redraw                bool
51	inputRects            []Rect
52	inputNil, inputStaged bool
53	// layoutErr is the error of the last layout that made Build fail, nil
54	// after a layout that succeeded. Measure reports it.
55	layoutErr error
56	// measuring marks the temporary runtime of Renderer.Measure: it shares the
57	// style and text caches of a Renderer, which only that Renderer's frames
58	// may age.
59	measuring bool
60}
61
62func newRuntime() *runtime {
63	return &runtime{wake: make(chan struct{}, 1), styles: css.Compile(css.UA(), css.Sheet{}), width: 800, height: 600, scale: 1, edits: make(map[string]*edit.State)}
64}
65func (r *runtime) Queue(ev inputEvent) {
66	if ev.Target == nil {
67		return
68	}
69	r.mu.Lock()
70	r.pending = append(r.pending, ev)
71	r.mu.Unlock()
72	select {
73	case r.wake <- struct{}{}:
74	default:
75	}
76}
77
78// Redraw requests a frame even without input; duplicate requests coalesce.
79func (r *runtime) Redraw() {
80	r.mu.Lock()
81	r.redraw = true
82	r.mu.Unlock()
83	select {
84	case r.wake <- struct{}{}:
85	default:
86	}
87}
88
89// Target is a test-only committed-child-path helper; platform input uses route and Hit.
90func (r *runtime) Target(path ...int) *identity {
91	r.mu.Lock()
92	defer r.mu.Unlock()
93	e := r.committed
94	for _, i := range path {
95		if e == nil || i < 0 || i >= len(e.children) {
96			return nil
97		}
98		e = e.children[i]
99	}
100	if e == nil {
101		return nil
102	}
103	return e.identity
104}
105
106// Build drains all already queued input in one synchronous frame. A concurrent
107// or re-entrant call never waits: it requests another frame and returns false.
108// The owner completes its commit before the loop can build that next frame.
109func (r *runtime) Build(view func(*Frame)) bool {
110	if !r.buildMu.TryLock() {
111		r.Redraw()
112		return false
113	}
114	defer r.buildMu.Unlock()
115	r.mu.Lock()
116	// Discard the wake token for the batch we are about to process. New input
117	// arriving during construction leaves its own wake token for the next build.
118	select {
119	case <-r.wake:
120	default:
121	}
122	if len(r.pending) == 0 && !r.redraw && r.generation != 0 {
123		r.mu.Unlock()
124		return false
125	}
126	events := r.pending
127	r.pending = nil
128	r.redraw = false
129	r.generation++
130	gen := r.generation
131	previous := r.committed
132	state := r.state
133	width, height := r.width, r.height
134	r.mu.Unlock()
135	// Build in the arena that does not hold the committed tree, which this
136	// frame reads as previous. A frame that fails to commit leaves it as is.
137	next := 1 - r.committedArena
138	arena := &r.arenas[next]
139	arena.reset()
140	r.frame = Frame{arena: arena, width: width, height: height, generation: gen, active: true, events: events, previous: previous, styles: r.styles, state: state, edits: r.edits, clipboard: r.clipboard, ime: r.ime, layout: r.output, owner: r}
141	f := &r.frame
142	defer func() {
143		f.active = false
144		if !r.measuring {
145			r.styles.EndFrame()
146			if r.textEngine != nil {
147				r.textEngine.EndFrame()
148			}
149		}
150		// A panicking view is not committed; the next build may retry.
151		if recovered := recover(); recovered != nil {
152			r.Redraw()
153			panic(recovered)
154		}
155	}()
156	view(f)
157	r.mu.Lock()
158	// State can change while the view runs. Schedule another frame instead of
159	// overwriting that newer input with this frame's snapshot.
160	r.layoutErr = nil
161	if f.root != nil {
162		f.refresh(f.root)
163		input := r.layoutTree(f.root)
164		// Lay out in the arena that does not hold r.output, which the view and
165		// scrollCaret still read as the committed layout.
166		nextLayout := 1 - r.outputArena
167		opts := layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine, Arena: &r.layouts[nextLayout]}
168		out, err := layout.Layout(input, opts)
169		if err != nil {
170			r.layoutErr = err
171			r.mu.Unlock()
172			r.Redraw()
173			return false
174		}
175		// Re-layout at most once after moving a focused caret into its editor
176		// viewport; the offset is per frame identity and bounded by measured extent.
177		// Reusing the arena is safe: out is not read after scrollCaret returns.
178		if r.scrollCaret(f.root, &out) {
179			input = r.layoutTree(f.root)
180			out, err = layout.Layout(input, opts)
181			if err != nil {
182				r.layoutErr = err
183				r.mu.Unlock()
184				r.Redraw()
185				return false
186			}
187		}
188		r.paintEditors(f.root, &out)
189		r.paintIndicators(f.root, &out)
190		opts.Arena.Keep(out.Display) // recycle the list grown by the decorations
191		r.output, r.outputArena = out, nextLayout
192	}
193	r.committed, r.committedArena = f.root, next
194	// Events mutate the model while the view runs, so elements declared before
195	// the handling control painted the previous value. One follow-up frame,
196	// without events, shows the settled state; it cannot schedule another.
197	if len(events) > 0 {
198		r.redraw = true
199	}
200	// Release every losing editor before enabling the winner: the IME port
201	// represents one seat, not one port per editor. Never depend on map order.
202	for id, editor := range r.edits {
203		e := r.lookup(id)
204		if e == nil || (e.typ != "input" && e.typ != "textarea") || !e.identity.same(r.state.focus) || e.disabled {
205			editor.Blur(r.ime)
206			if e == nil || (e.typ != "input" && e.typ != "textarea") {
207				delete(r.edits, id)
208			}
209		}
210	}
211	if focused := r.lookup(idKey(r.state.focus)); focused != nil && (focused.typ == "input" || focused.typ == "textarea") && !focused.disabled {
212		if editor := r.edits[idKey(focused.identity)]; editor != nil && !editor.IMEActive() && r.ime != nil {
213			editor.Focus(r.ime, focused.password, focused.typ == "textarea")
214		}
215	}
216	if r.state.capture != nil && resultByID(r.output.Tree, idKey(r.state.capture)) == nil {
217		r.state.capture = nil
218		r.state.down = false
219		r.redraw = true
220	}
221	if r.state.space != nil && (resultByID(r.output.Tree, idKey(r.state.space)) == nil || !focusable(r.lookup(idKey(r.state.space)))) {
222		r.state.space = nil
223		r.redraw = true
224	}
225	if r.state.focus != nil && (resultByID(r.output.Tree, idKey(r.state.focus)) == nil || !focusable(r.lookup(idKey(r.state.focus)))) {
226		r.setFocus(nil, false)
227		r.redraw = true
228	}
229	if r.state.inside {
230		hit := r.hit(r.state.x, r.state.y)
231		var id *identity
232		if hit != nil {
233			id = hit.identity
234		}
235		if !r.state.hover.same(id) {
236			r.state.hover = id
237			r.redraw = true
238		}
239	}
240	for key := range r.state.scroll {
241		n := resultByID(r.output.Tree, key)
242		e := r.lookup(key)
243		if n == nil || (!scrollable(e, n) && (e == nil || (e.typ != "input" && e.typ != "textarea"))) {
244			delete(r.state.scroll, key)
245		} else {
246			r.state.scroll[key] = layout.Size{W: n.ScrollX, H: n.ScrollY}
247		}
248	}
249	follow := r.redraw
250	r.mu.Unlock()
251	if follow {
252		select {
253		case r.wake <- struct{}{}:
254		default:
255		}
256	}
257	return true
258}
259
260// Wait blocks until input/redraw is available or the context is cancelled.
261func (r *runtime) Wait(ctx context.Context) error {
262	select {
263	case <-ctx.Done():
264		return ctx.Err()
265	case <-r.wake:
266		return nil
267	}
268}

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