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