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