@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.
@@ -1,876 +0,0 @@
1
- package tview
2
-
3
- // Build-time instrumentation injected by termwright into a private copy of
4
- // tview. Upstream never sees this file; it exists only in the copy a
5
- // termwright-driven build compiles against.
6
- //
7
- // Being inside the package is the whole point: the semantic state a test wants
8
- // — a button's label, a list's selection, a page's visibility — lives in
9
- // unexported fields that no external adapter can read without reflection.
10
- // Grid is the clearest case: its `items` field has no accessor at all, so the
11
- // out-of-package adapter has to be handed a callback, and here it is a field
12
- // read.
13
- //
14
- // Three rules from the Phase 0 audit (docs/architecture/audit/tview.md §1–2)
15
- // are load-bearing, and breaking any of them turns a working application into
16
- // a hang or a lie:
17
- //
18
- // 1. The hook runs inside Application.draw(), which holds the application's
19
- // write lock for the whole frame. Anything that waits on the event loop —
20
- // QueueUpdate, QueueUpdateDraw, Draw, SetFocus, Stop — deadlocks, because
21
- // the loop is the goroutine currently inside draw().
22
- // 2. Reading primitive state is safe from here and essentially nowhere else:
23
- // rects are assigned by parents *during* the draw, so another goroutine
24
- // reading GetRect races the layout.
25
- // 3. The marker must follow the frame's bytes. The hook sits after
26
- // screen.Show(), which is why publication is synchronous here rather than
27
- // handed to a goroutine: a marker written later could land after the next
28
- // frame's bytes and pair the tree with the wrong screen.
29
-
30
- import (
31
- "errors"
32
- "os"
33
- "reflect"
34
- "strconv"
35
- "strings"
36
- "sync"
37
- "sync/atomic"
38
- "time"
39
-
40
- "github.com/gdamore/tcell/v2"
41
-
42
- "github.com/gorce-ai/termwright/clients/go/annotate"
43
- "github.com/gorce-ai/termwright/clients/go/protocol"
44
- )
45
-
46
- // probeName and probeVersion identify this probe in the handshake, distinctly
47
- // from the hand-written adapter so a session can be told apart in diagnostics.
48
- const (
49
- probeName = "termwright-probe-tview"
50
- probeVersion = "0.2.0"
51
- frameworkVersion = "v0.42.0"
52
- )
53
-
54
- // termwrightProbeState is nil for an uninstrumented run, which is every run
55
- // that does not carry the handshake variables.
56
- type termwrightProbeState struct {
57
- client *protocol.Client
58
-
59
- mu sync.Mutex
60
- ids map[Primitive]string
61
- nextID int
62
- dropped atomic.Uint64
63
- timedOut atomic.Uint64
64
- frames atomic.Uint64
65
- redrawPending atomic.Bool
66
- }
67
-
68
- // TermwrightProbeStats reports what the probe did and failed to do.
69
- //
70
- // Exported because the conformance fixture asserts on it: a drop counter no
71
- // test can read is a drop counter nobody notices.
72
- type TermwrightProbeStats struct {
73
- Frames uint64
74
- Dropped uint64
75
- TimedOut uint64
76
- FullSnapshotPending bool
77
- }
78
-
79
- // TermwrightProbeStatistics returns the counters, or zeroes when dormant.
80
- func TermwrightProbeStatistics() TermwrightProbeStats {
81
- p := termwrightProbe
82
- if p == nil {
83
- return TermwrightProbeStats{}
84
- }
85
- return TermwrightProbeStats{
86
- Frames: p.frames.Load(),
87
- Dropped: p.dropped.Load(),
88
- TimedOut: p.timedOut.Load(),
89
- FullSnapshotPending: p.client.FullSnapshotRequired(),
90
- }
91
- }
92
-
93
- var termwrightProbe = newTermwrightProbe()
94
-
95
- // newTermwrightProbe honours the dormant rule: without an endpoint and a token
96
- // the copy behaves exactly like upstream. `FromEnv` returns nil in that case,
97
- // so there is one branch and no second source of truth about what "dormant"
98
- // means.
99
- func newTermwrightProbe() *termwrightProbeState {
100
- client := protocol.FromEnv(protocol.Options{
101
- AdapterName: probeName,
102
- AdapterVersion: probeVersion,
103
- Probe: &protocol.ProbeInfo{
104
- Framework: "tview",
105
- FrameworkVersion: frameworkVersion,
106
- ProbeVersion: probeVersion,
107
- IdentityKind: protocol.ProbeIdentityStable,
108
- Capabilities: []protocol.ProbeCapability{
109
- protocol.ProbeCapStableIdentity,
110
- protocol.ProbeCapAnnotations,
111
- },
112
- },
113
- // Bounds one frame write. The publish below happens under the
114
- // application's write lock, so an unbounded write would freeze
115
- // rendering whenever the driver stops reading — a frozen debugger, a
116
- // slow consumer, a transport torn down mid-frame. A quarter of a
117
- // second of not being read means the driver is not keeping up, and
118
- // the next frame carries newer state anyway.
119
- WriteTimeout: protocol.DefaultWriteTimeout,
120
- })
121
- if client == nil {
122
- return nil
123
- }
124
- p := &termwrightProbeState{client: client, ids: make(map[Primitive]string)}
125
- // The handshake must not block the first frame; publishing is a no-op
126
- // until it completes.
127
- go func() { _ = client.Start(protocol.DialTimeout) }()
128
- return p
129
- }
130
-
131
- // termwrightAfterFrame is called from draw(), after screen.Show() has flushed
132
- // the frame's bytes.
133
- func termwrightAfterFrame(a *Application, screen tcell.Screen) {
134
- if p := termwrightProbe; p != nil {
135
- p.afterFrame(a, screen)
136
- }
137
- }
138
-
139
- // afterFrame is the method form, so a test can drive an instance of its own
140
- // rather than the package-level probe a real run installs.
141
- func (p *termwrightProbeState) afterFrame(a *Application, screen tcell.Screen) {
142
- if screen == nil || a == nil {
143
- return
144
- }
145
- if !p.client.Connected() {
146
- p.redrawAfterHandshake(a)
147
- return
148
- }
149
-
150
- columns, rows := screen.Size()
151
- if columns <= 0 || rows <= 0 {
152
- return
153
- }
154
-
155
- snapshot := p.snapshot(a, columns, rows, p.client.QualifiedObservations())
156
- marker, err := p.client.Publish(snapshot)
157
- if err != nil || marker == "" {
158
- p.onPublishFailed(err)
159
- return
160
- }
161
- // After the bytes, which is the whole reason this call site exists.
162
- _, _ = os.Stdout.WriteString(marker)
163
- p.frames.Add(1)
164
- }
165
-
166
- // redrawAfterHandshake closes the startup race for applications whose first
167
- // frame is also their last frame until input arrives. The initial draw must not
168
- // block on the driver, so the handshake remains asynchronous; once connected,
169
- // one queued draw publishes the current tree without requiring synthetic user
170
- // input. QueueUpdateDraw is the framework's supported cross-goroutine path.
171
- func (p *termwrightProbeState) redrawAfterHandshake(a *Application) {
172
- if !p.redrawPending.CompareAndSwap(false, true) {
173
- return
174
- }
175
- go func() {
176
- defer p.redrawPending.Store(false)
177
- deadline := time.Now().Add(2 * time.Second)
178
- for !p.client.Connected() && time.Now().Before(deadline) {
179
- time.Sleep(5 * time.Millisecond)
180
- }
181
- if p.client.Connected() {
182
- a.QueueUpdateDraw(func() {})
183
- }
184
- }()
185
- }
186
-
187
- // onPublishFailed records a frame the driver will never see.
188
- //
189
- // Three things have to happen together, and leaving out any one of them
190
- // produces a worse failure than dropping the frame did:
191
- //
192
- // 1. **No marker.** A marker names a revision; writing one for a tree that
193
- // never arrived makes the driver wait for it and then report
194
- // revision-expired — a diagnosis pointing at the adapter's timing rather
195
- // than at the driver that stopped reading.
196
- // 2. **A full snapshot next.** This probe has now lost part of its own fact
197
- // stream, and a later delta would be based on a revision the driver never
198
- // received. The producer obligation is to re-send the whole tree, and the
199
- // client keeps the flag until a full tree is actually built.
200
- // 3. **Keep rendering.** The application is mid-frame under its own lock;
201
- // instrumentation failing is not the application failing.
202
- func (p *termwrightProbeState) onPublishFailed(err error) {
203
- p.dropped.Add(1)
204
- p.client.RequireFullSnapshot()
205
-
206
- if errors.Is(err, protocol.ErrWriteTimeout) {
207
- // The stream now holds part of a frame and has no resynchronisation
208
- // point, so the client closes the session. Nothing to retry: the next
209
- // Publish returns immediately and the application draws on.
210
- p.timedOut.Add(1)
211
- }
212
- }
213
-
214
- // identity returns a stable id for a primitive.
215
- //
216
- // The pointer is the identity: tview retains its widget tree across frames, so
217
- // the same *Button is the same button, and that is what makes the IR's
218
- // `stable` identity kind honest here rather than a fabricated ordinal.
219
- func (p *termwrightProbeState) identity(primitive Primitive) string {
220
- p.mu.Lock()
221
- defer p.mu.Unlock()
222
- if id, ok := p.ids[primitive]; ok {
223
- return id
224
- }
225
- p.nextID++
226
- id := "n" + strconv.Itoa(p.nextID)
227
- p.ids[primitive] = id
228
- return id
229
- }
230
-
231
- // snapshot walks the retained tree into the wire form.
232
- func (p *termwrightProbeState) snapshot(a *Application, columns, rows int, qualified bool) *protocol.Snapshot {
233
- var snapshot *protocol.Snapshot
234
- if qualified {
235
- snapshot = protocol.NewSnapshotV2("", 0, columns, rows)
236
- snapshot.HitGrid = termwrightUnsupportedHitGrid()
237
- } else {
238
- snapshot = protocol.NewSnapshot("", 0, columns, rows)
239
- }
240
- if a.root == nil {
241
- return snapshot
242
- }
243
- keys := make(map[annotate.SemanticKey]string)
244
- duplicates := make(map[annotate.SemanticKey]struct{})
245
- pending := make([]termwrightRelations, 0)
246
- p.walk(a.root, "", false, columns, rows, qualified, snapshot, keys, duplicates, &pending)
247
- maxRelations := protocol.DefaultLimits.MaxRelationTargets
248
- if p.client != nil {
249
- maxRelations = p.client.Limits().MaxRelationTargets
250
- }
251
- termwrightResolveRelations(snapshot, keys, duplicates, pending, maxRelations)
252
- return snapshot
253
- }
254
-
255
- // termwrightRelations holds author references until every node has been
256
- // visited. A label may be drawn after the control it labels, so resolving in
257
- // walk order would make declaration order part of the API.
258
- type termwrightRelations struct {
259
- nodeIndex int
260
- labelledBy []annotate.SemanticKey
261
- describedBy []annotate.SemanticKey
262
- }
263
-
264
- // walk appends one node and recurses.
265
- //
266
- // `hidden` is inherited: a widget on an unshown page is not merely unfocused,
267
- // it is not on screen, and every descendant of it is in the same position.
268
- func (p *termwrightProbeState) walk(
269
- primitive Primitive,
270
- parentID string,
271
- hidden bool,
272
- columns, rows int,
273
- qualified bool,
274
- snapshot *protocol.Snapshot,
275
- keys map[annotate.SemanticKey]string,
276
- duplicates map[annotate.SemanticKey]struct{},
277
- pending *[]termwrightRelations,
278
- ) {
279
- if primitive == nil {
280
- return
281
- }
282
-
283
- id := p.identity(primitive)
284
- children := termwrightChildren(primitive)
285
-
286
- // HasFocus reports true for ancestors of the focused primitive as well, so
287
- // the flag belongs to the deepest one that claims it.
288
- focused := !hidden && primitive.HasFocus() && !termwrightAnyFocus(children)
289
-
290
- role := termwrightRole(primitive)
291
- node := protocol.Node{
292
- ID: id,
293
- ParentID: parentID,
294
- Role: role,
295
- Name: termwrightName(primitive),
296
- Value: termwrightValue(primitive),
297
- State: termwrightState(primitive, focused, hidden),
298
- P: protocol.ProvenanceFramework,
299
- PX: map[string]string{
300
- "role": protocol.ProvenanceRecognizer,
301
- },
302
- }
303
- if qualified {
304
- node.Geometry = termwrightGeometry(primitive, hidden, columns, rows)
305
- } else {
306
- node.Bounds = termwrightBounds(primitive, columns, rows)
307
- }
308
- // Required for a generic node, and useful on every other one: it is what
309
- // keeps a widget this probe does not know about alive and identifiable
310
- // rather than flattened into an anonymous region.
311
- node.FrameworkType = termwrightTypeName(primitive)
312
- // An author's annotation is merged on top of the observed facts, and only
313
- // where the probe has nothing better: it may say what a widget *is*, never
314
- // where it is or whether it has the focus. Those the probe measured.
315
- meta, annotated := annotate.Lookup(primitive)
316
- if annotated {
317
- termwrightApplyAnnotation(meta, &node)
318
- termwrightRegisterKey(meta.Key, id, keys, duplicates)
319
- }
320
- if parentID == "" {
321
- snapshot.RootIDs = append(snapshot.RootIDs, id)
322
- }
323
- snapshot.Nodes = append(snapshot.Nodes, node)
324
- if annotated && (len(meta.LabelledBy) > 0 || len(meta.DescribedBy) > 0) {
325
- *pending = append(*pending, termwrightRelations{
326
- nodeIndex: len(snapshot.Nodes) - 1,
327
- labelledBy: meta.LabelledBy,
328
- describedBy: meta.DescribedBy,
329
- })
330
- }
331
-
332
- for _, child := range children {
333
- p.walk(child.primitive, id, hidden || child.hidden, columns, rows, qualified, snapshot, keys, duplicates, pending)
334
- }
335
- p.appendSynthetic(primitive, id, hidden, qualified, snapshot)
336
- }
337
-
338
- func termwrightRegisterKey(
339
- key annotate.SemanticKey,
340
- id string,
341
- keys map[annotate.SemanticKey]string,
342
- duplicates map[annotate.SemanticKey]struct{},
343
- ) {
344
- if key == "" {
345
- return
346
- }
347
- if _, duplicate := duplicates[key]; duplicate {
348
- return
349
- }
350
- if previous, exists := keys[key]; exists && previous != id {
351
- delete(keys, key)
352
- duplicates[key] = struct{}{}
353
- return
354
- }
355
- keys[key] = id
356
- }
357
-
358
- func termwrightResolveRelations(
359
- snapshot *protocol.Snapshot,
360
- keys map[annotate.SemanticKey]string,
361
- duplicates map[annotate.SemanticKey]struct{},
362
- pending []termwrightRelations,
363
- maxRelations int,
364
- ) {
365
- resolve := func(references []annotate.SemanticKey) []string {
366
- resolved := make([]string, 0, len(references))
367
- seen := make(map[string]struct{}, len(references))
368
- for _, key := range references {
369
- if len(resolved) >= maxRelations {
370
- break
371
- }
372
- if _, duplicate := duplicates[key]; duplicate {
373
- continue
374
- }
375
- id, found := keys[key]
376
- if !found {
377
- continue
378
- }
379
- if _, repeated := seen[id]; repeated {
380
- continue
381
- }
382
- seen[id] = struct{}{}
383
- resolved = append(resolved, id)
384
- }
385
- return resolved
386
- }
387
-
388
- for _, relation := range pending {
389
- node := &snapshot.Nodes[relation.nodeIndex]
390
- if ids := resolve(relation.labelledBy); len(ids) > 0 {
391
- node.LabelledBy = ids
392
- termwrightProvenance(node, "labelledBy", protocol.ProvenanceAnnotation)
393
- }
394
- if ids := resolve(relation.describedBy); len(ids) > 0 {
395
- node.DescribedBy = ids
396
- termwrightProvenance(node, "describedBy", protocol.ProvenanceAnnotation)
397
- }
398
- }
399
- }
400
-
401
- func termwrightProvenance(node *protocol.Node, field, source string) {
402
- if node.PX == nil {
403
- node.PX = make(map[string]string)
404
- }
405
- node.PX[field] = source
406
- }
407
-
408
- // termwrightApplyAnnotation merges what the application declared.
409
- //
410
- // tview retains its widgets, so a registry keyed by the primitive's identity
411
- // works here — which is why tview annotates by registration while Charm, whose
412
- // components are copied values, annotates through an interface.
413
- func termwrightApplyAnnotation(meta annotate.Semantics, node *protocol.Node) {
414
- if meta.Role != "" {
415
- // Validated against the closed set and dropped when unknown, rather
416
- // than guessed at: exhaustive switches downstream depend on that set
417
- // staying closed, and a typo in an annotation is the author's to fix.
418
- if role := protocol.Role(meta.Role); protocol.ValidRole(role) {
419
- node.Role = role
420
- termwrightProvenance(node, "role", protocol.ProvenanceAnnotation)
421
- }
422
- }
423
- if meta.Name != "" {
424
- node.Name = meta.Name
425
- termwrightProvenance(node, "name", protocol.ProvenanceAnnotation)
426
- }
427
- if meta.TestID != "" {
428
- node.TestID = meta.TestID
429
- termwrightProvenance(node, "testId", protocol.ProvenanceAnnotation)
430
- }
431
- if meta.Description != "" {
432
- node.Description = meta.Description
433
- termwrightProvenance(node, "description", protocol.ProvenanceAnnotation)
434
- }
435
- // Domain state has its own namespace, so it cannot pollute the closed
436
- // portable state vocabulary or masquerade as prose.
437
- if len(meta.Domain) > 0 {
438
- node.Extended = make(map[string]any, len(meta.Domain))
439
- for key, value := range meta.Domain {
440
- node.Extended[key] = value
441
- }
442
- termwrightProvenance(node, "extended", protocol.ProvenanceAnnotation)
443
- }
444
- seenActions := make(map[protocol.Action]struct{}, len(meta.Actions))
445
- for _, action := range meta.Actions {
446
- if !protocol.ValidAction(action) {
447
- continue
448
- }
449
- if _, duplicate := seenActions[action]; duplicate {
450
- continue
451
- }
452
- seenActions[action] = struct{}{}
453
- node.Actions = append(node.Actions, action)
454
- }
455
- if len(node.Actions) > 0 {
456
- termwrightProvenance(node, "actions", protocol.ProvenanceAnnotation)
457
- }
458
- }
459
-
460
- // termwrightChild is a child plus whether its container is showing it.
461
- type termwrightChild struct {
462
- primitive Primitive
463
- hidden bool
464
- }
465
-
466
- // termwrightChildren enumerates a container's children from inside the package.
467
- func termwrightChildren(p Primitive) []termwrightChild {
468
- switch c := p.(type) {
469
- case *Flex:
470
- children := make([]termwrightChild, 0, len(c.items))
471
- for _, item := range c.items {
472
- if item.Item != nil {
473
- children = append(children, termwrightChild{primitive: item.Item})
474
- }
475
- }
476
- return children
477
- case *Grid:
478
- // The case an out-of-package adapter cannot serve at all. `visible`
479
- // carries the last draw's decision, which is exactly what a test means
480
- // by "is it on screen".
481
- children := make([]termwrightChild, 0, len(c.items))
482
- for _, item := range c.items {
483
- if item.Item != nil {
484
- children = append(children, termwrightChild{primitive: item.Item, hidden: !item.visible})
485
- }
486
- }
487
- return children
488
- case *Pages:
489
- children := make([]termwrightChild, 0, len(c.pages))
490
- for _, page := range c.pages {
491
- if page.Item != nil {
492
- children = append(children, termwrightChild{primitive: page.Item, hidden: !page.Visible})
493
- }
494
- }
495
- return children
496
- case *Frame:
497
- if c.primitive != nil {
498
- return []termwrightChild{{primitive: c.primitive}}
499
- }
500
- case *Form:
501
- children := make([]termwrightChild, 0, len(c.items)+len(c.buttons))
502
- for _, item := range c.items {
503
- children = append(children, termwrightChild{primitive: item})
504
- }
505
- for _, button := range c.buttons {
506
- children = append(children, termwrightChild{primitive: button})
507
- }
508
- return children
509
- case *Modal:
510
- if c.frame != nil {
511
- return []termwrightChild{{primitive: c.frame}}
512
- }
513
- }
514
- return nil
515
- }
516
-
517
- func termwrightAnyFocus(children []termwrightChild) bool {
518
- for _, child := range children {
519
- if child.primitive != nil && child.primitive.HasFocus() {
520
- return true
521
- }
522
- }
523
- return false
524
- }
525
-
526
- // termwrightRole maps a widget type to the closed role set.
527
- //
528
- // Deliberately identical to the hand-written adapter's mapping: the two must
529
- // agree, or the same application would describe itself differently depending
530
- // on how it was instrumented, and every conformance snapshot would fork.
531
- func termwrightRole(p Primitive) protocol.Role {
532
- switch p.(type) {
533
- case *Button:
534
- return protocol.RoleButton
535
- case *Checkbox:
536
- return protocol.RoleCheckbox
537
- case *InputField, *TextArea:
538
- return protocol.RoleTextbox
539
- case *DropDown, *List, *TreeView:
540
- return protocol.RoleList
541
- case *Table:
542
- return protocol.RoleTable
543
- case *TextView:
544
- return protocol.RoleText
545
- case *Modal:
546
- return protocol.RoleDialog
547
- case *Form, *Flex, *Grid, *Pages, *Frame, *Box:
548
- return protocol.RoleRegion
549
- }
550
- // Never dropped: an unrecognised widget keeps its bounds, its children and
551
- // its own type name, which is what makes a new tview release degrade
552
- // rather than disappear.
553
- return protocol.RoleGeneric
554
- }
555
-
556
- // termwrightName derives the accessible name.
557
- func termwrightName(p Primitive) string {
558
- switch widget := p.(type) {
559
- case *Button:
560
- return widget.GetLabel()
561
- case *Checkbox:
562
- return termwrightFirst(widget.GetLabel(), widget.GetTitle())
563
- case *InputField:
564
- return termwrightFirst(widget.GetLabel(), widget.GetTitle())
565
- case *DropDown:
566
- return termwrightFirst(widget.GetLabel(), widget.GetTitle())
567
- case *TextArea:
568
- return termwrightFirst(widget.GetLabel(), widget.GetTitle())
569
- case *TextView:
570
- return termwrightFirst(widget.GetTitle(), termwrightTrim(widget.GetText(true)))
571
- case *Modal:
572
- // Modal exposes no getter at all; the text is the only name it has.
573
- return termwrightTrim(widget.text)
574
- case *Box:
575
- return widget.GetTitle()
576
- }
577
- if boxed, ok := p.(interface{ GetTitle() string }); ok {
578
- return boxed.GetTitle()
579
- }
580
- return ""
581
- }
582
-
583
- // termwrightValue reports the current value of a value-bearing widget.
584
- //
585
- // A pointer because the empty string is a fact: `""` says the field is empty,
586
- // absent says this widget carries no value at all. Collapsing the two would
587
- // make an assertion on an emptied input box unwritable.
588
- func termwrightValue(p Primitive) *string {
589
- switch widget := p.(type) {
590
- case *InputField:
591
- text := widget.GetText()
592
- return &text
593
- case *TextArea:
594
- text := widget.GetText()
595
- return &text
596
- case *DropDown:
597
- _, text := widget.GetCurrentOption()
598
- return &text
599
- }
600
- return nil
601
- }
602
-
603
- // termwrightTypeName is the framework's own name for the widget, without the
604
- // package qualifier that would be identical on every node.
605
- func termwrightTypeName(p Primitive) string {
606
- name := reflect.TypeOf(p).String()
607
- if index := strings.LastIndex(name, "."); index >= 0 {
608
- name = name[index+1:]
609
- }
610
- return name
611
- }
612
-
613
- // termwrightBounds reports where the widget was drawn.
614
- //
615
- // This is the IR's `intendedRect`: what the parent assigned, not a claim on
616
- // cells. tview computes no clip, so `visibleRect` is genuinely unobservable
617
- // here and is not invented — a widget scrolled out of a Grid still reports the
618
- // rectangle it was given.
619
- func termwrightBounds(p Primitive, columns, rows int) *protocol.Rect {
620
- x, y, width, height := p.GetRect()
621
- if width <= 0 || height <= 0 {
622
- return nil
623
- }
624
- if x >= columns || y >= rows {
625
- return nil
626
- }
627
- return &protocol.Rect{Row: y, Column: x, Width: width, Height: height}
628
- }
629
-
630
- // termwrightGeometry qualifies only facts the retained tview tree exposes.
631
- // GetRect is the parent's intended allocation. The framework exposes no
632
- // general nested clipping or paint ownership, so visibleRect is limited to the
633
- // exact viewport intersection and pointer hit testing remains unsupported.
634
- func termwrightGeometry(p Primitive, hidden bool, columns, rows int) *protocol.NodeGeometryObservations {
635
- displayed := !hidden
636
- geometry := &protocol.NodeGeometryObservations{
637
- Displayed: protocol.Observation[bool]{Status: "known", Value: &displayed, Evidence: "probe"},
638
- }
639
- if hidden {
640
- geometry.IntendedRect = protocol.Observation[protocol.Rect]{Status: "absent", Reason: "not-displayed"}
641
- geometry.VisibleRect = protocol.Observation[protocol.Rect]{Status: "absent", Reason: "not-displayed"}
642
- return geometry
643
- }
644
-
645
- x, y, width, height := p.GetRect()
646
- if width <= 0 || height <= 0 {
647
- geometry.IntendedRect = protocol.Observation[protocol.Rect]{Status: "absent", Reason: "not-laid-out"}
648
- geometry.VisibleRect = protocol.Observation[protocol.Rect]{Status: "absent", Reason: "not-laid-out"}
649
- return geometry
650
- }
651
- intended := protocol.Rect{Row: y, Column: x, Width: width, Height: height}
652
- visible := termwrightViewportIntersection(intended, columns, rows)
653
- geometry.IntendedRect = protocol.Observation[protocol.Rect]{Status: "known", Value: &intended, Evidence: "probe"}
654
- geometry.VisibleRect = protocol.Observation[protocol.Rect]{Status: "known", Value: &visible, Evidence: "viewport-clip"}
655
- return geometry
656
- }
657
-
658
- func termwrightViewportIntersection(rect protocol.Rect, columns, rows int) protocol.Rect {
659
- left := termwrightMax(0, termwrightMin(rect.Column, columns))
660
- top := termwrightMax(0, termwrightMin(rect.Row, rows))
661
- right := termwrightMax(left, termwrightMin(rect.Column+rect.Width, columns))
662
- bottom := termwrightMax(top, termwrightMin(rect.Row+rect.Height, rows))
663
- return protocol.Rect{Row: top, Column: left, Width: right - left, Height: bottom - top}
664
- }
665
-
666
- func termwrightMin(left, right int) int {
667
- if left < right {
668
- return left
669
- }
670
- return right
671
- }
672
-
673
- func termwrightMax(left, right int) int {
674
- if left > right {
675
- return left
676
- }
677
- return right
678
- }
679
-
680
- func termwrightUnsupportedHitGrid() *protocol.Observation[protocol.PointerHitGrid] {
681
- return &protocol.Observation[protocol.PointerHitGrid]{
682
- Status: "unsupported", Capability: "pointer-hit-grid", Reason: "framework-unobservable",
683
- }
684
- }
685
-
686
- // termwrightScroll reports a scroll offset only when it is a fact.
687
- //
688
- // Several of these fields are meaningless until the widget has been drawn once
689
- // (the audit lists them: TextView.pageSize, TreeView.nodes, Table.visibleRows
690
- // and friends), and tview leaves some of them negative until then. A negative
691
- // offset is not "scrolled backwards", it is "not decided yet" — publishing it
692
- // asserts something false and, since the schema requires a non-negative
693
- // integer, gets the whole snapshot refused.
694
- func termwrightScroll(offset int) *int {
695
- if offset < 0 {
696
- return nil
697
- }
698
- return protocol.Int(offset)
699
- }
700
-
701
- // termwrightCount is the same guard for set sizes.
702
- func termwrightCount(count int) *int {
703
- if count < 0 {
704
- return nil
705
- }
706
- return protocol.Int(count)
707
- }
708
-
709
- // termwrightState reads the observable state of one widget.
710
- func termwrightState(p Primitive, focused, hidden bool) *protocol.State {
711
- state := protocol.State{}
712
- empty := true
713
-
714
- if focused {
715
- state.Focused = protocol.Bool(true)
716
- empty = false
717
- }
718
- if hidden {
719
- state.Hidden = protocol.Bool(true)
720
- empty = false
721
- }
722
-
723
- switch widget := p.(type) {
724
- case *Button:
725
- if widget.IsDisabled() {
726
- state.Disabled = protocol.Bool(true)
727
- empty = false
728
- }
729
- case *Checkbox:
730
- state.Checked = widget.IsChecked()
731
- if widget.disabled {
732
- state.Disabled = protocol.Bool(true)
733
- }
734
- empty = false
735
- case *DropDown:
736
- if widget.disabled {
737
- state.Disabled = protocol.Bool(true)
738
- empty = false
739
- }
740
- state.SetSize = termwrightCount(widget.GetOptionCount())
741
- state.Expanded = protocol.Bool(widget.IsOpen())
742
- empty = false
743
- case *TextArea:
744
- if widget.GetDisabled() {
745
- state.Disabled = protocol.Bool(true)
746
- }
747
- row, _ := widget.GetOffset()
748
- state.ScrollOffset = termwrightScroll(row)
749
- empty = false
750
- case *List:
751
- state.SetSize = termwrightCount(widget.GetItemCount())
752
- // Named `itemOffset` here and `lineOffset`, `rowOffset` or `offsetY`
753
- // on the other four scrollables; there is no single field to reach for.
754
- offset, _ := widget.GetOffset()
755
- state.ScrollOffset = termwrightScroll(offset)
756
- empty = false
757
- case *Table:
758
- state.SetSize = termwrightCount(widget.GetRowCount())
759
- row, _ := widget.GetOffset()
760
- state.ScrollOffset = termwrightScroll(row)
761
- empty = false
762
- case *TextView:
763
- row, _ := widget.GetScrollOffset()
764
- state.ScrollOffset = termwrightScroll(row)
765
- empty = false
766
- case *TreeView:
767
- state.ScrollOffset = termwrightScroll(widget.GetScrollOffset())
768
- state.SetSize = termwrightCount(widget.GetRowCount())
769
- empty = false
770
- case *Modal:
771
- state.Modal = protocol.Bool(true)
772
- empty = false
773
- }
774
-
775
- if empty {
776
- return nil
777
- }
778
- return &state
779
- }
780
-
781
- // appendSynthetic emits nodes for entries that are not primitives of their own
782
- // — list items and dropdown options — so they are addressable by role and name.
783
- // They carry no bounds, which the schema allows.
784
- func (p *termwrightProbeState) appendSynthetic(
785
- primitive Primitive,
786
- parentID string,
787
- hidden bool,
788
- qualified bool,
789
- snapshot *protocol.Snapshot,
790
- ) {
791
- switch widget := primitive.(type) {
792
- case *List:
793
- current := widget.GetCurrentItem()
794
- count := widget.GetItemCount()
795
- for index := 0; index < count; index++ {
796
- main, secondary := widget.GetItemText(index)
797
- node := protocol.Node{
798
- ID: parentID + ":item" + strconv.Itoa(index),
799
- ParentID: parentID,
800
- Role: protocol.RoleListItem,
801
- Name: termwrightFirst(main, secondary),
802
- State: termwrightItemState(index == current, index, count, hidden),
803
- P: protocol.ProvenanceFramework,
804
- PX: map[string]string{
805
- "role": protocol.ProvenanceRecognizer,
806
- },
807
- }
808
- termwrightSyntheticGeometry(&node, qualified)
809
- snapshot.Nodes = append(snapshot.Nodes, node)
810
- }
811
- case *DropDown:
812
- current, _ := widget.GetCurrentOption()
813
- count := widget.GetOptionCount()
814
- for index := 0; index < count; index++ {
815
- node := protocol.Node{
816
- ID: parentID + ":option" + strconv.Itoa(index),
817
- ParentID: parentID,
818
- Role: protocol.RoleListItem,
819
- Name: widget.options[index].Text,
820
- State: termwrightItemState(index == current, index, count, hidden),
821
- P: protocol.ProvenanceFramework,
822
- PX: map[string]string{
823
- "role": protocol.ProvenanceRecognizer,
824
- },
825
- }
826
- termwrightSyntheticGeometry(&node, qualified)
827
- snapshot.Nodes = append(snapshot.Nodes, node)
828
- }
829
- }
830
- }
831
-
832
- func termwrightSyntheticGeometry(node *protocol.Node, qualified bool) {
833
- if !qualified {
834
- return
835
- }
836
- node.Geometry = &protocol.NodeGeometryObservations{
837
- Displayed: protocol.Observation[bool]{Status: "unknown", Reason: "not-reported"},
838
- IntendedRect: protocol.Observation[protocol.Rect]{Status: "unknown", Reason: "not-reported"},
839
- VisibleRect: protocol.Observation[protocol.Rect]{Status: "unknown", Reason: "clip-unobservable"},
840
- }
841
- }
842
-
843
- func termwrightItemState(selected bool, index, count int, hidden bool) *protocol.State {
844
- state := protocol.State{
845
- Selected: protocol.Bool(selected),
846
- PositionInSet: protocol.Int(index + 1),
847
- SetSize: protocol.Int(count),
848
- }
849
- if hidden {
850
- state.Hidden = protocol.Bool(true)
851
- }
852
- return &state
853
- }
854
-
855
- func termwrightFirst(candidates ...string) string {
856
- for _, candidate := range candidates {
857
- if trimmed := termwrightTrim(candidate); trimmed != "" {
858
- return trimmed
859
- }
860
- }
861
- return ""
862
- }
863
-
864
- // termwrightTrim collapses the padding widgets use for layout, so a name reads
865
- // the way it looks rather than the way it was spaced.
866
- func termwrightTrim(text string) string {
867
- start := 0
868
- end := len(text)
869
- for start < end && (text[start] == ' ' || text[start] == '\t' || text[start] == '\n' || text[start] == '\r') {
870
- start++
871
- }
872
- for end > start && (text[end-1] == ' ' || text[end-1] == '\t' || text[end-1] == '\n' || text[end-1] == '\r') {
873
- end--
874
- }
875
- return text[start:end]
876
- }