v0.1.0 47aaa0f

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