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}
53
54/// A search result ready for display โ€” pure data, no backend types
55/// (RFC-027 boundary rule).
56#[derive(Debug, Clone, PartialEq)]
57pub struct SearchResultDisplay {
58    pub display_path: String,
59    pub title: Option<String>,
60    pub heading_path: Option<String>,
61    pub snippet: Option<String>,
62    pub keyword_rank: u32,
63    pub badges: Vec<String>,
64}
65
66
67/// One required file and its check result shown in the wizard.
68#[derive(Debug, Clone, PartialEq)]
69pub struct WizardFileCheck {
70    pub relative_path: String,
71    pub found: bool,
72    pub size_mb: Option<f64>,
73}
74
75/// Which stage of the startup wizard the user is on.
76#[derive(Debug, Clone, PartialEq)]
77pub enum WizardState {
78    /// First launch or model never configured.
79    NotConfigured,
80    /// Was configured, but files are gone.
81    FileMissing { previous_dir: String },
82    /// User submitted a path; file checks complete.
83    Checked { model_dir: String, checks: Vec<WizardFileCheck>, all_ok: bool },
84    /// All files verified โ€” ready to proceed.
85    Ready { model_dir: String },
86}
87
88/// The whole-app view model.
89#[derive(Debug, Clone)]
90pub struct AppState {
91    pub active_view: ViewId,
92    pub locale: Locale,
93    pub query: String,
94    pub last_query: Option<String>,
95    pub search_mode: SearchMode,
96    pub search_results: Vec<SearchResultDisplay>,
97    pub search_running: bool,
98    pub selected_result: Option<usize>,
99    pub storage_rows: Vec<(String, u64, u64)>,
100    pub health: IndexHealth,
101    pub sources: Vec<SourceCard>,
102    pub capability: SearchCapability,
103    pub storage_total_bytes: u64,
104    /// Active startup wizard, or `None` when startup succeeded.
105    pub wizard: Option<WizardState>,
106    /// Text-input path the user is typing in the wizard.
107    pub wizard_path_input: String,
108}
109
110impl Default for AppState {
111    fn default() -> Self {
112        Self {
113            active_view: ViewId::Search,
114            locale: Locale::default(),
115            query: String::new(),
116            last_query: None,
117            search_mode: SearchMode::Auto,
118            search_results: Vec::new(),
119            search_running: false,
120            selected_result: None,
121            storage_rows: Vec::new(),
122            health: IndexHealth::default(),
123            sources: Vec::new(),
124            capability: SearchCapability::KeywordOnly,
125            storage_total_bytes: 0,
126            wizard: None,
127            wizard_path_input: String::new(),
128        }
129    }
130}
131
132/// UI messages.
133#[derive(Debug, Clone)]
134pub enum Message {
135    Switch(ViewId),
136    QueryChanged(String),
137    SubmitSearch,
138    SearchResultsReady(Vec<SearchResultDisplay>),
139    SearchError(String),
140    SelectResult(usize),
141    OpenSourceFile(String),
142    SetSearchMode(SearchMode),
143    PersistLocale(Locale),
144    SetLocale(Locale),
145    StorageDataReady(Vec<(String, u64, u64)>),
146    // Startup wizard
147    WizardPathChanged(String),
148    WizardValidate,
149    WizardChecked { model_dir: String, checks: Vec<WizardFileCheck>, all_ok: bool },
150    WizardAccept,
151    WizardSkip,
152}
153
154impl AppState {
155    pub fn update(&mut self, message: &Message) {
156        match message {
157            Message::Switch(view) => self.active_view = *view,
158            Message::QueryChanged(query) => self.query = query.clone(),
159            Message::SubmitSearch => {
160                let trimmed = self.query.trim();
161                if !trimmed.is_empty() {
162                    self.last_query = Some(trimmed.to_string());
163                    self.search_running = true;
164                    self.search_results.clear();
165                    self.selected_result = None;
166                }
167            }
168            Message::SearchResultsReady(results) => {
169                self.search_results = results.clone();
170                self.search_running = false;
171                self.selected_result = None;
172            }
173            Message::SearchError(_) => {
174                self.search_running = false;
175            }
176            Message::SelectResult(idx) => self.selected_result = Some(*idx),
177            Message::OpenSourceFile(_) => {} // handled by orbok-app
178            Message::SetSearchMode(mode) => self.search_mode = *mode,
179            Message::PersistLocale(locale) | Message::SetLocale(locale) => self.locale = *locale,
180            Message::StorageDataReady(rows) => self.storage_rows = rows.clone(),
181            Message::WizardPathChanged(p) => self.wizard_path_input = p.clone(),
182            Message::WizardValidate => {} // handled in orbok-app update
183            Message::WizardChecked { model_dir, checks, all_ok } => {
184                self.wizard = Some(if *all_ok {
185                    WizardState::Ready { model_dir: model_dir.clone() }
186                } else {
187                    WizardState::Checked {
188                        model_dir: model_dir.clone(),
189                        checks: checks.clone(),
190                        all_ok: false,
191                    }
192                });
193            }
194            Message::WizardAccept => {
195                // orbok-app writes the model dir to OrbokSettings; ui
196                // transitions to full capability.
197                self.capability = SearchCapability::Hybrid;
198                self.wizard = None;
199                self.wizard_path_input = String::new();
200            }
201            Message::WizardSkip => {
202                self.capability = SearchCapability::KeywordOnly;
203                self.wizard = None;
204                self.wizard_path_input = String::new();
205            }
206        }
207    }
208}