Skip to main content

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}