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 navigation group for the two-level sidebar + tab layout.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum NavGroup {
15    Search,
16    Ai,
17    Settings,
18}
19
20/// Top-level pages (GUI external design ยง3.1 order).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ViewId {
23    Search,
24    Sources,
25    Indexing,
26    Storage,
27    Models,
28    Settings,
29}
30
31impl ViewId {
32    pub const ALL: &'static [ViewId] = &[
33        ViewId::Search,
34        ViewId::Sources,
35        ViewId::Indexing,
36        ViewId::Storage,
37        ViewId::Models,
38        ViewId::Settings,
39    ];
40
41    /// Which top-level navigation group this view belongs to.
42    pub fn group(self) -> NavGroup {
43        match self {
44            ViewId::Search | ViewId::Sources => NavGroup::Search,
45            ViewId::Indexing | ViewId::Storage | ViewId::Models => NavGroup::Ai,
46            ViewId::Settings => NavGroup::Settings,
47        }
48    }
49
50    /// Default view to activate when the user first enters a group.
51    pub fn group_default(group: NavGroup) -> Self {
52        match group {
53            NavGroup::Search => ViewId::Search,
54            NavGroup::Ai => ViewId::Indexing,
55            NavGroup::Settings => ViewId::Settings,
56        }
57    }
58}
59
60
61/// Sidebar index-health summary.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub struct IndexHealth {
64    pub indexed: u64,
65    pub stale: u64,
66    pub failed: u64,
67    pub queued: u64,
68}
69
70/// One source card for the Sources view.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct SourceCard {
73    pub display_name: String,
74    pub display_path: String,
75    pub indexed: u64,
76    pub stale: u64,
77    pub failed: u64,
78    pub active: bool,
79    pub source_id: String,
80}
81
82/// A search result ready for display โ€” pure data, no backend types
83/// (RFC-027 boundary rule).
84#[derive(Debug, Clone, PartialEq)]
85pub struct SearchResultDisplay {
86    pub display_path: String,
87    pub title: Option<String>,
88    pub heading_path: Option<String>,
89    pub snippet: Option<String>,
90    pub keyword_rank: u32,
91    pub badges: Vec<String>,
92}
93
94
95/// One required file and its check result shown in the wizard.
96#[derive(Debug, Clone, PartialEq)]
97pub struct WizardFileCheck {
98    pub relative_path: String,
99    pub found: bool,
100    pub size_mb: Option<f64>,
101}
102
103/// Which stage of the startup wizard the user is on.
104#[derive(Debug, Clone, PartialEq)]
105pub enum WizardState {
106    /// First launch or model never configured.
107    NotConfigured,
108    /// Was configured, but files are gone.
109    FileMissing { previous_dir: String, checks: Vec<WizardFileCheck> },
110    /// User submitted a path; file checks complete.
111    Checked { model_dir: String, checks: Vec<WizardFileCheck>, all_ok: bool },
112    /// All files verified โ€” ready to proceed.
113    Ready { model_dir: String },
114    /// HuggingFace download in progress.
115    Downloading {
116        dest_dir: String,
117        /// Filename currently being downloaded.
118        current_file: String,
119        bytes: u64,
120        total: Option<u64>,
121        files_done: u32,
122        files_total: u32,
123    },
124}
125
126/// The whole-app view model.
127#[derive(Debug, Clone)]
128pub struct AppState {
129    pub active_view: ViewId,
130    pub locale: Locale,
131    pub query: String,
132    pub last_query: Option<String>,
133    pub search_mode: SearchMode,
134    pub search_results: Vec<SearchResultDisplay>,
135    pub search_running: bool,
136    pub selected_result: Option<usize>,
137    pub storage_rows: Vec<(String, u64, u64)>,
138    pub health: IndexHealth,
139    pub sources: Vec<SourceCard>,
140    pub capability: SearchCapability,
141    pub storage_total_bytes: u64,
142    /// Active startup wizard, or `None` when startup succeeded.
143    pub wizard: Option<WizardState>,
144    /// Text-input path the user is typing in the wizard.
145    pub wizard_path_input: String,
146    /// Text input for the "add source" path field.
147    pub source_path_input: String,
148    /// When false (default), hide technical detail. Mature users can toggle on.
149    pub show_advanced: bool,
150}
151
152impl Default for AppState {
153    fn default() -> Self {
154        Self {
155            active_view: ViewId::Search,
156            locale: Locale::default(),
157            query: String::new(),
158            last_query: None,
159            search_mode: SearchMode::Auto,
160            search_results: Vec::new(),
161            search_running: false,
162            selected_result: None,
163            storage_rows: Vec::new(),
164            health: IndexHealth::default(),
165            sources: Vec::new(),
166            capability: SearchCapability::KeywordOnly,
167            storage_total_bytes: 0,
168            wizard: None,
169            wizard_path_input: String::new(),
170            source_path_input: String::new(),
171            show_advanced: false,
172        }
173    }
174}
175
176/// UI messages.
177#[derive(Debug, Clone)]
178pub enum Message {
179    Switch(ViewId),
180    SwitchGroup(NavGroup),
181    ToggleAdvanced,
182    QueryChanged(String),
183    SubmitSearch,
184    SearchResultsReady(Vec<SearchResultDisplay>),
185    SearchError(String),
186    SelectResult(usize),
187    OpenSourceFile(String),
188    SetSearchMode(SearchMode),
189    PersistLocale(Locale),
190    SetLocale(Locale),
191    StorageDataReady(Vec<(String, u64, u64)>),
192    // Startup wizard
193    WizardPathChanged(String),
194    WizardValidate,
195    WizardChecked { model_dir: String, checks: Vec<WizardFileCheck>, all_ok: bool },
196    WizardAccept,
197    WizardSkip,
198    // Source management
199    SourcePathChanged(String),
200    RequestAddSource,
201    SourceAdded(SourceCard),
202    SourceRemoved(String),   // source_id
203    ScanCompleted(IndexHealth),
204    // Download
205    DownloadModel,
206    DownloadStarted { dest_dir: String },
207    DownloadFileProgress {
208        file: String,
209        bytes: u64,
210        total: Option<u64>,
211        files_done: u32,
212        files_total: u32,
213    },
214    DownloadAllComplete { dest_dir: String },
215    DownloadFailed(String),
216    // Startup population
217    HealthUpdated(IndexHealth),
218    SourcesLoaded(Vec<SourceCard>),
219}
220
221impl AppState {
222    pub fn update(&mut self, message: &Message) {
223        match message {
224            Message::Switch(view) => self.active_view = *view,
225            Message::SwitchGroup(group) => self.active_view = ViewId::group_default(*group),
226            Message::ToggleAdvanced => self.show_advanced = !self.show_advanced,
227            Message::QueryChanged(query) => self.query = query.clone(),
228            Message::SubmitSearch => {
229                let trimmed = self.query.trim();
230                if !trimmed.is_empty() {
231                    self.last_query = Some(trimmed.to_string());
232                    self.search_running = true;
233                    self.search_results.clear();
234                    self.selected_result = None;
235                }
236            }
237            Message::SearchResultsReady(results) => {
238                self.search_results = results.clone();
239                self.search_running = false;
240                self.selected_result = None;
241            }
242            Message::SearchError(_) => {
243                self.search_running = false;
244            }
245            Message::SelectResult(idx) => self.selected_result = Some(*idx),
246            Message::OpenSourceFile(_) => {} // handled by orbok-app
247            Message::SetSearchMode(mode) => self.search_mode = *mode,
248            Message::PersistLocale(locale) | Message::SetLocale(locale) => self.locale = *locale,
249            Message::StorageDataReady(rows) => self.storage_rows = rows.clone(),
250            Message::WizardPathChanged(p) => self.wizard_path_input = p.clone(),
251            Message::WizardValidate => {} // handled in orbok-app update
252            Message::WizardChecked { model_dir, checks, all_ok } => {
253                self.wizard = Some(if *all_ok {
254                    WizardState::Ready { model_dir: model_dir.clone() }
255                } else {
256                    WizardState::Checked {
257                        model_dir: model_dir.clone(),
258                        checks: checks.clone(),
259                        all_ok: false,
260                    }
261                });
262            }
263            Message::WizardAccept => {
264                // orbok-app writes the model dir to OrbokSettings; ui
265                // transitions to full capability.
266                self.capability = SearchCapability::Hybrid;
267                self.wizard = None;
268                self.wizard_path_input = String::new();
269            }
270            Message::WizardSkip => {
271                self.capability = SearchCapability::KeywordOnly;
272                self.wizard = None;
273                self.wizard_path_input = String::new();
274            }
275            Message::DownloadModel => {
276                // Transition handled in orbok-app main.rs (needs the data_dir).
277                // The UI just switches to a "waiting" state until DownloadStarted arrives.
278            }
279            Message::DownloadStarted { dest_dir } => {
280                self.wizard = Some(WizardState::Downloading {
281                    dest_dir: dest_dir.clone(),
282                    current_file: String::new(),
283                    bytes: 0,
284                    total: None,
285                    files_done: 0,
286                    files_total: 2,
287                });
288            }
289            Message::DownloadFileProgress { file, bytes, total, files_done, files_total } => {
290                if let Some(WizardState::Downloading { current_file, bytes: b, total: t, files_done: fd, files_total: ft, .. }) =
291                    &mut self.wizard
292                {
293                    *current_file = file.clone();
294                    *b = *bytes;
295                    *t = *total;
296                    *fd = *files_done;
297                    *ft = *files_total;
298                }
299            }
300            Message::DownloadAllComplete { dest_dir } => {
301                // Switch directly to wizard-accepted flow.
302                self.wizard = Some(WizardState::Ready { model_dir: dest_dir.clone() });
303            }
304            Message::DownloadFailed(_reason) => {
305                // Return to NotConfigured so the user can try again.
306self.wizard = Some(WizardState::NotConfigured);
307            }
308            Message::SourcePathChanged(p) => self.source_path_input = p.clone(),
309            Message::RequestAddSource => {} // handled in orbok-app
310            Message::SourceAdded(card) => {
311                self.sources.push(card.clone());
312                self.source_path_input = String::new();
313            }
314            Message::SourceRemoved(id) => self.sources.retain(|s| s.source_id != *id),
315            Message::ScanCompleted(health) | Message::HealthUpdated(health) => {
316                self.health = *health;
317                // Update per-source counts from the fresh health data.
318            }
319            Message::SourcesLoaded(cards) => self.sources = cards.clone(),
320        }
321    }
322}