v0.4.0 2de4e1d

runtime.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"os"
9	"path/filepath"
10	"sync"
11
12	"github.com/bnema/nefergui/internal/platform/wayland"
13	"github.com/bnema/nefergui/internal/presentation/session"
14	"github.com/bnema/nefergui/internal/render"
15
16	"github.com/bnema/nefergui/internal/css"
17	"github.com/bnema/nefergui/internal/edit"
18	"github.com/bnema/nefergui/internal/layout"
19	"github.com/bnema/nefergui/internal/text"
20)
21
22// inputEvent is routed against the last committed layout. Target is opaque.
23type inputEvent struct {
24	Target      *identity
25	Kind        string
26	Text        string
27	Err         error
28	Shift, Ctrl bool
29	IME         edit.IMEBatch
30	X, Y        float64     // pointer position in logical pixels
31	Track       layout.Rect // committed slider content box
32}
33
34// runtime serializes frame construction; Queue and Redraw may be called concurrently.
35// Re-entrant or concurrent Build requests a follow-up redraw instead of waiting.
36type runtime struct {
37	buildMu               sync.Mutex
38	mu                    sync.Mutex
39	pending               []inputEvent
40	wake                  chan struct{}
41	generation            uint64
42	committed             *element
43	styles                *css.Engine
44	output                layout.Output
45	state                 interactionState
46	width, height, scale  float64
47	textEngine            *text.Engine
48	edits                 map[string]*edit.State
49	clipboard             edit.Clipboard
50	pasteCtx              context.Context
51	pasteCancel           context.CancelFunc
52	pasteID               uint64
53	ime                   edit.IME
54	redraw                bool
55	consumed              bool // the last committed build handled input events
56	inputRects            []Rect
57	inputOut              []wayland.Rect
58	inputNil, inputStaged bool
59}
60
61func newRuntime() *runtime {
62	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)}
63}
64func (r *runtime) Queue(ev inputEvent) {
65	if ev.Target == nil {
66		return
67	}
68	r.mu.Lock()
69	r.pending = append(r.pending, ev)
70	r.mu.Unlock()
71	select {
72	case r.wake <- struct{}{}:
73	default:
74	}
75}
76
77// Redraw requests a frame even without input; duplicate requests coalesce.
78func (r *runtime) Redraw() {
79	r.mu.Lock()
80	r.redraw = true
81	r.mu.Unlock()
82	select {
83	case r.wake <- struct{}{}:
84	default:
85	}
86}
87
88// Target is a test-only committed-child-path helper; platform input uses route and Hit.
89func (r *runtime) Target(path ...int) *identity {
90	r.mu.Lock()
91	defer r.mu.Unlock()
92	e := r.committed
93	for _, i := range path {
94		if e == nil || i < 0 || i >= len(e.children) {
95			return nil
96		}
97		e = e.children[i]
98	}
99	if e == nil {
100		return nil
101	}
102	return e.identity
103}
104
105// Build drains all already queued input in one synchronous frame. A concurrent
106// or re-entrant call never waits: it requests another frame and returns false.
107// The owner completes its commit before the loop can build that next frame.
108func (r *runtime) Build(view func(*Frame)) bool {
109	if !r.buildMu.TryLock() {
110		r.Redraw()
111		return false
112	}
113	defer r.buildMu.Unlock()
114	r.mu.Lock()
115	// Discard the wake token for the batch we are about to process. New input
116	// arriving during construction leaves its own wake token for the next build.
117	select {
118	case <-r.wake:
119	default:
120	}
121	if len(r.pending) == 0 && !r.redraw && r.generation != 0 {
122		r.mu.Unlock()
123		return false
124	}
125	events := r.pending
126	r.pending = nil
127	r.redraw = false
128	r.generation++
129	gen := r.generation
130	previous := r.committed
131	state := r.state
132	width, height := r.width, r.height
133	r.mu.Unlock()
134	f := &Frame{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}
135	defer func() {
136		f.active = false
137		r.styles.EndFrame()
138		if r.textEngine != nil {
139			r.textEngine.EndFrame()
140		}
141		// A panicking view is not committed; the next build may retry.
142		if recovered := recover(); recovered != nil {
143			r.Redraw()
144			panic(recovered)
145		}
146	}()
147	view(f)
148	r.mu.Lock()
149	// State can change while the view runs. Schedule another frame instead of
150	// overwriting that newer input with this frame's snapshot.
151	if f.root != nil {
152		var refresh func(*element)
153		refresh = func(e *element) {
154			f.compute(e)
155			for _, ch := range e.children {
156				refresh(ch)
157			}
158		}
159		refresh(f.root)
160		input := r.layoutTree(f.root)
161		out, err := layout.Layout(input, layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine})
162		if err != nil {
163			r.mu.Unlock()
164			r.Redraw()
165			return false
166		}
167		// Re-layout at most once after moving a focused caret into its editor
168		// viewport; the offset is per frame identity and bounded by measured extent.
169		if r.scrollCaret(f.root, &out) {
170			input = r.layoutTree(f.root)
171			out, err = layout.Layout(input, layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine})
172			if err != nil {
173				r.mu.Unlock()
174				r.Redraw()
175				return false
176			}
177		}
178		r.paintEditors(f.root, &out)
179		r.paintIndicators(f.root, &out)
180		r.output = out
181	}
182	r.committed = f.root
183	// Events mutate the model while the view runs, so elements declared before
184	// the handling control painted the previous value. One follow-up frame,
185	// without events, shows the settled state; it cannot schedule another.
186	if len(events) > 0 {
187		r.redraw = true
188	}
189	r.consumed = len(events) > 0
190	// Release every losing editor before enabling the winner: the IME port
191	// represents one seat, not one port per editor. Never depend on map order.
192	for id, editor := range r.edits {
193		e := r.lookup(id)
194		if e == nil || (e.typ != "input" && e.typ != "textarea") || !e.identity.same(r.state.focus) || e.disabled {
195			editor.Blur(r.ime)
196			if e == nil || (e.typ != "input" && e.typ != "textarea") {
197				delete(r.edits, id)
198			}
199		}
200	}
201	if focused := r.lookup(idKey(r.state.focus)); focused != nil && (focused.typ == "input" || focused.typ == "textarea") && !focused.disabled {
202		if editor := r.edits[idKey(focused.identity)]; editor != nil && !editor.IMEActive() && r.ime != nil {
203			editor.Focus(r.ime, focused.password, focused.typ == "textarea")
204		}
205	}
206	if r.state.capture != nil && resultByID(r.output.Tree, idKey(r.state.capture)) == nil {
207		r.state.capture = nil
208		r.state.down = false
209		r.redraw = true
210	}
211	if r.state.space != nil && (resultByID(r.output.Tree, idKey(r.state.space)) == nil || !focusable(r.lookup(idKey(r.state.space)))) {
212		r.state.space = nil
213		r.redraw = true
214	}
215	if r.state.focus != nil && (resultByID(r.output.Tree, idKey(r.state.focus)) == nil || !focusable(r.lookup(idKey(r.state.focus)))) {
216		r.setFocus(nil, false)
217		r.redraw = true
218	}
219	if r.state.inside {
220		hit := r.hit(r.state.x, r.state.y)
221		var id *identity
222		if hit != nil {
223			id = hit.identity
224		}
225		if !r.state.hover.same(id) {
226			r.state.hover = id
227			r.redraw = true
228		}
229	}
230	for key := range r.state.scroll {
231		n := resultByID(r.output.Tree, key)
232		e := r.lookup(key)
233		if n == nil || (!scrollable(e, n) && (e == nil || (e.typ != "input" && e.typ != "textarea"))) {
234			delete(r.state.scroll, key)
235		} else {
236			r.state.scroll[key] = layout.Size{W: n.ScrollX, H: n.ScrollY}
237		}
238	}
239	follow := r.redraw
240	r.mu.Unlock()
241	if follow {
242		select {
243		case r.wake <- struct{}{}:
244		default:
245		}
246	}
247	return true
248}
249
250// consumedInput reports whether the last committed build handled input events.
251func (r *runtime) consumedInput() bool {
252	r.mu.Lock()
253	defer r.mu.Unlock()
254	return r.consumed
255}
256
257// Wait blocks until input/redraw is available or the context is cancelled.
258func (r *runtime) Wait(ctx context.Context) error {
259	select {
260	case <-ctx.Done():
261		return ctx.Err()
262	case <-r.wake:
263		return nil
264	}
265}
266
267// Run builds and presents an immediate view on the Wayland session owner loop.
268func Run[T any](ctx context.Context, model *T, view func(*Frame, *T), options ...WindowOption) error {
269	return runWithCommit(ctx, model, view, nil, options...)
270}
271
272// RunFrames is a deterministic harness entry: each commit requests a redraw.
273func RunFrames[T any](ctx context.Context, frames int, model *T, view func(*Frame, *T), committed func(uint64) error, options ...WindowOption) error {
274	if frames < 1 {
275		return errors.New("nefergui: frames must be positive")
276	}
277	return runWithCommit(ctx, model, view, func(frame uint64) (bool, error) {
278		if committed != nil {
279			if err := committed(frame); err != nil {
280				return false, err
281			}
282		}
283		return frame >= uint64(frames), nil
284	}, options...)
285}
286
287func runWithCommit[T any](ctx context.Context, model *T, view func(*Frame, *T), committed func(uint64) (bool, error), options ...WindowOption) (err error) {
288	if model == nil || view == nil {
289		return errors.New("nefergui: nil model or view")
290	}
291	if err := ctx.Err(); err != nil {
292		return err
293	}
294	cfg := windowConfig{title: "NeferGUI", width: 800, height: 600}
295	for _, o := range options {
296		if o == nil {
297			return errors.New("nefergui: nil window option")
298		}
299		o.window(&cfg)
300	}
301	if cfg.width <= 0 || cfg.height <= 0 || int64(cfg.width) > 1<<31-1 || int64(cfg.height) > 1<<31-1 {
302		return fmt.Errorf("nefergui: invalid size %dx%d", cfg.width, cfg.height)
303	}
304	if cfg.layer != nil && cfg.layer.AllOutputs {
305		if committed != nil {
306			return errors.New("nefergui: AllOutputs excludes RunFrames")
307		}
308		return runAllOutputs(ctx, cfg, func(f *Frame) { view(f, model) })
309	}
310	r := newRuntime()
311	if cfg.styles != "" {
312		data, err := os.ReadFile(cfg.styles)
313		if err != nil {
314			return fmt.Errorf("nefergui: styles: %w", err)
315		}
316		r.styles = css.Compile(css.UA(), css.Parse(string(data)))
317	}
318	catalog, err := text.Load(text.SystemSource{})
319	if err != nil {
320		return fmt.Errorf("nefergui: fonts: %w", err)
321	}
322	if len(catalog.Faces) == 0 {
323		return errors.New("nefergui: no usable fonts")
324	}
325	r.setTextEngine(text.NewEngine(catalog))
326	var surface wayland.SurfaceOptions
327	if cfg.layer != nil {
328		if surface, err = cfg.layer.surfaceOptions(); err != nil {
329			return err
330		}
331	}
332	s, err := session.OpenWithOptions(ctx, "", int32(cfg.width), int32(cfg.height), cfg.transparent, surface)
333	if err != nil {
334		return fmt.Errorf("nefergui: open platform: %w", err)
335	}
336	// Close errors (debug artifact writes) are reported after any earlier error.
337	defer func() { err = errors.Join(err, s.Close()) }()
338	if cfg.onSurface != nil {
339		if err := startupHook(ctx, cfg.onSurface, WaylandSurface{Display: s.Window.Display, Surface: s.Window.Surface}); err != nil {
340			if ctx.Err() != nil {
341				return err
342			}
343			return fmt.Errorf("nefergui: surface hook: %w", err)
344		}
345	}
346	if err := s.Window.SetTitle(cfg.title); err != nil { // no-op for layer surfaces
347		return fmt.Errorf("nefergui: title: %w", err)
348	}
349	r.pasteCtx = ctx
350	defer func() {
351		if r.pasteCancel != nil {
352			r.pasteCancel()
353		}
354	}()
355	var clipboard *wayland.Clipboard
356	var ime *wayland.IME
357	if s.Window.Seat != nil {
358		clipboard, err = wayland.NewClipboard(s.Window.Display, s.Window.Seat, func() uint32 {
359			if a := s.Window.InputAdapter(); a != nil {
360				return a.SelectionSerial()
361			}
362			return 0
363		}, s.Window.PostClipboard)
364		if err != nil {
365			return fmt.Errorf("nefergui: clipboard: %w", err)
366		}
367		defer clipboard.Close()
368		ime, err = wayland.NewIME(s.Window.Display, s.Window.Seat, s.Window.Surface, s.Window.PostIME)
369		if err != nil {
370			return fmt.Errorf("nefergui: IME: %w", err)
371		}
372		defer ime.Close()
373	}
374	if clipboard != nil {
375		r.clipboard = clipboard
376	}
377	if ime != nil {
378		r.ime = ime
379	}
380	loopCtx, stop := context.WithCancel(ctx)
381	defer stop()
382	if cfg.wake != nil {
383		go r.forwardWake(loopCtx, cfg.wake)
384	}
385	hooks := &inputHooks{onInput: cfg.onInput, onResize: cfg.onResize}
386	finished := false
387	err = s.RunApp(loopCtx, session.AppHooks{
388		Wake: r.wake,
389		Input: func(inputs []wayland.Input) error {
390			for _, input := range inputs {
391				// An overflow reset keeps the latest enter or leave in the
392				// queue, so the serial stays valid across it.
393				switch {
394				case input.Enter:
395					s.Window.PointerEntered(input.Serial, input.PointerGen)
396				case input.Kind == "leave":
397					s.Window.PointerLeft()
398				}
399				if hooks.input(input) { // raw hook first; controls still route the event
400					r.Redraw()
401				}
402				r.routeInput(input)
403			}
404			// Hover moves without a rebuild; update the cursor from the last
405			// committed styles now, and again after the next build.
406			return s.Window.SetCursor(cursorShape(r.cursor()))
407		},
408		Event: func(ev wayland.Event) error {
409			switch ev.Kind {
410			case wayland.ClipboardInput:
411				if clipboard != nil {
412					clipboard.ApplyClipboard(ev.Clipboard)
413				}
414			case wayland.IMEInput:
415				if ime != nil {
416					if batch, ok := ime.ApplyIME(ev.IME); ok {
417						r.route(platformInput{Kind: "ime-done", IME: batch})
418					}
419				}
420			}
421			return nil
422		},
423		Committed: func(frame uint64) error {
424			if committed == nil {
425				return nil
426			}
427			done, err := committed(frame)
428			if err != nil {
429				return err
430			}
431			if done {
432				finished = true
433				stop()
434			} else {
435				r.Redraw()
436			}
437			return nil
438		},
439		Draw: func() (render.Frame, bool, error) {
440			if hooks.resize(int(s.Window.Width), int(s.Window.Height), s.Window.Scale) {
441				r.Redraw() // an OnResize callback may have changed the model
442			}
443			r.route(platformInput{Kind: "resize", Width: float64(s.Window.Width), Height: float64(s.Window.Height), Scale: s.Window.Scale})
444			if !r.Build(func(f *Frame) { view(f, model) }) {
445				return render.Frame{}, false, nil
446			}
447			if dir := os.Getenv("NEFERGUI_DEBUG_DIR"); dir != "" {
448				data, e := json.Marshal(r.output.Tree)
449				if e != nil {
450					return render.Frame{}, false, e
451				}
452				if e = os.WriteFile(filepath.Join(dir, "layout.json"), data, 0600); e != nil {
453					return render.Frame{}, false, e
454				}
455			}
456			if ime != nil && ime.Err != nil {
457				return render.Frame{}, false, ime.Err
458			}
459			if err := r.applyInputRects(s.Window.StageInputRects); err != nil {
460				return render.Frame{}, false, err
461			}
462			if err := s.Window.SetCursor(cursorShape(r.cursor())); err != nil {
463				return render.Frame{}, false, err
464			}
465			w, h, err := s.Window.PhysicalSize()
466			if err != nil {
467				return render.Frame{}, false, err
468			}
469			frame, err := s.Preparer.Prepare(r.output.Display, s.Window.Scale, int(w), int(h))
470			return frame, true, err
471		},
472	})
473	if finished && errors.Is(err, context.Canceled) && ctx.Err() == nil {
474		return nil
475	}
476	return err
477}
478
479type WindowOption interface{ window(*windowConfig) }
480type windowConfig struct {
481	title, styles string
482	width, height int
483	transparent   bool
484	layer         *LayerConfig
485	onInput       func(InputEvent) bool
486	onResize      func(int, int, float64)
487	wake          <-chan struct{}
488	onSurface     func(context.Context, WaylandSurface) error
489}
490type windowOption func(*windowConfig)
491
492func (o windowOption) window(c *windowConfig) { o(c) }
493func Title(s string) WindowOption             { return windowOption(func(c *windowConfig) { c.title = s }) }
494func Size(w, h int) WindowOption {
495	return windowOption(func(c *windowConfig) { c.width = w; c.height = h })
496}
497func Styles(s string) WindowOption { return windowOption(func(c *windowConfig) { c.styles = s }) }
498
499// Transparent requests an alpha-capable Wayland surface (opaque by default).
500func Transparent() WindowOption { return windowOption(func(c *windowConfig) { c.transparent = true }) }

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