Skip to main content

termwright_protocol/
tree.rs

1//! Semantic tree DTOs.
2//!
3//! Unset optionals are omitted from the wire form: the schema is strict, so an
4//! explicit `null` is a validation failure rather than "absent".
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::roles::{Action, Role};
12
13/// Zero-based viewport cell rectangle.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct Rect {
17    /// Zero-based row of the top edge.
18    pub row: i64,
19    /// Zero-based column of the left edge.
20    pub column: i64,
21    /// Width in cells; zero means nothing is painted.
22    pub width: i64,
23    /// Height in cells; zero means nothing is painted.
24    pub height: i64,
25}
26
27impl Rect {
28    /// Build a rectangle from absolute viewport coordinates.
29    pub fn new(row: i64, column: i64, width: i64, height: i64) -> Self {
30        Self {
31            row,
32            column,
33            width,
34            height,
35        }
36    }
37}
38
39/// Evidence-qualified fact. Unknown and unsupported are never coerced to false.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)]
42pub enum Observation<T> {
43    /// The producer knows the value and names the evidence behind it.
44    Known {
45        /// Observed value.
46        value: T,
47        /// Provenance of the observation.
48        evidence: String,
49    },
50    /// The fact has no value for the named lifecycle/layout reason.
51    Absent {
52        /// Why no value exists.
53        reason: String,
54    },
55    /// The fact may become observable on a later revision.
56    Unknown {
57        /// Why evidence is not currently available.
58        reason: String,
59    },
60    /// The negotiated producer cannot provide this capability.
61    Unsupported {
62        /// Missing wire or framework capability.
63        capability: String,
64        /// Why the capability is unavailable.
65        reason: String,
66    },
67}
68
69/// Display and layout facts for one protocol-v2 semantic node.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase", deny_unknown_fields)]
72pub struct NodeGeometryObservations {
73    /// Effective display state through the complete ancestor chain.
74    pub displayed: Observation<bool>,
75    /// Layout rectangle before viewport clipping.
76    pub intended_rect: Observation<Rect>,
77    /// Rectangle remaining after framework clipping.
78    pub visible_rect: Observation<Rect>,
79}
80
81/// One non-overlapping rectangle owned by an exact pointer recipient.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct PointerHitRegion {
85    /// Half-open viewport-cell rectangle.
86    pub rect: Rect,
87    /// Semantic node id receiving a fresh pointer event in this rectangle.
88    pub recipient_id: String,
89}
90
91/// Compressed exact fresh-pointer routing grid for a completed frame.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct PointerHitGrid {
95    /// Non-overlapping recipient rectangles.
96    pub regions: Vec<PointerHitRegion>,
97}
98
99/// Whether a tri-state control is on, off, or partially selected.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(untagged)]
102pub enum Checked {
103    /// Plain on/off.
104    Flag(bool),
105    /// The literal string `"mixed"`.
106    Mixed(MixedState),
107}
108
109/// The `"mixed"` literal, as its own type so serde can keep the schema closed.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111pub enum MixedState {
112    /// The literal `"mixed"`.
113    #[serde(rename = "mixed")]
114    Mixed,
115}
116
117/// Layout direction of a composite widget.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum Orientation {
121    /// Laid out left to right.
122    Horizontal,
123    /// Laid out top to bottom.
124    Vertical,
125}
126
127/// Cursor rendering style.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "lowercase")]
130pub enum CursorShape {
131    /// A filled block cursor.
132    Block,
133    /// An underline cursor.
134    Underline,
135    /// A vertical bar cursor.
136    Bar,
137}
138
139/// The closed state set. `None` means "not asserted", not "false".
140#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase", deny_unknown_fields)]
142pub struct State {
143    /// The control refuses interaction.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub disabled: Option<bool>,
146    /// Keyboard input goes here.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub focused: Option<bool>,
149    /// The node is selected within its parent set.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub selected: Option<bool>,
152    /// Checked, unchecked, or mixed.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub checked: Option<Checked>,
155    /// A disclosure is open.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub expanded: Option<bool>,
158    /// The node traps interaction while it is present.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub modal: Option<bool>,
161    /// Content is being loaded or recomputed.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub busy: Option<bool>,
164    /// Present in the tree but not painted.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub hidden: Option<bool>,
167    /// Every cell is outside the visible area — scrolled away, not
168    /// undisplayed. Implies [`State::hidden`]; the pair without it is refused
169    /// by validation.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub offscreen: Option<bool>,
172    /// Value is displayed but cannot be edited.
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub readonly: Option<bool>,
175    /// The text control accepts newlines.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub multiline: Option<bool>,
178    /// Layout direction of a composite widget.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub orientation: Option<Orientation>,
181    /// Heading or tree depth, starting at 1.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub level: Option<i64>,
184    /// One-based position among siblings.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub position_in_set: Option<i64>,
187    /// Number of siblings in the set.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub set_size: Option<i64>,
190    /// First visible unit of scrollable content.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub scroll_offset: Option<i64>,
193    /// Total scrollable units.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub scroll_extent: Option<i64>,
196}
197
198impl State {
199    /// Whether every member is unset, in which case the field is omitted.
200    pub fn is_empty(&self) -> bool {
201        *self == State::default()
202    }
203}
204
205/// Maps grapheme offsets of a node's text onto cell coordinates.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase", deny_unknown_fields)]
208pub struct TextRange {
209    /// First grapheme offset covered by `rect`.
210    pub start_offset: i64,
211    /// Offset just past the last grapheme covered by `rect`.
212    pub end_offset: i64,
213    /// Cells the offset span occupies.
214    pub rect: Rect,
215}
216
217/// One accessible node. `bounds`, when present, are absolute viewport cells.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase", deny_unknown_fields)]
220pub struct Node {
221    /// Stable identity within the session.
222    pub id: String,
223    /// Parent node, or `None` for a root.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub parent_id: Option<String>,
226    /// Semantic role from the closed v1 set.
227    pub role: Role,
228    /// Accessible name; empty when the node has none.
229    pub name: String,
230    /// Longer description, when one exists.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub description: Option<String>,
233    /// Current value of a value-bearing node.
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub value: Option<String>,
236    /// Absolute viewport cells, when the node is painted.
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub bounds: Option<Rect>,
239    /// Asserted state flags; unset members are not claims.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub state: Option<State>,
242    /// Application-defined JSON state, separate from portable state flags.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub extended: Option<BTreeMap<String, Value>>,
245    /// Capability hints, never callback endpoints.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub actions: Option<Vec<Action>>,
248    /// Ids of nodes that name this one.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub labelled_by: Option<Vec<String>>,
251    /// Ids of nodes that describe this one.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub described_by: Option<Vec<String>>,
254    /// Offset-to-cell mapping for this node's text.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub text_ranges: Option<Vec<TextRange>>,
257    /// Author-supplied test id.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub test_id: Option<String>,
260    /// What the UI framework calls this widget. Required when `role` is
261    /// [`Role::Generic`]: an unrecognised widget must at least name its own
262    /// type, so a reader can tell one unknown thing from another.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub framework_type: Option<String>,
265    /// Whether the producer can say if these cells are covered by something
266    /// painted later. Only a producer that observes paint order may say
267    /// `Known`; the driver refuses pointer actions on anything else.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub occlusion: Option<Occlusion>,
270    /// Where this node's facts came from, as a whole.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub p: Option<Provenance>,
273    /// Where individual fields came from, when they differ from `p`.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub px: Option<BTreeMap<String, Provenance>>,
276    /// Protocol v2 qualified layout facts; omitted by strict v1 snapshots.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub geometry: Option<NodeGeometryObservations>,
279}
280
281/// Whether covered cells are answerable for a node.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "lowercase")]
284pub enum Occlusion {
285    /// The producer observes paint order and can answer.
286    Known,
287    /// It cannot; the driver refuses pointer actions on this node.
288    Unknown,
289}
290
291/// Where a semantic fact came from. Closed set.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "lowercase")]
294pub enum Provenance {
295    /// What the author wrote.
296    Annotation,
297    /// What our rules concluded.
298    Recognizer,
299    /// What the framework itself reported.
300    Framework,
301    /// What matching across sources implied.
302    Correlation,
303    /// A guess that happened to be useful.
304    Heuristic,
305}
306
307impl Node {
308    /// A node with only the required fields set.
309    pub fn new(id: impl Into<String>, role: Role, name: impl Into<String>) -> Self {
310        Self {
311            id: id.into(),
312            parent_id: None,
313            role,
314            name: name.into(),
315            description: None,
316            value: None,
317            bounds: None,
318            state: None,
319            extended: None,
320            actions: None,
321            labelled_by: None,
322            described_by: None,
323            text_ranges: None,
324            test_id: None,
325            framework_type: None,
326            occlusion: None,
327            p: None,
328            px: None,
329            geometry: None,
330        }
331    }
332
333    /// Name what the framework calls this widget, which the protocol requires
334    /// for a [`Role::Generic`] node.
335    pub fn with_framework_type(mut self, framework_type: impl Into<String>) -> Self {
336        self.framework_type = Some(framework_type.into());
337        self
338    }
339
340    /// Attach this node to a parent.
341    pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
342        self.parent_id = Some(parent_id.into());
343        self
344    }
345
346    /// Set absolute viewport bounds.
347    pub fn with_bounds(mut self, bounds: Rect) -> Self {
348        self.bounds = Some(bounds);
349        self
350    }
351
352    /// Set the state flags, dropping them when nothing is asserted.
353    pub fn with_state(mut self, state: State) -> Self {
354        self.state = if state.is_empty() { None } else { Some(state) };
355        self
356    }
357
358    /// Attach application-defined JSON state.
359    pub fn with_extended(mut self, extended: BTreeMap<String, Value>) -> Self {
360        self.extended = Some(extended);
361        self
362    }
363
364    /// Declare which actions the node supports.
365    pub fn with_actions(mut self, actions: Vec<Action>) -> Self {
366        self.actions = Some(actions);
367        self
368    }
369
370    /// Set the author-supplied test id.
371    pub fn with_test_id(mut self, test_id: impl Into<String>) -> Self {
372        self.test_id = Some(test_id.into());
373        self
374    }
375}
376
377/// Terminal cursor position, in viewport cells.
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(deny_unknown_fields)]
380pub struct Cursor {
381    /// Zero-based row of the top edge.
382    pub row: i64,
383    /// Zero-based column of the left edge.
384    pub column: i64,
385    /// Whether the terminal is showing the cursor.
386    pub visible: bool,
387    /// Cursor rendering style, when the app sets one.
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub shape: Option<CursorShape>,
390}
391
392/// The whole tree for one committed render.
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "camelCase", deny_unknown_fields)]
395pub struct Snapshot {
396    /// Snapshot format version; always 1.
397    pub v: u8,
398    /// Session this snapshot belongs to.
399    pub session_id: String,
400    /// Render revision, strictly increasing per session.
401    pub revision: i64,
402    /// Viewport width in cells.
403    pub columns: i64,
404    /// Viewport height in cells.
405    pub rows: i64,
406    /// Cursor position, when the app reports one.
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub cursor: Option<Cursor>,
409    /// Ids of the parentless nodes, in document order.
410    pub root_ids: Vec<String>,
411    /// Every node in the tree.
412    pub nodes: Vec<Node>,
413    /// Qualified coordinate space for all known geometry in protocol v2.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub coordinate_space: Option<Observation<String>>,
416    /// Exact fresh-pointer ownership map for protocol v2, when supported.
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub hit_grid: Option<Observation<PointerHitGrid>>,
419}
420
421impl Snapshot {
422    /// An empty snapshot for a viewport. The session id and revision are
423    /// filled in by [`crate::Client::publish`].
424    pub fn new(columns: i64, rows: i64) -> Self {
425        Self {
426            v: 1,
427            session_id: String::new(),
428            revision: 0,
429            columns,
430            rows,
431            cursor: None,
432            root_ids: Vec::new(),
433            nodes: Vec::new(),
434            coordinate_space: None,
435            hit_grid: None,
436        }
437    }
438
439    /// Empty qualified v2 snapshot. Every appended node still needs Geometry.
440    pub fn new_v2(columns: i64, rows: i64) -> Self {
441        Self {
442            v: 2,
443            coordinate_space: Some(Observation::Known {
444                value: "viewport-cells".into(),
445                evidence: "adapter".into(),
446            }),
447            hit_grid: Some(Observation::Unsupported {
448                capability: "pointer-hit-grid".into(),
449                reason: "framework-unobservable".into(),
450            }),
451            ..Self::new(columns, rows)
452        }
453    }
454
455    /// Append a node, recording it as a root when it declares no parent.
456    pub fn push(&mut self, node: Node) {
457        if node.parent_id.is_none() {
458            self.root_ids.push(node.id.clone());
459        }
460        self.nodes.push(node);
461    }
462}