termwright-protocol 0.3.1

Semantic side-channel client for the termwright terminal test driver: framing, render-commit markers, snapshot validation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
//! Semantic tree DTOs.
//!
//! Unset optionals are omitted from the wire form: the schema is strict, so an
//! explicit `null` is a validation failure rather than "absent".

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::roles::{Action, Role};

/// Zero-based viewport cell rectangle.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Rect {
    /// Zero-based row of the top edge.
    pub row: i64,
    /// Zero-based column of the left edge.
    pub column: i64,
    /// Width in cells; zero means nothing is painted.
    pub width: i64,
    /// Height in cells; zero means nothing is painted.
    pub height: i64,
}

impl Rect {
    /// Build a rectangle from absolute viewport coordinates.
    pub fn new(row: i64, column: i64, width: i64, height: i64) -> Self {
        Self {
            row,
            column,
            width,
            height,
        }
    }
}

/// Evidence-qualified fact. Unknown and unsupported are never coerced to false.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)]
pub enum Observation<T> {
    /// The producer knows the value and names the evidence behind it.
    Known {
        /// Observed value.
        value: T,
        /// Provenance of the observation.
        evidence: EvidenceProvenance,
    },
    /// The fact has no value for the named lifecycle/layout reason.
    Absent {
        /// Why no value exists.
        reason: String,
        /// Authoritative provenance proving that no value exists.
        evidence: EvidenceProvenance,
    },
    /// The fact may become observable on a later revision.
    Unknown {
        /// Why evidence is not currently available.
        reason: String,
    },
    /// The negotiated producer cannot provide this capability.
    Unsupported {
        /// Missing wire or framework capability.
        capability: String,
        /// Why the capability is unavailable.
        reason: String,
    },
}

/// Whether a semantic value may safely cross normal artifact boundaries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SemanticValueSensitivity {
    /// The application declares the value safe to expose.
    Public,
    /// The value is sensitive and must be redacted by default.
    Sensitive,
}

/// A semantic value with absence, support and confidentiality preserved.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)]
pub enum SemanticValueObservation {
    /// The value is known for this committed revision.
    Known {
        /// Exact plaintext value.
        value: String,
        /// Application-declared artifact sensitivity.
        sensitivity: SemanticValueSensitivity,
        /// Source proving this value for the committed revision.
        evidence: EvidenceProvenance,
    },
    /// The node authoritatively has no value now.
    Absent {
        /// Why the node currently has no semantic value.
        reason: String,
        /// Authoritative source proving absence.
        evidence: EvidenceProvenance,
    },
    /// A later paired revision may provide the value.
    Unknown {
        /// Retryable revision-domain reason.
        reason: String,
    },
    /// The frozen contract cannot provide semantic values.
    Unsupported {
        /// Always `semantic-value` on the v2 wire.
        capability: String,
        /// Why the capability is unavailable.
        reason: String,
    },
    /// A value exists but its plaintext is deliberately not transported.
    Withheld {
        /// Policy or sensitivity reason for withholding plaintext.
        reason: String,
        /// Sensitivity of the omitted value.
        sensitivity: SemanticValueSensitivity,
    },
}

/// Provenance carried by every known physical observation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EvidenceProvenance {
    /// Layer that supplied the fact.
    pub source: EvidenceSource,
    /// How that layer obtained the fact.
    pub method: EvidenceMethod,
    /// Whether the fact is safe for behavior or diagnostic only.
    pub strength: EvidenceStrength,
    /// Stable identity of the provider that made the observation.
    pub provider_id: String,
}

/// Layer that supplied a known observation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EvidenceSource {
    /// The UI framework itself.
    Framework,
    /// Application-authored information.
    Application,
    /// The terminal emulator or terminal grid.
    Terminal,
    /// A recognizer derived the fact from another representation.
    Recognizer,
    /// The Termwright driver measured the fact.
    Driver,
}

/// Method used to produce a known observation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EvidenceMethod {
    /// Native framework/runtime observation.
    Native,
    /// Observation made by installed instrumentation.
    Instrumented,
    /// Explicit application declaration.
    Declared,
    /// Correlation across independently identified facts.
    Correlated,
    /// Direct measurement.
    Measured,
    /// Deterministic derivation from stronger facts.
    Derived,
    /// Best-effort heuristic; never behavioral authority.
    Heuristic,
}

