rite_tui/model.rs
1//! UI state. Pure data, never reaches for I/O or terminal handles.
2
3use std::collections::{HashMap, VecDeque};
4
5use chrono::{DateTime, Local, Timelike};
6use rite_model::{Prompt, RoleId, StepId};
7use rite_runtime::{Environment, ExecEvent, Icon, MaterialOverview, PromptId, SystemInfo};
8
9/// Maximum length of a deviation note. Anything longer is rejected before
10/// the command is sent, keeps the transcript line readable.
11pub(crate) const DEVIATION_INPUT_MAX: usize = 280;
12
13/// Maximum number of log lines retained for the visible feed.
14pub(crate) const LOG_CAPACITY: usize = 200;
15
16/// Top-level UI state. A single [`Screen`] discriminant captures what
17/// the user is looking at, so the borrow checker enforces that we can't
18/// render two contradictory views.
19#[derive(Debug)]
20pub struct Model {
21 /// Ceremony name, as soon as `CeremonyStarted` arrives.
22 pub ceremony_name: Option<String>,
23 /// Ceremony description, as authored in the DSL.
24 pub ceremony_description: Option<String>,
25 /// Declared materials. Populated from `CeremonyStarted`.
26 pub ceremony_materials: Vec<MaterialOverview>,
27 /// Total number of steps in the execution plan, when known.
28 pub ceremony_step_count: Option<usize>,
29 /// Role display names, from the `RoleDeclared` facts at ceremony start.
30 pub role_names: HashMap<RoleId, String>,
31 /// Static build/host identity for the System tab. Populated from
32 /// `UiSignal::SystemInfo`, emitted once at ceremony start.
33 pub system_info: Option<SystemInfo>,
34 /// Live device environment for the System tab. Populated from
35 /// `UiSignal::Environment`; replaced wholesale on each re-emission.
36 pub environment: Option<Environment>,
37 /// Current screen.
38 pub screen: Screen,
39 /// Screen to restore when a modal is dismissed. `None` when the
40 /// current screen is itself the underlying step screen.
41 pub return_to: Option<Screen>,
42 /// Most-recent step being executed, if any.
43 pub current_step: Option<StepView>,
44 /// Bounded log feed.
45 pub log: VecDeque<LogLine>,
46 /// Deviations recorded so far.
47 pub deviations: Vec<DeviationView>,
48 /// Pending prompt the runtime is awaiting an answer to.
49 pub pending_prompt: Option<PendingPrompt>,
50 /// Lifecycle phase.
51 pub running: RunningState,
52 /// Frame counter for spinner animation. Advances on every `Tick`.
53 pub tick: u64,
54 /// Wall-clock snapshot, refreshed on every `Tick`. Rendered in the
55 /// header bar; kept in the model so `view` stays pure and snapshot
56 /// tests can pin a deterministic time.
57 pub now: DateTime<Local>,
58 /// Log-tab scroll offset, counted in lines *up from the tail*.
59 /// `0` = following the tail; positive = scrolled into history.
60 /// Clamped to `log.len()` whenever the feed is pushed or evicted.
61 pub log_scroll: usize,
62 /// FIFO of executor events waiting to be applied. `Msg::Exec` pushes
63 /// here; `Msg::Tick` pops at most one per `LOG_DRIP_TICKS` ticks so
64 /// a burst of facts from a single step types out one line at a time
65 /// instead of landing in the log all at once.
66 pub(crate) pending_events: VecDeque<ExecEvent>,
67}
68
69impl Default for Model {
70 fn default() -> Self {
71 Self {
72 ceremony_name: None,
73 ceremony_description: None,
74 ceremony_materials: Vec::new(),
75 ceremony_step_count: None,
76 role_names: HashMap::new(),
77 system_info: None,
78 environment: None,
79 screen: Screen::Step {
80 tab: StepTab::Overview,
81 },
82 return_to: None,
83 current_step: None,
84 log: VecDeque::with_capacity(LOG_CAPACITY),
85 deviations: Vec::new(),
86 pending_prompt: None,
87 running: RunningState::Active,
88 tick: 0,
89 now: Local::now(),
90 log_scroll: 0,
91 pending_events: VecDeque::new(),
92 }
93 }
94}
95
96impl Model {
97 /// Build a fresh model.
98 #[must_use]
99 pub fn new() -> Self {
100 Self::default()
101 }
102
103 /// Push a log line, evicting the oldest entry if the bounded feed is full.
104 ///
105 /// When the user is scrolled up (`log_scroll > 0`), the visible content
106 /// is held in place by clamping `log_scroll` to the new length.
107 ///
108 /// Any incoming line implies the previously-spinning operation has
109 /// finished, so a trailing `Icon::Spinner` entry is converted to
110 /// `Icon::Checkmark` before the new line is pushed, the spinner
111 /// stops animating in the feed.
112 pub(crate) fn push_log(&mut self, line: LogLine) {
113 if let Some(LogLine::Entry {
114 icon: icon @ Icon::Spinner,
115 ..
116 }) = self.log.back_mut()
117 {
118 *icon = Icon::Checkmark;
119 }
120 if self.log.len() == LOG_CAPACITY {
121 self.log.pop_front();
122 }
123 self.log.push_back(line);
124 if self.log_scroll > self.log.len() {
125 self.log_scroll = self.log.len();
126 }
127 }
128
129 /// Push a regular log entry, tagging it with the current step label
130 /// and the model's wall-clock snapshot so the Ceremony tab can render
131 /// a per-row step and timestamp column.
132 pub(crate) fn push_entry(&mut self, icon: Icon, text: String) {
133 self.push_entry_at(icon, text, self.now);
134 }
135
136 /// Push a log entry stamped with an explicit event time. Used for facts so
137 /// the Ceremony tab's timestamp column shows when the event actually
138 /// happened, not when the drip queue drained it. `push_entry` (for signals,
139 /// which have no event time) delegates here with the live `now`.
140 pub(crate) fn push_entry_at(&mut self, icon: Icon, text: String, at: DateTime<Local>) {
141 let step = self.current_step.as_ref().map(|s| s.label.clone());
142 self.push_log(LogLine::Entry {
143 icon,
144 text,
145 step,
146 at,
147 });
148 }
149
150 /// Push a modal in front of the current screen, remembering what to
151 /// return to when it is dismissed. Idempotent if already inside a modal.
152 pub(crate) fn open_modal(&mut self, modal: Screen) {
153 if self.return_to.is_none() {
154 self.return_to = Some(self.screen.clone());
155 }
156 self.screen = modal;
157 }
158
159 /// Restore the saved screen if any. No-op when called outside a modal.
160 pub(crate) fn close_modal(&mut self) {
161 if let Some(prev) = self.return_to.take() {
162 self.screen = prev;
163 }
164 }
165
166 /// Whether the log feed currently ends with an active spinner that
167 /// must keep animating. Invariant: `push_log` converts any prior
168 /// trailing `Icon::Spinner` to a `Checkmark`, so at most the tail
169 /// entry can be an active spinner.
170 #[must_use]
171 pub fn has_active_spinner(&self) -> bool {
172 matches!(
173 self.log.back(),
174 Some(LogLine::Entry {
175 icon: Icon::Spinner,
176 ..
177 })
178 )
179 }
180
181 /// Equivalence key for the visible header clock. The colon blinks
182 /// on second parity and the minute is the smallest displayed unit,
183 /// so the rendered header changes iff this key changes.
184 #[must_use]
185 pub fn clock_display_key(&self) -> (u32, u32) {
186 (self.now.minute(), self.now.second() % 2)
187 }
188}
189
190/// Single source of truth for which screen is visible. Because only one
191/// variant is active at a time, we can't render two contradictory views
192/// or sit "in a modal over a different modal", exactly one screen,
193/// always.
194#[derive(Debug, Clone)]
195pub enum Screen {
196 /// Step execution screen. Default and most common.
197 Step {
198 /// Active tab within the step screen.
199 tab: StepTab,
200 },
201 /// Operator is composing a deviation note.
202 DeviationModal {
203 /// Partial deviation text the operator is composing.
204 input: String,
205 },
206 /// Confirming abort. `y` sends `UiCommand::Abort`; `n` / Esc returns.
207 AbortConfirm,
208 /// Terminal "ceremony completed" screen.
209 Completed {
210 /// Final transcript fingerprint, populated by `ExecEvent::Finalized`
211 /// that arrives right after the `CeremonyCompleted` fact.
212 fingerprint: Option<String>,
213 },
214 /// Terminal "ceremony failed" screen.
215 Failed {
216 /// Failure reason.
217 reason: String,
218 },
219 /// Terminal "ceremony aborted" screen. Distinct from `Failed`: an abort is
220 /// a deliberate operator decision, not an error.
221 Aborted,
222}
223
224impl Screen {
225 /// Whether this screen is a terminal end-state (no further interaction).
226 #[must_use]
227 pub fn is_terminal(&self) -> bool {
228 matches!(
229 self,
230 Screen::Completed { .. } | Screen::Failed { .. } | Screen::Aborted
231 )
232 }
233}
234
235/// Active tab on the main screen.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum StepTab {
238 /// Pre-step ceremony overview: name, description, declared
239 /// materials, step count. Default tab on boot; the operator
240 /// confirms here before the first step runs.
241 Overview,
242 /// Unified ceremony view: timestamped log feed with past-step
243 /// dimming and the pending prompt pinned at the bottom.
244 Ceremony,
245 /// Deviations side panel. Reserved for non-log content (operator
246 /// notes, attestation summary, etc.) once those land.
247 Deviations,
248 /// Environment dashboard: build/host identity header plus the live
249 /// device inventory (disks today; peripherals and network later).
250 /// Situational awareness during the run, not part of the transcript.
251 System,
252}
253
254/// Lifecycle phase of the ceremony as observed by the UI.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum RunningState {
257 /// Ceremony is still running.
258 Active,
259 /// User has requested abort; waiting for the executor to unwind.
260 Aborting,
261 /// Done (success or failure). Loop exits.
262 Done,
263}
264
265/// Snapshot of the currently-executing step.
266#[derive(Debug, Clone)]
267pub struct StepView {
268 /// Step identifier.
269 pub id: StepId,
270 /// Step label as authored in the DSL.
271 pub label: String,
272 /// Human-readable role name for display.
273 pub role_name: String,
274}
275
276/// Bounded log feed entry.
277#[derive(Debug, Clone)]
278pub enum LogLine {
279 /// Regular log line emitted by the runtime as a `UiSignal::LogLine`.
280 Entry {
281 /// Visual cue.
282 icon: Icon,
283 /// Line text.
284 text: String,
285 /// Step the entry belongs to (the current step at push time).
286 /// Rendered as a per-row step column on the Ceremony tab.
287 step: Option<String>,
288 /// Wall-clock instant at push time; rendered as `HH:MM:SS` in
289 /// the Ceremony tab's timestamp column.
290 at: DateTime<Local>,
291 },
292 /// Visual marker pushed at every `StepStarted`. Replaces the old
293 /// role-transition modal, operators see the boundary inline.
294 StepDivider {
295 /// Step label as authored in the DSL (e.g. `"2.1"`).
296 label: String,
297 /// Human-readable role name for the new step.
298 role_name: String,
299 },
300 /// Visual marker pushed at every `ActStarted`. Acts subdivide
301 /// ceremonies and contain steps; the divider visualises the
302 /// hierarchical boundary above [`Self::StepDivider`].
303 ActDivider {
304 /// Act label as authored in the DSL.
305 label: String,
306 },
307}
308
309/// Deviation as recorded in the live UI.
310#[derive(Debug, Clone)]
311pub struct DeviationView {
312 /// Step in which the deviation was recorded, if any.
313 pub step: Option<StepId>,
314 /// Verbatim deviation text.
315 pub text: String,
316 /// Wall-clock instant from the fact's timestamp, rendered as
317 /// `HH:MM:SS` in the Deviations tab.
318 pub at: DateTime<Local>,
319}
320
321/// In-flight prompt the UI is awaiting an answer to.
322#[derive(Debug, Clone)]
323pub struct PendingPrompt {
324 /// Prompt identifier (echoed back in the response command).
325 pub prompt_id: PromptId,
326 /// The prompt itself.
327 pub prompt: Prompt,
328 /// Partial user input (used for text/literal/secret prompts).
329 pub input: String,
330 /// Most recent rejection reason from the validator, if any.
331 pub rejection: Option<String>,
332 /// Where a value shown for writing down stands; unused by other
333 /// prompts.
334 pub reveal: RevealPhase,
335 /// Rows typed so far into a prompt that takes a value row by row;
336 /// unused by other prompts.
337 pub entry: RowEntry,
338}
339
340/// A value typed row by row: the rows taken so far and what the last
341/// check said.
342#[derive(Debug, Clone, Default)]
343pub struct RowEntry {
344 /// Rows taken, in order.
345 pub rows: Vec<EnteredRow>,
346 /// What the last row's check said, until the next one.
347 pub notice: Option<RowNotice>,
348}
349
350/// One row taken: as typed, which is what the runtime reads again, and as
351/// it reads, which is what the screen shows. Both wiped when dropped.
352#[derive(Debug, Clone)]
353pub struct EnteredRow {
354 /// The row as the person typed it.
355 pub typed: zeroize::Zeroizing<String>,
356 /// The row as it should read, repairs made.
357 pub reads: zeroize::Zeroizing<String>,
358}
359
360/// What a row's check said.
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub enum RowNotice {
363 /// The row was taken with a repair the person should check on the
364 /// sheet.
365 Repaired(String),
366 /// The row was not taken, and why.
367 Refused(String),
368}
369
370/// A value to write down goes through three phases in its window: it is
371/// announced, then shown, then shown with the question before it goes,
372/// since it is not shown again.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
374pub enum RevealPhase {
375 /// Announced: the window says what is coming, without the value.
376 #[default]
377 Ready,
378 /// The value is on screen.
379 Shown,
380 /// The value is on screen with the question: written down?
381 Confirm,
382}