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