1package ui
2
3import (
4 "context"
5 "sync"
6
7 "github.com/bnema/nefergui/internal/css"
8 "github.com/bnema/nefergui/internal/edit"
9 "github.com/bnema/nefergui/internal/layout"
10 "github.com/bnema/nefergui/internal/text"
11)
12
13// inputEvent is routed against the last committed layout. Target is opaque.
14type inputEvent struct {
15 Target *identity
16 Kind string
17 Text string
18 Err error
19 Shift, Ctrl bool
20 IME edit.IMEBatch
21 X, Y float64 // pointer position in logical pixels
22 Track layout.Rect // committed slider content box
23}
24
25// runtime serializes frame construction; Queue and Redraw may be called concurrently.
26// Re-entrant or concurrent Build requests a follow-up redraw instead of waiting.
27type runtime struct {
28 buildMu sync.Mutex
29 mu sync.Mutex
30 pending []inputEvent
31 wake chan struct{}
32 generation uint64
33 committed *element
34 frame Frame // reused every build; Node handles are fenced by generation
35 arenas [2]elementArena // one holds the committed tree, the other builds the next
36 committedArena int
37 styles *css.Engine
38 output layout.Output
39 layouts [2]layout.Arena // one holds output, the other builds the next
40 outputArena int
41 state interactionState
42 width, height, scale float64
43 textEngine *text.Engine
44 edits map[string]*edit.State
45 clipboard edit.Clipboard
46 pasteCtx context.Context
47 pasteCancel context.CancelFunc
48 pasteID uint64
49 ime edit.IME
50 redraw bool
51 inputRects []Rect
52 inputNil, inputStaged bool
53}
54
55func newRuntime() *runtime {
56 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)}
57}
58func (r *runtime) Queue(ev inputEvent) {
59 if ev.Target == nil {
60 return
61 }
62 r.mu.Lock()
63 r.pending = append(r.pending, ev)
64 r.mu.Unlock()
65 select {
66 case r.wake <- struct{}{}:
67 default:
68 }
69}
70
71// Redraw requests a frame even without input; duplicate requests coalesce.
72func (r *runtime) Redraw() {
73 r.mu.Lock()
74 r.redraw = true
75 r.mu.Unlock()
76 select {
77 case r.wake <- struct{}{}:
78 default:
79 }
80}
81
82// Target is a test-only committed-child-path helper; platform input uses route and Hit.
83func (r *runtime) Target(path ...int) *identity {
84 r.mu.Lock()
85 defer r.mu.Unlock()
86 e := r.committed
87 for _, i := range path {
88 if e == nil || i < 0 || i >= len(e.children) {
89 return nil
90 }
91 e = e.children[i]
92 }
93 if e == nil {
94 return nil
95 }
96 return e.identity
97}
98
99// Build drains all already queued input in one synchronous frame. A concurrent
100// or re-entrant call never waits: it requests another frame and returns false.
101// The owner completes its commit before the loop can build that next frame.
102func (r *runtime) Build(view func(*Frame)) bool {
103 if !r.buildMu.TryLock() {
104 r.Redraw()
105 return false
106 }
107 defer r.buildMu.Unlock()
108 r.mu.Lock()
109 // Discard the wake token for the batch we are about to process. New input
110 // arriving during construction leaves its own wake token for the next build.
111 select {
112 case <-r.wake:
113 default:
114 }
115 if len(r.pending) == 0 && !r.redraw && r.generation != 0 {
116 r.mu.Unlock()
117 return false
118 }
119 events := r.pending
120 r.pending = nil
121 r.redraw = false
122 r.generation++
123 gen := r.generation
124 previous := r.committed
125 state := r.state
126 width, height := r.width, r.height
127 r.mu.Unlock()
128 // Build in the arena that does not hold the committed tree, which this
129 // frame reads as previous. A frame that fails to commit leaves it as is.
130 next := 1 - r.committedArena
131 arena := &r.arenas[next]
132 arena.reset()
133 r.frame = Frame{arena: arena, 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 f := &r.frame
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 f.refresh(f.root)
153 input := r.layoutTree(f.root)
154 // Lay out in the arena that does not hold r.output, which the view and
155 // scrollCaret still read as the committed layout.
156 nextLayout := 1 - r.outputArena
157 opts := layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine, Arena: &r.layouts[nextLayout]}
158 out, err := layout.Layout(input, opts)
159 if err != nil {
160 r.mu.Unlock()
161 r.Redraw()
162 return false
163 }
164 // Re-layout at most once after moving a focused caret into its editor
165 // viewport; the offset is per frame identity and bounded by measured extent.
166 // Reusing the arena is safe: out is not read after scrollCaret returns.
167 if r.scrollCaret(f.root, &out) {
168 input = r.layoutTree(f.root)
169 out, err = layout.Layout(input, opts)
170 if err != nil {
171 r.mu.Unlock()
172 r.Redraw()
173 return false
174 }
175 }
176 r.paintEditors(f.root, &out)
177 r.paintIndicators(f.root, &out)
178 opts.Arena.Keep(out.Display) // recycle the list grown by the decorations
179 r.output, r.outputArena = out, nextLayout
180 }
181 r.committed, r.committedArena = f.root, next
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}