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