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