Skip to main content

orbok_ui/
shell.rs

1//! Application shell (RFC-027): snora `AppLayout` with a two-level navigation:
2//! a vertical sidebar for the three top-level groups (Search, AI, Settings) and
3//! a horizontal tab bar for the sub-views within each group.
4
5use crate::i18n::{MessageKey, tr};
6use crate::state::{AppState, Message, NavGroup, ViewId};
7use crate::views;
8use iced::Element;
9use lucide_icons::Icon as LucideIcon;
10use snora::{AppLayout, Icon, LayoutDirection, SideBar, SideBarItem, Tab, TabBar, render,
11            widget::{app_side_bar, app_tab_bar}};
12
13fn tab_action_to_msg(action: snora::TabAction<ViewId>) -> Message {
14    let snora::TabAction::Pressed(id) = action;
15    Message::Switch(id)
16}
17
18fn build_tab_bar(tabs: Vec<Tab<ViewId>>, active: ViewId) -> Element<'static, Message> {
19    app_tab_bar(
20        TabBar { tabs, active },
21        &tab_action_to_msg,
22        LayoutDirection::Ltr,
23    )
24}
25
26/// The iced application wrapper around [`AppState`].
27#[derive(Default)]
28pub struct OrbokApp {
29    pub state: AppState,
30}
31
32impl OrbokApp {
33    pub fn with_state(state: AppState) -> Self {
34        Self { state }
35    }
36
37    pub fn update(&mut self, message: Message) {
38        self.state.update(&message);
39    }
40
41    pub fn view(&self) -> Element<'_, Message> {
42        let locale = self.state.locale;
43
44        // ── Startup wizard takes priority ──────────────────────────────
45        if self.state.wizard.is_some() {
46            return views::wizard_view(&self.state);
47        }
48
49        // ── Sidebar: three top-level groups ───────────────────────────
50        let sidebar_items: Vec<SideBarItem<Message, NavGroup>> = vec![
51            SideBarItem {
52                view_id: NavGroup::Search,
53                icon: Icon::Lucide(LucideIcon::Search),
54                tooltip: tr(locale, MessageKey::NavSearch).to_string(),
55                on_press: Message::SwitchGroup(NavGroup::Search),
56            },
57            SideBarItem {
58                view_id: NavGroup::Ai,
59                icon: Icon::Lucide(LucideIcon::BrainCircuit),
60                tooltip: tr(locale, MessageKey::NavAi).to_string(),
61                on_press: Message::SwitchGroup(NavGroup::Ai),
62            },
63            SideBarItem {
64                view_id: NavGroup::Settings,
65                icon: Icon::Lucide(LucideIcon::Settings),
66                tooltip: tr(locale, MessageKey::NavSettings).to_string(),
67                on_press: Message::SwitchGroup(NavGroup::Settings),
68            },
69        ];
70        let side_bar = app_side_bar(
71            SideBar {
72                items: sidebar_items,
73                active: self.state.active_view.group(),
74            },
75            LayoutDirection::Ltr,
76        );
77
78        // ── Tab bar: sub-views within the active group ─────────────────
79        let tab_bar_el: Option<Element<'_, Message>> =
80            match self.state.active_view.group() {
81                NavGroup::Search => {
82                    Some(build_tab_bar(
83                        vec![
84                            Tab { id: ViewId::Search,  label: tr(locale, MessageKey::NavSearch).to_string(),  icon: None },
85                            Tab { id: ViewId::Sources, label: tr(locale, MessageKey::NavSources).to_string(), icon: None },
86                        ],
87                        self.state.active_view,
88                    ))
89                }
90                NavGroup::Ai => {
91                    Some(build_tab_bar(
92                        vec![
93                            Tab { id: ViewId::Indexing, label: tr(locale, MessageKey::NavIndexing).to_string(), icon: None },
94                            Tab { id: ViewId::Storage,  label: tr(locale, MessageKey::NavStorage).to_string(),  icon: None },
95                            Tab { id: ViewId::Models,   label: tr(locale, MessageKey::NavModels).to_string(),   icon: None },
96                        ],
97                        self.state.active_view,
98                    ))
99                }
100                NavGroup::Settings => None,
101            };
102
103        // ── Active page body ───────────────────────────────────────────
104        let page_body = match self.state.active_view {
105            ViewId::Search   => views::search_view(&self.state),
106            ViewId::Sources  => views::sources_view(&self.state),
107            ViewId::Indexing => views::indexing_view(&self.state),
108            ViewId::Storage  => views::storage_view(&self.state),
109            ViewId::Models   => views::models_view(&self.state),
110            ViewId::Settings => views::settings_view(&self.state),
111        };
112
113        // Compose: tab bar (if any) stacked above the page body.
114        let body: Element<'_, Message> = if let Some(tabs) = tab_bar_el {
115            iced::widget::column![tabs, page_body]
116                .spacing(0)
117                .into()
118        } else {
119            page_body
120        };
121
122        render(AppLayout::new(body).side_bar(side_bar))
123    }
124
125    pub fn title(&self) -> String {
126        tr(self.state.locale, MessageKey::AppTitle).to_string()
127    }
128}