Skip to main content

orbok_ui/
state.rs

1//! Headless UI state (view models) and the message vocabulary.
2//!
3//! Everything here is plain data โ€” testable without a display server.
4//! `orbok-app` populates these structs from backend services; views
5//! render them; `update` mutates them. No iced types appear in this
6//! module so state logic stays UI-framework-agnostic.
7
8use crate::i18n::Locale;
9use orbok_models::SearchCapability;
10use orbok_search::SearchMode;
11
12/// Top-level pages (GUI external design ยง3.1 order).
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ViewId {
15    Search,
16    Sources,
17    Indexing,
18    Storage,
19    Models,
20    Settings,
21}
22
23impl ViewId {
24    pub const ALL: &'static [ViewId] = &[
25        ViewId::Search,
26        ViewId::Sources,
27        ViewId::Indexing,
28        ViewId::Storage,
29        ViewId::Models,
30        ViewId::Settings,
31    ];
32}
33
34/// Sidebar index-health summary.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct IndexHealth {
37    pub indexed: u64,
38    pub stale: u64,
39    pub failed: u64,
40    pub queued: u64,
41}
42
43/// One source card for the Sources view.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SourceCard {
46    pub display_name: String,
47    pub display_path: String,
48    pub indexed: u64,
49    pub stale: u64,
50    pub failed: u64,
51    pub active: bool,
52    pub source_id: String,
53}
54
55/// A search result ready for display โ€” pure data, no backend types
56/// (RFC-027 boundary rule).
57#[derive(Debug, Clone, PartialEq)]
58pub struct SearchResultDisplay {
59    pub display_path: String,
60    pub title: Option<String>,
61    pub heading_path: Option<String>,
62    pub snippet: Option<String>,
63    pub keyword_rank: u32,
64    pub badges: Vec<String>,
65}
66
67
68/// One required file and its check result shown in the wizard.
69#[derive(Debug, Clone, PartialEq)]
70pub struct WizardFileCheck {
71    pub relative_path: String,
72    pub found: bool,
73    pub size_mb: Option<f64>,
74}
75
76/// Which stage of the startup wizard the user is on.
77#[derive(Debug, Clone, PartialEq)]
78pub enum WizardState {
79    /// First launch or model never configured.
80    NotConfigured,
81    /// Was configured, but files are gone.
82    FileMissing { previous_dir: String, checks: Vec<WizardFileCheck> },
83    /// User submitted a path; file checks complete.
84    Checked { model_dir: String, checks: Vec<WizardFileCheck>, all_ok: bool },
85    /// All files verified โ€” ready to proceed.
86    Ready { model_dir: String },
87}
88
89/// The whole-app view model.
90#[derive(Debug, Clone)]
91pub struct AppState {
92    pub active_view: ViewId,
93    pub locale: Locale,
94    pub query: String,
95    pub last_query: Option<String>,
96    pub search_mode: SearchMode,
97    pub search_results: Vec<SearchResultDisplay>,
98    pub search_running: bool,
99    pub selected_result: Option<usize>,
100    pub storage_rows: Vec<(String, u64, u64)>,
101    pub health: IndexHealth,
102    pub sources: Vec<SourceCard>,
103    pub capability: SearchCapability,
104    pub storage_total_bytes: u64,
105    /// Active startup wizard, or `None` when startup succeeded.
106    pub wizard: Option<WizardState>,
107    /// Text-input path the user is typing in the wizard.
108    pub wizard_path_input: String,
109    /// Text input for the "add source" path field.
110    pub source_path_input: String,
111}
112
113impl Default for AppState {
114    fn default() -> Self {
115        Self {
116            active_view: ViewId::Search,
117            locale: Locale::default(),
118            query: String::new(),
119            last_query: None,
120            search_mode: SearchMode::Auto,
121            search_results: Vec::new(),
122            search_running: false,
123            selected_result: None,
124            storage_rows: Vec::new(),
125            health: IndexHealth::default(),
126            sources: Vec::new(),
127            capability: SearchCapability::KeywordOnly,
128            storage_total_bytes: 0,
129            wizard: None,
130            wizard_path_input: String::new(),
131            source_path_input: String::new(),
132        }
133    }
134}
135
136/// UI messages.
137#[derive(Debug, Clone)]
138pub enum Message {
139    Switch(ViewId),
140    QueryChanged(String),
141    SubmitSearch,
142    SearchResultsReady(Vec<SearchResultDisplay>),
143    SearchError(String),
144    SelectResult(usize),
145    OpenSourceFile(String),
146    SetSearchMode(SearchMode),
147    PersistLocale(Locale),
148    SetLocale(Locale),
149    StorageDataReady(Vec<(String, u64, u64)>),
150    // Startup wizard
151    WizardPathChanged(String),
152    WizardValidate,
153    WizardChecked { model_dir: String, checks: Vec<WizardFileCheck>, all_ok: bool },
154    WizardAccept,
155    WizardSkip,
156    // Source management
157    SourcePathChanged(String),
158    RequestAddSource,
159    SourceAdded(SourceCard),
160    SourceRemoved(String),   // source_id
161    ScanCompleted(IndexHealth),
162    // Startup population
163    HealthUpdated(IndexHealth),
164    SourcesLoaded(Vec<SourceCard>),
165}
166
167impl AppState {
168    pub fn update(&mut self, message: &Message) {
169        match message {
170            Message::Switch(view) => self.active_view = *view,
171            Message::QueryChanged(query) => self.query = query.clone(),
172            Message::SubmitSearch => {
173                let trimmed = self.query.trim();
174                if !trimmed.is_empty() {
175                    self.last_query = Some(trimmed.to_string());
176                    self.search_running = true;
177                    self.search_results.clear();
178                    self.selected_result = None;
179                }
180            }
181            Message::SearchResultsReady(results) => {
182                self.search_results = results.clone();
183                self.search_running = false;
184                self.selected_result = None;
185            }
186            Message::SearchError(_) => {
187                self.search_running = false;
188            }
189            Message::SelectResult(idx) => self.selected_result = Some(*idx),
190            Message::OpenSourceFile(_) => {} // handled by orbok-app
191            Message::SetSearchMode(mode) => self.search_mode = *mode,
192            Message::PersistLocale(locale) | Message::SetLocale(locale) => self.locale = *locale,
193            Message::StorageDataReady(rows) => self.storage_rows = rows.clone(),
194            Message::WizardPathChanged(p) => self.wizard_path_input = p.clone(),
195            Message::WizardValidate => {} // handled in orbok-app update
196            Message::WizardChecked { model_dir, checks, all_ok } => {
197                self.wizard = Some(if *all_ok {
198                    WizardState::Ready { model_dir: model_dir.clone() }
199                } else {
200                    WizardState::Checked {
201                        model_dir: model_dir.clone(),
202                        checks: checks.clone(),
203                        all_ok: false,
204                    }
205                });
206            }
207            Message::WizardAccept => {
208                // orbok-app writes the model dir to OrbokSettings; ui
209                // transitions to full capability.
210                self.capability = SearchCapability::Hybrid;
211                self.wizard = None;
212                self.wizard_path_input = String::new();
213            }
214            Message::WizardSkip => {
215                self.capability = SearchCapability::KeywordOnly;
216                self.wizard = None;
217                self.wizard_path_input = String::new();
218            }
219            Message::SourcePathChanged(p) => self.source_path_input = p.clone(),
220            Message::RequestAddSource => {} // handled in orbok-app
221            Message::SourceAdded(card) => {
222                self.sources.push(card.clone());
223                self.source_path_input = String::new();
224            }
225            Message::SourceRemoved(id) => self.sources.retain(|s| s.source_id != *id),
226            Message::ScanCompleted(health) | Message::HealthUpdated(health) => {
227                self.health = *health;
228                // Update per-source counts from the fresh health data.
229            }
230            Message::SourcesLoaded(cards) => self.sources = cards.clone(),
231        }
232    }
233}