Skip to main content

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}