/// Authority of a known observation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EvidenceStrength {
    /// May be used to decide behavior.
    Authoritative,
    /// May be shown for diagnosis but not used as behavioral proof.
    Diagnostic,
}

/// Display and layout facts for one protocol-v2 semantic node.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NodeGeometryObservations {
    /// Effective display state through the complete ancestor chain.
    pub displayed: Observation<bool>,
    /// Layout rectangle before viewport clipping.
    pub intended_rect: Observation<Rect>,
    /// Rectangle remaining after framework clipping.
    pub visible_rect: Observation<Rect>,
}

/// One non-overlapping rectangle owned by an exact pointer recipient.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PointerHitRegion {
    /// Half-open viewport-cell rectangle.
    pub rect: Rect,
    /// Semantic node id receiving a fresh pointer event in this rectangle.
    pub recipient_id: String,
}

/// Compressed exact fresh-pointer routing grid for a completed frame.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PointerHitGrid {
    /// Non-overlapping recipient rectangles.
    pub regions: Vec<PointerHitRegion>,
}

/// One canonical half-open pointer row run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProviderPointerSpan {
    /// Viewport row containing the run.
    pub row: i64,
    /// Inclusive starting column.
    pub from: i64,
    /// Exclusive ending column.
    pub to: i64,
}

/// Pointer-only application region; never layout or clipping geometry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProviderPointerRegion {
    /// Semantic node that the production router associates with the region.
    pub recipient_id: String,
    /// Bounding rectangle used only as pointer-region metadata.
    pub region_bounds: Rect,
    /// Exact possibly disjoint owned cells as canonical row spans.
    pub spans: Vec<ProviderPointerSpan>,
}

/// Exact viewport cells painted by an application's production painter.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProviderPaintedRegion {
    /// Semantic node whose production painter produced these cells.
    pub recipient_id: String,
    /// Bounding rectangle of all attributed spans.
    pub region_bounds: Rect,
    /// Exact possibly disjoint cells as canonical row spans.
    pub spans: Vec<ProviderPointerSpan>,
}

/// Production keybinding recipes for one semantic recipient and revision.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProviderActionRecipes {
    /// Semantic node the application strategy targets.
    pub recipient_id: String,
    /// Data-only recipes executed later by Termwright's PTY devices.
    pub recipes: Vec<PhysicalInputRecipe>,
}

/// Exact production focus-manager result for one committed revision.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)]
pub enum ProviderFocusState {
    /// One semantic recipient owns focus.
    Focused {
        /// Stable semantic recipient id.
        #[serde(rename = "recipientId")]
        recipient_id: String,
    },
    /// The production focus manager authoritatively reports no focus owner.
    None,
}

/// Production terminal parser configuration for one committed revision.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProviderTerminalInputModes {
    /// Mouse tracking level accepted by the production parser.
    pub mouse_tracking: String,
    /// Mouse report encoding accepted by the production parser.
    pub mouse_encoding: String,
    /// Whether the production parser accepts terminal focus reports.
    pub focus_reporting: String,
}

/// Revision-bound application evidence. `status` is available, lost, or violation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProviderRevisionEvidence {
    /// Stable negotiated provider identity.
    pub provider_id: String,
    /// Session this evidence belongs to.
    pub session_id: String,
    /// Semantic revision described by the evidence.
    pub revision: i64,
    /// `available`, `lost`, or `violation`.
    pub status: String,
    /// Provenance present for available evidence.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub evidence: Option<EvidenceProvenance>,
    /// Exact pointer regions when the provider supplies them.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pointer_regions: Option<Vec<ProviderPointerRegion>>,
    /// Production focus-manager result when negotiated.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub focus_state: Option<ProviderFocusState>,
    /// Production action recipes when negotiated.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub action_recipes: Option<Vec<ProviderActionRecipes>>,
    /// Production application viewport facts when negotiated.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub scroll_states: Option<Vec<ProviderScrollState>>,
    /// Production painter attribution when negotiated.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub painted_regions: Option<Vec<ProviderPaintedRegion>>,
    /// Production terminal parser configuration when negotiated.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub input_modes: Option<ProviderTerminalInputModes>,
    /// Complete verified production hit grid when negotiated.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hit_grid: Option<PointerHitGrid>,
    /// Diagnostic explanation for lost or violating providers.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

