diff --git a/src/main.rs b/src/main.rs index fc4a56f..f621624 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,18 +91,18 @@ //! //! ### Where to Start //! -//! - **New to the codebase?** Start with `src/main.rs` (this file) and `src/ui/rootview.rs` +//! - **New to the codebase?** Start with `src/main.rs` (this file) and `src/ui/app.rs` //! to understand the application entry point and main window structure. //! -//! - **Want to understand device communication?** Read `src/device/mod.rs` and -//! `src/device/io.rs` for the high-level API, then dive into `src/device/rescue/mod.rs` -//! (PC/SC protocol) or `src/device/fido/mod.rs` (FIDO2/CTAP2 protocol). +//! - **Want to understand device communication?** Read `src/hal/mod.rs` and +//! `src/hal/io.rs` for the high-level API, then dive into `src/hal/rescue/mod.rs` +//! (PC/SC protocol) or `src/hal/fido/mod.rs` (FIDO2/CTAP2 protocol). //! -//! - **Working on UI features?** Explore `src/ui/views/` for page implementations and -//! `src/ui/components/` for reusable widgets. See `src/ui/types.rs` for state types. +//! - **Working on UI features?** Explore `src/ui/screens/` for page implementations and +//! `src/ui/components/` for reusable widgets. See `src/ui/models/` for shared state. //! -//! - **Adding new device commands?** Check `src/device/fido/constants.rs` and -//! `src/device/rescue/constants.rs` for protocol constants, then implement in the +//! - **Adding new device commands?** Check `src/hal/fido/constants.rs` and +//! `src/hal/rescue/constants.rs` for protocol constants, then implement in the //! appropriate protocol module. //! //! --- @@ -143,7 +143,7 @@ //! │ ├── main.rs # ← THIS FILE: Application entry point //! │ ├── error.rs # Application-wide error types (PFError) //! │ ├── logging.rs # log4rs configuration -//! │ ├── device/ # Hardware communication layer +//! │ ├── hal/ # Hardware abstraction layer //! │ │ ├── mod.rs # Module root, re-exports //! │ │ ├── io.rs # High-level API bridging rescue and FIDO //! │ │ ├── types.rs # Shared data structures @@ -156,17 +156,34 @@ //! │ │ └── hid.rs # USB HID transport //! │ └── ui/ # GPUI frontend //! │ ├── mod.rs -//! │ ├── rootview.rs # Main window, sidebar routing -//! │ ├── types.rs # UI state types +//! │ ├── app.rs # ApplicationRoot, AppModels, layout, Render //! │ ├── assets.rs # rust-embed asset loader //! │ ├── colors.rs # Theme color constants -//! │ ├── views/ # Page views (sidebar sections) +//! │ ├── models/ # Shared reactive state (DeviceRepo) //! │ │ ├── mod.rs -//! │ │ ├── home.rs -//! │ │ ├── passkeys.rs -//! │ │ ├── config.rs -//! │ │ ├── security.rs -//! │ │ └── about.rs +//! │ │ └── device.rs +//! │ ├── screens/ # Page views (sidebar sections) +//! │ │ ├── mod.rs +//! │ │ ├── home/ +//! │ │ │ ├── mod.rs +//! │ │ │ ├── view.rs +//! │ │ │ └── view_model.rs +//! │ │ ├── passkeys/ +//! │ │ │ ├── mod.rs +//! │ │ │ ├── view.rs +//! │ │ │ └── view_model.rs +//! │ │ ├── config/ +//! │ │ │ ├── mod.rs +//! │ │ │ ├── view.rs +//! │ │ │ └── view_model.rs +//! │ │ ├── security/ +//! │ │ │ ├── mod.rs +//! │ │ │ ├── view.rs +//! │ │ │ └── view_model.rs +//! │ │ └── about/ +//! │ │ ├── mod.rs +//! │ │ ├── view.rs +//! │ │ └── view_model.rs //! │ └── components/ # Reusable UI widgets //! │ ├── mod.rs //! │ ├── button.rs @@ -257,22 +274,22 @@ //! ┌─────────────────────────────────────────────────────────────┐ //! │ UI Layer (src/ui/) │ //! │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -//! │ │ HomeView │ │Passkeys │ │ConfigView│ │Security │ │ -//! │ │ │ │View │ │ │ │View │ │ +//! │ │ HomeView │ │Passkeys │ │ConfigVM │ │Security │ │ +//! │ │ │ │ViewModel │ │ │ │View │ │ //! │ └────┬─────┘ └─────┬────┘ └──────┬───┘ └───────┬──┘ │ //! │ │ │ │ │ │ //! │ └──────────────┴──────┬───────┴──────────────┘ │ //! │ │ │ //! │ ┌────────▼────────┐ │ -//! │ │ ApplicationRoot │ (rootview.rs) │ -//! │ │ DeviceState │ │ +//! │ │ ApplicationRoot │ (app.rs) │ +//! │ │ AppModels │ │ //! │ │ LayoutState │ │ -//! │ │ ViewCache │ │ +//! │ │ ViewModelStore │ │ //! │ └────────┬────────┘ │ //! └─────────────────────────────┼───────────────────────────────┘ //! │ //! ┌─────────────────────────────▼───────────────────────────────┐ -//! │ Device I/O Layer (src/device/io.rs) │ +//! │ Hardware I/O Layer (src/hal/io.rs) │ //! │ High-level API: read_device_details() │ //! │ write_config() │ //! │ get_credentials() │ @@ -283,7 +300,7 @@ //! │ │ //! ┌────────────▼────────────┐ ┌─────────────────▼─────────────┐ //! │ Rescue Protocol │ │ FIDO2 Protocol │ -//! │ (src/device/rescue/) │ │ (src/device/fido/) │ +//! │ (src/hal/rescue/) │ │ (src/hal/fido/) │ //! │ │ │ │ //! │ PC/SC + ISO 7816-4 │ │ CTAPHID + CTAP2 │ //! │ APDU commands │ │ CBOR messages │ @@ -303,8 +320,8 @@ //! //! ### Key Design Principles //! -//! 1. **Separation of Concerns**: Device communication (`src/device/`) is completely -//! separate from UI code (`src/ui/`). No GPUI imports exist in device modules. +//! 1. **Separation of Concerns**: Device communication (`src/hal/`) is completely +//! separate from UI code (`src/ui/`). No GPUI imports exist in HAL modules. //! //! 2. **Protocol Abstraction**: The `io.rs` layer provides a unified API that //! automatically selects Rescue or FIDO2 based on device capabilities and @@ -314,8 +331,8 @@ //! and RS-Key (Rust firmware) with the same UI, detecting firmware type via //! AAGUID and adapting behavior accordingly. //! -//! 4. **State-Driven UI**: The `ApplicationRoot` maintains device state and -//! propagates changes to views via the `DeviceConnectionState` struct. +//! 4. **State-Driven UI**: The `ApplicationRoot` holds `AppModels` with a reactive +//! `Entity` that views subscribe to via `DeviceEvent::Updated`. //! //! --- //! @@ -346,7 +363,7 @@ //! └── Vendor commands (0xC1/0xC2) //! //! ▼ -//! Update DeviceConnectionState +//! Update DeviceRepo //! │ //! ├── status: FullDeviceStatus //! ├── fido_info: FidoDeviceInfo @@ -360,7 +377,7 @@ //! ### Configuration Write Flow //! //! ```text -//! User edits config form (ConfigView) +//! User edits config form (ConfigViewModel) //! │ //! ▼ //! io::write_config(config, method, pin) @@ -387,7 +404,7 @@ //! ### Credential Management Flow //! //! ```text -//! PasskeysView::unlock_storage(pin) +//! PasskeysViewModel::unlock_storage(pin) //! │ //! ▼ //! io::get_credentials(pin) @@ -407,7 +424,7 @@ //! Parse CBOR responses → Vec //! │ //! ▼ -//! Display credentials in PasskeysView table +//! Display credentials in PasskeysViewModel table //! ``` //! //! --- @@ -503,9 +520,9 @@ //! ```text //! ApplicationRoot::new(cx) //! │ -//! ├── DeviceConnectionState::new() +//! ├── AppModels { device: Entity } //! ├── LayoutState::new() -//! ├── ViewCache::new() +//! ├── ViewModelStore::new() [all None, lazy-init] //! │ //! └── refresh_device_status() → io::read_device_details() //! @@ -515,21 +532,22 @@ //! │ └── on_select → updates LayoutState.active_view //! │ //! └── Render content based on active_view: -//! ├── Home → HomeView::build() [stateless] -//! ├── Passkeys → PasskeysView::new() [stateful, cached] -//! ├── Configuration → ConfigView::new() [stateful, cached] -//! ├── Security → SecurityView::build() [stateless] -//! └── About → AboutView::build() [stateless] +//! ├── Home → HomeViewModel::new() [lazy, cached] +//! ├── Passkeys → PasskeysViewModel::new() [lazy, cached] +//! ├── Configuration → ConfigViewModel::new() [lazy, cached] +//! ├── Security → SecurityViewModel::new() [lazy, cached] +//! └── About → AboutViewModel::new() [lazy, cached] //! ``` //! //! ### State Management //! -//! - **DeviceConnectionState**: Holds device status, FIDO info, LED config, errors +//! - **AppModels**: Dependency injection bag holding `Entity` +//! - **DeviceRepo**: Reactive device state (status, FIDO info, LED config, errors) //! - **LayoutState**: Active view, sidebar width/collapse state -//! - **ViewCache**: Cached entity views (PasskeysView, ConfigView) to preserve state +//! - **ViewModelStore**: Lazy-initialized cached entity views, all use `get_or_insert_with` //! -//! State flows downward from `ApplicationRoot` to views via props. Views communicate -//! upward via callbacks (`on_select`, `on_refresh`) and events (`PasskeysEvent`). +//! State flows via `Entity::read(cx)`. Views subscribe to `DeviceEvent::Updated` +//! and communicate upward via callbacks (`on_select`, `on_refresh`) and events (`PasskeysEvent`). //! //! --- //! diff --git a/src/ui/app.rs b/src/ui/app.rs index bab07a4..06915cf 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,24 +1,39 @@ +//! Root application wiring — owns all shared state, layout, and view-model lifecycles. +//! +//! [`ApplicationRoot`] is the top-level GPUI component. It polls device hardware via +//! `refresh_device_status`, routes between screens based on `ActiveView`, and manages +//! the sidebar, title bar, and sidebar toggle button. + use crate::hal::io; use crate::hal::types::{DeviceMethod, FirmwareType}; +use crate::ui::components::sidebar::AppSidebar; use crate::ui::models::device::DeviceRepo; use crate::ui::screens::{ - about::AboutViewModel, config::ConfigView, home::HomeViewModel, passkeys::PasskeysView, - security::SecurityViewModel, + about::AboutViewModel, config::ConfigViewModel, home::HomeViewModel, passkeys::PasskeysEvent, + passkeys::PasskeysViewModel, security::SecurityViewModel, }; +use gpui::prelude::*; use gpui::*; +use gpui_component::Root; +use gpui_component::{ + ActiveTheme, Icon, TitleBar, WindowExt, h_flex, scroll::ScrollableElement, v_flex, +}; gpui::actions!(picoforge, [ToggleSidebar]); +/// Shared reactive models accessible to every screen view-model. pub struct AppModels { pub device: Entity, } +/// Lazy-initialisation registry for screen view-models. Each field is `None` +/// until its screen is navigated to, then created via `get_or_insert_with`. pub struct ViewModelStore { pub home: Option>, pub about: Option>, pub security: Option>, - pub passkeys: Option>, - pub config: Option>, + pub passkeys: Option>, + pub config: Option>, } impl ViewModelStore { @@ -33,6 +48,7 @@ impl ViewModelStore { } } +/// Which screen is currently displayed in the content area. #[derive(Clone, Copy, PartialEq, Debug)] pub enum ActiveView { Home, @@ -42,6 +58,7 @@ pub enum ActiveView { About, } +/// Dimensions and collapse state of the sidebar panel. #[derive(Clone, Debug, PartialEq)] pub struct LayoutState { pub active_view: ActiveView, @@ -61,6 +78,7 @@ impl LayoutState { } } +/// Top-level GPUI component — owns models, layout, and wires the sidebar + content routing. pub struct ApplicationRoot { pub models: AppModels, pub view_state: LayoutState, @@ -69,6 +87,7 @@ pub struct ApplicationRoot { } impl ApplicationRoot { + /// Creates the root, initialises `DeviceRepo`, and triggers an immediate device poll. pub fn new(cx: &mut Context) -> Self { let device = cx.new(|_| DeviceRepo::new()); @@ -87,6 +106,9 @@ impl ApplicationRoot { self.focus_handle.clone() } + /// Polls the HAL layer for device status, FIDO info, LED config, and management app data. + /// Writes results into [`DeviceRepo`] and refreshes subscribed view-models. + /// Skips if a load is already in progress. pub(crate) fn refresh_device_status( &mut self, window: Option<&mut Window>, @@ -163,8 +185,206 @@ impl ApplicationRoot { cx.notify(); } + /// Toggles the sidebar between collapsed and expanded state. pub fn toggle_sidebar(&mut self, cx: &mut Context) { self.view_state.is_sidebar_collapsed = !self.view_state.is_sidebar_collapsed; cx.notify(); } } + +impl Render for ApplicationRoot { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let window_width = window.bounds().size.width; + let is_window_wide = window_width > px(800.0); + let is_sidebar_collapsed = self.view_state.is_sidebar_collapsed || !is_window_wide; + + let target_width = if is_sidebar_collapsed { + px(48.) + } else { + px(255.) + }; + + if (self.view_state.sidebar_width - target_width).abs() > px(0.1) { + self.view_state.sidebar_width = self.view_state.sidebar_width + + (target_width - self.view_state.sidebar_width) * 0.2; + window.request_animation_frame(); + } else { + self.view_state.sidebar_width = target_width; + } + + let dialog_layer = Root::render_dialog_layer(window, cx); + let sheet_layer = Root::render_sheet_layer(window, cx); + + let title_bar = TitleBar::new().bg(cx.theme().title_bar).child( + h_flex() + .w_full() + .justify_between() + .bg(cx.theme().title_bar) + .items_center() + .cursor(gpui::CursorStyle::OpenHand), + ); + + let content_area = v_flex() + .track_focus(&self.focus_handle) + .key_context("ApplicationRoot") + .on_action(cx.listener(|this, _: &ToggleSidebar, _, cx| { + this.toggle_sidebar(cx); + })) + .min_h(px(0.)) + .min_w(px(0.)) + .overflow_y_scrollbar() + .flex_grow() + .bg(cx.theme().background) + .child(match self.view_state.active_view { + ActiveView::Home => { + let view = self.views_store.home.get_or_insert_with(|| { + cx.new(|cx| HomeViewModel::new(window, cx, &self.models)) + }); + view.clone().into_any_element() + } + ActiveView::Passkeys => { + let view = self.views_store.passkeys.get_or_insert_with(|| { + let view = cx.new(|cx| PasskeysViewModel::new(window, cx, &self.models)); + cx.subscribe_in( + &view, + window, + |_, _, event: &PasskeysEvent, window, cx| match event { + PasskeysEvent::Notification(msg) => { + window.push_notification(msg.to_string(), cx); + } + }, + ) + .detach(); + view + }); + view.clone().into_any_element() + } + ActiveView::Configuration => { + let view = self.views_store.config.get_or_insert_with(|| { + cx.new(|cx| ConfigViewModel::new(window, cx, &self.models)) + }); + view.clone().into_any_element() + } + ActiveView::Security => { + let view = self.views_store.security.get_or_insert_with(|| { + cx.new(|cx| SecurityViewModel::new(window, cx, &self.models)) + }); + view.clone().into_any_element() + } + ActiveView::About => { + let view = self.views_store.about.get_or_insert_with(|| { + cx.new(|cx| AboutViewModel::new(window, cx, &self.models)) + }); + view.clone().into_any_element() + } + }); + + let sidebar = AppSidebar::new( + self.view_state.active_view, + self.view_state.sidebar_width, + is_sidebar_collapsed, + &self.models, + ) + .on_select(|this: &mut Self, view, _, _| { + this.view_state.active_view = view; + }) + .on_refresh(|this, window, cx| { + this.refresh_device_status(Some(window), cx); + }); + + let sidebar_bg = cx.theme().sidebar; + let border_color = cx.theme().sidebar_border; + let sidebar_fg = cx.theme().sidebar_foreground; + let is_toggle_visible = !is_sidebar_collapsed || self.view_state.sidebar_toggle_hovered; + let sidebar_width = self.view_state.sidebar_width; + let toggle_icon = if is_sidebar_collapsed { + "icons/chevron-right.svg" + } else { + "icons/chevron-left.svg" + }; + let toggle_tooltip = if is_sidebar_collapsed { + "Expand" + } else { + "Collapse" + }; + let toggle_btn = div() + .id("sidebar-toggle-zone") + .absolute() + .left(sidebar_width - px(14.)) + .top_0() + .bottom_0() + .w(px(28.)) + .flex() + .items_center() + .justify_center() + .on_hover(cx.listener(|this, hovered, _, cx| { + this.view_state.sidebar_toggle_hovered = *hovered; + cx.notify(); + })) + .child( + div() + .id("sidebar-toggle-btn") + .w(px(24.)) + .h(px(24.)) + .rounded_full() + .bg(sidebar_bg) + .border_1() + .border_color(border_color) + .flex() + .items_center() + .justify_center() + .cursor(gpui::CursorStyle::PointingHand) + .opacity(if is_toggle_visible { 1.0 } else { 0.0 }) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(toggle_tooltip) + .action(&ToggleSidebar, None) + .build(window, cx) + }) + .on_click(cx.listener(|this, _, _, _| { + this.view_state.is_sidebar_collapsed = + !this.view_state.is_sidebar_collapsed; + })) + .child(Icon::default().path(toggle_icon).text_color(sidebar_fg)), + ); + + #[cfg(target_os = "macos")] + let content_column = content_area; + #[cfg(not(target_os = "macos"))] + let content_column = v_flex().size_full().child(title_bar).child(content_area); + + let main_area = h_flex() + .id("main-area") + .relative() + .items_start() + .map(|this| { + if cfg!(target_os = "macos") { + this.flex_1().min_h(px(0.)) + } else { + this.size_full() + } + }) + .child( + div() + .h_full() + .w(sidebar_width) + .flex_shrink_0() + .child(sidebar.render(cx)), + ) + .child(content_column.h_full().flex_1().w_0()) + .child(toggle_btn); + + #[cfg(target_os = "macos")] + let body = v_flex().size_full().child(title_bar).child(main_area); + + #[cfg(not(target_os = "macos"))] + let body = main_area; + + div() + .id("application-root") + .size_full() + .overflow_hidden() + .child(body) + .children(dialog_layer) + .children(sheet_layer) + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a94a446..67e9aba 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,7 +1,142 @@ +//! # PicoForge UI Layer +//! +//! This module is the entire frontend of PicoForge — a GPUI-based desktop application +//! that communicates with physical Pico FIDO / RS-Key hardware security keys. +//! +//! ## Architecture +//! +//! The UI follows a **reactive component tree** model. At the root sits +//! [`ApplicationRoot`](app::ApplicationRoot), which holds: +//! +//! * **Shared reactive state** — [`AppModels`](app::AppModels) wrapping +//! [`DeviceRepo`](models::device::DeviceRepo), an `Entity` that any +//! view-model can read or write. +//! * **View-model registry** — [`ViewModelStore`](app::ViewModelStore) that +//! lazily initializes each screen's view-model on first navigation. +//! * **Layout state** — [`LayoutState`](app::LayoutState) tracking active sidebar +//! selection and sidebar width/collapse. +//! +//! Each screen (Home, Passkeys, Configuration, Security, About) is split into: +//! * `view_model.rs` — the reactive state machine (`Entity`, `EventEmitter`) +//! * `view.rs` — the `Render` impl that builds GPUI elements from view-model state +//! +//! Views never call device hardware directly; they read from `DeviceRepo` (which +//! is populated by `ApplicationRoot::refresh_device_status`) and emit events that +//! flow upward through the component tree. +//! +//! ## GPUI Concepts (zed-industries/gpui) +//! +//! GPUI — — is a +//! hybrid immediate/retained-mode GPU-accelerated UI framework for Rust. +//! +//! | Concept | Role | +//! |---|---| +//! | `Entity` | Reference-counted smart pointer to reactive state. Created via `cx.new(\|cx\| T::new(...))`. | +//! | `Context` | (also `WindowContext`) — used for creating entities, subscribing to events, notifying. | +//! | `Window` | Window-level operations: animations, notifications, dialogs, bounds. | +//! | `Render` trait | `fn render(&mut self, window, cx) -> impl IntoElement`. The framework calls this on `cx.notify()`. | +//! | `cx.notify()` | Schedule a re-render. Every mutation to reactive state (inside `Entity::update`) must call this. | +//! | `cx.subscribe(&entity, callback)` | Listen for events emitted by another entity. **Unbounded** — must `cx.subscribe_in` and `detach()` or leak. | +//! | `EventEmitter` | Trait an entity impls to declare it emits events of type `E`. | +//! | `FocusHandle` | Focus management — returned by `cx.focus_handle()`. Needed for keyboard event routing (`track_focus`, `key_context`). | +//! | `actions!` | Macro for defining custom actions (e.g. `ToggleSidebar`). Bound to elements via `on_action(...)`. | +//! +//! **Lifecycle:** When state changes, call `cx.notify()` inside an `Entity::update` +//! closure. GPUI re-invokes `Render` on the next frame, comparing the new element +//! tree against the previous one (reconciliation). +//! +//! ## gpui-component Concepts (longbridge/gpui-component) +//! +//! gpui-component — — provides +//! 60+ pre-built widgets on top of GPUI. PicoForge uses the **librekeys fork** +//! (`fix/client-window-linux` branch). +//! +//! * **Stateless widgets** — builder-pattern constructors like +//! `Button::new("id").primary().label("text").on_click(...)`. +//! These are lightweight; they produce `impl IntoElement` in `Render`. +//! * **Stateful components** — `InputState`, `SelectState`, `SliderState`, etc., +//! created via `cx.new(|cx| ...)` and stored in the view-model. They need +//! `cx.subscribe(&state, callback)` to react to user interaction. +//! * **Theming** — `ActiveTheme` trait on `cx` (e.g. `cx.theme().background`). +//! Themes come from JSON (e.g. `themes/picoforge-zinc.json`). `Root::render_dialog_layer` +//! and `Root::render_sheet_layer` must be called in the root `Render`. +//! * **Layout helpers** — `h_flex`, `v_flex` for flexbox-style layouts, +//! `scroll::ScrollableElement` for scroll containers, `TitleBar` for window chrome, +//! `Icon` for SVG icons, `tooltip::Tooltip` for hover tooltips. +//! * **`Root`** — top-level wrapper created once per window: +//! `cx.new(|cx| Root::new(content_view, window, cx))`. +//! +//! ## Tree Structure (file-by-file) +//! +//! ```text +//! src/ui/ +//! ├── mod.rs # This file — module declaration + architecture docs +//! ├── app.rs # ApplicationRoot, AppModels, LayoutState, ViewModelStore +//! │ # Root Render: sidebar, title bar, content routing, toggle button +//! │ # refresh_device_status: polls HAL, updates DeviceRepo +//! ├── assets.rs # AssetLoaderImpl via rust-embed (loads SVGs from static/) +//! ├── colors.rs # Zinc palette constants (u32 RGB). WIP — HSLA migration planned. +//! │ # Reference: https://ui.shadcn.com/colors +//! ├── models/ +//! │ ├── mod.rs # pub mod device +//! │ └── device.rs # DeviceRepo — reactive state for device status, FIDO info, +//! │ # LED config, management apps, loading/error flags. +//! │ # Implements EventEmitter +//! ├── components/ +//! │ ├── mod.rs # Module declarations for sub-components +//! │ ├── button.rs # Custom button widgets +//! │ ├── card.rs # Card container widgets +//! │ ├── dialog.rs # Custom dialog widgets +//! │ ├── page_view.rs # Page view layout container +//! │ ├── sidebar.rs # AppSidebar — SVG navigation sidebar (Home, Passkeys, etc.) +//! │ │ # Handles selection highlighting, tooltips, nav callbacks +//! │ └── tag.rs # Tag/badge widgets +//! ├── screens/ +//! │ ├── mod.rs # pub mod home, config, passkeys, security, about +//! │ ├── home/ +//! │ │ ├── mod.rs # HomeView re-export +//! │ │ ├── view_model.rs # HomeViewModel — device summary state +//! │ │ └── view.rs # HomeView — device status cards, connection info +//! │ ├── config/ +//! │ │ ├── mod.rs # ConfigView re-export +//! │ │ ├── view_model.rs # ConfigViewModel — PIN management, LED, transport config +//! │ │ └── view.rs # ConfigView — configuration form UI +//! │ ├── passkeys/ +//! │ │ ├── mod.rs # PasskeysView re-export +//! │ │ ├── view_model.rs # PasskeysViewModel — credential list, unlock state +//! │ │ └── view.rs # PasskeysView — passkey table, credential operations +//! │ ├── security/ +//! │ │ ├── mod.rs # SecurityView re-export +//! │ │ ├── view_model.rs # SecurityViewModel — reset, attestation, FIDO2 config +//! │ │ └── view.rs # SecurityView — security settings UI +//! │ └── about/ +//! │ ├── mod.rs # AboutView re-export +//! │ ├── view_model.rs # AboutViewModel — version, firmware details +//! │ └── view.rs # AboutView — build info, licenses, firmware version +//! ``` +//! +//! ## Navigation & Data Flow +//! +//! 1. `ApplicationRoot::render` reads `LayoutState.active_view` to decide which +//! screen to display. Each screen's view-model is lazily created via +//! `get_or_insert_with` on `ViewModelStore`. +//! 2. `AppSidebar::on_select` sets `LayoutState.active_view` and calls `cx.notify()`. +//! 3. `refresh_device_status` (called at startup and on sidebar refresh) invokes +//! `crate::hal::io::read_device_details()`, writes results into `DeviceRepo`, +//! then notifies all subscribers. +//! 4. Screen view-models read `DeviceRepo` in their `Render` or event handlers +//! via `models.device.read(cx)`. +//! +//! ## External References +//! +//! * GPUI framework: +//! * gpui-component library: +//! * gpui-component docs: +//! * shadcn color palette (used in `colors.rs`): + pub mod app; pub mod assets; pub mod colors; pub mod components; pub mod models; -pub mod rootview; pub mod screens; diff --git a/src/ui/rootview.rs b/src/ui/rootview.rs deleted file mode 100644 index 7ce53d6..0000000 --- a/src/ui/rootview.rs +++ /dev/null @@ -1,209 +0,0 @@ -use crate::ui::app::{ActiveView, ApplicationRoot, ToggleSidebar}; -use crate::ui::components::sidebar::AppSidebar; -use crate::ui::screens::{ - about::AboutViewModel, config::ConfigView, home::HomeViewModel, passkeys::PasskeysEvent, - passkeys::PasskeysView, security::SecurityViewModel, -}; -use gpui::prelude::*; -use gpui::*; -use gpui_component::Root; -use gpui_component::{ - ActiveTheme, Icon, TitleBar, WindowExt, h_flex, scroll::ScrollableElement, v_flex, -}; - -impl Render for ApplicationRoot { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let window_width = window.bounds().size.width; - let is_window_wide = window_width > px(800.0); - let is_sidebar_collapsed = self.view_state.is_sidebar_collapsed || !is_window_wide; - - let target_width = if is_sidebar_collapsed { - px(48.) - } else { - px(255.) - }; - - if (self.view_state.sidebar_width - target_width).abs() > px(0.1) { - self.view_state.sidebar_width = self.view_state.sidebar_width - + (target_width - self.view_state.sidebar_width) * 0.2; - window.request_animation_frame(); - } else { - self.view_state.sidebar_width = target_width; - } - - let dialog_layer = Root::render_dialog_layer(window, cx); - let sheet_layer = Root::render_sheet_layer(window, cx); - - let title_bar = TitleBar::new().bg(cx.theme().title_bar).child( - h_flex() - .w_full() - .justify_between() - .bg(cx.theme().title_bar) - .items_center() - .cursor(gpui::CursorStyle::OpenHand), - ); - - let content_area = v_flex() - .track_focus(&self.focus_handle) - .key_context("ApplicationRoot") - .on_action(cx.listener(|this, _: &ToggleSidebar, _, cx| { - this.toggle_sidebar(cx); - })) - .min_h(px(0.)) - .min_w(px(0.)) - .overflow_y_scrollbar() - .flex_grow() - .bg(cx.theme().background) - .child(match self.view_state.active_view { - ActiveView::Home => { - let view = self.views_store.home.get_or_insert_with(|| { - cx.new(|cx| HomeViewModel::new(window, cx, &self.models)) - }); - view.clone().into_any_element() - } - ActiveView::Passkeys => { - let view = self.views_store.passkeys.get_or_insert_with(|| { - let view = cx.new(|cx| PasskeysView::new(window, cx, &self.models)); - cx.subscribe_in( - &view, - window, - |_, _, event: &PasskeysEvent, window, cx| match event { - PasskeysEvent::Notification(msg) => { - window.push_notification(msg.to_string(), cx); - } - }, - ) - .detach(); - view - }); - view.clone().into_any_element() - } - ActiveView::Configuration => { - let view = self.views_store.config.get_or_insert_with(|| { - cx.new(|cx| ConfigView::new(window, cx, &self.models)) - }); - view.clone().into_any_element() - } - ActiveView::Security => { - let view = self.views_store.security.get_or_insert_with(|| { - cx.new(|cx| SecurityViewModel::new(window, cx, &self.models)) - }); - view.clone().into_any_element() - } - ActiveView::About => { - let view = self.views_store.about.get_or_insert_with(|| { - cx.new(|cx| AboutViewModel::new(window, cx, &self.models)) - }); - view.clone().into_any_element() - } - }); - - let sidebar = AppSidebar::new( - self.view_state.active_view, - self.view_state.sidebar_width, - is_sidebar_collapsed, - &self.models, - ) - .on_select(|this: &mut Self, view, _, _| { - this.view_state.active_view = view; - }) - .on_refresh(|this, window, cx| { - this.refresh_device_status(Some(window), cx); - }); - - let sidebar_bg = cx.theme().sidebar; - let border_color = cx.theme().sidebar_border; - let sidebar_fg = cx.theme().sidebar_foreground; - let is_toggle_visible = !is_sidebar_collapsed || self.view_state.sidebar_toggle_hovered; - let sidebar_width = self.view_state.sidebar_width; - let toggle_icon = if is_sidebar_collapsed { - "icons/chevron-right.svg" - } else { - "icons/chevron-left.svg" - }; - let toggle_tooltip = if is_sidebar_collapsed { - "Expand" - } else { - "Collapse" - }; - let toggle_btn = div() - .id("sidebar-toggle-zone") - .absolute() - .left(sidebar_width - px(14.)) - .top_0() - .bottom_0() - .w(px(28.)) - .flex() - .items_center() - .justify_center() - .on_hover(cx.listener(|this, hovered, _, cx| { - this.view_state.sidebar_toggle_hovered = *hovered; - cx.notify(); - })) - .child( - div() - .id("sidebar-toggle-btn") - .w(px(24.)) - .h(px(24.)) - .rounded_full() - .bg(sidebar_bg) - .border_1() - .border_color(border_color) - .flex() - .items_center() - .justify_center() - .cursor(gpui::CursorStyle::PointingHand) - .opacity(if is_toggle_visible { 1.0 } else { 0.0 }) - .tooltip(move |window, cx| { - gpui_component::tooltip::Tooltip::new(toggle_tooltip) - .action(&ToggleSidebar, None) - .build(window, cx) - }) - .on_click(cx.listener(|this, _, _, _| { - this.view_state.is_sidebar_collapsed = - !this.view_state.is_sidebar_collapsed; - })) - .child(Icon::default().path(toggle_icon).text_color(sidebar_fg)), - ); - - #[cfg(target_os = "macos")] - let content_column = content_area; - #[cfg(not(target_os = "macos"))] - let content_column = v_flex().size_full().child(title_bar).child(content_area); - - let main_area = h_flex() - .id("main-area") - .relative() - .items_start() - .map(|this| { - if cfg!(target_os = "macos") { - this.flex_1().min_h(px(0.)) - } else { - this.size_full() - } - }) - .child( - div() - .h_full() - .w(sidebar_width) - .flex_shrink_0() - .child(sidebar.render(cx)), - ) - .child(content_column.h_full().flex_1().w_0()) - .child(toggle_btn); - - #[cfg(target_os = "macos")] - let body = v_flex().size_full().child(title_bar).child(main_area); - - #[cfg(not(target_os = "macos"))] - let body = main_area; - - div() - .id("application-root") - .size_full() - .overflow_hidden() - .child(body) - .children(dialog_layer) - .children(sheet_layer) - } -} diff --git a/src/ui/screens/config/mod.rs b/src/ui/screens/config/mod.rs index 88011ad..fbb1237 100644 --- a/src/ui/screens/config/mod.rs +++ b/src/ui/screens/config/mod.rs @@ -1,3 +1,3 @@ pub mod view; pub mod view_model; -pub use view_model::ConfigView; +pub use view_model::ConfigViewModel; diff --git a/src/ui/screens/config/view.rs b/src/ui/screens/config/view.rs index e278b88..a1798a2 100644 --- a/src/ui/screens/config/view.rs +++ b/src/ui/screens/config/view.rs @@ -4,7 +4,7 @@ use crate::hal::rescue::constants::{ }; use crate::hal::types::DeviceMethod; use crate::ui::components::{card::Card, page_view::PageView}; -use crate::ui::screens::config::view_model::ConfigView; +use crate::ui::screens::config::view_model::ConfigViewModel; use gpui::*; use gpui_component::button::{ButtonCustomVariant, ButtonVariants}; use gpui_component::{ @@ -12,7 +12,7 @@ use gpui_component::{ slider::Slider, switch::Switch, v_flex, }; -impl ConfigView { +impl ConfigViewModel { fn render_identity_card( &self, theme: &Theme, @@ -549,7 +549,7 @@ impl ConfigView { } } -impl Render for ConfigView { +impl Render for ConfigViewModel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); let has_device = self.device.read(cx).status.is_some(); @@ -581,7 +581,7 @@ impl Render for ConfigView { let is_fido = status.as_ref().map(|s| s.method.clone()) == Some(DeviceMethod::Fido); let supports_legacy_fido_config = status .as_ref() - .map(ConfigView::status_supports_legacy_fido_config) + .map(ConfigViewModel::status_supports_legacy_fido_config) .unwrap_or(false); let hardware_config_disabled = is_fido && !supports_legacy_fido_config; diff --git a/src/ui/screens/config/view_model.rs b/src/ui/screens/config/view_model.rs index 305a2b7..f4f468f 100644 --- a/src/ui/screens/config/view_model.rs +++ b/src/ui/screens/config/view_model.rs @@ -193,7 +193,7 @@ pub(super) enum StatusDialogHandle { Status(WeakEntity), } -pub struct ConfigView { +pub struct ConfigViewModel { pub(super) device: Entity, pub(super) vendor_select: Entity>>, pub(super) vid_input: Entity, @@ -221,7 +221,7 @@ pub struct ConfigView { pub(super) _task: Option>, } -impl ConfigView { +impl ConfigViewModel { pub fn new(window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { let device = models.device.clone(); cx.subscribe(&device, |_, _, _: &DeviceEvent, cx| cx.notify()) diff --git a/src/ui/screens/passkeys/mod.rs b/src/ui/screens/passkeys/mod.rs index ee71166..e4b8dee 100644 --- a/src/ui/screens/passkeys/mod.rs +++ b/src/ui/screens/passkeys/mod.rs @@ -1,3 +1,3 @@ pub mod view; pub mod view_model; -pub use view_model::{PasskeysEvent, PasskeysView}; +pub use view_model::{PasskeysEvent, PasskeysViewModel}; diff --git a/src/ui/screens/passkeys/view.rs b/src/ui/screens/passkeys/view.rs index 5c0580d..878e59e 100644 --- a/src/ui/screens/passkeys/view.rs +++ b/src/ui/screens/passkeys/view.rs @@ -5,7 +5,7 @@ use crate::ui::components::{ dialog, page_view::PageView, }; -use crate::ui::screens::passkeys::view_model::{PasskeysEvent, PasskeysView}; +use crate::ui::screens::passkeys::view_model::{PasskeysEvent, PasskeysViewModel}; use directories::UserDirs; use gpui::prelude::FluentBuilder; use gpui::*; @@ -15,7 +15,7 @@ use gpui_component::{ ActiveTheme, Icon, Sizable, StyledExt, Theme, badge::Badge, h_flex, switch::Switch, v_flex, }; -impl PasskeysView { +impl PasskeysViewModel { fn render_enterprise_attestation(&self, cx: &mut Context) -> impl IntoElement { let csr_ready = self.csr_pem.is_some(); let show_csr = self.show_csr && csr_ready; @@ -686,7 +686,7 @@ impl PasskeysView { } } -impl Render for PasskeysView { +impl Render for PasskeysViewModel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let device = self.device.read(cx); let device_connected = device.status.is_some(); diff --git a/src/ui/screens/passkeys/view_model.rs b/src/ui/screens/passkeys/view_model.rs index 214071f..db0b2ef 100644 --- a/src/ui/screens/passkeys/view_model.rs +++ b/src/ui/screens/passkeys/view_model.rs @@ -10,7 +10,7 @@ use gpui::*; use gpui_component::button::ButtonVariants; use gpui_component::{ActiveTheme, StyledExt, WindowExt}; -pub struct PasskeysView { +pub struct PasskeysViewModel { pub(super) device: Entity, pub(super) credentials: Vec, pub(super) unlocked: bool, @@ -26,9 +26,9 @@ pub enum PasskeysEvent { Notification(String), } -impl EventEmitter for PasskeysView {} +impl EventEmitter for PasskeysViewModel {} -impl PasskeysView { +impl PasskeysViewModel { pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { let device = models.device.clone(); cx.subscribe(&device, |_, _, _: &DeviceEvent, cx| cx.notify())