@termwright/probe-tview 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1368 @@
1
+ package tview
2
+
3
+ // Add-only instrumentation injected by Go's compiler tool executor. Upstream
4
+ // bytes are never copied or edited; this compilation unit joins package tview
5
+ // only for a Termwright-owned build.
6
+ //
7
+ // Being inside the package is necessary only for sealed state with no public
8
+ // accessor: Grid items/visibility, Modal content, disabled state that tview
9
+ // does not expose, sensitive-value transforms, and indexed DropDown options.
10
+ // Everything else below deliberately uses public getters.
11
+ //
12
+ // Three rules from the Phase 0 audit (docs/architecture/audit/tview.md §1–2)
13
+ // are load-bearing, and breaking any of them turns a working application into
14
+ // a hang or a lie:
15
+ //
16
+ // 1. Screen.Show runs inside Application.draw(), which holds the application's
17
+ // write lock for the whole frame. The decorator must never wait on or
18
+ // re-enter the event loop from that boundary.
19
+ // 2. Reading primitive state is safe at this boundary and essentially nowhere else:
20
+ // rects are assigned by parents *during* the draw, so another goroutine
21
+ // reading GetRect races the layout.
22
+ // 3. The marker must follow the frame's bytes on the screen's exact writer.
23
+ // Semantic socket delivery is independently ordered by a bounded worker;
24
+ // only complete pre-encoded revisions admitted to it receive a marker.
25
+
26
+ import (
27
+ "errors"
28
+ "reflect"
29
+ "runtime/debug"
30
+ "sort"
31
+ "strconv"
32
+ "strings"
33
+ "sync"
34
+ "sync/atomic"
35
+
36
+ "github.com/gdamore/tcell/v2"
37
+
38
+ "github.com/gorce-ai/termwright/clients/go/annotate"
39
+ "github.com/gorce-ai/termwright/clients/go/evidence"
40
+ "github.com/gorce-ai/termwright/clients/go/probehost"
41
+ "github.com/gorce-ai/termwright/clients/go/protocol"
42
+ )
43
+
44
+ // probeName and probeVersion identify this probe in the handshake, distinctly
45
+ // from the hand-written adapter so a session can be told apart in diagnostics.
46
+ const (
47
+ probeName = "termwright-probe-tview"
48
+ probeVersion = "0.3.0"
49
+ )
50
+
51
+ // termwrightProbeState is nil for an uninstrumented run, which is every run
52
+ // that does not carry the handshake variables.
53
+ type termwrightProbeState struct {
54
+ client *protocol.Client
55
+ publisher atomic.Pointer[protocol.PublicationQueue]
56
+ shutdown sync.Once
57
+ application *Application
58
+ recoveryStop chan struct{}
59
+ recoveryStopOnce sync.Once
60
+ recoveryPending atomic.Bool
61
+ recoveryWorkers sync.WaitGroup
62
+
63
+ mu sync.Mutex
64
+ ids map[Primitive]string
65
+ nextID int
66
+ dropped atomic.Uint64
67
+ timedOut atomic.Uint64
68
+ frames atomic.Uint64
69
+ }
70
+
71
+ type termwrightPublicationSink interface {
72
+ TryPublish(*protocol.Snapshot) (string, error)
73
+ ReadyAfterDrop() <-chan struct{}
74
+ Fail(string, string)
75
+ }
76
+
77
+ // TermwrightProbeStats reports what the probe did and failed to do.
78
+ //
79
+ // Exported because the conformance fixture asserts on it: a drop counter no
80
+ // test can read is a drop counter nobody notices.
81
+ type TermwrightProbeStats struct {
82
+ Frames uint64
83
+ Dropped uint64
84
+ TimedOut uint64
85
+ }
86
+
87
+ // TermwrightProbeStatistics returns the counters, or zeroes when dormant.
88
+ func TermwrightProbeStatistics() TermwrightProbeStats {
89
+ p := termwrightLastProbe.Load()
90
+ if p == nil {
91
+ return TermwrightProbeStats{}
92
+ }
93
+ return TermwrightProbeStats{
94
+ Frames: p.frames.Load(),
95
+ Dropped: p.dropped.Load(),
96
+ TimedOut: p.timedOut.Load(),
97
+ }
98
+ }
99
+
100
+ var (
101
+ termwrightLastProbe atomic.Pointer[termwrightProbeState]
102
+ )
103
+
104
+ // termwrightScreen turns the public Screen.Show boundary into the causal
105
+ // commit hook tview itself does not expose. Embedding preserves the complete
106
+ // tcell.Screen surface; only Show is observed. The underlying screen remains
107
+ // the sole owner of terminal output and marker delivery.
108
+ type termwrightScreen struct {
109
+ tcell.Screen
110
+ application *Application
111
+ commit func(Primitive, tcell.Screen)
112
+ fail func(string, string)
113
+ previousBefore func(tcell.Screen) bool
114
+ previousAfter func(tcell.Screen)
115
+ beforeHook func(tcell.Screen) bool
116
+ afterHook func(tcell.Screen)
117
+ showing atomic.Bool
118
+ failed atomic.Bool
119
+ detached atomic.Bool
120
+ cleaned atomic.Bool
121
+ finalized atomic.Bool
122
+ phase atomic.Int32
123
+ diagnose sync.Once
124
+ diagnosis sync.WaitGroup
125
+ owned bool
126
+ }
127
+
128
+ const (
129
+ termwrightPhaseIdle int32 = iota
130
+ termwrightPhaseCleared
131
+ termwrightPhaseDrawing
132
+ termwrightPhaseFinal
133
+ )
134
+
135
+ // Clear observes tview's public frame prelude without changing it. SetRoot may
136
+ // also clear between frames, so only an idle screen starts a new candidate;
137
+ // the before-draw hook remains the authoritative transition into drawing.
138
+ func (s *termwrightScreen) Clear() {
139
+ s.phase.CompareAndSwap(termwrightPhaseIdle, termwrightPhaseCleared)
140
+ s.Screen.Clear()
141
+ }
142
+
143
+ func (s *termwrightScreen) beforeDraw(screen tcell.Screen) bool {
144
+ if !s.phase.CompareAndSwap(termwrightPhaseCleared, termwrightPhaseDrawing) {
145
+ s.failClosed("tview entered beforeDraw without completing the previous decorated frame")
146
+ s.phase.Store(termwrightPhaseDrawing)
147
+ }
148
+ shortCircuit := false
149
+ if s.previousBefore != nil {
150
+ shortCircuit = s.previousBefore(screen)
151
+ }
152
+ if shortCircuit {
153
+ s.phase.CompareAndSwap(termwrightPhaseDrawing, termwrightPhaseFinal)
154
+ }
155
+ return shortCircuit
156
+ }
157
+
158
+ func (s *termwrightScreen) afterDraw(screen tcell.Screen) {
159
+ // The application's callback may itself call Show. Keep that Show unarmed:
160
+ // only tview's own Show after this wrapper returns is the final boundary.
161
+ if s.previousAfter != nil {
162
+ s.previousAfter(screen)
163
+ }
164
+ if !s.phase.CompareAndSwap(termwrightPhaseDrawing, termwrightPhaseFinal) {
165
+ s.failClosed("tview entered afterDraw outside the decorated frame phase")
166
+ }
167
+ }
168
+
169
+ func termwrightSameHook(left, right any) bool {
170
+ if left == nil || right == nil {
171
+ return left == nil && right == nil
172
+ }
173
+ return reflect.ValueOf(left).Pointer() == reflect.ValueOf(right).Pointer()
174
+ }
175
+
176
+ func (s *termwrightScreen) hooksIntact() bool {
177
+ return termwrightSameHook(s.application.beforeDraw, s.beforeHook) &&
178
+ termwrightSameHook(s.application.afterDraw, s.afterHook)
179
+ }
180
+
181
+ func (s *termwrightScreen) failClosed(message string) {
182
+ if !s.failed.CompareAndSwap(false, true) {
183
+ return
184
+ }
185
+ if s.fail != nil {
186
+ s.fail("adapter-guarantee-violation", message)
187
+ }
188
+ }
189
+
190
+ func (s *termwrightScreen) Show() {
191
+ if !s.showing.CompareAndSwap(false, true) {
192
+ // A Screen implementation which re-enters its decorator cannot provide a
193
+ // single completed output boundary. Refuse semantics without taking a
194
+ // mutex (which would deadlock the application draw goroutine).
195
+ s.failClosed("tview screen re-entered Show before the previous frame committed")
196
+ return
197
+ }
198
+ defer s.showing.Store(false)
199
+
200
+ s.Screen.Show()
201
+ phase := s.phase.Load()
202
+ // Idle Show calls are outside Application.draw and carry no semantic frame.
203
+ // In particular, do not read the lock-free upstream hook fields there: an
204
+ // application may legally configure them before Run. Non-idle phases are
205
+ // entered only by Application.Clear/beforeDraw while draw holds its lock.
206
+ if phase != termwrightPhaseIdle && !s.hooksIntact() {
207
+ s.failClosed("tview replaced a decorated beforeDraw or afterDraw hook at runtime; final frame observation cannot continue")
208
+ }
209
+ if s.detached.Load() || s.failed.Load() {
210
+ return
211
+ }
212
+ // A custom Primitive or application hook may call Show while drawing. Those
213
+ // output flushes are real, but they are not complete Application frames. The
214
+ // composed lifecycle hooks arm exactly the Show issued by Application.draw.
215
+ if phase != termwrightPhaseFinal || !s.phase.CompareAndSwap(termwrightPhaseFinal, termwrightPhaseIdle) {
216
+ return
217
+ }
218
+ // Application.draw holds the application write lock across Screen.Show.
219
+ // Direct access is therefore the lock-safe T1 equivalent of a public root
220
+ // getter and follows SetRoot changes frame by frame.
221
+ if s.commit != nil {
222
+ s.commit(s.application.root, s.Screen)
223
+ }
224
+ }
225
+
226
+ // Fini distinguishes normal Application.Stop (which clears application.screen
227
+ // first) from a runtime SetScreen replacement (which finalizes the current
228
+ // screen while it is still installed). Replacements cannot silently continue
229
+ // with an undecorated sink: semantic publication fails closed before the new
230
+ // screen can render.
231
+ func (s *termwrightScreen) Fini() {
232
+ // Stop calls Screen.Fini while holding Application's write lock. Waiting for
233
+ // an RLock here would therefore deadlock the application. SetScreen, in
234
+ // contrast, releases the lock before finalizing the old screen, so a
235
+ // successful TryRLock lets us diagnose that unsupported replacement exactly.
236
+ replaced := false
237
+ if s.application.TryRLock() {
238
+ replaced = s.application.screen == s
239
+ s.application.RUnlock()
240
+ } else {
241
+ // The normal case is Stop. Defer the distinction until the writer releases
242
+ // the lock: Stop has already cleared screen, whereas an unrelated writer
243
+ // leaves a live Application whose semantics must fail with a diagnostic.
244
+ // Add to the lifecycle group before publishing detached=true so cleanup
245
+ // can never race Wait against Add.
246
+ s.diagnose.Do(func() {
247
+ s.diagnosis.Add(1)
248
+ s.failed.Store(true)
249
+ s.detached.Store(true)
250
+ go func() {
251
+ defer s.diagnosis.Done()
252
+ s.application.RLock()
253
+ alive := s.application.screen != nil
254
+ s.application.RUnlock()
255
+ if alive && s.fail != nil {
256
+ s.fail("adapter-guarantee-violation", "tview screen finalized while the Application remained active; semantic observation cannot continue")
257
+ }
258
+ }()
259
+ })
260
+ }
261
+ if replaced {
262
+ s.failed.Store(true)
263
+ s.detached.Store(true)
264
+ if s.fail != nil {
265
+ s.fail("adapter-guarantee-violation", "tview replaced its decorated Screen at runtime; the new output sink is not semantically observed")
266
+ }
267
+ }
268
+ s.finishUnderlying()
269
+ }
270
+
271
+ func (s *termwrightScreen) finishUnderlying() {
272
+ if s.finalized.CompareAndSwap(false, true) {
273
+ s.Screen.Fini()
274
+ }
275
+ }
276
+
277
+ func init() {
278
+ probehost.Register("tview", func(application, root any) (func(), error) {
279
+ a, applicationOK := application.(*Application)
280
+ primitive, rootOK := root.(Primitive)
281
+ if !applicationOK || a == nil || !rootOK || primitive == nil {
282
+ return nil, errors.New("termwright tview probe requires a non-nil *tview.Application and tview.Primitive root")
283
+ }
284
+ return termwrightAttach(a, primitive)
285
+ })
286
+ }
287
+
288
+ // newTermwrightProbe honours the dormant rule: without an endpoint and a token
289
+ // the compiler-injected build behaves exactly like upstream. `FromEnv` returns nil in that case,
290
+ // so there is one branch and no second source of truth about what "dormant"
291
+ // means.
292
+ func newTermwrightProbe(frameworkVersion string) *termwrightProbeState {
293
+ client := protocol.FromEnv(protocol.Options{
294
+ AdapterName: probeName,
295
+ AdapterVersion: probeVersion,
296
+ Probe: &protocol.ProbeInfo{
297
+ Framework: "tview",
298
+ FrameworkVersion: frameworkVersion,
299
+ ProbeVersion: probeVersion,
300
+ IdentityKind: protocol.ProbeIdentityStable,
301
+ Capabilities: []protocol.ProbeCapability{
302
+ protocol.ProbeCapStableIdentity,
303
+ protocol.ProbeCapIntendedRect,
304
+ protocol.ProbeCapAnnotations,
305
+ },
306
+ Instrumentation: &protocol.ProbeInstrumentation{
307
+ HighestTier: protocol.ProbeTierT1,
308
+ SemanticClass: protocol.ProbeSemanticClassA,
309
+ DegradedCapabilities: []protocol.SessionCapabilityID{
310
+ "clipped-geometry", "custom-container-enumeration",
311
+ },
312
+ },
313
+ },
314
+ Capabilities: []protocol.Capability{
315
+ protocol.CapTree,
316
+ protocol.CapIntendedGeometry,
317
+ protocol.CapStates,
318
+ protocol.CapFocusState,
319
+ protocol.CapActions,
320
+ protocol.CapActionRecipes,
321
+ protocol.CapRenderRevisions,
322
+ },
323
+ // Socket deadlines are worker watchdogs only. The render hook never
324
+ // performs a socket write; it uses bounded non-blocking admission.
325
+ WriteTimeout: protocol.DefaultWriteTimeout,
326
+ // Freeze the application's production evidence providers into the same
327
+ // hello as the certified tview adapter. Providers only contribute facts;
328
+ // all actions still enter through tcell's real terminal input path.
329
+ EvidenceProviders: evidence.DefaultRegistry(),
330
+ })
331
+ if client == nil {
332
+ return nil
333
+ }
334
+ return &termwrightProbeState{
335
+ client: client,
336
+ ids: make(map[Primitive]string),
337
+ recoveryStop: make(chan struct{}),
338
+ }
339
+ }
340
+
341
+ func (p *termwrightProbeState) connect() error {
342
+ if err := p.client.Start(protocol.DialTimeout); err != nil {
343
+ return err
344
+ }
345
+ publisher, err := protocol.NewPublicationQueue(p.client, 2)
346
+ if err != nil {
347
+ return err
348
+ }
349
+ p.publisher.Store(publisher)
350
+ return nil
351
+ }
352
+
353
+ // termwrightAttach is called by the public one-line adapter before Run. The
354
+ // handshake, screen creation and allocations therefore happen outside
355
+ // Application.draw's lock.
356
+ func termwrightAttach(a *Application, root Primitive) (func(), error) {
357
+ frameworkVersion, versionErr := termwrightFrameworkVersion()
358
+ if versionErr != nil {
359
+ return nil, versionErr
360
+ }
361
+ p := newTermwrightProbe(frameworkVersion)
362
+ if p == nil {
363
+ return func() {}, nil
364
+ }
365
+ if err := p.connect(); err != nil {
366
+ _ = p.client.Close()
367
+ return nil, err
368
+ }
369
+ p.application = a
370
+
371
+ decorated, installErr := termwrightInstallScreen(a, p)
372
+ if installErr != nil {
373
+ p.close()
374
+ return nil, installErr
375
+ }
376
+ termwrightLastProbe.Store(p)
377
+ return func() {
378
+ decorated.detach()
379
+ p.close()
380
+ }, nil
381
+ }
382
+
383
+ // termwrightInstallScreen installs the decorator directly while Attach still
384
+ // owns the pre-Run lifecycle. Application.SetScreen cannot replace an already
385
+ // configured pre-Run screen: it finalizes it and queues an asynchronous runtime
386
+ // replacement. T1 access avoids that behavior without touching upstream code.
387
+ func termwrightInstallScreen(a *Application, p *termwrightProbeState) (*termwrightScreen, error) {
388
+ a.Lock()
389
+ defer a.Unlock()
390
+ if existing, ok := a.screen.(*termwrightScreen); ok && !existing.detached.Load() {
391
+ return nil, errors.New("termwright tview probe is already attached to this Application")
392
+ }
393
+
394
+ underlying := a.screen
395
+ owned := false
396
+ if underlying == nil {
397
+ var err error
398
+ underlying, err = tcell.NewScreen()
399
+ if err != nil {
400
+ return nil, err
401
+ }
402
+ if err = underlying.Init(); err != nil {
403
+ return nil, err
404
+ }
405
+ owned = true
406
+ if a.enableMouse {
407
+ underlying.EnableMouse()
408
+ } else {
409
+ underlying.DisableMouse()
410
+ }
411
+ if a.enablePaste {
412
+ underlying.EnablePaste()
413
+ } else {
414
+ underlying.DisablePaste()
415
+ }
416
+ if a.title != "" {
417
+ underlying.SetTitle(a.title)
418
+ }
419
+ }
420
+ decorated := &termwrightScreen{
421
+ Screen: underlying,
422
+ application: a,
423
+ owned: owned,
424
+ commit: p.afterFrame,
425
+ fail: func(code, message string) {
426
+ if publisher := p.publisher.Load(); publisher != nil {
427
+ publisher.Fail(code, message)
428
+ }
429
+ },
430
+ }
431
+ decorated.previousBefore = a.beforeDraw
432
+ decorated.previousAfter = a.afterDraw
433
+ decorated.beforeHook = decorated.beforeDraw
434
+ decorated.afterHook = decorated.afterDraw
435
+ a.beforeDraw = decorated.beforeHook
436
+ a.afterDraw = decorated.afterHook
437
+ a.screen = decorated
438
+ return decorated, nil
439
+ }
440
+
441
+ // detach restores only the screen installation this decorator still owns. A
442
+ // later application SetScreen wins and is never overwritten during cleanup.
443
+ func (s *termwrightScreen) detach() {
444
+ if s.cleaned.CompareAndSwap(false, true) {
445
+ s.application.Lock()
446
+ if !s.hooksIntact() {
447
+ s.failClosed("tview lifecycle hooks were displaced before probe cleanup")
448
+ }
449
+ s.detached.Store(true)
450
+ owned := s.application.screen == s
451
+ if termwrightSameHook(s.application.beforeDraw, s.beforeHook) {
452
+ s.application.beforeDraw = s.previousBefore
453
+ }
454
+ if termwrightSameHook(s.application.afterDraw, s.afterHook) {
455
+ s.application.afterDraw = s.previousAfter
456
+ }
457
+ if owned {
458
+ if s.owned {
459
+ s.application.screen = nil
460
+ } else {
461
+ s.application.screen = s.Screen
462
+ }
463
+ }
464
+ s.application.Unlock()
465
+ if owned && s.owned {
466
+ s.finishUnderlying()
467
+ }
468
+ }
469
+ // The publication queue is closed immediately after detach. Do not let a
470
+ // deferred lifecycle diagnostic race that close or outlive probe cleanup.
471
+ s.diagnosis.Wait()
472
+ }
473
+
474
+ func termwrightFrameworkVersion() (string, error) {
475
+ build, ok := debug.ReadBuildInfo()
476
+ if !ok {
477
+ return "", errors.New("termwright tview probe cannot read the application build graph")
478
+ }
479
+ for _, dependency := range build.Deps {
480
+ if dependency.Path != "github.com/rivo/tview" {
481
+ continue
482
+ }
483
+ return termwrightDependencyVersion(dependency), nil
484
+ }
485
+ return "", errors.New("termwright tview probe did not find tview in the application build graph")
486
+ }
487
+
488
+ func termwrightDependencyVersion(dependency *debug.Module) string {
489
+ version := dependency.Version
490
+ if dependency.Replace != nil && dependency.Replace.Version != "" {
491
+ version = dependency.Replace.Version
492
+ }
493
+ if version == "" || version == "(devel)" {
494
+ // Version is advisory for a T1 integration. The compiler has already
495
+ // checked the actual private-symbol contract, so a workspace/local module
496
+ // must not be rejected merely because build metadata calls it devel.
497
+ return "capability-local"
498
+ }
499
+ return version
500
+ }
501
+
502
+ func (p *termwrightProbeState) close() {
503
+ p.shutdown.Do(func() {
504
+ p.recoveryStopOnce.Do(func() { close(p.recoveryStop) })
505
+ if publisher := p.publisher.Swap(nil); publisher != nil {
506
+ publisher.Shutdown()
507
+ } else {
508
+ _ = p.client.Close()
509
+ }
510
+ p.recoveryWorkers.Wait()
511
+ })
512
+ }
513
+
514
+ // afterFrame is the method form, so a test can drive an instance of its own
515
+ // rather than the package-level probe a real run installs.
516
+ func (p *termwrightProbeState) afterFrame(root Primitive, screen tcell.Screen) {
517
+ if screen == nil {
518
+ return
519
+ }
520
+ if !p.client.Connected() {
521
+ return
522
+ }
523
+ columns, rows := screen.Size()
524
+ if columns <= 0 || rows <= 0 {
525
+ return
526
+ }
527
+
528
+ snapshot, duplicateKey, snapshotErr := p.snapshot(root, columns, rows)
529
+ if snapshotErr != nil {
530
+ p.onPublishFailed(p.publisher.Load(), snapshotErr)
531
+ return
532
+ }
533
+ if duplicateKey != "" {
534
+ if publisher := p.publisher.Load(); publisher != nil {
535
+ publisher.Fail("duplicate-semantic-key", "duplicate SemanticKey: "+string(duplicateKey))
536
+ }
537
+ return
538
+ }
539
+ publisher := p.publisher.Load()
540
+ if publisher == nil {
541
+ return
542
+ }
543
+ p.publishFrame(publisher, snapshot, screen)
544
+ }
545
+
546
+ // publishFrame is the deterministic adapter boundary between a completed
547
+ // framework snapshot and the same-writer marker. Keeping it separate lets the
548
+ // adapter test inject a controlled refusal instead of relying on host socket
549
+ // buffer saturation.
550
+ func (p *termwrightProbeState) publishFrame(publisher termwrightPublicationSink, snapshot *protocol.Snapshot, screen tcell.Screen) {
551
+ marker, err := publisher.TryPublish(snapshot)
552
+ if err != nil || marker == "" {
553
+ p.onPublishFailed(publisher, err)
554
+ return
555
+ }
556
+ // Show and the marker use the same terminal writer. Publication is only a
557
+ // committed frame once all marker bytes have followed the screen bytes.
558
+ if writeErr := termwrightWriteMarker(screen, marker); writeErr != nil {
559
+ p.dropped.Add(1)
560
+ publisher.Fail("adapter-guarantee-violation", "tview could not write the complete render marker through the screen commit writer")
561
+ return
562
+ }
563
+ p.frames.Add(1)
564
+ }
565
+
566
+ type termwrightWindowsMarkerScreen interface {
567
+ TermwrightWriteMarker(string) error
568
+ }
569
+
570
+ func termwrightWriteMarker(screen tcell.Screen, marker string) error {
571
+ if terminal, ok := screen.Tty(); ok && terminal != nil {
572
+ written, err := terminal.Write([]byte(marker))
573
+ if err != nil {
574
+ return err
575
+ }
576
+ if written != len(marker) {
577
+ return errors.New("tcell terminal accepted a partial render marker")
578
+ }
579
+ return nil
580
+ }
581
+ if windows, ok := screen.(termwrightWindowsMarkerScreen); ok {
582
+ return windows.TermwrightWriteMarker(marker)
583
+ }
584
+ return errors.New("tcell screen exposes no writer used by Show")
585
+ }
586
+
587
+ // onPublishFailed records a frame the driver will never see.
588
+ //
589
+ // Two things have to happen together, and leaving out either one
590
+ // produces a worse failure than dropping the frame did:
591
+ //
592
+ // 1. **No marker.** A marker names a revision; writing one for a tree that
593
+ // never arrived makes the driver wait for it and then report
594
+ // revision-pairing-watchdog — a diagnosis pointing at the adapter's timing rather
595
+ // than at the driver that stopped reading.
596
+ // 2. **Keep rendering.** The application is mid-frame under its own lock;
597
+ // instrumentation failing is not the application failing.
598
+ func (p *termwrightProbeState) onPublishFailed(publisher termwrightPublicationSink, err error) {
599
+ p.dropped.Add(1)
600
+ if errors.Is(err, protocol.ErrPublicationQueueFull) && publisher != nil {
601
+ p.requestAuthoritativeRedraw(publisher.ReadyAfterDrop())
602
+ return
603
+ }
604
+ if errors.Is(err, protocol.ErrPublicationQueueBusy) {
605
+ // Busy is a non-blocking admission refusal: a lifecycle or worker edge
606
+ // briefly owned one of the queue's locks. Defer a fresh draw to tview's
607
+ // event loop instead of turning that expected contention into a fatal or
608
+ // waiting in Screen.Show. The current snapshot receives no revision or
609
+ // marker and is never replayed.
610
+ ready := make(chan struct{})
611
+ close(ready)
612
+ p.requestAuthoritativeRedraw(ready)
613
+ return
614
+ }
615
+ message := "tview rendered a frame that semantic publication did not admit"
616
+ if err != nil {
617
+ message += ": " + err.Error()
618
+ }
619
+ if publisher != nil {
620
+ publisher.Fail("adapter-guarantee-violation", message)
621
+ }
622
+ if errors.Is(err, protocol.ErrPublicationWorkerFailed) {
623
+ // The worker closes an unrecoverable stream and admits no later marker.
624
+ // This counter retains its public name for probe statistics, but no
625
+ // transport deadline runs on the render thread anymore.
626
+ p.timedOut.Add(1)
627
+ }
628
+ }
629
+
630
+ // requestAuthoritativeRedraw turns bounded-queue loss into an explicit,
631
+ // causal recovery. The render callback only records the drop and starts one
632
+ // waiter. Once the publication worker has dequeued an item, that waiter queues
633
+ // a fresh framework draw on tview's own event loop. The fresh draw re-reads the
634
+ // retained widget tree and emits its own same-writer marker; the rejected
635
+ // revision is never replayed or assigned a number.
636
+ func (p *termwrightProbeState) requestAuthoritativeRedraw(ready <-chan struct{}) {
637
+ if p.application == nil || ready == nil || p.recoveryStop == nil {
638
+ return
639
+ }
640
+ if !p.recoveryPending.CompareAndSwap(false, true) {
641
+ return
642
+ }
643
+ p.recoveryWorkers.Add(1)
644
+ go func() {
645
+ defer p.recoveryWorkers.Done()
646
+ select {
647
+ case <-ready:
648
+ case <-p.recoveryStop:
649
+ p.recoveryPending.Store(false)
650
+ return
651
+ }
652
+
653
+ redraw := queuedUpdate{f: func() {
654
+ p.recoveryPending.Store(false)
655
+ select {
656
+ case <-p.recoveryStop:
657
+ return
658
+ default:
659
+ p.application.draw()
660
+ }
661
+ }}
662
+ select {
663
+ case p.application.updates <- redraw:
664
+ case <-p.recoveryStop:
665
+ p.recoveryPending.Store(false)
666
+ }
667
+ }()
668
+ }
669
+
670
+ // identity returns a stable id for a primitive.
671
+ //
672
+ // The pointer is the identity: tview retains its widget tree across frames, so
673
+ // the same *Button is the same button, and that is what makes the IR's
674
+ // `stable` identity kind honest here rather than a fabricated ordinal.
675
+ func (p *termwrightProbeState) identity(primitive Primitive) (string, error) {
676
+ typeOf := reflect.TypeOf(primitive)
677
+ if typeOf == nil || !typeOf.Comparable() {
678
+ return "", errors.New("tview custom Primitive has no stable comparable session identity: " + termwrightTypeName(primitive))
679
+ }
680
+ p.mu.Lock()
681
+ defer p.mu.Unlock()
682
+ if id, ok := p.ids[primitive]; ok {
683
+ return id, nil
684
+ }
685
+ p.nextID++
686
+ id := "n" + strconv.Itoa(p.nextID)
687
+ p.ids[primitive] = id
688
+ return id, nil
689
+ }
690
+
691
+ // snapshot walks the retained tree into the wire form.
692
+ func (p *termwrightProbeState) snapshot(root Primitive, columns, rows int) (*protocol.Snapshot, annotate.SemanticKey, error) {
693
+ snapshot := protocol.NewSnapshot("", 0, columns, rows)
694
+ snapshot.HitGrid = termwrightUnsupportedHitGrid()
695
+ if root == nil {
696
+ return snapshot, "", nil
697
+ }
698
+ keys := make(map[annotate.SemanticKey]string)
699
+ duplicates := make(map[annotate.SemanticKey]struct{})
700
+ pending := make([]termwrightRelations, 0)
701
+ if err := p.walk(root, "", false, columns, rows, snapshot, keys, duplicates, &pending); err != nil {
702
+ return snapshot, "", err
703
+ }
704
+ if len(duplicates) > 0 {
705
+ ordered := make([]string, 0, len(duplicates))
706
+ for key := range duplicates {
707
+ ordered = append(ordered, string(key))
708
+ }
709
+ sort.Strings(ordered)
710
+ return snapshot, annotate.SemanticKey(ordered[0]), nil
711
+ }
712
+ maxRelations := protocol.DefaultLimits.MaxRelationTargets
713
+ if p.client != nil {
714
+ maxRelations = p.client.Limits().MaxRelationTargets
715
+ }
716
+ termwrightResolveRelations(snapshot, keys, duplicates, pending, maxRelations)
717
+ return snapshot, "", nil
718
+ }
719
+
720
+ // termwrightRelations holds author references until every node has been
721
+ // visited. A label may be drawn after the control it labels, so resolving in
722
+ // walk order would make declaration order part of the API.
723
+ type termwrightRelations struct {
724
+ nodeIndex int
725
+ labelledBy []annotate.SemanticKey
726
+ describedBy []annotate.SemanticKey
727
+ }
728
+
729
+ // walk appends one node and recurses.
730
+ //
731
+ // `hidden` is inherited: a widget on an unshown page is not merely unfocused,
732
+ // it is not on screen, and every descendant of it is in the same position.
733
+ func (p *termwrightProbeState) walk(
734
+ primitive Primitive,
735
+ parentID string,
736
+ hidden bool,
737
+ columns, rows int,
738
+ snapshot *protocol.Snapshot,
739
+ keys map[annotate.SemanticKey]string,
740
+ duplicates map[annotate.SemanticKey]struct{},
741
+ pending *[]termwrightRelations,
742
+ ) error {
743
+ if primitive == nil {
744
+ return nil
745
+ }
746
+
747
+ id, identityErr := p.identity(primitive)
748
+ if identityErr != nil {
749
+ return identityErr
750
+ }
751
+ children := termwrightChildren(primitive)
752
+
753
+ // HasFocus reports true for ancestors of the focused primitive as well, so
754
+ // the flag belongs to the deepest one that claims it.
755
+ focused := !hidden && primitive.HasFocus() && !termwrightAnyFocus(children)
756
+
757
+ role := termwrightRole(primitive)
758
+ node := protocol.Node{
759
+ ID: id,
760
+ ParentID: parentID,
761
+ Role: role,
762
+ Name: termwrightName(primitive),
763
+ Value: termwrightValue(primitive),
764
+ State: termwrightState(primitive, focused, hidden),
765
+ P: protocol.ProvenanceFramework,
766
+ PX: map[string]string{
767
+ "role": protocol.ProvenanceRecognizer,
768
+ },
769
+ }
770
+ node.Actions, node.InputRecipes = termwrightPhysicalSemantics(primitive)
771
+ if len(node.Actions) > 0 {
772
+ termwrightProvenance(&node, "actions", protocol.ProvenanceFramework)
773
+ }
774
+ if len(node.InputRecipes) > 0 {
775
+ termwrightProvenance(&node, "inputRecipes", protocol.ProvenanceFramework)
776
+ }
777
+ node.Geometry = termwrightGeometry(primitive, hidden)
778
+ // Required for a generic node, and useful on every other one: it is what
779
+ // keeps a widget this probe does not know about alive and identifiable
780
+ // rather than flattened into an anonymous region.
781
+ node.FrameworkType = termwrightTypeName(primitive)
782
+ node.OpaqueChildren = !termwrightKnownPrimitive(primitive)
783
+ // An author's annotation is merged on top of the observed facts, and only
784
+ // where the probe has nothing better: it may say what a widget *is*, never
785
+ // where it is or whether it has the focus. Those the probe measured.
786
+ meta, annotated := annotate.Lookup(primitive)
787
+ if annotated {
788
+ termwrightApplyAnnotation(meta, &node)
789
+ termwrightSortActions(node.Actions)
790
+ termwrightRegisterKey(meta.Key, id, keys, duplicates)
791
+ }
792
+ // Unknown primitives remain explicit generic nodes with their concrete
793
+ // framework type and typed opaqueChildren marker. Extended remains
794
+ // exclusively application-defined state.
795
+ if parentID == "" {
796
+ snapshot.RootIDs = append(snapshot.RootIDs, id)
797
+ }
798
+ snapshot.Nodes = append(snapshot.Nodes, node)
799
+ if annotated && (len(meta.LabelledBy) > 0 || len(meta.DescribedBy) > 0) {
800
+ *pending = append(*pending, termwrightRelations{
801
+ nodeIndex: len(snapshot.Nodes) - 1,
802
+ labelledBy: meta.LabelledBy,
803
+ describedBy: meta.DescribedBy,
804
+ })
805
+ }
806
+
807
+ for _, child := range children {
808
+ if err := p.walk(child.primitive, id, hidden || child.hidden, columns, rows, snapshot, keys, duplicates, pending); err != nil {
809
+ return err
810
+ }
811
+ }
812
+ p.appendSynthetic(primitive, id, hidden, snapshot)
813
+ return nil
814
+ }
815
+
816
+ // termwrightKnownPrimitive distinguishes built-in leaves from author-defined
817
+ // primitives whose possible children tview does not expose. Unknown values are
818
+ // retained as explicit opaque nodes instead of silently presenting them as
819
+ // complete leaves.
820
+ func termwrightKnownPrimitive(primitive Primitive) bool {
821
+ switch primitive.(type) {
822
+ case *Box, *Button, *Checkbox, *DropDown, *Flex, *Form, *Frame, *Grid,
823
+ *Image, *InputField, *List, *Modal, *Pages, *Table,
824
+ *TextArea, *TextView, *TreeView:
825
+ return true
826
+ default:
827
+ return false
828
+ }
829
+ }
830
+
831
+ func termwrightRegisterKey(
832
+ key annotate.SemanticKey,
833
+ id string,
834
+ keys map[annotate.SemanticKey]string,
835
+ duplicates map[annotate.SemanticKey]struct{},
836
+ ) {
837
+ if key == "" {
838
+ return
839
+ }
840
+ if _, duplicate := duplicates[key]; duplicate {
841
+ return
842
+ }
843
+ if previous, exists := keys[key]; exists && previous != id {
844
+ delete(keys, key)
845
+ duplicates[key] = struct{}{}
846
+ return
847
+ }
848
+ keys[key] = id
849
+ }
850
+
851
+ func termwrightResolveRelations(
852
+ snapshot *protocol.Snapshot,
853
+ keys map[annotate.SemanticKey]string,
854
+ duplicates map[annotate.SemanticKey]struct{},
855
+ pending []termwrightRelations,
856
+ maxRelations int,
857
+ ) {
858
+ resolve := func(references []annotate.SemanticKey) []string {
859
+ resolved := make([]string, 0, len(references))
860
+ seen := make(map[string]struct{}, len(references))
861
+ for _, key := range references {
862
+ if len(resolved) >= maxRelations {
863
+ break
864
+ }
865
+ if _, duplicate := duplicates[key]; duplicate {
866
+ continue
867
+ }
868
+ id, found := keys[key]
869
+ if !found {
870
+ continue
871
+ }
872
+ if _, repeated := seen[id]; repeated {
873
+ continue
874
+ }
875
+ seen[id] = struct{}{}
876
+ resolved = append(resolved, id)
877
+ }
878
+ return resolved
879
+ }
880
+
881
+ for _, relation := range pending {
882
+ node := &snapshot.Nodes[relation.nodeIndex]
883
+ if ids := resolve(relation.labelledBy); len(ids) > 0 {
884
+ node.LabelledBy = ids
885
+ termwrightProvenance(node, "labelledBy", protocol.ProvenanceAnnotation)
886
+ }
887
+ if ids := resolve(relation.describedBy); len(ids) > 0 {
888
+ node.DescribedBy = ids
889
+ termwrightProvenance(node, "describedBy", protocol.ProvenanceAnnotation)
890
+ }
891
+ }
892
+ }
893
+
894
+ func termwrightProvenance(node *protocol.Node, field, source string) {
895
+ if node.PX == nil {
896
+ node.PX = make(map[string]string)
897
+ }
898
+ node.PX[field] = source
899
+ }
900
+
901
+ // termwrightApplyAnnotation merges what the application declared.
902
+ //
903
+ // tview retains its widgets, so a registry keyed by the primitive's identity
904
+ // works here — which is why tview annotates by registration while Charm, whose
905
+ // components are copied values, annotates through an interface.
906
+ func termwrightApplyAnnotation(meta annotate.Semantics, node *protocol.Node) {
907
+ if meta.Role != "" {
908
+ // Validated against the closed set and dropped when unknown, rather
909
+ // than guessed at: exhaustive switches downstream depend on that set
910
+ // staying closed, and a typo in an annotation is the author's to fix.
911
+ if role := protocol.Role(meta.Role); protocol.ValidRole(role) {
912
+ node.Role = role
913
+ termwrightProvenance(node, "role", protocol.ProvenanceAnnotation)
914
+ }
915
+ }
916
+ if meta.Name != "" {
917
+ node.Name = meta.Name
918
+ termwrightProvenance(node, "name", protocol.ProvenanceAnnotation)
919
+ }
920
+ if meta.TestID != "" {
921
+ node.TestID = meta.TestID
922
+ termwrightProvenance(node, "testId", protocol.ProvenanceAnnotation)
923
+ }
924
+ if meta.Description != "" {
925
+ node.Description = meta.Description
926
+ termwrightProvenance(node, "description", protocol.ProvenanceAnnotation)
927
+ }
928
+ // Domain state has its own namespace, so it cannot pollute the closed
929
+ // portable state vocabulary or masquerade as prose.
930
+ if len(meta.Domain) > 0 {
931
+ node.Extended = make(map[string]any, len(meta.Domain))
932
+ for key, value := range meta.Domain {
933
+ node.Extended[key] = value
934
+ }
935
+ termwrightProvenance(node, "extended", protocol.ProvenanceAnnotation)
936
+ }
937
+ seenActions := make(map[protocol.Action]struct{}, len(node.Actions)+len(meta.Actions))
938
+ for _, action := range node.Actions {
939
+ seenActions[action] = struct{}{}
940
+ }
941
+ annotationDeclaredAction := false
942
+ for _, action := range meta.Actions {
943
+ if !protocol.ValidAction(action) {
944
+ continue
945
+ }
946
+ annotationDeclaredAction = true
947
+ if _, duplicate := seenActions[action]; duplicate {
948
+ continue
949
+ }
950
+ seenActions[action] = struct{}{}
951
+ node.Actions = append(node.Actions, action)
952
+ }
953
+ if annotationDeclaredAction {
954
+ termwrightProvenance(node, "actions", protocol.ProvenanceAnnotation)
955
+ }
956
+ }
957
+
958
+ // termwrightPhysicalSemantics publishes only keybindings proven by the current
959
+ // conformance profile's InputHandler implementations. Candidate certification
960
+ // re-verifies these behaviors. They are data recipes executed later
961
+ // through the real PTY; this instrumentation never invokes a handler.
962
+ func termwrightPhysicalSemantics(p Primitive) ([]protocol.Action, []protocol.PhysicalInputRecipe) {
963
+ press := func(action protocol.Action, key string) ([]protocol.Action, []protocol.PhysicalInputRecipe) {
964
+ return []protocol.Action{action}, []protocol.PhysicalInputRecipe{{
965
+ Action: string(action), RequiresFocus: true,
966
+ Steps: []protocol.PhysicalInputRecipeStep{{Kind: "press", Key: key}},
967
+ }}
968
+ }
969
+ switch p.(type) {
970
+ case *Button:
971
+ return press(protocol.ActionActivate, "Enter")
972
+ case *Checkbox:
973
+ return press(protocol.ActionToggle, "Space")
974
+ default:
975
+ return nil, nil
976
+ }
977
+ }
978
+
979
+ func termwrightSortActions(actions []protocol.Action) {
980
+ order := map[protocol.Action]int{
981
+ protocol.ActionFocus: 0, protocol.ActionActivate: 1, protocol.ActionToggle: 2,
982
+ protocol.ActionSetValue: 3, protocol.ActionScroll: 4, protocol.ActionSelect: 5,
983
+ protocol.ActionExpand: 6,
984
+ }
985
+ sort.SliceStable(actions, func(left, right int) bool { return order[actions[left]] < order[actions[right]] })
986
+ }
987
+
988
+ // termwrightChild is a child plus whether its container is showing it.
989
+ type termwrightChild struct {
990
+ primitive Primitive
991
+ hidden bool
992
+ }
993
+
994
+ // termwrightChildren enumerates a container's children from inside the package.
995
+ func termwrightChildren(p Primitive) []termwrightChild {
996
+ switch c := p.(type) {
997
+ case *Flex:
998
+ children := make([]termwrightChild, 0, c.GetItemCount())
999
+ for index := 0; index < c.GetItemCount(); index++ {
1000
+ if item := c.GetItem(index); item != nil {
1001
+ children = append(children, termwrightChild{primitive: item})
1002
+ }
1003
+ }
1004
+ return children
1005
+ case *Grid:
1006
+ // The case an out-of-package adapter cannot serve at all. `visible`
1007
+ // carries the last draw's decision, which is exactly what a test means
1008
+ // by "is it on screen".
1009
+ children := make([]termwrightChild, 0, len(c.items))
1010
+ for _, item := range c.items {
1011
+ if item.Item != nil {
1012
+ children = append(children, termwrightChild{primitive: item.Item, hidden: !item.visible})
1013
+ }
1014
+ }
1015
+ return children
1016
+ case *Pages:
1017
+ visible := make(map[string]struct{}, c.GetPageCount())
1018
+ for _, name := range c.GetPageNames(true) {
1019
+ visible[name] = struct{}{}
1020
+ }
1021
+ // GetPageNames returns front-to-back; reverse it to preserve Draw's
1022
+ // public back-to-front paint order without reading Pages.pages.
1023
+ names := c.GetPageNames(false)
1024
+ children := make([]termwrightChild, 0, len(names))
1025
+ for index := len(names) - 1; index >= 0; index-- {
1026
+ name := names[index]
1027
+ if item := c.GetPage(name); item != nil {
1028
+ _, shown := visible[name]
1029
+ children = append(children, termwrightChild{primitive: item, hidden: !shown})
1030
+ }
1031
+ }
1032
+ return children
1033
+ case *Frame:
1034
+ if primitive := c.GetPrimitive(); primitive != nil {
1035
+ return []termwrightChild{{primitive: primitive}}
1036
+ }
1037
+ case *Form:
1038
+ children := make([]termwrightChild, 0, c.GetFormItemCount()+c.GetButtonCount())
1039
+ for index := 0; index < c.GetFormItemCount(); index++ {
1040
+ children = append(children, termwrightChild{primitive: c.GetFormItem(index)})
1041
+ }
1042
+ for index := 0; index < c.GetButtonCount(); index++ {
1043
+ children = append(children, termwrightChild{primitive: c.GetButton(index)})
1044
+ }
1045
+ return children
1046
+ case *Modal:
1047
+ if c.frame != nil {
1048
+ return []termwrightChild{{primitive: c.frame}}
1049
+ }
1050
+ }
1051
+ return nil
1052
+ }
1053
+
1054
+ func termwrightAnyFocus(children []termwrightChild) bool {
1055
+ for _, child := range children {
1056
+ if child.primitive != nil && child.primitive.HasFocus() {
1057
+ return true
1058
+ }
1059
+ }
1060
+ return false
1061
+ }
1062
+
1063
+ // termwrightRole maps a widget type to the closed role set.
1064
+ //
1065
+ // Deliberately identical to the hand-written adapter's mapping: the two must
1066
+ // agree, or the same application would describe itself differently depending
1067
+ // on how it was instrumented, and every conformance snapshot would fork.
1068
+ func termwrightRole(p Primitive) protocol.Role {
1069
+ switch p.(type) {
1070
+ case *Button:
1071
+ return protocol.RoleButton
1072
+ case *Checkbox:
1073
+ return protocol.RoleCheckbox
1074
+ case *InputField, *TextArea:
1075
+ return protocol.RoleTextbox
1076
+ case *DropDown, *List, *TreeView:
1077
+ return protocol.RoleList
1078
+ case *Table:
1079
+ return protocol.RoleTable
1080
+ case *TextView:
1081
+ return protocol.RoleText
1082
+ case *Modal:
1083
+ return protocol.RoleDialog
1084
+ case *Form, *Flex, *Grid, *Pages, *Frame, *Box:
1085
+ return protocol.RoleRegion
1086
+ }
1087
+ // Never dropped: an unrecognised widget keeps its geometry, its children and
1088
+ // its own type name, which is what makes a new tview release degrade
1089
+ // rather than disappear.
1090
+ return protocol.RoleGeneric
1091
+ }
1092
+
1093
+ // termwrightName derives the accessible name.
1094
+ func termwrightName(p Primitive) string {
1095
+ switch widget := p.(type) {
1096
+ case *Button:
1097
+ return widget.GetLabel()
1098
+ case *Checkbox:
1099
+ return termwrightFirst(widget.GetLabel(), widget.GetTitle())
1100
+ case *InputField:
1101
+ return termwrightFirst(widget.GetLabel(), widget.GetTitle())
1102
+ case *DropDown:
1103
+ return termwrightFirst(widget.GetLabel(), widget.GetTitle())
1104
+ case *TextArea:
1105
+ return termwrightFirst(widget.GetLabel(), widget.GetTitle())
1106
+ case *TextView:
1107
+ return termwrightFirst(widget.GetTitle(), termwrightTrim(widget.GetText(true)))
1108
+ case *Modal:
1109
+ // Modal exposes no getter at all; the text is the only name it has.
1110
+ return termwrightTrim(widget.text)
1111
+ case *Box:
1112
+ return widget.GetTitle()
1113
+ }
1114
+ if boxed, ok := p.(interface{ GetTitle() string }); ok {
1115
+ return boxed.GetTitle()
1116
+ }
1117
+ return ""
1118
+ }
1119
+
1120
+ // termwrightValue reports the current value of a value-bearing widget.
1121
+ //
1122
+ // The observation keeps an empty public value distinct from no value. A text
1123
+ // transform is production evidence that the displayed widget intentionally
1124
+ // withholds its source text (InputField passwords use this exact mechanism),
1125
+ // so the probe never exports that plaintext.
1126
+ func termwrightValue(p Primitive) *protocol.SemanticValueObservation {
1127
+ switch widget := p.(type) {
1128
+ case *InputField:
1129
+ if widget.textArea != nil && widget.textArea.transform != nil {
1130
+ return protocol.WithheldSensitiveValue()
1131
+ }
1132
+ return protocol.PublicValue(widget.GetText(), termwrightEvidence("native"))
1133
+ case *TextArea:
1134
+ if widget.transform != nil {
1135
+ return protocol.WithheldSensitiveValue()
1136
+ }
1137
+ return protocol.PublicValue(widget.GetText(), termwrightEvidence("native"))
1138
+ case *DropDown:
1139
+ _, text := widget.GetCurrentOption()
1140
+ return protocol.PublicValue(text, termwrightEvidence("native"))
1141
+ }
1142
+ return nil
1143
+ }
1144
+
1145
+ // termwrightTypeName is the framework's own name for the widget, without the
1146
+ // package qualifier that would be identical on every node.
1147
+ func termwrightTypeName(p Primitive) string {
1148
+ name := reflect.TypeOf(p).String()
1149
+ if index := strings.LastIndex(name, "."); index >= 0 {
1150
+ name = name[index+1:]
1151
+ }
1152
+ return name
1153
+ }
1154
+
1155
+ // termwrightGeometry qualifies only facts the retained tview tree exposes.
1156
+ // GetRect is the parent's intended allocation. The framework exposes no
1157
+ // general nested clipping or paint ownership, so visibleRect and pointer hit
1158
+ // testing remain unavailable.
1159
+ func termwrightGeometry(p Primitive, hidden bool) protocol.NodeGeometryObservations {
1160
+ displayed := !hidden
1161
+ geometry := protocol.NodeGeometryObservations{
1162
+ Displayed: protocol.Observation[bool]{Status: "known", Value: &displayed, Evidence: termwrightEvidence("instrumented")},
1163
+ }
1164
+ if hidden {
1165
+ geometry.IntendedRect = protocol.Observation[protocol.Rect]{Status: "absent", Reason: "not-displayed", Evidence: termwrightEvidence("instrumented")}
1166
+ geometry.VisibleRect = protocol.Observation[protocol.Rect]{Status: "absent", Reason: "not-displayed", Evidence: termwrightEvidence("instrumented")}
1167
+ return geometry
1168
+ }
1169
+
1170
+ x, y, width, height := p.GetRect()
1171
+ if width <= 0 || height <= 0 {
1172
+ geometry.IntendedRect = protocol.Observation[protocol.Rect]{Status: "absent", Reason: "not-laid-out", Evidence: termwrightEvidence("measured")}
1173
+ geometry.VisibleRect = protocol.Observation[protocol.Rect]{Status: "absent", Reason: "not-laid-out", Evidence: termwrightEvidence("measured")}
1174
+ return geometry
1175
+ }
1176
+ intended := protocol.Rect{Row: y, Column: x, Width: width, Height: height}
1177
+ geometry.IntendedRect = protocol.Observation[protocol.Rect]{Status: "known", Value: &intended, Evidence: termwrightEvidence("measured")}
1178
+ geometry.VisibleRect = protocol.Observation[protocol.Rect]{Status: "unsupported", Capability: string(protocol.CapClippedGeometry), Reason: "framework-unobservable"}
1179
+ return geometry
1180
+ }
1181
+
1182
+ func termwrightEvidence(method string) *protocol.EvidenceProvenance {
1183
+ return &protocol.EvidenceProvenance{
1184
+ Source: "framework", Method: method, Strength: "authoritative", ProviderID: probeName,
1185
+ }
1186
+ }
1187
+
1188
+ func termwrightUnsupportedHitGrid() protocol.Observation[protocol.PointerHitGrid] {
1189
+ return protocol.Observation[protocol.PointerHitGrid]{
1190
+ Status: "unsupported", Capability: "pointer-hit-grid", Reason: "framework-unobservable",
1191
+ }
1192
+ }
1193
+
1194
+ // termwrightCount is the same guard for set sizes.
1195
+ func termwrightCount(count int) *int {
1196
+ if count < 0 {
1197
+ return nil
1198
+ }
1199
+ return protocol.Int(count)
1200
+ }
1201
+
1202
+ // termwrightState reads the observable state of one widget.
1203
+ func termwrightState(p Primitive, focused, hidden bool) *protocol.State {
1204
+ state := protocol.State{}
1205
+ empty := true
1206
+
1207
+ if focused {
1208
+ state.Focused = protocol.Bool(true)
1209
+ empty = false
1210
+ }
1211
+ if hidden {
1212
+ state.Hidden = protocol.Bool(true)
1213
+ empty = false
1214
+ }
1215
+
1216
+ switch widget := p.(type) {
1217
+ case *Button:
1218
+ if widget.IsDisabled() {
1219
+ state.Disabled = protocol.Bool(true)
1220
+ empty = false
1221
+ }
1222
+ case *Checkbox:
1223
+ state.Checked = widget.IsChecked()
1224
+ if widget.disabled {
1225
+ state.Disabled = protocol.Bool(true)
1226
+ }
1227
+ empty = false
1228
+ case *DropDown:
1229
+ if widget.disabled {
1230
+ state.Disabled = protocol.Bool(true)
1231
+ empty = false
1232
+ }
1233
+ state.SetSize = termwrightCount(widget.GetOptionCount())
1234
+ state.Expanded = protocol.Bool(widget.IsOpen())
1235
+ empty = false
1236
+ case *TextArea:
1237
+ if widget.GetDisabled() {
1238
+ state.Disabled = protocol.Bool(true)
1239
+ empty = false
1240
+ }
1241
+ case *List:
1242
+ state.SetSize = termwrightCount(widget.GetItemCount())
1243
+ empty = false
1244
+ case *Table:
1245
+ state.SetSize = termwrightCount(widget.GetRowCount())
1246
+ empty = false
1247
+ case *TreeView:
1248
+ state.SetSize = termwrightCount(widget.GetRowCount())
1249
+ empty = false
1250
+ case *Modal:
1251
+ state.Modal = protocol.Bool(true)
1252
+ empty = false
1253
+ }
1254
+
1255
+ if empty {
1256
+ return nil
1257
+ }
1258
+ return &state
1259
+ }
1260
+
1261
+ // appendSynthetic emits nodes for entries that are not primitives of their own
1262
+ // — list items and dropdown options — so they are addressable by role and name.
1263
+ // Their geometry observations stay explicitly unknown because entries are not
1264
+ // primitives with framework-owned rectangles.
1265
+ func (p *termwrightProbeState) appendSynthetic(
1266
+ primitive Primitive,
1267
+ parentID string,
1268
+ hidden bool,
1269
+ snapshot *protocol.Snapshot,
1270
+ ) {
1271
+ switch widget := primitive.(type) {
1272
+ case *List:
1273
+ current := widget.GetCurrentItem()
1274
+ count := widget.GetItemCount()
1275
+ itemOffset, _ := widget.GetOffset()
1276
+ x, y, width, height := widget.GetInnerRect()
1277
+ for index := 0; index < count; index++ {
1278
+ main, secondary := widget.GetItemText(index)
1279
+ node := protocol.Node{
1280
+ ID: parentID + ":item" + strconv.Itoa(index),
1281
+ ParentID: parentID,
1282
+ Role: protocol.RoleListItem,
1283
+ Name: termwrightFirst(main, secondary),
1284
+ State: termwrightItemState(index == current, index, count, hidden),
1285
+ P: protocol.ProvenanceFramework,
1286
+ PX: map[string]string{
1287
+ "role": protocol.ProvenanceRecognizer,
1288
+ },
1289
+ }
1290
+ termwrightListItemGeometry(&node, x, y, width, height, index-itemOffset, hidden)
1291
+ snapshot.Nodes = append(snapshot.Nodes, node)
1292
+ }
1293
+ case *DropDown:
1294
+ current, _ := widget.GetCurrentOption()
1295
+ count := widget.GetOptionCount()
1296
+ x, y, width, height := widget.GetRect()
1297
+ for index := 0; index < count; index++ {
1298
+ node := protocol.Node{
1299
+ ID: parentID + ":option" + strconv.Itoa(index),
1300
+ ParentID: parentID,
1301
+ Role: protocol.RoleListItem,
1302
+ Name: widget.options[index].Text,
1303
+ State: termwrightItemState(index == current, index, count, hidden),
1304
+ P: protocol.ProvenanceFramework,
1305
+ PX: map[string]string{
1306
+ "role": protocol.ProvenanceRecognizer,
1307
+ },
1308
+ }
1309
+ termwrightOwnedControlGeometry(&node, x, y, width, height, hidden)
1310
+ snapshot.Nodes = append(snapshot.Nodes, node)
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ func termwrightListItemGeometry(node *protocol.Node, x, y, width, height, row int, hidden bool) {
1316
+ displayed := !hidden && row >= 0 && row < height && width > 0
1317
+ rect := protocol.Rect{Row: y + row, Column: x, Width: width, Height: 1}
1318
+ node.Geometry = protocol.NodeGeometryObservations{
1319
+ Displayed: protocol.Observation[bool]{Status: "known", Value: &displayed, Evidence: termwrightEvidence("derived")},
1320
+ IntendedRect: protocol.Observation[protocol.Rect]{Status: "known", Value: &rect, Evidence: termwrightEvidence("derived")},
1321
+ VisibleRect: protocol.Observation[protocol.Rect]{Status: "unsupported", Capability: string(protocol.CapClippedGeometry), Reason: "framework-unobservable"},
1322
+ }
1323
+ }
1324
+
1325
+ func termwrightOwnedControlGeometry(node *protocol.Node, x, y, width, height int, hidden bool) {
1326
+ displayed := !hidden && width > 0 && height > 0
1327
+ rect := protocol.Rect{Row: y, Column: x, Width: width, Height: height}
1328
+ node.Geometry = protocol.NodeGeometryObservations{
1329
+ Displayed: protocol.Observation[bool]{Status: "known", Value: &displayed, Evidence: termwrightEvidence("derived")},
1330
+ IntendedRect: protocol.Observation[protocol.Rect]{Status: "known", Value: &rect, Evidence: termwrightEvidence("derived")},
1331
+ VisibleRect: protocol.Observation[protocol.Rect]{Status: "unsupported", Capability: string(protocol.CapClippedGeometry), Reason: "framework-unobservable"},
1332
+ }
1333
+ }
1334
+
1335
+ func termwrightItemState(selected bool, index, count int, hidden bool) *protocol.State {
1336
+ state := protocol.State{
1337
+ Selected: protocol.Bool(selected),
1338
+ PositionInSet: protocol.Int(index + 1),
1339
+ SetSize: protocol.Int(count),
1340
+ }
1341
+ if hidden {
1342
+ state.Hidden = protocol.Bool(true)
1343
+ }
1344
+ return &state
1345
+ }
1346
+
1347
+ func termwrightFirst(candidates ...string) string {
1348
+ for _, candidate := range candidates {
1349
+ if trimmed := termwrightTrim(candidate); trimmed != "" {
1350
+ return trimmed
1351
+ }
1352
+ }
1353
+ return ""
1354
+ }
1355
+
1356
+ // termwrightTrim collapses the padding widgets use for layout, so a name reads
1357
+ // the way it looks rather than the way it was spaced.
1358
+ func termwrightTrim(text string) string {
1359
+ start := 0
1360
+ end := len(text)
1361
+ for start < end && (text[start] == ' ' || text[start] == '\t' || text[start] == '\n' || text[start] == '\r') {
1362
+ start++
1363
+ }
1364
+ for end > start && (text[end-1] == ' ' || text[end-1] == '\t' || text[end-1] == '\n' || text[end-1] == '\r') {
1365
+ end--
1366
+ }
1367
+ return text[start:end]
1368
+ }