/// Revision-bound application viewport state for one semantic recipient.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProviderScrollState {
    /// Stable semantic recipient id.
    pub recipient_id: String,
    /// Logical scroll axis.
    pub axis: Orientation,
    /// First visible logical unit.
    pub offset: i64,
    /// Visible logical units.
    pub viewport: i64,
    /// Total logical units.
    pub extent: i64,
}

/// Exact painted cells attached to a semantic node observation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SemanticPaintedRegion {
    /// Bounding rectangle of all attributed spans.
    pub region_bounds: Rect,
    /// Exact possibly disjoint cells as canonical row spans.
    pub spans: Vec<ProviderPointerSpan>,
}

/// Whether a tri-state control is on, off, or partially selected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Checked {
    /// Plain on/off.
    Flag(bool),
    /// The literal string `"mixed"`.
    Mixed(MixedState),
}

/// The `"mixed"` literal, as its own type so serde can keep the schema closed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MixedState {
    /// The literal `"mixed"`.
    #[serde(rename = "mixed")]
    Mixed,
}

/// Layout direction of a composite widget.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Orientation {
    /// Laid out left to right.
    Horizontal,
    /// Laid out top to bottom.
    Vertical,
}

/// Cursor rendering style.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CursorShape {
    /// A filled block cursor.
    Block,
    /// An underline cursor.
    Underline,
    /// A vertical bar cursor.
    Bar,
}

/// The closed state set. `None` means "not asserted", not "false".
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct State {
    /// The control refuses interaction.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub disabled: Option<bool>,
    /// Keyboard input goes here.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub focused: Option<bool>,
    /// The node is selected within its parent set.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub selected: Option<bool>,
    /// Checked, unchecked, or mixed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub checked: Option<Checked>,
    /// A disclosure is open.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expanded: Option<bool>,
    /// The node traps interaction while it is present.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub modal: Option<bool>,
    /// Content is being loaded or recomputed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub busy: Option<bool>,
    /// Present in the tree but not painted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hidden: Option<bool>,
    /// Every cell is outside the visible area — scrolled away, not
    /// undisplayed. Implies [`State::hidden`]; the pair without it is refused
    /// by validation.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub offscreen: Option<bool>,
    /// Value is displayed but cannot be edited.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub readonly: Option<bool>,
    /// The text control accepts newlines.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub multiline: Option<bool>,
    /// The control requires a value or selection before submission.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub required: Option<bool>,
    /// The composite permits more than one selected descendant.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub multiselectable: Option<bool>,
    /// Layout direction of a composite widget.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub orientation: Option<Orientation>,
    /// Heading or tree depth, starting at 1.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub level: Option<i64>,
    /// One-based position among siblings.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub position_in_set: Option<i64>,
    /// Number of siblings in the set.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub set_size: Option<i64>,
}

impl State {
    /// Whether every member is unset, in which case the field is omitted.
    pub fn is_empty(&self) -> bool {
        *self == State::default()
    }
}

/// Maps grapheme offsets of a node's text onto cell coordinates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TextRange {
    /// First grapheme offset covered by `rect`.
    pub start_offset: i64,
    /// Offset just past the last grapheme covered by `rect`.
    pub end_offset: i64,
    /// Cells the offset span occupies.
    pub rect: Rect,
}

/// Semantic intent backed by a physical PTY-input recipe.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PhysicalInputRecipeAction {
    /// Move authoritative application focus to the target.
    Focus,
    /// Invoke the target's primary action.
    Activate,
    /// Change the target's checked/toggled state.
    Toggle,
    /// Replace the target's editable value.
    SetValue,
}

