diff --git a/Cargo.toml b/Cargo.toml index aca9192..7a15c73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "pages-tui" version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" +description = "Type-safe TUI page routing with single-generic orchestration" [features] default = [] diff --git a/README.md b/README.md index 71f1875..34a031f 100644 --- a/README.md +++ b/README.md @@ -1,321 +1,142 @@ -# TUI Orchestrator +WARNING this library is purely GLM4.7/Opus4.5 generated. +Its based on a real production code that was not yet decoupled into a library. +This library is core concept extracted for no_std usage. +For more info visit: https://gitlab.com/filipriec/komp_ac_client +# pages-tui -A complete, **ready-to-use TUI framework** that handles input routing, focus management, page navigation, and lifecycle hooks—so you can define your pages, buttons, and logic, and it just works. +Type-safe TUI page routing with single-generic orchestration. ## Features -- **Zero boilerplate** - Define components, library handles everything else -- **Ready to use** - Register pages and run, no manual wiring needed -- **Sensible defaults** - Works without configuration -- **Fully extendable** - Customize via traits when needed -- **no_std compatible** - Works on embedded systems and WebAssembly -- **Backend-agnostic** - No crossterm/ratatui dependencies -- **Zero unsafe** - Pure Rust, no unsafe code +| Feature | Description | +|---------|-------------| +| (none) | Pure `no_std` + heapless. No allocator required. | +| `alloc` | Enables dynamic allocation (Vec, Box, HashMap). | +| `std` | Full std support (implies `alloc`). | -## Quick Start - -### Define Your Component - -```rust -extern crate alloc; - -use tui_orchestrator::prelude::*; - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -enum LoginFocus { - Username, - Password, - LoginButton, - CancelButton, -} - -#[derive(Debug, Clone)] -enum LoginEvent { - AttemptLogin { username: String, password: String }, - Cancel, -} - -struct LoginPage { - username: alloc::string::String, - password: alloc::string::String, -} - -impl Component for LoginPage { - type Focus = LoginFocus; - type Action = ComponentAction; - type Event = LoginEvent; - - fn targets(&self) -> &[Self::Focus] { - &[ - LoginFocus::Username, - LoginFocus::Password, - LoginFocus::LoginButton, - LoginFocus::CancelButton, - ] - } - - fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result> { - match (focus, action) { - (LoginFocus::LoginButton, ComponentAction::Select) => { - Ok(Some(LoginEvent::AttemptLogin { - username: self.username.clone(), - password: self.password.clone(), - })) - } - (LoginFocus::CancelButton, ComponentAction::Select) => { - Ok(Some(LoginEvent::Cancel)) - } - _ => Ok(None), - } - } - - fn handle_text(&mut self, focus: &Self::Focus, ch: char) -> Result> { - match focus { - LoginFocus::Username => { - self.username.push(ch); - Ok(None) - } - LoginFocus::Password => { - self.password.push(ch); - Ok(None) - } - _ => Ok(None), - } - } -} -``` - -### Register and Run - -```rust -use tui_orchestrator::prelude::*; - -fn main() -> Result<()> { - let mut orch = Orchestrator::builder() - .with_page("login", LoginPage::new()) - .with_default_bindings() - .build()?; - - orch.navigate_to("login")?; - - orch.run(&mut MyInputSource)?; -} -``` - -**That's it.** The library handles: -- Input processing (read keys, route to actions) -- Focus management (next/prev navigation) -- Page navigation (on_exit, swap, on_enter) -- Default keybindings (Tab=Next, Enter=Select) -- Event collection and routing - ---- - -## Core Concepts - -### Component - -The main abstraction in tui_orchestrator. A component represents a page or UI section with focusable elements. - -```rust -pub trait Component { - type Focus: FocusId; // What can receive focus - type Action: Action; // What actions this handles - type Event: Clone + Debug; // Events this component emits - - fn targets(&self) -> &[Self::Focus]; - fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result>; -} -``` - -**Optional methods** (all have defaults): -- `on_enter()` - Called when component becomes active -- `on_exit()` - Called when component becomes inactive -- `on_focus()` - Called when a focus target gains focus -- `on_blur()` - Called when a focus target loses focus -- `handle_text()` - Called when character is typed -- `can_navigate_forward/backward()` - Control focus movement - -### Component Actions - -Standard actions the library provides: - -```rust -pub enum ComponentAction { - Next, // Tab - Prev, // Shift+Tab - First, // Home - Last, // End - Select, // Enter - Cancel, // Esc - TypeChar(char), // Any character - Backspace, // Backspace - Delete, // Delete - Custom(usize), // User-defined -} -``` - -### Focus Management - -Focus tracks which element is currently active. The library provides: - -- `FocusManager` - Generic focus tracking -- `FocusQuery` - Read-only focus state for rendering -- Automatic navigation (next, prev, first, last) - -### Orchestrator - -The complete TUI runtime that wires everything together: - -- `Orchestrator` - Main framework struct -- `process_frame()` - Process one input frame -- `run()` - Complete main loop -- Extension points for custom behavior - ---- - -## Extension Points - -For complex applications (like komp_ac), the library provides extension points to customize behavior: - -### ModeResolver - -Customize how modes are resolved (dynamic vs static). - -```rust -impl ModeResolver for CustomResolver { - fn resolve(&self, focus: &dyn Any) -> Vec { ... } -} -``` - -### OverlayManager - -Customize overlay types (dialogs, command palettes, search). - -```rust -impl OverlayManager for CustomOverlayManager { - fn is_active(&self) -> bool { ... } - fn handle_input(&mut self, key: Key) -> Option { ... } -} -``` - -### EventHandler - -Customize how events are routed to handlers. - -```rust -impl EventHandler for CustomHandler { - fn handle(&mut self, event: AppEvent) -> Result { ... } -} -``` - ---- - -## Example: Multi-Page App - -```rust -#[derive(Debug, Clone)] -enum MyPage { - Login(LoginPage), - Home(HomePage), - Settings(SettingsPage), -} - -fn main() -> Result<()> { - let mut orch = Orchestrator::builder() - .with_page("login", LoginPage::new()) - .with_page("home", HomePage::new()) - .with_page("settings", SettingsPage::new()) - .with_default_bindings() - .build()?; - - orch.navigate_to("login")?; - - orch.run()?; -} -``` - -Navigation with history: -```rust -orch.navigate_to("home")?; -orch.navigate_to("settings")?; -orch.back()? // Return to home -``` - ---- - -## Feature Flags +### Default: Pure `no_std` Heapless ```toml [dependencies] -tui_orchestrator = { version = "0.1", features = ["std"] } - -# Optional features -sequences = ["alloc"] # Enable multi-key sequences +pages-tui = "0.2" ``` -- `default` - No features (pure no_std) -- `std` - Enable std library support -- `alloc` - Enable alloc support (needed for collections) +No allocator needed! Uses `heapless` collections with const generic capacities. ---- +### With Allocation -## Design Philosophy +```toml +[dependencies] +pages-tui = { version = "0.2", features = ["alloc"] } +``` -1. **Plugin-play model** - Library is runtime, components are plugins -2. **Sensible defaults** - Zero configuration works -3. **Optional everything** - Define only what you need -4. **Extension points** - Override defaults when needed -5. **User-focused** - "register page" not "bind chord to registry" -6. **no_std first** - Works on embedded, opt-in std +Uses `Vec`, `Box`, `HashMap` for dynamic sizing and trait objects. ---- +### With Full Std -## For komp_ac Integration +```toml +[dependencies] +pages-tui = { version = "0.2", features = ["std"] } +``` -komp_ac can: -1. Implement `Component` trait for all pages -2. Use library's `Orchestrator` as runtime -3. Extend with custom `ModeResolver` for dynamic Canvas-style modes -4. Extend with custom `OverlayManager` for command palette, find file, search -5. Extend with custom `EventHandler` for page/global/canvas routing +## Usage -**Result:** komp_ac uses library's core while keeping all custom behavior. +Define your page as an enum that implements the `Page` trait: -See [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) for details. +```rust +use pages_tui::prelude::*; ---- +#[derive(Debug, Clone)] +enum MyPage { + Home { counter: i32 }, + Settings { dark_mode: bool }, +} -## Migration Guide +impl Page for MyPage { + type Focus = MyFocus; + type Action = MyAction; + type Event = MyEvent; -If you're migrating from a TUI built with manual wiring: + fn targets(&self) -> &[Self::Focus] { + match self { + MyPage::Home { .. } => &[MyFocus::Button(0)], + MyPage::Settings { .. } => &[MyFocus::Toggle], + } + } -1. **Identify components** - What are your pages/sections? -2. **Implement Component trait** - `targets()`, `handle()`, optional hooks -3. **Remove manual orchestration** - Delete manual focus/binding/router setup -4. **Use Orchestrator** - Register pages and run -5. **Add extensions if needed** - ModeResolver, OverlayManager, EventHandler + fn handle(&mut self, focus: &Self::Focus, action: Self::Action) + -> Result, ComponentError> + { + // Handle actions... + Ok(None) + } +} -The library handles everything else. +// Create orchestrator with single generic +let mut app: Orchestrator = Orchestrator::new(); ---- +// Register pages +app.register(MyPage::Home { counter: 0 }); +app.register(MyPage::Settings { dark_mode: false }); -## Examples +// Navigate (associated data is ignored for lookup) +app.navigate_to(MyPage::Home { counter: 999 }).unwrap(); +``` -See `examples/` directory for complete working applications: -- `simple_app.rs` - Basic multi-page TUI -- `form_app.rs` - Form with text input -- `extended_app.rs` - Using extension points +## Capacity Configuration (no_std) ---- +In `no_std` mode without `alloc`, configure capacities via const generics: -## Documentation +```rust +// Orchestrator +let mut app: Orchestrator = Orchestrator::new(); +``` -- [PLAN.md](PLAN.md) - Complete implementation plan -- [REDESIGN.md](REDESIGN.md) - Framework architecture deep dive -- [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) - Integration examples and patterns +| Generic | Default | Description | +|---------|---------|-------------| +| `PAGES` | 8 | Maximum registered pages | +| `HISTORY` | 16 | Navigation history depth | +| `FOCUS` | 16 | Focus targets per page | +| `BINDINGS` | 32 | Key bindings | +| `MODES` | 8 | Mode stack depth | +| `EVENTS` | 8 | Pending event buffer | ---- +With `alloc`, these limits don't apply. + +## API Differences + +### `process_frame` Return Type + +```rust +// With alloc: returns Vec +let events: Vec = app.process_frame(key)?; + +// Without alloc: returns Option +let event: Option = app.process_frame(key)?; +``` + +### EventBus + +```rust +// With alloc: register handlers via Box +app.event_bus_mut().register(Box::new(my_handler)); + +// Without alloc: poll pending events +for event in app.event_bus_mut().drain() { + // handle event +} +``` + +### Custom Handlers (alloc only) + +```rust +#[cfg(feature = "alloc")] +{ + app.set_action_resolver(MyResolver); + app.set_command_handler(MyHandler); + app.set_state_coordinator(MyCoordinator); +} +``` ## License diff --git a/examples/simple.rs b/examples/simple.rs new file mode 100644 index 0000000..62c1d56 --- /dev/null +++ b/examples/simple.rs @@ -0,0 +1,132 @@ +// path_from_the_root: examples/simple.rs +// +// Simple example that works with any feature configuration. +// +// Run with alloc: cargo run --example simple --features alloc +// Run without alloc: cargo run --example simple + +use pages_tui::prelude::*; + +// ============================================================================= +// Types +// ============================================================================= + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Focus { + Item(usize), +} + +impl FocusId for Focus {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Act { + Select, + Up, + Down, +} + +impl Action for Act {} + +#[derive(Debug, Clone)] +pub enum Evt { + Selected(usize), +} + +// ============================================================================= +// Pages +// ============================================================================= + +#[derive(Debug, Clone)] +pub enum AppPage { + List { cursor: usize }, + Detail { item_id: usize }, +} + +// Static focus targets +const LIST_FOCUS: &[Focus] = &[Focus::Item(0), Focus::Item(1), Focus::Item(2)]; +const DETAIL_FOCUS: &[Focus] = &[Focus::Item(0)]; + +impl Page for AppPage { + type Focus = Focus; + type Action = Act; + type Event = Evt; + + fn targets(&self) -> &[Self::Focus] { + match self { + AppPage::List { .. } => LIST_FOCUS, + AppPage::Detail { .. } => DETAIL_FOCUS, + } + } + + fn handle( + &mut self, + focus: &Self::Focus, + action: Self::Action, + ) -> Result, ComponentError> { + match (self, action) { + (AppPage::List { cursor }, Act::Select) => { + if let Focus::Item(idx) = focus { + *cursor = *idx; + return Ok(Some(Evt::Selected(*idx))); + } + } + _ => {} + } + Ok(None) + } +} + +// ============================================================================= +// Main +// ============================================================================= + +fn main() { + // Works with both alloc and no_std! + // Default capacities are used, override with const generics if needed: + // Orchestrator::::new() + let mut app: Orchestrator = Orchestrator::new(); + + // Register pages + app.register(AppPage::List { cursor: 0 }); + app.register(AppPage::Detail { item_id: 0 }); + + // Bind keys + app.bind(Key::enter(), Act::Select); + app.bind(Key::up(), Act::Up); + app.bind(Key::down(), Act::Down); + + // Navigate to list + app.navigate_to(AppPage::List { cursor: 0 }).unwrap(); + + // Process a key + #[cfg(feature = "alloc")] + { + let events = app.process_frame(Key::enter()).unwrap(); + for e in events { + println!("Event: {:?}", e); + } + } + + #[cfg(not(feature = "alloc"))] + { + if let Ok(Some(e)) = app.process_frame(Key::enter()) { + // In no_std, you'd handle this differently + let _ = e; + } + } + + // Check current page + match app.current() { + Some(AppPage::List { cursor }) => { + println!("On list page, cursor at {}", cursor); + } + Some(AppPage::Detail { item_id }) => { + println!("On detail page for item {}", item_id); + } + None => { + println!("No page"); + } + } + + println!("✅ Simple example complete"); +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5a9df81 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1768564909, + "narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/src/focus/manager.rs b/src/focus/manager.rs index 2876bce..fa30d9d 100644 --- a/src/focus/manager.rs +++ b/src/focus/manager.rs @@ -1,39 +1,76 @@ -// path_from_the_root: src/focus/manager.rs +// src/focus/manager.rs use super::error::FocusError; use super::id::FocusId; use super::query::FocusQuery; +#[cfg(feature = "alloc")] +extern crate alloc; + +#[cfg(feature = "alloc")] +type Targets = alloc::vec::Vec; + +#[cfg(not(feature = "alloc"))] +type Targets = heapless::Vec; + #[derive(Debug, Clone)] -pub struct FocusManager { - targets: alloc::vec::Vec, +pub struct FocusManager { + targets: Targets, index: usize, overlay: Option, } -impl Default for FocusManager { +impl Default for FocusManager { fn default() -> Self { Self::new() } } -impl FocusManager { +impl FocusManager { pub fn new() -> Self { Self { - targets: alloc::vec::Vec::new(), + targets: Targets::new(), index: 0, overlay: None, } } + /// No-alloc friendly: replace targets from a slice. + pub fn set_targets_from_slice(&mut self, targets: &[F]) { + self.targets.clear(); + self.index = 0; + self.overlay = None; + + for t in targets { + #[cfg(feature = "alloc")] + { + self.targets.push(t.clone()); + } + #[cfg(not(feature = "alloc"))] + { + let _ = self.targets.push(t.clone()); + } + } + } + + /// Alloc-only convenience API (keeps your older call sites viable). + #[cfg(feature = "alloc")] pub fn set_targets(&mut self, targets: alloc::vec::Vec) { self.targets = targets; self.index = 0; + self.overlay = None; } pub fn add_target(&mut self, id: F) { if !self.targets.contains(&id) { - self.targets.push(id); + #[cfg(feature = "alloc")] + { + self.targets.push(id); + } + #[cfg(not(feature = "alloc"))] + { + let _ = self.targets.push(id); + } } } @@ -50,14 +87,11 @@ impl FocusManager { if let Some(overlay) = &self.overlay { return Some(overlay); } - self.targets.get(self.index) } pub fn query(&self) -> FocusQuery<'_, F> { - FocusQuery { - current: self.current(), - } + FocusQuery { current: self.current() } } pub fn is_focused(&self, id: &F) -> bool { @@ -72,7 +106,6 @@ impl FocusManager { if self.targets.is_empty() { return Err(FocusError::EmptyTargets); } - if let Some(pos) = self.targets.iter().position(|t| t == &id) { self.index = pos; self.overlay = None; @@ -94,7 +127,6 @@ impl FocusManager { if self.overlay.is_some() { return; } - if !self.targets.is_empty() && self.index < self.targets.len() - 1 { self.index += 1; } @@ -104,7 +136,6 @@ impl FocusManager { if self.overlay.is_some() { return; } - if !self.targets.is_empty() && self.index > 0 { self.index -= 1; } @@ -125,47 +156,5 @@ impl FocusManager { pub fn targets(&self) -> &[F] { &self.targets } - - pub fn len(&self) -> usize { - self.targets.len() - } - - pub fn is_empty(&self) -> bool { - self.targets.is_empty() - } - - pub fn current_index(&self) -> Option { - if self.targets.is_empty() { - None - } else { - Some(self.index) - } - } - - pub fn wrap_next(&mut self) { - if !self.targets.is_empty() { - self.index = (self.index + 1) % self.targets.len(); - } - } - - pub fn wrap_prev(&mut self) { - if !self.targets.is_empty() { - self.index = if self.index == 0 { - self.targets.len() - 1 - } else { - self.index - 1 - }; - } - } - - pub fn is_first(&self) -> bool { - self.current_index() == Some(0) - } - - pub fn is_last(&self) -> bool { - match self.current_index() { - Some(idx) => idx == self.targets.len().saturating_sub(1), - None => false, - } - } } + diff --git a/src/focus/traits.rs b/src/focus/traits.rs index aff6752..07011f7 100644 --- a/src/focus/traits.rs +++ b/src/focus/traits.rs @@ -1,12 +1,13 @@ -// path_from_the_root: src/focus/traits.rs +// src/focus/traits.rs use super::error::FocusError; use super::id::FocusId; pub trait Focusable { - fn focus_targets(&self) -> alloc::vec::Vec; + fn focus_targets(&self) -> &[F]; fn on_focus_change(&mut self, _id: &F) -> Result<(), FocusError> { Ok(()) } } + diff --git a/src/input/key.rs b/src/input/key.rs index f5d1e7e..0e7fc4a 100644 --- a/src/input/key.rs +++ b/src/input/key.rs @@ -1,3 +1,9 @@ +// src/input/key.rs +use core::fmt; + +#[cfg(feature = "alloc")] +extern crate alloc; + /// Represents a key code without modifiers. /// /// This includes character keys, special keys (Enter, Tab, Esc, etc.), @@ -207,41 +213,6 @@ impl Key { modifiers: KeyModifiers::new(), } } - - pub fn display_string(&self) -> alloc::string::String { - let mut out = alloc::string::String::new(); - if self.modifiers.control { - out.push_str("Ctrl+"); - } - if self.modifiers.alt { - out.push_str("Alt+"); - } - if self.modifiers.shift { - out.push_str("Shift+"); - } - match self.code { - KeyCode::Char(c) => out.push(c), - KeyCode::Enter => out.push_str("Enter"), - KeyCode::Tab => out.push_str("Tab"), - KeyCode::Esc => out.push_str("Esc"), - KeyCode::Backspace => out.push_str("Backspace"), - KeyCode::Delete => out.push_str("Delete"), - KeyCode::Up => out.push_str("Up"), - KeyCode::Down => out.push_str("Down"), - KeyCode::Left => out.push_str("Left"), - KeyCode::Right => out.push_str("Right"), - KeyCode::F(n) => { - out.push('F'); - out.push(char::from_digit(n as u32, 10).unwrap_or('0')); - } - KeyCode::Home => out.push_str("Home"), - KeyCode::End => out.push_str("End"), - KeyCode::PageUp => out.push_str("PageUp"), - KeyCode::PageDown => out.push_str("PageDown"), - KeyCode::Null => out.push_str("Null"), - } - out - } } impl From for Key { @@ -252,3 +223,51 @@ impl From for Key { } } } + +impl fmt::Display for Key { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.modifiers.control { + f.write_str("Ctrl+")?; + } + if self.modifiers.alt { + f.write_str("Alt+")?; + } + if self.modifiers.shift { + f.write_str("Shift+")?; + } + match self.code { + KeyCode::Char(c) => write!(f, "{c}"), + KeyCode::Enter => f.write_str("Enter"), + KeyCode::Tab => f.write_str("Tab"), + KeyCode::Esc => f.write_str("Esc"), + KeyCode::Backspace => f.write_str("Backspace"), + KeyCode::Delete => f.write_str("Delete"), + KeyCode::Up => f.write_str("Up"), + KeyCode::Down => f.write_str("Down"), + KeyCode::Left => f.write_str("Left"), + KeyCode::Right => f.write_str("Right"), + KeyCode::Home => f.write_str("Home"), + KeyCode::End => f.write_str("End"), + KeyCode::PageUp => f.write_str("PageUp"), + KeyCode::PageDown => f.write_str("PageDown"), + KeyCode::Null => f.write_str("Null"), + KeyCode::F(n) => write!(f, "F{n}"), + } + } +} + +impl Key { + #[cfg(feature = "alloc")] + pub fn display_string(&self) -> alloc::string::String { + alloc::format!("{self}") + } + + #[cfg(not(feature = "alloc"))] + pub fn display_string(&self) -> heapless::String<32> { + use core::fmt::Write; + let mut s = heapless::String::<32>::new(); + let _ = write!(&mut s, "{self}"); + s + } +} + diff --git a/src/lib.rs b/src/lib.rs index bbcefab..30d50c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,19 @@ +// src/lib.rs + #![no_std] +#[cfg(feature = "alloc")] extern crate alloc; pub mod component; pub mod focus; pub mod input; pub mod orchestrator; +pub mod page; +pub mod prelude; -pub mod prelude { - pub use crate::component::{Component, ComponentAction, ComponentError}; - pub use crate::focus::{FocusError, FocusId, FocusManager, FocusQuery, Focusable}; - pub use crate::input::{Action, Bindings, Key, KeyCode, KeyModifiers, MatchResult}; - pub use crate::orchestrator::{ - ActionResolver, CommandHandler, CommandResult, DefaultActionResolver, - DefaultCommandHandler, DefaultStateCoordinator, EventBus, EventHandler, ModeName, - ModeStack, Orchestrator, Router, RouterEvent, StateCoordinator, StateSync, - }; -} +pub use component::{Component, ComponentAction, ComponentError}; +pub use focus::{FocusError, FocusId, FocusManager, FocusQuery, Focusable}; +pub use input::{Action, Bindings, Key, KeyCode, KeyModifiers}; +pub use orchestrator::{Orchestrator, Router, RouterEvent}; +pub use page::Page; diff --git a/src/orchestrator/action_resolver.rs b/src/orchestrator/action_resolver.rs index dc031ef..f53b3ba 100644 --- a/src/orchestrator/action_resolver.rs +++ b/src/orchestrator/action_resolver.rs @@ -1,21 +1,22 @@ -// path_from_the_root: src/orchestrator/action_resolver.rs +// src/orchestrator/action_resolver.rs -use crate::component::Component; +use crate::page::Page; -pub struct ResolveContext<'a, C: Component> { - pub component: &'a C, - pub focus: &'a C::Focus, - pub action: C::Action, +pub struct ResolveContext<'a, P: Page> { + pub page: &'a P, + pub focus: &'a P::Focus, + pub action: P::Action, } -pub trait ActionResolver { - fn resolve(&mut self, ctx: ResolveContext) -> C::Action; +pub trait ActionResolver { + fn resolve(&mut self, ctx: ResolveContext

) -> P::Action; } pub struct DefaultActionResolver; -impl ActionResolver for DefaultActionResolver { - fn resolve(&mut self, ctx: ResolveContext) -> C::Action { +impl ActionResolver

for DefaultActionResolver { + fn resolve(&mut self, ctx: ResolveContext

) -> P::Action { ctx.action } } + diff --git a/src/orchestrator/core.rs b/src/orchestrator/core.rs index 69db88f..735873c 100644 --- a/src/orchestrator/core.rs +++ b/src/orchestrator/core.rs @@ -1,65 +1,133 @@ -// path_from_the_root: src/orchestrator/core.rs - -extern crate alloc; +// src/orchestrator/core.rs +#[cfg(feature = "alloc")] use alloc::boxed::Box; -use alloc::string::String; +#[cfg(feature = "alloc")] use alloc::vec::Vec; -use crate::component::{Component, ComponentError}; +use crate::component::ComponentError; +use crate::focus::FocusManager; use crate::input::{Bindings, Key}; use crate::orchestrator::{ ActionResolver, CommandHandler, CommandResult, DefaultActionResolver, DefaultCommandHandler, DefaultStateCoordinator, EventBus, ModeName, ModeStack, ResolveContext, Router, RouterEvent, StateCoordinator, }; +use crate::page::Page; -pub struct Orchestrator { - router: Router, - bindings: Bindings, - action_resolver: Box>, - command_handler: Box>, - state_coordinator: Box>, - modes: ModeStack, - event_bus: EventBus, +/// Main orchestrator with configurable capacity. +/// +/// # Const Generics (for `no_std` without `alloc`) +/// - `PAGES`: Maximum registered pages (default: 8) +/// - `HISTORY`: Maximum navigation history (default: 16) +/// - `FOCUS`: Maximum focus targets per page (default: 16) +/// - `BINDINGS`: Maximum key bindings (default: 32) +/// - `MODES`: Maximum mode stack depth (default: 8) +/// - `EVENTS`: Maximum pending events (default: 8) +/// +/// With `alloc` feature, these limits don't apply and trait objects are used +/// for extensibility (ActionResolver, CommandHandler, StateCoordinator). +/// +/// Without `alloc`, concrete default implementations are used. +pub struct Orchestrator< + P: Page, + const PAGES: usize = 8, + const HISTORY: usize = 16, + const FOCUS: usize = 16, + const BINDINGS: usize = 32, + const MODES: usize = 8, + const EVENTS: usize = 8, +> { + router: Router, + bindings: Bindings, + + #[cfg(feature = "alloc")] + action_resolver: Box>, + #[cfg(not(feature = "alloc"))] + action_resolver: DefaultActionResolver, + + #[cfg(feature = "alloc")] + command_handler: Box>, + #[cfg(not(feature = "alloc"))] + command_handler: DefaultCommandHandler, + + #[cfg(feature = "alloc")] + state_coordinator: Box>, + #[cfg(not(feature = "alloc"))] + state_coordinator: DefaultStateCoordinator

, + + modes: ModeStack, + + #[cfg(feature = "alloc")] + event_bus: EventBus, + #[cfg(not(feature = "alloc"))] + event_bus: EventBus, + running: bool, } -impl Default for Orchestrator +impl< + P: Page + 'static, + const PAGES: usize, + const HISTORY: usize, + const FOCUS: usize, + const BINDINGS: usize, + const MODES: usize, + const EVENTS: usize, +> Default for Orchestrator where - C::Action: Clone + 'static, + P::Action: Clone + 'static, { fn default() -> Self { Self::new() } } -impl Orchestrator +impl< + P: Page + 'static, + const PAGES: usize, + const HISTORY: usize, + const FOCUS: usize, + const BINDINGS: usize, + const MODES: usize, + const EVENTS: usize, +> Orchestrator where - C::Action: Clone + 'static, - C::Event: Clone, + P::Action: Clone + 'static, + P::Event: Clone, { pub fn new() -> Self { Self { router: Router::new(), bindings: Bindings::new(), + #[cfg(feature = "alloc")] action_resolver: Box::new(DefaultActionResolver), + #[cfg(not(feature = "alloc"))] + action_resolver: DefaultActionResolver, + #[cfg(feature = "alloc")] command_handler: Box::new(DefaultCommandHandler::default()), + #[cfg(not(feature = "alloc"))] + command_handler: DefaultCommandHandler::default(), + #[cfg(feature = "alloc")] state_coordinator: Box::new(DefaultStateCoordinator), + #[cfg(not(feature = "alloc"))] + state_coordinator: DefaultStateCoordinator::default(), modes: ModeStack::new(), event_bus: EventBus::new(), running: true, } } - pub fn register_page(&mut self, id: String, page: C) { - self.router.register(id, page); + /// Register a page. Returns false if capacity exceeded (no_std only). + pub fn register(&mut self, page: P) -> bool { + self.router.register(page) } - pub fn navigate_to(&mut self, id: String) -> Result<(), ComponentError> { + /// Navigate to a page variant. The associated data is ignored for lookup. + pub fn navigate_to(&mut self, target: P) -> Result<(), ComponentError> { if let Some(RouterEvent::Navigated { from, to }) = self .router - .navigate(id.clone()) + .navigate(target) .map_err(|_| ComponentError::InvalidFocus)? { let _ = self.state_coordinator.on_navigate(from, to); @@ -67,6 +135,19 @@ where Ok(()) } + /// Navigate to a page, registering it first if not already registered. + pub fn navigate_or_register(&mut self, page: P) -> Result<(), ComponentError> { + if let Some(RouterEvent::Navigated { from, to }) = self + .router + .navigate_or_register(page) + .map_err(|_| ComponentError::InvalidFocus)? + { + let _ = self.state_coordinator.on_navigate(from, to); + } + Ok(()) + } + + /// Go back in navigation history. pub fn back(&mut self) -> Result<(), ComponentError> { if let Some(RouterEvent::Back { to }) = self .router @@ -78,6 +159,7 @@ where Ok(()) } + /// Go forward in navigation history. pub fn forward(&mut self) -> Result<(), ComponentError> { if let Some(RouterEvent::Forward { to }) = self .router @@ -89,11 +171,14 @@ where Ok(()) } - pub fn bind(&mut self, key: Key, action: C::Action) { + /// Bind a key to an action. + pub fn bind(&mut self, key: Key, action: P::Action) { self.bindings.bind(key, action); } - pub fn process_frame(&mut self, key: Key) -> Result, ComponentError> { + /// Process a frame with a key input. + #[cfg(feature = "alloc")] + pub fn process_frame(&mut self, key: Key) -> Result, ComponentError> { if !self.running { return Ok(Vec::new()); } @@ -115,12 +200,12 @@ where } else if let Some(action) = self.bindings.get(&key) { let action = action.clone(); - if let Some(_) = self.router.current_id() { - let component = self.router.current().ok_or(ComponentError::NoComponent)?; + if self.router.current().is_some() { + let page = self.router.current().ok_or(ComponentError::NoComponent)?; let focus = self.router.focus_manager().current().ok_or(ComponentError::NoComponent)?; let ctx = ResolveContext { - component, + page, focus, action, }; @@ -139,15 +224,61 @@ where Ok(events) } - fn handle_action(&mut self, action: C::Action) -> Result, ComponentError> { + /// Process a frame with a key input (no_std version). + #[cfg(not(feature = "alloc"))] + pub fn process_frame(&mut self, key: Key) -> Result, ComponentError> { + if !self.running { + return Ok(None); + } + + if self.command_handler.is_active() { + match self.command_handler.handle(key) { + CommandResult::Resolved(action) => { + let event = self.handle_action(action)?; + if let Some(ref e) = event { + self.event_bus.emit(e.clone()); + } + return Ok(event); + } + CommandResult::Incomplete | CommandResult::Unknown => {} + CommandResult::Exit => { + self.command_handler.exit(); + } + } + } else if let Some(action) = self.bindings.get(&key) { + let action = action.clone(); + + if self.router.current().is_some() { + let page = self.router.current().ok_or(ComponentError::NoComponent)?; + let focus = self.router.focus_manager().current().ok_or(ComponentError::NoComponent)?; + + let ctx = ResolveContext { + page, + focus, + action, + }; + let resolved_action = self.action_resolver.resolve(ctx); + + let event = self.handle_action(resolved_action)?; + if let Some(ref e) = event { + self.event_bus.emit(e.clone()); + } + return Ok(event); + } + } + + Ok(None) + } + + fn handle_action(&mut self, action: P::Action) -> Result, ComponentError> { let focus = self.router.focus_manager().current().cloned(); if let Some(focus) = focus { let old_focus = self.router.focus_manager().current().cloned(); let result = { - let component = self.router.current_mut().ok_or(ComponentError::NoComponent)?; - component.handle(&focus, action)? + let page = self.router.current_mut().ok_or(ComponentError::NoComponent)?; + page.handle(&focus, action)? }; let new_focus = self.router.focus_manager().current().cloned(); @@ -162,58 +293,83 @@ where } } - pub fn current(&self) -> Option<&C> { + /// Get current page reference. + pub fn current(&self) -> Option<&P> { self.router.current() } - pub fn current_mut(&mut self) -> Option<&mut C> { + /// Get current page mutable reference. + pub fn current_mut(&mut self) -> Option<&mut P> { self.router.current_mut() } - pub fn focus_manager(&self) -> &crate::focus::FocusManager { + /// Check if currently on a specific page variant. + pub fn is_on(&self, check: F) -> bool + where + F: Fn(&P) -> bool, + { + self.router.is_on(check) + } + + pub fn focus_manager(&self) -> &FocusManager { self.router.focus_manager() } - pub fn focus_manager_mut(&mut self) -> &mut crate::focus::FocusManager { + pub fn focus_manager_mut(&mut self) -> &mut FocusManager { self.router.focus_manager_mut() } - pub fn modes(&self) -> &ModeStack { + pub fn modes(&self) -> &ModeStack { &self.modes } - pub fn modes_mut(&mut self) -> &mut ModeStack { + pub fn modes_mut(&mut self) -> &mut ModeStack { &mut self.modes } - pub fn push_mode(&mut self, mode: ModeName) { - self.modes.push(mode); + pub fn push_mode(&mut self, mode: ModeName) -> bool { + self.modes.push(mode) } pub fn pop_mode(&mut self) -> Option { self.modes.pop() } - pub fn event_bus(&self) -> &EventBus { + #[cfg(feature = "alloc")] + pub fn event_bus(&self) -> &EventBus { &self.event_bus } - pub fn event_bus_mut(&mut self) -> &mut EventBus { + #[cfg(feature = "alloc")] + pub fn event_bus_mut(&mut self) -> &mut EventBus { &mut self.event_bus } - pub fn set_action_resolver + 'static>(&mut self, resolver: R) { + #[cfg(not(feature = "alloc"))] + pub fn event_bus(&self) -> &EventBus { + &self.event_bus + } + + #[cfg(not(feature = "alloc"))] + pub fn event_bus_mut(&mut self) -> &mut EventBus { + &mut self.event_bus + } + + /// Set custom action resolver (alloc only). + #[cfg(feature = "alloc")] + pub fn set_action_resolver + 'static>(&mut self, resolver: R) { self.action_resolver = Box::new(resolver); } - pub fn set_command_handler + 'static>( - &mut self, - handler: H, - ) { + /// Set custom command handler (alloc only). + #[cfg(feature = "alloc")] + pub fn set_command_handler + 'static>(&mut self, handler: H) { self.command_handler = Box::new(handler); } - pub fn set_state_coordinator + 'static>(&mut self, coordinator: S) { + /// Set custom state coordinator (alloc only). + #[cfg(feature = "alloc")] + pub fn set_state_coordinator + 'static>(&mut self, coordinator: S) { self.state_coordinator = Box::new(coordinator); } @@ -229,7 +385,27 @@ where self.running = true; } - pub fn current_id(&self) -> Option<&String> { - self.router.current_id() + pub fn router(&self) -> &Router { + &self.router + } + + pub fn router_mut(&mut self) -> &mut Router { + &mut self.router + } + + pub fn can_go_back(&self) -> bool { + self.router.can_go_back() + } + + pub fn can_go_forward(&self) -> bool { + self.router.can_go_forward() + } + + pub fn bindings(&self) -> &Bindings { + &self.bindings + } + + pub fn bindings_mut(&mut self) -> &mut Bindings { + &mut self.bindings } } diff --git a/src/orchestrator/event_bus.rs b/src/orchestrator/event_bus.rs index a7c5902..5af97e0 100644 --- a/src/orchestrator/event_bus.rs +++ b/src/orchestrator/event_bus.rs @@ -1,46 +1,80 @@ -// path_from_the_root: src/orchestrator/event_bus.rs +// src/orchestrator/event_bus.rs +#[cfg(feature = "alloc")] extern crate alloc; -use alloc::boxed::Box; -use alloc::vec::Vec; - pub trait EventHandler { fn handle(&mut self, event: E); } -pub struct EventBus { - handlers: Vec>>, +#[cfg(feature = "alloc")] +pub struct EventBus { + handlers: alloc::vec::Vec>>, } -impl Default for EventBus { +#[cfg(not(feature = "alloc"))] +pub struct EventBus { + queue: heapless::Deque, +} + +impl Default for EventBus { fn default() -> Self { Self::new() } } -impl EventBus { +impl EventBus { pub fn new() -> Self { - Self { - handlers: Vec::new(), + #[cfg(feature = "alloc")] + { + Self { handlers: alloc::vec::Vec::new() } + } + #[cfg(not(feature = "alloc"))] + { + Self { queue: heapless::Deque::new() } } } - pub fn register(&mut self, handler: Box>) { + #[cfg(feature = "alloc")] + pub fn register(&mut self, handler: alloc::boxed::Box>) { self.handlers.push(handler); } pub fn emit(&mut self, event: E) { - for handler in &mut self.handlers { - handler.handle(event.clone()); + #[cfg(feature = "alloc")] + { + for handler in &mut self.handlers { + handler.handle(event.clone()); + } + } + #[cfg(not(feature = "alloc"))] + { + if self.queue.is_full() { + let _ = self.queue.pop_front(); + } + let _ = self.queue.push_back(event); } } + #[cfg(not(feature = "alloc"))] + pub fn pop(&mut self) -> Option { + self.queue.pop_front() + } + + #[cfg(feature = "alloc")] pub fn handler_count(&self) -> usize { self.handlers.len() } pub fn is_empty(&self) -> bool { - self.handlers.is_empty() + #[cfg(feature = "alloc")] + { + self.handlers.is_empty() + } + #[cfg(not(feature = "alloc"))] + { + self.queue.is_empty() + } } } + diff --git a/src/orchestrator/mode.rs b/src/orchestrator/mode.rs index 40f51f8..72b471c 100644 --- a/src/orchestrator/mode.rs +++ b/src/orchestrator/mode.rs @@ -1,8 +1,7 @@ -// path_from_the_root: src/orchestrator/mode.rs +// src/orchestrator/mode.rs -use alloc::collections::BTreeMap; -use alloc::string::String; -use alloc::vec::Vec; +#[cfg(feature = "alloc")] +extern crate alloc; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ModeName { @@ -10,21 +9,43 @@ pub enum ModeName { Navigation, Editing, Command, - Custom(String), + Custom(heapless::String<32>), } -#[derive(Debug, Clone, Default)] -pub struct ModeStack { - modes: Vec, +#[derive(Debug, Clone)] +pub struct ModeStack { + #[cfg(feature = "alloc")] + modes: alloc::vec::Vec, + #[cfg(not(feature = "alloc"))] + modes: heapless::Vec, } -impl ModeStack { +impl Default for ModeStack { + fn default() -> Self { + Self::new() + } +} + +impl ModeStack { pub fn new() -> Self { - Self { modes: Vec::new() } + Self { + #[cfg(feature = "alloc")] + modes: alloc::vec::Vec::new(), + #[cfg(not(feature = "alloc"))] + modes: heapless::Vec::new(), + } } - pub fn push(&mut self, mode: ModeName) { - self.modes.push(mode); + pub fn push(&mut self, mode: ModeName) -> bool { + #[cfg(feature = "alloc")] + { + self.modes.push(mode); + true + } + #[cfg(not(feature = "alloc"))] + { + self.modes.push(mode).is_ok() + } } pub fn pop(&mut self) -> Option { @@ -51,29 +72,29 @@ impl ModeStack { self.modes.clear(); } - pub fn reset(&mut self, mode: ModeName) { + pub fn reset(&mut self, mode: ModeName) -> bool { self.modes.clear(); - self.modes.push(mode); + self.push(mode) } } +#[cfg(feature = "alloc")] #[derive(Debug, Clone, Default)] pub struct ModeResolver { - mappings: BTreeMap>, + mappings: alloc::collections::BTreeMap>, } +#[cfg(feature = "alloc")] impl ModeResolver { pub fn new() -> Self { - Self { - mappings: BTreeMap::new(), - } + Self { mappings: alloc::collections::BTreeMap::new() } } - pub fn register(&mut self, key: String, modes: Vec) { + pub fn register(&mut self, key: alloc::string::String, modes: alloc::vec::Vec) { self.mappings.insert(key, modes); } - pub fn resolve(&self, key: &str) -> Option<&Vec> { + pub fn resolve(&self, key: &str) -> Option<&alloc::vec::Vec> { self.mappings.get(key) } @@ -81,3 +102,4 @@ impl ModeResolver { self.mappings.is_empty() } } + diff --git a/src/orchestrator/router.rs b/src/orchestrator/router.rs index 92bedd5..326308a 100644 --- a/src/orchestrator/router.rs +++ b/src/orchestrator/router.rs @@ -1,256 +1,362 @@ // path_from_the_root: src/orchestrator/router.rs -extern crate alloc; - -use alloc::string::String; +#[cfg(feature = "alloc")] use alloc::vec::Vec; -#[cfg(feature = "alloc")] -use hashbrown::HashMap; - -use crate::component::Component; use crate::focus::{FocusError, FocusManager}; +use crate::page::{same_page, Page}; +/// Events emitted by the router during navigation. #[derive(Debug, Clone)] -pub enum RouterEvent { - Navigated { from: Option, to: String }, - Back { to: String }, - Forward { to: String }, +pub enum RouterEvent { + /// Navigated from one page to another. + Navigated { from: Option

, to: P }, + /// Went back in history. + Back { to: P }, + /// Went forward in history. + Forward { to: P }, } -pub struct Router { +/// Page router with configurable capacity. +/// +/// # Const Generics (for `no_std` without `alloc`) +/// - `PAGES`: Maximum number of registered pages (default: 8) +/// - `HISTORY`: Maximum history depth (default: 16) +/// - `FOCUS`: Maximum focus targets per page (default: 16) +/// +/// When `alloc` feature is enabled, these limits don't apply. +/// +/// Pages are identified by their enum discriminant (variant), not by associated data. +/// This means `Page::Home { count: 0 }` and `Page::Home { count: 5 }` are the same page. +pub struct Router { #[cfg(feature = "alloc")] - pages: HashMap, + pages: Vec

, #[cfg(not(feature = "alloc"))] - pages: Vec<(String, C)>, - current: Option, - history: Vec, - future: Vec, - focus: FocusManager, + pages: heapless::Vec, + + current_index: Option, + + #[cfg(feature = "alloc")] + history: Vec, + #[cfg(not(feature = "alloc"))] + history: heapless::Vec, + + #[cfg(feature = "alloc")] + future: Vec, + #[cfg(not(feature = "alloc"))] + future: heapless::Vec, + + focus: FocusManager, } -impl Default for Router { +impl Default + for Router +{ fn default() -> Self { Self::new() } } -impl Router { +impl + Router +{ pub fn new() -> Self { Self { #[cfg(feature = "alloc")] - pages: HashMap::new(), - #[cfg(not(feature = "alloc"))] pages: Vec::new(), - current: None, + #[cfg(not(feature = "alloc"))] + pages: heapless::Vec::new(), + + current_index: None, + + #[cfg(feature = "alloc")] history: Vec::new(), + #[cfg(not(feature = "alloc"))] + history: heapless::Vec::new(), + + #[cfg(feature = "alloc")] future: Vec::new(), + #[cfg(not(feature = "alloc"))] + future: heapless::Vec::new(), + focus: FocusManager::new(), } } - pub fn register(&mut self, id: String, mut page: C) { - #[cfg(feature = "alloc")] - { - if self.current.as_ref() == Some(&id) { - let _ = page.on_enter(); - let targets = page.targets(); - self.focus.set_targets(targets.to_vec()); - } - self.pages.insert(id, page); - } + /// Find page index by discriminant match. + fn find_index(&self, target: &P) -> Option { + self.pages.iter().position(|p| same_page(p, target)) + } - #[cfg(not(feature = "alloc"))] - { - if self.current.as_ref() == Some(&id) { - let _ = page.on_enter(); - let targets = page.targets(); - self.focus.set_targets(targets.to_vec()); + /// Register a page. If a page with the same variant exists, it's replaced. + /// + /// Returns `true` if successful, `false` if capacity exceeded (no_std only). + pub fn register(&mut self, page: P) -> bool { + if let Some(idx) = self.find_index(&page) { + // Replace existing page + self.pages[idx] = page; + + // If this is the current page, update focus targets + if self.current_index == Some(idx) { + self.focus.set_targets_from_slice(self.pages[idx].targets()); } - self.pages.push((id, page)); + true + } else { + // Check if this should be the current page (first registration) + let is_first = self.current_index.is_none() && self.pages.is_empty(); + + #[cfg(feature = "alloc")] + { + self.pages.push(page); + } + #[cfg(not(feature = "alloc"))] + { + if self.pages.push(page).is_err() { + return false; + } + } + + if is_first { + let idx = self.pages.len() - 1; + let _ = self.pages[idx].on_enter(); + self.focus.set_targets_from_slice(self.pages[idx].targets()); + self.current_index = Some(idx); + } + + true } } - fn get_page_mut(&mut self, id: &str) -> Option<&mut C> { - #[cfg(feature = "alloc")] - { - self.pages.get_mut(id) + /// Navigate to a page. The page must be registered first. + /// + /// Pass any instance of the variant you want to navigate to. + /// The associated data is ignored - only the variant matters. + pub fn navigate(&mut self, target: P) -> Result>, FocusError> { + let target_idx = match self.find_index(&target) { + Some(idx) => idx, + None => return Ok(None), // Page not registered + }; + + // If already on this page, no-op + if self.current_index == Some(target_idx) { + return Ok(None); } - #[cfg(not(feature = "alloc"))] - { - self.pages - .iter_mut() - .find(|(page_id, _)| page_id == id) - .map(|(_, page)| page) - } - } - - fn get_page(&self, id: &str) -> Option<&C> { - #[cfg(feature = "alloc")] - { - self.pages.get(id) - } - - #[cfg(not(feature = "alloc"))] - { - self.pages - .iter() - .find(|(page_id, _)| page_id == id) - .map(|(_, page)| page) - } - } - - pub fn navigate(&mut self, id: String) -> Result, FocusError> { - let result = if let Some(current_id) = self.current.take() { - if let Some(current_page) = self.get_page_mut(¤t_id) { - let _ = current_page.on_exit(); + let event = if let Some(current_idx) = self.current_index.take() { + // Exit current page + let _ = self.pages[current_idx].on_exit(); + + #[cfg(feature = "alloc")] + { + self.history.push(current_idx); } - self.history.push(current_id.clone()); + #[cfg(not(feature = "alloc"))] + { + // In heapless mode, drop oldest if full + if self.history.is_full() { + self.history.remove(0); + } + let _ = self.history.push(current_idx); + } + self.future.clear(); + Some(RouterEvent::Navigated { - from: Some(current_id), - to: id.clone(), + from: Some(self.pages[current_idx].clone()), + to: self.pages[target_idx].clone(), }) } else { Some(RouterEvent::Navigated { from: None, - to: id.clone(), + to: self.pages[target_idx].clone(), }) }; - let targets = if let Some(page) = self.get_page_mut(&id) { - let _ = page.on_enter(); - Some(page.targets().to_vec()) - } else { - None + // Enter new page + self.current_index = Some(target_idx); + let _ = self.pages[target_idx].on_enter(); + self.focus.set_targets_from_slice(self.pages[target_idx].targets()); + + Ok(event) + } + + /// Navigate to a page, registering it first if needed. + /// + /// Returns `Err` if registration failed (capacity exceeded in no_std). + pub fn navigate_or_register(&mut self, page: P) -> Result>, FocusError> { + if self.find_index(&page).is_none() { + if !self.register(page.clone()) { + return Err(FocusError::EmptyTargets); // Capacity error + } + } + self.navigate(page) + } + + /// Go back in history. + pub fn back(&mut self) -> Result>, FocusError> { + let current_idx = match self.current_index { + Some(idx) => idx, + None => return Ok(None), }; - if let Some(targets) = targets { - self.focus.set_targets(targets); + let prev_idx = match self.history.pop() { + Some(idx) => idx, + None => return Ok(None), + }; + + // Exit current, push to future + let _ = self.pages[current_idx].on_exit(); + + #[cfg(feature = "alloc")] + { + self.future.push(current_idx); } - - self.current = Some(id); - Ok(result) - } - - pub fn back(&mut self) -> Result, FocusError> { - if let Some(current) = self.current.take() { - if let Some(from) = self.history.pop() { - self.future.push(current.clone()); - let to = from.clone(); - - if let Some(current_page) = self.get_page_mut(¤t) { - let _ = current_page.on_exit(); - } - - self.current = Some(to.clone()); - - let targets = if let Some(page) = self.get_page_mut(&to) { - let _ = page.on_enter(); - Some(page.targets().to_vec()) - } else { - None - }; - - if let Some(targets) = targets { - self.focus.set_targets(targets); - } - - Ok(Some(RouterEvent::Back { to })) - } else { - self.current = Some(current); - Ok(None) + #[cfg(not(feature = "alloc"))] + { + if self.future.is_full() { + self.future.remove(0); } - } else { - Ok(None) + let _ = self.future.push(current_idx); } + + // Enter previous + self.current_index = Some(prev_idx); + let _ = self.pages[prev_idx].on_enter(); + self.focus.set_targets_from_slice(self.pages[prev_idx].targets()); + + Ok(Some(RouterEvent::Back { + to: self.pages[prev_idx].clone(), + })) } - pub fn forward(&mut self) -> Result, FocusError> { - if let Some(current) = self.current.take() { - if let Some(from) = self.future.pop() { - self.history.push(current.clone()); - let to = from.clone(); + /// Go forward in history. + pub fn forward(&mut self) -> Result>, FocusError> { + let current_idx = match self.current_index { + Some(idx) => idx, + None => return Ok(None), + }; - if let Some(current_page) = self.get_page_mut(¤t) { - let _ = current_page.on_exit(); - } + let next_idx = match self.future.pop() { + Some(idx) => idx, + None => return Ok(None), + }; - self.current = Some(to.clone()); - - let targets = if let Some(page) = self.get_page_mut(&to) { - let _ = page.on_enter(); - Some(page.targets().to_vec()) - } else { - None - }; - - if let Some(targets) = targets { - self.focus.set_targets(targets); - } - - Ok(Some(RouterEvent::Forward { to })) - } else { - self.current = Some(current); - Ok(None) + // Exit current, push to history + let _ = self.pages[current_idx].on_exit(); + + #[cfg(feature = "alloc")] + { + self.history.push(current_idx); + } + #[cfg(not(feature = "alloc"))] + { + if self.history.is_full() { + self.history.remove(0); } - } else { - Ok(None) + let _ = self.history.push(current_idx); } + + // Enter next + self.current_index = Some(next_idx); + let _ = self.pages[next_idx].on_enter(); + self.focus.set_targets_from_slice(self.pages[next_idx].targets()); + + Ok(Some(RouterEvent::Forward { + to: self.pages[next_idx].clone(), + })) } - pub fn current(&self) -> Option<&C> { - self.current.as_ref().and_then(|id| self.get_page(id)) + /// Get current page reference. + pub fn current(&self) -> Option<&P> { + self.current_index.map(|idx| &self.pages[idx]) } - pub fn current_mut(&mut self) -> Option<&mut C> { - if let Some(id) = self.current.clone() { - self.get_page_mut(&id) - } else { - None - } + /// Get current page mutable reference. + pub fn current_mut(&mut self) -> Option<&mut P> { + self.current_index.map(|idx| &mut self.pages[idx]) } - pub fn current_id(&self) -> Option<&String> { - self.current.as_ref() + /// Get a clone of the current page (useful for matching variants). + pub fn current_page(&self) -> Option

{ + self.current().cloned() } - pub fn focus_manager(&self) -> &FocusManager { + pub fn focus_manager(&self) -> &FocusManager { &self.focus } - pub fn focus_manager_mut(&mut self) -> &mut FocusManager { + pub fn focus_manager_mut(&mut self) -> &mut FocusManager { &mut self.focus } - pub fn history(&self) -> &[String] { - &self.history + /// Check if on a specific page variant. + pub fn is_on(&self, check: F) -> bool + where + F: Fn(&P) -> bool, + { + self.current().map(|p| check(p)).unwrap_or(false) } - pub fn future(&self) -> &[String] { - &self.future + /// Get page by variant (ignoring associated data). + pub fn get_page(&self, target: &P) -> Option<&P> { + self.find_index(target).map(|idx| &self.pages[idx]) + } + + /// Get mutable page by variant. + pub fn get_page_mut(&mut self, target: &P) -> Option<&mut P> { + self.find_index(target).map(|idx| &mut self.pages[idx]) + } + + pub fn history_len(&self) -> usize { + self.history.len() + } + + pub fn can_go_back(&self) -> bool { + !self.history.is_empty() + } + + pub fn can_go_forward(&self) -> bool { + !self.future.is_empty() } pub fn page_count(&self) -> usize { + self.pages.len() + } + + pub fn has_page(&self, target: &P) -> bool { + self.find_index(target).is_some() + } + + /// Iterate over all registered pages. + pub fn pages(&self) -> impl Iterator { + self.pages.iter() + } + + /// Iterate mutably over all registered pages. + pub fn pages_mut(&mut self) -> impl Iterator { + self.pages.iter_mut() + } + + /// Capacity info (only meaningful in no_std mode). + pub fn pages_capacity(&self) -> usize { #[cfg(feature = "alloc")] { - self.pages.len() + self.pages.capacity() } #[cfg(not(feature = "alloc"))] { - self.pages.len() + PAGES } } - pub fn has_page(&self, id: &str) -> bool { + pub fn history_capacity(&self) -> usize { #[cfg(feature = "alloc")] { - self.pages.contains_key(id) + self.history.capacity() } #[cfg(not(feature = "alloc"))] { - self.pages.iter().any(|(page_id, _)| page_id == id) + HISTORY } } } diff --git a/src/orchestrator/state_coordinator.rs b/src/orchestrator/state_coordinator.rs index c21a549..985872d 100644 --- a/src/orchestrator/state_coordinator.rs +++ b/src/orchestrator/state_coordinator.rs @@ -1,48 +1,63 @@ -// path_from_the_root: src/orchestrator/state_coordinator.rs +// src/orchestrator/state_coordinator.rs -use alloc::string::String; -use alloc::vec::Vec; +use crate::orchestrator::ModeName; +use crate::page::Page; -use crate::component::Component; +#[cfg(feature = "alloc")] +extern crate alloc; + +#[cfg(feature = "alloc")] +pub type StateError = alloc::string::String; + +#[cfg(not(feature = "alloc"))] +pub type StateError = &'static str; pub enum StateSync { Synced, - Conflict { details: String }, + Conflict { details: StateError }, } -pub trait StateCoordinator { - fn on_navigate(&mut self, from: Option, to: String) -> Result; +pub trait StateCoordinator { + fn on_navigate(&mut self, from: Option

, to: P) -> Result; fn on_focus_change( &mut self, - old: Option, - new: Option, - ) -> Result; + old: Option, + new: Option, + ) -> Result; - fn on_mode_change(&mut self, _old: Vec, _new: Vec) - -> Result; + fn on_mode_change(&mut self, _old: &[ModeName], _new: &[ModeName]) + -> Result; } -pub struct DefaultStateCoordinator; +pub struct DefaultStateCoordinator { + _p: core::marker::PhantomData

, +} -impl StateCoordinator for DefaultStateCoordinator { - fn on_navigate(&mut self, _from: Option, _to: String) -> Result { +impl Default for DefaultStateCoordinator

{ + fn default() -> Self { + Self { _p: core::marker::PhantomData } + } +} + +impl StateCoordinator

for DefaultStateCoordinator

{ + fn on_navigate(&mut self, _from: Option

, _to: P) -> Result { Ok(StateSync::Synced) } fn on_focus_change( &mut self, - _old: Option, - _new: Option, - ) -> Result { + _old: Option, + _new: Option, + ) -> Result { Ok(StateSync::Synced) } fn on_mode_change( &mut self, - _old: Vec, - _new: Vec, - ) -> Result { + _old: &[ModeName], + _new: &[ModeName], + ) -> Result { Ok(StateSync::Synced) } } diff --git a/src/page/mod.rs b/src/page/mod.rs new file mode 100644 index 0000000..f207d36 --- /dev/null +++ b/src/page/mod.rs @@ -0,0 +1,17 @@ +// src/page/mod.rs + +use crate::component::Component; + +/// A "page" is just a component that is cloneable and debuggable. +/// +/// The router identifies pages by enum discriminant (variant), +/// so pages should typically be enums. +pub trait Page: Component + Clone + core::fmt::Debug {} +impl Page for T where T: Component + Clone + core::fmt::Debug {} + +/// Compare pages by discriminant (variant), ignoring associated data. +#[inline] +pub fn same_page(a: &P, b: &P) -> bool { + core::mem::discriminant(a) == core::mem::discriminant(b) +} + diff --git a/src/prelude.rs b/src/prelude.rs index b65aec1..02d9c6d 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -5,3 +5,6 @@ pub use crate::component::error::ComponentError; pub use crate::component::Component; pub use crate::focus::*; pub use crate::input::*; +pub use crate::page::Page; +pub use crate::orchestrator::{Orchestrator, Router, RouterEvent}; +