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