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 // layoutErr is the error of the last layout that made Build fail, nil
54 // after a layout that succeeded. Measure reports it.
55 layoutErr error
56 // measuring marks the temporary runtime of Renderer.Measure: it shares the
57 // style and text caches of a Renderer, which only that Renderer's frames
58 // may age.
59 measuring bool
60}
61
62func newRuntime() *runtime {
63 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)}
64}
65func (r *runtime) Queue(ev inputEvent) {
66 if ev.Target == nil {
67 return
68 }
69 r.mu.Lock()
70 r.pending = append(r.pending, ev)
71 r.mu.Unlock()
72 select {
73 case r.wake <- struct{}{}:
74 default:
75 }
76}
77
78// Redraw requests a frame even without input; duplicate requests coalesce.
79func (r *runtime) Redraw() {
80 r.mu.Lock()
81 r.redraw = true
82 r.mu.Unlock()
83 select {
84 case r.wake <- struct{}{}:
85 default:
86 }
87}
88
89// Target is a test-only committed-child-path helper; platform input uses route and Hit.
90func (r *runtime) Target(path ...int) *identity {
91 r.mu.Lock()
92 defer r.mu.Unlock()
93 e := r.committed
94 for _, i := range path {
95 if e == nil || i < 0 || i >= len(e.children) {
96 return nil
97 }
98 e = e.children[i]
99 }
100 if e == nil {
101 return nil
102 }
103 return e.identity
104}
105
106// Build drains all already queued input in one synchronous frame. A concurrent
107// or re-entrant call never waits: it requests another frame and returns false.
108// The owner completes its commit before the loop can build that next frame.
109func (r *runtime) Build(view func(*Frame)) bool {
110 if !r.buildMu.TryLock() {
111 r.Redraw()
112 return false
113 }
114 defer r.buildMu.Unlock()
115 r.mu.Lock()
116 // Discard the wake token for the batch we are about to process. New input
117 // arriving during construction leaves its own wake token for the next build.
118 select {
119 case <-r.wake:
120 default:
121 }
122 if len(r.pending) == 0 && !r.redraw && r.generation != 0 {
123 r.mu.Unlock()
124 return false
125 }
126 events := r.pending
127 r.pending = nil
128 r.redraw = false
129 r.generation++
130 gen := r.generation
131 previous := r.committed
132 state := r.state
133 width, height := r.width, r.height
134 r.mu.Unlock()
135 // Build in the arena that does not hold the committed tree, which this
136 // frame reads as previous. A frame that fails to commit leaves it as is.
137 next := 1 - r.committedArena
138 arena := &r.arenas[next]
139 arena.reset()
140 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}
141 f := &r.frame
142 defer func() {
143 f.active = false
144 if !r.measuring {
145 r.styles.EndFrame()
146 if r.textEngine != nil {
147 r.textEngine.EndFrame()
148 }
149 }
150 // A panicking view is not committed; the next build may retry.
151 if recovered := recover(); recovered != nil {
152 r.Redraw()
153 panic(recovered)
154 }
155 }()
156 view(f)
157 r.mu.Lock()
158 // State can change while the view runs. Schedule another frame instead of
159 // overwriting that newer input with this frame's snapshot.
160 r.layoutErr = nil
161 if f.root != nil {
162 f.refresh(f.root)
163 input := r.layoutTree(f.root)
164 // Lay out in the arena that does not hold r.output, which the view and
165 // scrollCaret still read as the committed layout.
166 nextLayout := 1 - r.outputArena
167 opts := layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine, Arena: &r.layouts[nextLayout]}
168 out, err := layout.Layout(input, opts)
169 if err != nil {
170 r.layoutErr = err
171 r.mu.Unlock()
172 r.Redraw()
173 return false
174 }
175 // Re-layout at most once after moving a focused caret into its editor
176 // viewport; the offset is per frame identity and bounded by measured extent.
177 // Reusing the arena is safe: out is not read after scrollCaret returns.
178 if r.scrollCaret(f.root, &out) {
179 input = r.layoutTree(f.root)
180 out, err = layout.Layout(input, opts)
181 if err != nil {
182 r.layoutErr = err
183 r.mu.Unlock()
184 r.Redraw()
185 return false
186 }
187 }
188 r.paintEditors(f.root, &out)
189 r.paintIndicators(f.root, &out)
190 opts.Arena.Keep(out.Display) // recycle the list grown by the decorations
191 r.output, r.outputArena = out, nextLayout
192 }
193 r.committed, r.committedArena = f.root, next
194 // Events mutate the model while the view runs, so elements declared before
195 // the handling control painted the previous value. One follow-up frame,
196 // without events, shows the settled state; it cannot schedule another.
197 if len(events) > 0 {
198 r.redraw = true
199 }
200 // Release every losing editor before enabling the winner: the IME port
201 // represents one seat, not one port per editor. Never depend on map order.
202 for id, editor := range r.edits {
203 e := r.lookup(id)
204 if e == nil || (e.typ != "input" && e.typ != "textarea") || !e.identity.same(r.state.focus) || e.disabled {
205 editor.Blur(r.ime)
206 if e == nil || (e.typ != "input" && e.typ != "textarea") {
207 delete(r.edits, id)
208 }
209 }
210 }
211 if focused := r.lookup(idKey(r.state.focus)); focused != nil && (focused.typ == "input" || focused.typ == "textarea") && !focused.disabled {
212 if editor := r.edits[idKey(focused.identity)]; editor != nil && !editor.IMEActive() && r.ime != nil {
213 editor.Focus(r.ime, focused.password, focused.typ == "textarea")
214 }
215 }
216 if r.state.capture != nil && resultByID(r.output.Tree, idKey(r.state.capture)) == nil {
217 r.state.capture = nil
218 r.state.down = false
219 r.redraw = true
220 }
221 if r.state.space != nil && (resultByID(r.output.Tree, idKey(r.state.space)) == nil || !focusable(r.lookup(idKey(r.state.space)))) {
222 r.state.space = nil
223 r.redraw = true
224 }
225 if r.state.focus != nil && (resultByID(r.output.Tree, idKey(r.state.focus)) == nil || !focusable(r.lookup(idKey(r.state.focus)))) {
226 r.setFocus(nil, false)
227 r.redraw = true
228 }
229 if r.state.inside {
230 hit := r.hit(r.state.x, r.state.y)
231 var id *identity
232 if hit != nil {
233 id = hit.identity
234 }
235 if !r.state.hover.same(id) {
236 r.state.hover = id
237 r.redraw = true
238 }
239 }
240 for key := range r.state.scroll {
241 n := resultByID(r.output.Tree, key)
242 e := r.lookup(key)
243 if n == nil || (!scrollable(e, n) && (e == nil || (e.typ != "input" && e.typ != "textarea"))) {
244 delete(r.state.scroll, key)
245 } else {
246 r.state.scroll[key] = layout.Size{W: n.ScrollX, H: n.ScrollY}
247 }
248 }
249 follow := r.redraw
250 r.mu.Unlock()
251 if follow {
252 select {
253 case r.wake <- struct{}{}:
254 default:
255 }
256 }
257 return true
258}
259
260// Wait blocks until input/redraw is available or the context is cancelled.
261func (r *runtime) Wait(ctx context.Context) error {
262 select {
263 case <-ctx.Done():
264 return ctx.Err()
265 case <-r.wake:
266 return nil
267 }
268}