/// One data-only recipe step; no framework callback can cross this boundary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum PhysicalInputRecipeStep {
    /// Press one terminal key through the real PTY input device.
    Press {
        /// Key descriptor interpreted by Termwright's keyboard device.
        key: String,
    },
    /// Insert the value supplied to the current semantic action at execution time.
    InsertActionValue,
}

/// A revision-bound, data-only physical strategy for one semantic intent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PhysicalInputRecipe {
    /// Semantic intent implemented by this recipe.
    pub action: PhysicalInputRecipeAction,
    /// Whether the target must already be focused before executing the steps.
    pub requires_focus: bool,
    /// Ordered real keyboard operations. At least one step is required.
    pub steps: Vec<PhysicalInputRecipeStep>,
}

/// One accessible node with evidence-qualified geometry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Node {
    /// Stable identity within the session.
    pub id: String,
    /// Parent node, or `None` for a root.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_id: Option<String>,
    /// Semantic role from the current closed set.
    pub role: Role,
    /// Accessible name; empty when the node has none.
    pub name: String,
    /// Longer description, when one exists.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Current value of a value-bearing node, without collapsing secrets.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub value: Option<SemanticValueObservation>,
    /// Asserted state flags; unset members are not claims.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub state: Option<State>,
    /// Application-defined JSON state, separate from portable state flags.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub extended: Option<BTreeMap<String, Value>>,
    /// Capability hints, never callback endpoints.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub actions: Option<Vec<Action>>,
    /// Authoritative physical recipe executed only by Termwright devices.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub input_recipes: Option<Vec<PhysicalInputRecipe>>,
    /// Ids of nodes that name this one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub labelled_by: Option<Vec<String>>,
    /// Ids of nodes that describe this one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub described_by: Option<Vec<String>>,
    /// Offset-to-cell mapping for this node's text.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text_ranges: Option<Vec<TextRange>>,
    /// Author-supplied test id.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub test_id: Option<String>,
    /// What the UI framework calls this widget. Required when `role` is
    /// [`Role::Generic`]: an unrecognised widget must at least name its own
    /// type, so a reader can tell one unknown thing from another.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub framework_type: Option<String>,
    /// This node may own children the framework cannot enumerate.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub opaque_children: bool,
    /// Where this node's facts came from, as a whole.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub p: Option<Provenance>,
    /// Where individual fields came from, when they differ from `p`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub px: Option<BTreeMap<String, Provenance>>,
    /// Qualified layout facts for this committed observation.
    pub geometry: NodeGeometryObservations,
    /// Production application viewport state, not terminal scrollback.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub scroll: Option<Observation<ScrollState>>,
    /// Exact production-painted cells, distinct from layout and pointer ownership.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub painted_region: Option<Observation<SemanticPaintedRegion>>,
}

/// Application viewport state in production-defined logical units.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ScrollState {
    /// Logical scroll axis.
    pub axis: Orientation,
    /// First visible logical unit.
    pub offset: i64,
    /// Visible logical units.
    pub viewport: i64,
    /// Total logical units.
    pub extent: i64,
}

/// Where a semantic fact came from. Closed set.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Provenance {
    /// What the author wrote.
    Annotation,
    /// What our rules concluded.
    Recognizer,
    /// What the framework itself reported.
    Framework,
    /// What the application's production mechanism reported.
    Application,
    /// What matching across sources implied.
    Correlation,
    /// A guess that happened to be useful.
    Heuristic,
}

