kaptein_viewmodel/surface.rs
1//! Layer 3 — surface kinds.
2//!
3//! A small, **closed** set of surfaces. Each frontend implements the set once; new
4//! views are combinations of these, never new variants. The set is complete by design —
5//! `Form` and `Matrix` are included because both appear in the roadmap (see ADR-0005).
6//!
7//! `SurfaceKind` is derived from `Surface` with `strum::EnumDiscriminants`, so the two
8//! lists can never drift apart.
9
10use serde::{Deserialize, Serialize};
11use strum::EnumDiscriminants;
12
13/// A single surface, carrying the kind and any kind-specific data.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, EnumDiscriminants)]
15#[strum_discriminants(derive(Serialize, Deserialize))]
16#[strum_discriminants(name(SurfaceKind))]
17pub enum Surface {
18 Table {
19 columns: Vec<Column>,
20 },
21 Tree {
22 columns: Vec<Column>,
23 },
24 /// Known incomplete: `Graph` carries no data yet (topology layout is still to be
25 /// designed — see ADR-0005's "known incomplete" note).
26 Graph,
27 Form {
28 fields: Vec<Field>,
29 },
30 /// Two-dimensional virtualized data. Axis labels are the *identity* of each axis;
31 /// the cells themselves are queried through the data plane (like `Table`), never
32 /// materialized here.
33 Matrix {
34 row_axis: Vec<String>,
35 col_axis: Vec<String>,
36 },
37 /// Known incomplete: stream contents are described by the data plane, not this unit.
38 Stream,
39 Editor {
40 mode: EditorMode,
41 },
42 /// Known incomplete: chart configuration (series, axes) is still to be designed.
43 Chart,
44 /// Known incomplete: terminal I/O framing is still to be designed.
45 Terminal,
46}
47
48impl Surface {
49 pub fn kind(&self) -> SurfaceKind {
50 SurfaceKind::from(self)
51 }
52}
53
54/// Which projections implement which surface kind, and at what fidelity.
55///
56/// This is the **support matrix** that replaces the (unreachable) promise of universal
57/// feature-parity. "TUI has no force-directed Graph layout — it has a keyboard-navigable
58/// Tree projection of the same graph" is a documented design decision here, not a
59/// contract breach. Contract tests assert against this matrix, not against parity.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum SupportLevel {
63 /// Full surface: the kind renders natively.
64 Full,
65 /// Alternate projection: the same semantics, a different geometry (e.g. Graph → Tree).
66 Alternate,
67 /// Not supported by this projection.
68 None,
69}
70
71/// A projection (which frontend or agent surface).
72///
73/// Only the **rendering** projections are here. Headless and MCP are consumers of the
74/// *semantic layer* (they read the data plane and action graph, they never render a
75/// surface), so they are a different axis — not members of this enum. Modelling them as
76/// projections that are always `None` conflates two distinct concepts and implies that
77/// e.g. MCP "cannot do graph things", when in fact MCP's `blast_radius` tool *is* graph
78/// data (ADR-0013).
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum Projection {
82 Tui,
83 Gui,
84 Browser,
85}
86
87/// Look up the support level for a `(projection, kind)` pair.
88///
89/// This is an **exhaustive** match, not a sparse lookup table. The compiler forces every
90/// new `SurfaceKind` to be considered here — which is the whole point of a closed set. A
91/// sparse table with a `None` default silently lied about `(Tui, Table)` (the primary
92/// surface) and 19 of the other 26 pairs.
93pub fn support_level(projection: Projection, kind: SurfaceKind) -> SupportLevel {
94 use Projection::*;
95 use SupportLevel::*;
96 use SurfaceKind::*;
97
98 match (projection, kind) {
99 // These six render natively in every projection.
100 (_, Table) => Full,
101 (_, Tree) => Full,
102 (_, Form) => Full,
103 (_, Matrix) => Full,
104 (_, Stream) => Full,
105 (_, Chart) => Full,
106
107 // Graph: force-directed + mouse in GUI/browser, keyboard-navigable tree
108 // projection in the TUI (same data, different geometry).
109 (Tui, Graph) => Alternate,
110 (Gui, Graph) => Full,
111 (Browser, Graph) => Full,
112
113 // Editor: $EDITOR handoff in the TUI, a real editor in GUI/browser (no $EDITOR
114 // exists in a browser).
115 (Tui, Editor) => Alternate,
116 (Gui, Editor) => Full,
117 (Browser, Editor) => Full,
118
119 // Terminal: PTY pass-through in the TUI, a full VT emulator in GUI/browser.
120 (Tui, Terminal) => Full,
121 (Gui, Terminal) => Full,
122 (Browser, Terminal) => Full,
123 }
124}
125
126/// The closed set of surface kinds, derived from `Surface` via
127/// `#[strum_discriminants(name(SurfaceKind))]`, so the variant lists cannot drift apart.
128/// Adding a new kind is a breaking contract change (see `docs/versioning.md`).
129///
130/// The mode of an `Editor` surface. Diff is a *mode* (two buffers), not a separate kind.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub enum EditorMode {
133 Single,
134 Diff { left: String, right: String },
135}
136
137/// A column definition. The view-model owns *meaning* (id, header key, data kind,
138/// sortability, and the data-binding `field`); the frontend owns *geometry* (rendered
139/// width in cells vs. font metrics). There is deliberately no `width` field here.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct Column {
142 pub id: String,
143 /// Message key resolved by the frontend for i18n (the view-model emits keys + args,
144 /// not localized strings).
145 pub header_key: String,
146 /// The data kind — semantics (numeric vs. text) drives alignment and sort order.
147 pub kind: ColumnKind,
148 pub sortable: bool,
149 /// For lens (view-definition) columns: the dotted JSON path that supplies this
150 /// column's value (e.g. `metadata.name`, `spec.instances`). `None` for built-in
151 /// surfaces and for the `Status` column, whose value is *inferred* by the lens's
152 /// status rules rather than read from a single field (ADR-0012: the schema must
153 /// express where every value comes from).
154 #[serde(default)]
155 pub field: Option<String>,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum ColumnKind {
161 Text,
162 Number,
163 Timestamp,
164 Status,
165}
166
167/// A schema-driven form field. The semantic layer defines the field; the frontend
168/// renders the widget. Diff and validation live in the semantic layer.
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct Field {
171 pub id: String,
172 pub label_key: String,
173 pub kind: FieldKind,
174 pub required: bool,
175}
176
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178pub enum FieldKind {
179 Text,
180 Number,
181 Bool,
182 /// One-of selection (e.g. instance type, size class). Options are identity values;
183 /// display labels are message keys resolved by the frontend.
184 Choice {
185 options: Vec<String>,
186 },
187}