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