impl Node {
    /// A node with only the required fields set.
    pub fn new(id: impl Into<String>, role: Role, name: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            parent_id: None,
            role,
            name: name.into(),
            description: None,
            value: None,
            state: None,
            extended: None,
            actions: None,
            input_recipes: None,
            labelled_by: None,
            described_by: None,
            text_ranges: None,
            test_id: None,
            framework_type: None,
            opaque_children: false,
            p: None,
            px: None,
            geometry: NodeGeometryObservations {
                displayed: Observation::Unsupported {
                    capability: "displayed".into(),
                    reason: "framework-unobservable".into(),
                },
                intended_rect: Observation::Unsupported {
                    capability: "intended-geometry".into(),
                    reason: "framework-unobservable".into(),
                },
                visible_rect: Observation::Unsupported {
                    capability: "clipped-geometry".into(),
                    reason: "framework-unobservable".into(),
                },
            },
            scroll: None,
            painted_region: None,
        }
    }

    /// Name what the framework calls this widget, which the protocol requires
    /// for a [`Role::Generic`] node.
    pub fn with_framework_type(mut self, framework_type: impl Into<String>) -> Self {
        self.framework_type = Some(framework_type.into());
        self
    }

    /// Declare that this node may own children the probe cannot enumerate.
    #[must_use]
    pub fn with_opaque_children(mut self) -> Self {
        self.opaque_children = true;
        self
    }

    /// Attach this node to a parent.
    pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
        self.parent_id = Some(parent_id.into());
        self
    }

    /// Set evidence-qualified layout facts.
    pub fn with_geometry(mut self, geometry: NodeGeometryObservations) -> Self {
        self.geometry = geometry;
        self
    }

    /// Set the state flags, dropping them when nothing is asserted.
    pub fn with_state(mut self, state: State) -> Self {
        self.state = if state.is_empty() { None } else { Some(state) };
        self
    }

    /// Attach application-defined JSON state.
    pub fn with_extended(mut self, extended: BTreeMap<String, Value>) -> Self {
        self.extended = Some(extended);
        self
    }

    /// Declare which actions the node supports.
    pub fn with_actions(mut self, actions: Vec<Action>) -> Self {
        self.actions = Some(actions);
        self
    }

    /// Set the author-supplied test id.
    pub fn with_test_id(mut self, test_id: impl Into<String>) -> Self {
        self.test_id = Some(test_id.into());
        self
    }
}

/// Terminal cursor position, in viewport cells.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Cursor {
    /// Zero-based row of the top edge.
    pub row: i64,
    /// Zero-based column of the left edge.
    pub column: i64,
    /// Whether the terminal is showing the cursor.
    pub visible: bool,
    /// Cursor rendering style, when the app sets one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub shape: Option<CursorShape>,
}

/// The whole tree for one committed render.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Snapshot {
    /// Snapshot format version; always 2.
    pub v: u8,
    /// Session this snapshot belongs to.
    pub session_id: String,
    /// Render revision, strictly increasing per session.
    pub revision: i64,
    /// Viewport width in cells.
    pub columns: i64,
    /// Viewport height in cells.
    pub rows: i64,
    /// Cursor position, when the app reports one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cursor: Option<Cursor>,
    /// Ids of the parentless nodes, in document order.
    pub root_ids: Vec<String>,
    /// Every node in the tree.
    pub nodes: Vec<Node>,
    /// Qualified coordinate space for all known geometry.
    pub coordinate_space: Observation<String>,
    /// Exact fresh-pointer ownership map, or an explicit non-known result.
    pub hit_grid: Observation<PointerHitGrid>,
    /// Application evidence collected atomically for this semantic revision.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub provider_evidence: Vec<ProviderRevisionEvidence>,
}

impl Snapshot {
    /// An empty snapshot for a viewport. The session id and revision are
    /// filled in by [`crate::Client::publish`].
    pub fn new(columns: i64, rows: i64) -> Self {
        Self {
            v: 2,
            session_id: String::new(),
            revision: 0,
            columns,
            rows,
            cursor: None,
            root_ids: Vec::new(),
            nodes: Vec::new(),
            coordinate_space: Observation::Known {
                value: "viewport-cells".into(),
                evidence: EvidenceProvenance {
                    source: EvidenceSource::Framework,
                    method: EvidenceMethod::Instrumented,
                    strength: EvidenceStrength::Authoritative,
                    provider_id: "termwright-rust-client".into(),
                },
            },
            hit_grid: Observation::Unsupported {
                capability: "pointer-hit-grid".into(),
                reason: "framework-unobservable".into(),
            },
            provider_evidence: Vec::new(),
        }
    }

    /// Append a node, recording it as a root when it declares no parent.
    pub fn push(&mut self, node: Node) {
        if node.parent_id.is_none() {
            self.root_ids.push(node.id.clone());
        }
        self.nodes.push(node);
    }
}