Skip to main content

orbok_ui/
views.rs

1//! Page view functions (GUI external design §7, §8–§12 wireframes).
2//!
3//! Each page is a plain function taking the view model and returning an
4//! `Element` — the snora multi-view pattern. Empty states follow the
5//! design's required empty-state set.
6
7pub mod wizard;
8pub use wizard::wizard_view;
9
10use crate::i18n::{Locale, MessageKey, files_indexed, search_result_count, source_summary, tr};
11use crate::state::{AppState, Message};
12use snora::lucide;
13use iced::widget::{button, column, container, row, text, text_input};
14use iced::{Element, Length, Padding};
15use orbok_models::SearchCapability;
16
17/// Render a lucide icon as a sized Text widget using char::from().
18/// This is the same technique snora uses in icon_element_sized() and
19/// avoids the iced type-parameter mismatch that lucide_icons::iced::icon_*()
20/// can cause when multiple iced_core versions are in the dep graph.
21fn icon_text<'a>(glyph: char, size: f32) -> iced::widget::Text<'a> {
22    iced::widget::text(glyph.to_string())
23        .font(iced::Font::with_name("lucide"))
24        .size(size)
25}
26
27/// Small icon+label button. Padding gives a comfortable click target (~44px).
28fn icon_btn<'a>(
29    icon_el: iced::widget::Text<'a>,
30    label: &'a str,
31    msg: Message,
32) -> iced::widget::Button<'a, Message> {
33    button(row![icon_el, text(label).size(15)].spacing(6))
34        .padding(Padding::from([12.0, 16.0]))
35        .on_press(msg)
36}
37
38/// A friendly, actionable notice card (UX review §8). Shows a plain title,
39/// an explanation, and — for problems — a recovery action. Confirmations
40/// show a Dismiss action instead. Status is conveyed in words, never colour
41/// alone, satisfying the accessibility requirement.
42fn friendly_notice<'a>(
43    locale: Locale,
44    notice: &crate::notice::UserNotice,
45) -> Element<'a, Message> {
46    let action_label = notice
47        .action(locale)
48        .unwrap_or_else(|| tr(locale, MessageKey::NoticeDismiss));
49    container(
50        column![
51            text(notice.title(locale).to_string()).size(18),
52            text(notice.body(locale).to_string()).size(15),
53            button(text(action_label.to_string()).size(15))
54                .padding(Padding::from([10.0, 16.0]))
55                .on_press(Message::ClearNotice),
56        ]
57        .spacing(8),
58    )
59    .padding(Padding::from([16.0, 16.0]))
60    .width(Length::Fill)
61    .into()
62}
63
64fn page<'a>(content: iced::widget::Column<'a, Message>) -> Element<'a, Message> {
65    container(
66        iced::widget::scrollable(
67            container(content.spacing(10))
68                .padding(Padding::from([28.0, 40.0]))
69                .width(Length::Fill),
70        )
71        .height(Length::Fill),
72    )
73    .width(Length::Fill)
74    .height(Length::Fill)
75    .into()
76}
77
78fn heading(label: &str) -> iced::widget::Text<'_> {
79    text(label.to_string()).size(26)
80}
81
82/// Search view (§7): input, capability notice, empty states.
83pub fn search_view(state: &AppState) -> Element<'_, Message> {
84    let locale = state.locale;
85    let input = text_input(tr(locale, MessageKey::SearchPlaceholder), &state.query)
86        .on_input(Message::QueryChanged)
87        .on_submit(Message::SubmitSearch)
88        .padding(8);
89    let submit = icon_btn(icon_text(char::from(lucide::Search), 13.0), tr(locale, MessageKey::SearchButton), Message::SubmitSearch);
90
91    let mut content = column![
92        heading(tr(locale, MessageKey::NavSearch)),
93        row![container(input).width(Length::Fill), submit].spacing(8),
94    ];
95
96    // Surface any active notice (problem or confirmation) at the top of the
97    // page so failures are never silent (UX review P0).
98    if let Some(notice) = &state.notice {
99        content = content.push(friendly_notice(locale, notice));
100    }
101
102    // Search mode is "Auto" by default — only mature users need the
103    // Exact/Conceptual switch. Hidden behind the Advanced toggle (less is more).
104    if state.show_advanced {
105        content = content.push(
106            row![
107                text(tr(locale, MessageKey::SearchModeLabel)).size(12),
108                button(text(tr(locale, MessageKey::SearchModeAuto)).size(11))
109                    .on_press(Message::SetSearchMode(orbok_search::SearchMode::Auto)),
110                button(text(tr(locale, MessageKey::SearchModeExact)).size(11))
111                    .on_press(Message::SetSearchMode(orbok_search::SearchMode::Exact)),
112                button(text(tr(locale, MessageKey::SearchModeConceptual)).size(11))
113                    .on_press(Message::SetSearchMode(orbok_search::SearchMode::Conceptual)),
114            ]
115            .spacing(4),
116        );
117    }
118
119    if state.sources.is_empty() {
120        // Required empty state: no sources (GUI design §7.6).
121        content = content.push(
122            column![
123                text(tr(locale, MessageKey::SearchNoSourcesTitle)).size(18),
124                text(tr(locale, MessageKey::SearchNoSourcesBody)).size(15),
125                button(text(tr(locale, MessageKey::SearchAddSource)).size(15))
126                    .on_press(Message::Switch(crate::state::ViewId::Sources)),
127            ]
128            .spacing(6),
129        );
130    } else {
131        if state.capability == SearchCapability::KeywordOnly {
132            content = content
133                .push(text(tr(locale, MessageKey::SearchKeywordOnlyNotice)).size(12));
134        }
135        if state.search_running {
136            content = content.push(text("Searching…").size(15));
137        } else if let Some(last) = &state.last_query {
138            if state.search_results.is_empty() {
139                content = content.push(
140                    column![
141                        text(tr(locale, MessageKey::SearchNoResults)).size(15),
142                        text(format!("Query: {last}")).size(12),
143                    ]
144                    .spacing(4),
145                );
146            } else {
147                content = content.push(
148                    text(search_result_count(locale, state.search_results.len())).size(12),
149                );
150                for (i, result) in state.search_results.iter().enumerate() {
151                    let is_selected = state.selected_result == Some(i);
152                    let title_raw = result.title.as_deref().unwrap_or(&result.display_path);
153                    let title_str = if is_selected {
154                        std::borrow::Cow::Owned(format!("▶  {title_raw}"))
155                    } else {
156                        std::borrow::Cow::Borrowed(title_raw)
157                    };
158                    let title_str: &str = &title_str;
159                    let snippet_str = result
160                        .snippet
161                        .as_deref()
162                        .unwrap_or("(source unavailable)");
163                    let heading_str = result.heading_path.as_deref().unwrap_or("");
164                    let card = container(
165                        column![
166                            text(title_str.to_string()).size(15),
167                            text(result.display_path.clone()).size(12),
168                            if !heading_str.is_empty() { text(heading_str.to_string()).size(11) }
169                            else { text("").size(11) },
170                            text(snippet_str.chars().take(120).collect::<String>()).size(12),
171                            {
172                                // Less is more: by default show only status
173                                // badges that affect trust (Stale/Missing).
174                                // Match-type badges are advanced detail.
175                                let shown: Vec<String> = if state.show_advanced {
176                                    result.badges.clone()
177                                } else {
178                                    result.badges.iter()
179                                        .filter(|b| {
180                                            let l = b.to_lowercase();
181                                            l.contains("stale") || l.contains("missing")
182                                        })
183                                        .cloned()
184                                        .collect()
185                                };
186                                text(shown.join("  ")).size(11)
187                            },
188                        ]
189                        .spacing(2),
190                    )
191                    .padding(10);
192                    content = content.push(
193                        button(card).on_press(Message::SelectResult(i)),
194                    );
195                }
196            }
197        }
198    }
199    page(content)
200}
201
202/// Sources view (§8): add-source input, list or empty state.
203pub fn sources_view(state: &AppState) -> Element<'_, Message> {
204    let locale = state.locale;
205    // Add-source controls — folder picker button + optional manual path input.
206    let add_btn = icon_btn(icon_text(char::from(lucide::FolderPlus), 13.0), tr(locale, MessageKey::SourcesAddFolder), Message::RequestAddSource);
207    let add_input = text_input(
208        "Or type a path manually…",
209        &state.source_path_input,
210    )
211    .on_input(Message::SourcePathChanged)
212    .on_submit(Message::RequestAddSource)
213    .padding(8);
214    let recursive_note = text("All sub-folders are scanned recursively.").size(12);
215    let mut content = column![
216        heading(tr(locale, MessageKey::SourcesTitle)),
217        row![add_btn, container(add_input).width(Length::Fill)].spacing(8),
218        recursive_note,
219    ];
220
221    if let Some(notice) = &state.notice {
222        content = content.push(friendly_notice(locale, notice));
223    }
224    if state.sources.is_empty() {
225        content = content.push(
226            column![
227                text(tr(locale, MessageKey::SourcesEmptyTitle)).size(18),
228                text(tr(locale, MessageKey::SourcesEmptyBody)).size(15),
229            ]
230            .spacing(6),
231        );
232    } else {
233        for card in &state.sources {
234            let status = if card.active {
235                tr(locale, MessageKey::SourcesStatusActive)
236            } else {
237                tr(locale, MessageKey::SourcesStatusPaused)
238            };
239            let src_id = card.source_id.clone();
240            content = content.push(
241                container(
242                    column![
243                        text(card.display_name.clone()).size(15),
244                        text(card.display_path.clone()).size(12),
245                        text(source_summary(locale, card.indexed, card.stale, card.failed))
246                            .size(12),
247                        row![
248                            text(status).size(11),
249                            button(row![icon_text(char::from(lucide::Trash2), 12.0).size(12)].spacing(2))
250                                .on_press(Message::SourceRemoved(src_id)),
251                        ].spacing(8),
252                    ]
253                    .spacing(2),
254                )
255                .padding(10),
256            );
257        }
258    }
259    page(content)
260}
261
262/// Indexing view (§9): health summary cards.
263pub fn indexing_view(state: &AppState) -> Element<'_, Message> {
264    let locale = state.locale;
265    let h = state.health;
266    let cell = |label: &'static str, value: u64| {
267        container(column![text(label).size(12), text(value.to_string()).size(20)].spacing(2))
268            .padding(10)
269    };
270    // Less is more: always show "Indexed". Show queued/stale/failed cells
271    // only when they are non-zero (or when advanced view is on), so a healthy
272    // idle state is a single clean number rather than three zeros of noise.
273    let mut cells = row![cell(tr(locale, MessageKey::IndexingHealthIndexed), h.indexed)]
274        .spacing(10);
275    if h.queued > 0 || state.show_advanced {
276        cells = cells.push(cell(tr(locale, MessageKey::IndexingHealthQueued), h.queued));
277    }
278    if h.stale > 0 || state.show_advanced {
279        cells = cells.push(cell(tr(locale, MessageKey::IndexingHealthStale), h.stale));
280    }
281    if h.failed > 0 || state.show_advanced {
282        cells = cells.push(cell(tr(locale, MessageKey::IndexingHealthFailed), h.failed));
283    }
284    let content = column![
285        heading(tr(locale, MessageKey::IndexingTitle)),
286        cells,
287        text(if h.queued == 0 {
288            tr(locale, MessageKey::IndexingIdle).to_string()
289        } else {
290            files_indexed(locale, h.indexed)
291        })
292        .size(13),
293    ];
294    page(content)
295}
296
297/// Storage view (§10): safe cleanup vs danger zone, with real data.
298pub fn storage_view(state: &AppState) -> Element<'_, Message> {
299    let locale = state.locale;
300
301    // If confirmation is pending for a destructive reset, show the dialog only.
302    if state.confirm_reset {
303        let content = column![
304            text(tr(locale, MessageKey::StorageResetCatalog)).size(22),
305            text(tr(locale, MessageKey::StorageResetWarning)).size(15),
306            row![
307                button(text(tr(locale, MessageKey::Cancel)).size(15))
308                    .padding(Padding::from([12.0, 18.0]))
309                    .on_press(Message::CancelResetCatalog),
310                button(text(tr(locale, MessageKey::StorageResetCatalog)).size(15))
311                    .padding(Padding::from([12.0, 18.0]))
312                    .on_press(Message::ConfirmResetCatalog),
313            ]
314            .spacing(12),
315        ]
316        .spacing(16);
317        return page(content);
318    }
319    let total_bytes: u64 = state.storage_rows.iter().map(|(_, b, _)| b).sum();
320    let gib = total_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
321
322    let mut breakdown = column![
323        text(tr(locale, MessageKey::StorageTitle)).size(26),
324        text(tr(locale, MessageKey::StorageIntro)).size(15),
325        text(format!("{gib:.3} GiB total")).size(20),
326    ]
327    .spacing(4);
328
329    if !state.storage_rows.is_empty() {
330        if state.show_advanced {
331            // Advanced: raw per-category breakdown.
332            for (category, bytes, count) in &state.storage_rows {
333                if *bytes > 0 || *count > 0 {
334                    let mib = *bytes as f64 / (1024.0 * 1024.0);
335                    breakdown = breakdown.push(
336                        text(format!("  {category}: {mib:.1} MiB ({count} items)")).size(12),
337                    );
338                }
339            }
340        } else {
341            // Default: three friendly buckets — no engine jargon (less is more).
342            let mut search_index = 0u64;
343            let mut ai_models = 0u64;
344            let mut caches = 0u64;
345            for (category, bytes, _) in &state.storage_rows {
346                match category.as_str() {
347                    "keyword_index" | "vector_index" => search_index += bytes,
348                    "model_files" => ai_models += bytes,
349                    "snippet_cache" | "search_cache" | "temporary_extraction" => caches += bytes,
350                    _ => {} // persistent_catalog, logs — folded into total only
351                }
352            }
353            let mib = |b: u64| b as f64 / (1024.0 * 1024.0);
354            for (label, bytes) in [
355                (tr(locale, MessageKey::StorageGroupSearchIndex), search_index),
356                (tr(locale, MessageKey::StorageGroupModels), ai_models),
357                (tr(locale, MessageKey::StorageGroupCaches), caches),
358            ] {
359                if bytes > 0 {
360                    breakdown = breakdown.push(
361                        text(format!("  {label}: {:.1} MiB", mib(bytes))).size(15),
362                    );
363                }
364            }
365        }
366    }
367
368    let content = column![
369        breakdown,
370        text(tr(locale, MessageKey::StorageSafeCleanupHeading)).size(15),
371        row![
372            button(text(tr(locale, MessageKey::StorageClearSnippets)).size(15))
373            .padding(Padding::from([12.0, 16.0]))
374            .on_press(Message::CleanSnippets),
375            button(text(tr(locale, MessageKey::StorageClearSearchCache)).size(15))
376            .padding(Padding::from([12.0, 16.0]))
377            .on_press(Message::CleanSearchCache),
378        ]
379        .spacing(8),
380        text(tr(locale, MessageKey::StorageDangerHeading)).size(15),
381        button(text(tr(locale, MessageKey::StorageResetCatalog)).size(13))
382            .padding(Padding::from([12.0, 16.0]))
383            .on_press(Message::AskResetCatalog),
384        text(tr(locale, MessageKey::StorageResetWarning)).size(11),
385    ];
386    page(content)
387}
388
389/// Models view (§11): role statuses and keyword-only hint.
390pub fn models_view(state: &AppState) -> Element<'_, Message> {
391    let locale = state.locale;
392    let available = tr(locale, MessageKey::ModelsStatusAvailable);
393    let missing = tr(locale, MessageKey::ModelsStatusMissing);
394    let (embedding, reranker) = match state.capability {
395        SearchCapability::KeywordOnly => (missing, missing),
396        SearchCapability::Hybrid => (available, missing),
397        SearchCapability::HybridWithRerank => (available, available),
398    };
399    let mut content = column![
400        heading(tr(locale, MessageKey::ModelsTitle)),
401        text(format!("{}: {embedding}", tr(locale, MessageKey::ModelsEmbeddingRole))).size(14),
402        text(format!("{}: {reranker}", tr(locale, MessageKey::ModelsRerankerRole))).size(14),
403    ];
404    if state.capability == SearchCapability::KeywordOnly {
405        content = content.push(text(tr(locale, MessageKey::ModelsKeywordOnlyHint)).size(12));
406    }
407    page(content)
408}
409
410/// Settings view (§12): language picker + privacy section.
411pub fn settings_view(state: &AppState) -> Element<'_, Message> {
412    let locale = state.locale;
413    let mut language_row = row![].spacing(8);
414    for candidate in Locale::ALL {
415        let label = text(candidate.display_name()).size(13);
416        let mut b = button(label);
417        if *candidate != locale {
418            b = b.on_press(Message::SetLocale(*candidate));
419        }
420        language_row = language_row.push(b);
421    }
422    let content = column![
423        heading(tr(locale, MessageKey::SettingsTitle)),
424        text(tr(locale, MessageKey::SettingsLanguageHeading)).size(15),
425        language_row,
426        text(tr(locale, MessageKey::SettingsPrivacyHeading)).size(15),
427        text(tr(locale, MessageKey::SettingsPrivacyLocalOnly)).size(13),
428        text(tr(locale, MessageKey::SettingsAdvancedHeading)).size(15),
429        row![
430            button(
431                text(if state.show_advanced {
432                    tr(locale, MessageKey::SettingsAdvancedOn)
433                } else {
434                    tr(locale, MessageKey::SettingsAdvancedOff)
435                })
436                .size(13),
437            )
438            .on_press(Message::ToggleAdvanced),
439            text(tr(locale, MessageKey::SettingsAdvancedHint)).size(11),
440        ]
441        .spacing(8),
442    ];
443    page(content)
444}