Compare commits

...

5 Commits

Author SHA1 Message Date
Priec
f30a2a2758 generic library now instead of string based 2026-01-19 15:58:44 +01:00
Priec
feb22d270c flake for rust 2026-01-19 15:31:08 +01:00
Priec
d7f35690e3 working with strings to register a page, needs redesign 2026-01-19 13:10:14 +01:00
Priec
33002f89a6 default is no_std heapless 2026-01-18 12:10:43 +01:00
filipriec_vm
ad9bb78fc8 orchestrator 2026-01-12 00:59:05 +01:00
23 changed files with 1760 additions and 487 deletions

50
Cargo.lock generated
View File

@@ -3,22 +3,19 @@
version = 4 version = 4
[[package]] [[package]]
name = "allocator-api2" name = "byteorder"
version = "0.2.21" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "equivalent" name = "hash32"
version = "1.0.2" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
[[package]] "byteorder",
name = "foldhash" ]
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@@ -26,14 +23,35 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [ dependencies = [
"allocator-api2", "rustc-std-workspace-alloc",
"equivalent",
"foldhash",
] ]
[[package]] [[package]]
name = "tui_orchestrator" name = "heapless"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]]
name = "pages-tui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"hashbrown", "hashbrown",
"heapless",
] ]
[[package]]
name = "rustc-std-workspace-alloc"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d441c3b2ebf55cebf796bfdc265d67fa09db17b7bb6bd4be75c509e1e8fec3"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"

View File

@@ -1,16 +1,18 @@
[package] [package]
name = "tui_orchestrator" name = "pages-tui"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
description = "Type-safe TUI page routing with single-generic orchestration"
[features] [features]
default = ["std"] default = []
std = [] std = ["alloc"]
alloc = ["hashbrown"] alloc = ["dep:hashbrown"]
sequences = ["alloc"] sequences = ["alloc"]
[dependencies] [dependencies]
hashbrown = { version = "0.15", optional = true } hashbrown = { version = "0.15", optional = true, default-features = false, features = ["alloc"] }
heapless = { version = "0.9.2", default-features = false }
[dev-dependencies] [dev-dependencies]

397
README.md
View File

@@ -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 ## Features
- **Zero boilerplate** - Define components, library handles everything else | Feature | Description |
- **Ready to use** - Register pages and run, no manual wiring needed |---------|-------------|
- **Sensible defaults** - Works without configuration | (none) | Pure `no_std` + heapless. No allocator required. |
- **Fully extendable** - Customize via traits when needed | `alloc` | Enables dynamic allocation (Vec, Box, HashMap). |
- **no_std compatible** - Works on embedded systems and WebAssembly | `std` | Full std support (implies `alloc`). |
- **Backend-agnostic** - No crossterm/ratatui dependencies
- **Zero unsafe** - Pure Rust, no unsafe code
## Quick Start ### Default: Pure `no_std` Heapless
### 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<Option<Self::Event>> {
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<Option<Self::Event>> {
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<Option<Self::Event>>;
}
```
**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<F>` - 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<C>` - 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<ModeName> { ... }
}
```
### 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<OverlayResult> { ... }
}
```
### EventHandler
Customize how events are routed to handlers.
```rust
impl EventHandler<AppEvent> for CustomHandler {
fn handle(&mut self, event: AppEvent) -> Result<HandleResult> { ... }
}
```
---
## 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
```toml ```toml
[dependencies] [dependencies]
tui_orchestrator = { version = "0.1", features = ["std"] } pages-tui = "0.2"
# Optional features
sequences = ["alloc"] # Enable multi-key sequences
``` ```
- `default` - No features (pure no_std) No allocator needed! Uses `heapless` collections with const generic capacities.
- `std` - Enable std library support
- `alloc` - Enable alloc support (needed for collections)
--- ### With Allocation
## Design Philosophy ```toml
[dependencies]
pages-tui = { version = "0.2", features = ["alloc"] }
```
1. **Plugin-play model** - Library is runtime, components are plugins Uses `Vec`, `Box`, `HashMap` for dynamic sizing and trait objects.
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
--- ### With Full Std
## For komp_ac Integration ```toml
[dependencies]
pages-tui = { version = "0.2", features = ["std"] }
```
komp_ac can: ## Usage
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
**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? fn handle(&mut self, focus: &Self::Focus, action: Self::Action)
2. **Implement Component trait** - `targets()`, `handle()`, optional hooks -> Result<Option<Self::Event>, ComponentError>
3. **Remove manual orchestration** - Delete manual focus/binding/router setup {
4. **Use Orchestrator** - Register pages and run // Handle actions...
5. **Add extensions if needed** - ModeResolver, OverlayManager, EventHandler Ok(None)
}
}
The library handles everything else. // Create orchestrator with single generic
let mut app: Orchestrator<MyPage> = 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: ## Capacity Configuration (no_std)
- `simple_app.rs` - Basic multi-page TUI
- `form_app.rs` - Form with text input
- `extended_app.rs` - Using extension points
--- In `no_std` mode without `alloc`, configure capacities via const generics:
## Documentation ```rust
// Orchestrator<Page, PAGES, HISTORY, FOCUS, BINDINGS, MODES, EVENTS>
let mut app: Orchestrator<MyPage, 8, 16, 16, 32, 8, 8> = Orchestrator::new();
```
- [PLAN.md](PLAN.md) - Complete implementation plan | Generic | Default | Description |
- [REDESIGN.md](REDESIGN.md) - Framework architecture deep dive |---------|---------|-------------|
- [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) - Integration examples and patterns | `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<Event>
let events: Vec<MyEvent> = app.process_frame(key)?;
// Without alloc: returns Option<Event>
let event: Option<MyEvent> = app.process_frame(key)?;
```
### EventBus
```rust
// With alloc: register handlers via Box<dyn EventHandler>
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 ## License

132
examples/simple.rs Normal file
View File

@@ -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<Option<Self::Event>, 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::<AppPage, 4, 8, 8, 16, 4, 4>::new()
let mut app: Orchestrator<AppPage> = 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");
}

75
examples/simple_usage.rs Normal file
View File

@@ -0,0 +1,75 @@
extern crate alloc;
use tui_orchestrator::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Focus {
Username,
Password,
LoginButton,
}
impl FocusId for Focus {}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AppEvent {
LoginAttempt {
username: alloc::string::String,
password: alloc::string::String,
},
Quit,
}
struct LoginPage {
username: alloc::string::String,
password: alloc::string::String,
}
impl Component for LoginPage {
type Focus = Focus;
type Action = ComponentAction;
type Event = AppEvent;
fn targets(&self) -> &[Self::Focus] {
&[Focus::Username, Focus::Password, Focus::LoginButton]
}
fn handle(
&mut self,
focus: &Self::Focus,
action: Self::Action,
) -> Result<Option<Self::Event>, ComponentError> {
match (focus, action) {
(Focus::LoginButton, ComponentAction::Select) => Ok(Some(AppEvent::LoginAttempt {
username: self.username.clone(),
password: self.password.clone(),
})),
(Focus::Username, ComponentAction::TypeChar(c)) => {
self.username.push(c);
Ok(None)
}
(Focus::Password, ComponentAction::TypeChar(c)) => {
self.password.push(c);
Ok(None)
}
_ => Ok(None),
}
}
}
fn main() -> Result<(), ComponentError> {
let mut orch = Orchestrator::new();
orch.bind(Key::enter(), ComponentAction::Select);
orch.bind(Key::tab(), ComponentAction::Next);
orch.bind(Key::shift_tab(), ComponentAction::Prev);
let login_page = LoginPage {
username: alloc::string::String::new(),
password: alloc::string::String::new(),
};
orch.register_page(alloc::string::String::from("login"), login_page);
Ok(())
}

61
flake.lock generated Normal file
View File

@@ -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
}

49
flake.nix Normal file
View File

@@ -0,0 +1,49 @@
{
description = "Komp AC - Kompress Accounting";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
mermaid-cli
# Rust toolchain
rustc
cargo
rustfmt
clippy
cargo-watch
rust-analyzer
cargo-tarpaulin
cargo-flamegraph
rust-code-analysis
# C build tools (for your linker issue)
gcc
binutils
pkg-config
# OpenSSL for crypto dependencies
openssl
openssl.dev
];
shellHook = ''
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"
export OPENSSL_DIR="${pkgs.openssl.dev}"
export OPENSSL_LIB_DIR="${pkgs.openssl.out}/lib"
export OPENSSL_INCLUDE_DIR="${pkgs.openssl.dev}/include"
echo "🦀 Rust development environment loaded"
'';
};
}
);
}

View File

@@ -2,6 +2,7 @@
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ComponentError { pub enum ComponentError {
EmptyTargets, InvalidAction,
InvalidFocus, InvalidFocus,
NoComponent,
} }

View File

@@ -1,40 +1,77 @@
// path_from_the_root: src/focus/manager.rs // src/focus/manager.rs
use super::error::FocusError; use super::error::FocusError;
use super::id::FocusId; use super::id::FocusId;
use super::query::FocusQuery; use super::query::FocusQuery;
#[cfg(feature = "alloc")]
extern crate alloc;
#[cfg(feature = "alloc")]
type Targets<F, const N: usize> = alloc::vec::Vec<F>;
#[cfg(not(feature = "alloc"))]
type Targets<F, const N: usize> = heapless::Vec<F, N>;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FocusManager<F: FocusId> { pub struct FocusManager<F: FocusId, const N: usize = 16> {
targets: alloc::vec::Vec<F>, targets: Targets<F, N>,
index: usize, index: usize,
overlay: Option<F>, overlay: Option<F>,
} }
impl<F: FocusId> Default for FocusManager<F> { impl<F: FocusId, const N: usize> Default for FocusManager<F, N> {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
} }
} }
impl<F: FocusId> FocusManager<F> { impl<F: FocusId, const N: usize> FocusManager<F, N> {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
targets: alloc::vec::Vec::new(), targets: Targets::new(),
index: 0, index: 0,
overlay: None, 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<F>) { pub fn set_targets(&mut self, targets: alloc::vec::Vec<F>) {
self.targets = targets; self.targets = targets;
self.index = 0; self.index = 0;
self.overlay = None;
} }
pub fn add_target(&mut self, id: F) { pub fn add_target(&mut self, id: F) {
if !self.targets.contains(&id) { if !self.targets.contains(&id) {
#[cfg(feature = "alloc")]
{
self.targets.push(id); self.targets.push(id);
} }
#[cfg(not(feature = "alloc"))]
{
let _ = self.targets.push(id);
}
}
} }
pub fn remove_target(&mut self, id: &F) { pub fn remove_target(&mut self, id: &F) {
@@ -50,14 +87,11 @@ impl<F: FocusId> FocusManager<F> {
if let Some(overlay) = &self.overlay { if let Some(overlay) = &self.overlay {
return Some(overlay); return Some(overlay);
} }
self.targets.get(self.index) self.targets.get(self.index)
} }
pub fn query(&self) -> FocusQuery<'_, F> { pub fn query(&self) -> FocusQuery<'_, F> {
FocusQuery { FocusQuery { current: self.current() }
current: self.current(),
}
} }
pub fn is_focused(&self, id: &F) -> bool { pub fn is_focused(&self, id: &F) -> bool {
@@ -72,7 +106,6 @@ impl<F: FocusId> FocusManager<F> {
if self.targets.is_empty() { if self.targets.is_empty() {
return Err(FocusError::EmptyTargets); return Err(FocusError::EmptyTargets);
} }
if let Some(pos) = self.targets.iter().position(|t| t == &id) { if let Some(pos) = self.targets.iter().position(|t| t == &id) {
self.index = pos; self.index = pos;
self.overlay = None; self.overlay = None;
@@ -94,7 +127,6 @@ impl<F: FocusId> FocusManager<F> {
if self.overlay.is_some() { if self.overlay.is_some() {
return; return;
} }
if !self.targets.is_empty() && self.index < self.targets.len() - 1 { if !self.targets.is_empty() && self.index < self.targets.len() - 1 {
self.index += 1; self.index += 1;
} }
@@ -104,7 +136,6 @@ impl<F: FocusId> FocusManager<F> {
if self.overlay.is_some() { if self.overlay.is_some() {
return; return;
} }
if !self.targets.is_empty() && self.index > 0 { if !self.targets.is_empty() && self.index > 0 {
self.index -= 1; self.index -= 1;
} }
@@ -125,47 +156,5 @@ impl<F: FocusId> FocusManager<F> {
pub fn targets(&self) -> &[F] { pub fn targets(&self) -> &[F] {
&self.targets &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<usize> {
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,
}
}
} }

View File

@@ -1,12 +1,13 @@
// path_from_the_root: src/focus/traits.rs // src/focus/traits.rs
use super::error::FocusError; use super::error::FocusError;
use super::id::FocusId; use super::id::FocusId;
pub trait Focusable<F: FocusId> { pub trait Focusable<F: FocusId> {
fn focus_targets(&self) -> alloc::vec::Vec<F>; fn focus_targets(&self) -> &[F];
fn on_focus_change(&mut self, _id: &F) -> Result<(), FocusError> { fn on_focus_change(&mut self, _id: &F) -> Result<(), FocusError> {
Ok(()) Ok(())
} }
} }

View File

@@ -7,22 +7,22 @@ use hashbrown::HashSet;
/// Maps keys to actions. /// Maps keys to actions.
/// ///
/// When `alloc` feature is enabled, uses HashMap for O(1) lookup. /// When `alloc` feature is enabled, uses HashMap for O(1) lookup.
/// Without `alloc`, falls back to Vec with O(n) lookup. /// Without `alloc`, uses heapless::LinearMap with fixed capacity N.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Bindings<A: Action> { pub struct Bindings<A: Action, const N: usize = 32> {
#[cfg(feature = "alloc")] #[cfg(feature = "alloc")]
bindings: hashbrown::HashMap<Key, A>, bindings: hashbrown::HashMap<Key, A>,
#[cfg(not(feature = "alloc"))] #[cfg(not(feature = "alloc"))]
bindings: alloc::vec::Vec<(Key, A)>, bindings: heapless::LinearMap<Key, A, N>,
} }
impl<A: Action> Bindings<A> { impl<A: Action, const N: usize> Bindings<A, N> {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
#[cfg(feature = "alloc")] #[cfg(feature = "alloc")]
bindings: hashbrown::HashMap::new(), bindings: hashbrown::HashMap::new(),
#[cfg(not(feature = "alloc"))] #[cfg(not(feature = "alloc"))]
bindings: alloc::vec::Vec::new(), bindings: heapless::LinearMap::new(),
} }
} }
@@ -33,20 +33,15 @@ impl<A: Action> Bindings<A> {
} }
#[cfg(not(feature = "alloc"))] #[cfg(not(feature = "alloc"))]
{ {
self.bindings.push((key, action)); // V heapless verzii ignorujeme chybu pri plnej kapacite,
// alebo by ste mohli vrátiť Result.
let _ = self.bindings.insert(key, action);
} }
} }
pub fn get(&self, key: &Key) -> Option<&A> { pub fn get(&self, key: &Key) -> Option<&A> {
#[cfg(feature = "alloc")]
{
self.bindings.get(key) self.bindings.get(key)
} }
#[cfg(not(feature = "alloc"))]
{
self.bindings.iter().find(|(k, _)| k == key).map(|(_, a)| a)
}
}
pub fn remove(&mut self, key: &Key) { pub fn remove(&mut self, key: &Key) {
#[cfg(feature = "alloc")] #[cfg(feature = "alloc")]
@@ -55,108 +50,85 @@ impl<A: Action> Bindings<A> {
} }
#[cfg(not(feature = "alloc"))] #[cfg(not(feature = "alloc"))]
{ {
self.bindings.retain(|(k, _)| k != key); self.bindings.remove(key);
} }
} }
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
#[cfg(feature = "alloc")]
{
self.bindings.is_empty() self.bindings.is_empty()
} }
#[cfg(not(feature = "alloc"))]
{
self.bindings.is_empty()
}
}
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
#[cfg(feature = "alloc")]
{
self.bindings.len() self.bindings.len()
} }
#[cfg(not(feature = "alloc"))]
{
self.bindings.len()
}
}
pub fn keys(&self) -> alloc::vec::Vec<&Key> {
#[cfg(feature = "alloc")] #[cfg(feature = "alloc")]
{ pub fn keys(&self) -> alloc::vec::Vec<&Key> {
self.bindings.keys().collect() self.bindings.keys().collect()
} }
#[cfg(not(feature = "alloc"))]
{
self.bindings.iter().map(|(k, _)| k).collect()
}
}
#[cfg(not(feature = "alloc"))] #[cfg(not(feature = "alloc"))]
pub fn iter(&self) -> impl Iterator<Item = &(Key, A)> { pub fn keys(&self) -> heapless::Vec<&Key, N> {
let mut v = heapless::Vec::new();
for k in self.bindings.keys() {
let _ = v.push(k);
}
v
}
pub fn iter(&self) -> impl Iterator<Item = (&Key, &A)> {
self.bindings.iter() self.bindings.iter()
} }
} }
impl<A: Action> Default for Bindings<A> { impl<A: Action, const N: usize> Default for Bindings<A, N> {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
} }
} }
#[cfg(feature = "sequences")] #[cfg(feature = "sequences")]
impl<A: Action + core::hash::Hash + Eq> Bindings<A> { impl<A: Action + core::hash::Hash + Eq, const N: usize> Bindings<A, N> {
pub fn bind_sequence(&mut self, keys: alloc::vec::Vec<Key>, action: A) {
#[cfg(feature = "alloc")] #[cfg(feature = "alloc")]
{ pub fn bind_sequence(&mut self, keys: alloc::vec::Vec<Key>, action: A) {
for key in keys { for key in keys {
self.bindings.insert(key, action.clone()); self.bindings.insert(key, action.clone());
} }
} }
#[cfg(not(feature = "alloc"))] #[cfg(not(feature = "alloc"))]
{ pub fn bind_sequence<const K: usize>(&mut self, keys: heapless::Vec<Key, K>, action: A) {
for key in keys { for key in keys {
self.bindings.push((key, action.clone())); let _ = self.bindings.insert(key, action.clone());
}
} }
} }
#[cfg(feature = "alloc")]
pub fn get_sequences(&self) -> alloc::vec::Vec<&A> { pub fn get_sequences(&self) -> alloc::vec::Vec<&A> {
let mut actions = alloc::vec::Vec::new(); let mut actions = alloc::vec::Vec::new();
let mut seen = HashSet::new(); let mut seen = HashSet::new();
#[cfg(feature = "alloc")]
{
for (_, action) in &self.bindings { for (_, action) in &self.bindings {
if seen.insert(action) { if seen.insert(action) {
actions.push(action); actions.push(action);
} }
} }
actions
} }
#[cfg(not(feature = "alloc"))] #[cfg(not(feature = "alloc"))]
{ pub fn get_sequences(&self) -> heapless::Vec<&A, N> {
let mut actions = heapless::Vec::new();
// V no_std/no_alloc bez HashSetu robíme O(n^2) kontrolu unikátnosti
for (_, action) in &self.bindings { for (_, action) in &self.bindings {
if seen.insert(action) { if !actions.iter().any(|&a| a == action) {
actions.push(action); let _ = actions.push(action);
}
} }
} }
actions actions
} }
} }
/// Returns default key bindings for common TUI patterns. pub fn default_bindings() -> Bindings<ComponentAction, 16> {
///
/// Includes:
/// - Tab/Shift+Tab for next/prev navigation
/// - Enter for select
/// - Esc for cancel
/// - Arrow keys for directional movement
/// - Home/End/PageUp/PageDown for scrolling
/// - Backspace/Delete for editing
/// - Ctrl+C as custom quit action
///
/// Use as-is or extend with your own bindings.
pub fn default_bindings() -> Bindings<ComponentAction> {
let mut bindings = Bindings::new(); let mut bindings = Bindings::new();
bindings.bind(Key::tab(), ComponentAction::Next); bindings.bind(Key::tab(), ComponentAction::Next);
bindings.bind(Key::shift_tab(), ComponentAction::Prev); bindings.bind(Key::shift_tab(), ComponentAction::Prev);

View File

@@ -1,3 +1,9 @@
// src/input/key.rs
use core::fmt;
#[cfg(feature = "alloc")]
extern crate alloc;
/// Represents a key code without modifiers. /// Represents a key code without modifiers.
/// ///
/// This includes character keys, special keys (Enter, Tab, Esc, etc.), /// This includes character keys, special keys (Enter, Tab, Esc, etc.),
@@ -207,41 +213,6 @@ impl Key {
modifiers: KeyModifiers::new(), 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<KeyCode> for Key { impl From<KeyCode> for Key {
@@ -252,3 +223,51 @@ impl From<KeyCode> 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
}
}

View File

@@ -1,13 +1,19 @@
// src/lib.rs
#![no_std] #![no_std]
#[cfg(feature = "alloc")]
extern crate alloc; extern crate alloc;
pub mod component; pub mod component;
pub mod focus; pub mod focus;
pub mod input; pub mod input;
pub mod orchestrator;
pub mod page;
pub mod prelude;
pub mod prelude { pub use component::{Component, ComponentAction, ComponentError};
pub use crate::component::{Component, ComponentAction, ComponentError}; pub use focus::{FocusError, FocusId, FocusManager, FocusQuery, Focusable};
pub use crate::focus::{FocusError, FocusId, FocusManager, FocusQuery, Focusable}; pub use input::{Action, Bindings, Key, KeyCode, KeyModifiers};
pub use crate::input::{Action, Bindings, Key, KeyCode, KeyModifiers, MatchResult}; pub use orchestrator::{Orchestrator, Router, RouterEvent};
} pub use page::Page;

View File

@@ -0,0 +1,22 @@
// src/orchestrator/action_resolver.rs
use crate::page::Page;
pub struct ResolveContext<'a, P: Page> {
pub page: &'a P,
pub focus: &'a P::Focus,
pub action: P::Action,
}
pub trait ActionResolver<P: Page> {
fn resolve(&mut self, ctx: ResolveContext<P>) -> P::Action;
}
pub struct DefaultActionResolver;
impl<P: Page> ActionResolver<P> for DefaultActionResolver {
fn resolve(&mut self, ctx: ResolveContext<P>) -> P::Action {
ctx.action
}
}

View File

@@ -0,0 +1,47 @@
// path_from_the_root: src/orchestrator/command_handler.rs
use crate::input::Key;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandResult<A> {
Resolved(A),
Incomplete,
Unknown,
Exit,
}
pub trait CommandHandler<A: Clone> {
fn is_active(&self) -> bool;
fn handle(&mut self, key: Key) -> CommandResult<A>;
fn enter(&mut self);
fn exit(&mut self);
}
pub struct DefaultCommandHandler<A: Clone> {
_phantom: core::marker::PhantomData<A>,
}
impl<A: Clone> Default for DefaultCommandHandler<A> {
fn default() -> Self {
Self {
_phantom: core::marker::PhantomData,
}
}
}
impl<A: Clone> CommandHandler<A> for DefaultCommandHandler<A> {
fn is_active(&self) -> bool {
false
}
fn handle(&mut self, _key: Key) -> CommandResult<A> {
CommandResult::Exit
}
fn enter(&mut self) {}
fn exit(&mut self) {}
}

411
src/orchestrator/core.rs Normal file
View File

@@ -0,0 +1,411 @@
// src/orchestrator/core.rs
#[cfg(feature = "alloc")]
use alloc::boxed::Box;
#[cfg(feature = "alloc")]
use alloc::vec::Vec;
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;
/// 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<P, PAGES, HISTORY, FOCUS>,
bindings: Bindings<P::Action, BINDINGS>,
#[cfg(feature = "alloc")]
action_resolver: Box<dyn ActionResolver<P>>,
#[cfg(not(feature = "alloc"))]
action_resolver: DefaultActionResolver,
#[cfg(feature = "alloc")]
command_handler: Box<dyn CommandHandler<P::Action>>,
#[cfg(not(feature = "alloc"))]
command_handler: DefaultCommandHandler<P::Action>,
#[cfg(feature = "alloc")]
state_coordinator: Box<dyn StateCoordinator<P>>,
#[cfg(not(feature = "alloc"))]
state_coordinator: DefaultStateCoordinator<P>,
modes: ModeStack<MODES>,
#[cfg(feature = "alloc")]
event_bus: EventBus<P::Event>,
#[cfg(not(feature = "alloc"))]
event_bus: EventBus<P::Event, EVENTS>,
running: bool,
}
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<P, PAGES, HISTORY, FOCUS, BINDINGS, MODES, EVENTS>
where
P::Action: Clone + 'static,
{
fn default() -> Self {
Self::new()
}
}
impl<
P: Page + 'static,
const PAGES: usize,
const HISTORY: usize,
const FOCUS: usize,
const BINDINGS: usize,
const MODES: usize,
const EVENTS: usize,
> Orchestrator<P, PAGES, HISTORY, FOCUS, BINDINGS, MODES, EVENTS>
where
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,
}
}
/// Register a page. Returns false if capacity exceeded (no_std only).
pub fn register(&mut self, page: P) -> bool {
self.router.register(page)
}
/// 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(target)
.map_err(|_| ComponentError::InvalidFocus)?
{
let _ = self.state_coordinator.on_navigate(from, to);
}
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
.back()
.map_err(|_| ComponentError::InvalidFocus)?
{
let _ = self.state_coordinator.on_navigate(Some(to.clone()), to);
}
Ok(())
}
/// Go forward in navigation history.
pub fn forward(&mut self) -> Result<(), ComponentError> {
if let Some(RouterEvent::Forward { to }) = self
.router
.forward()
.map_err(|_| ComponentError::InvalidFocus)?
{
let _ = self.state_coordinator.on_navigate(Some(to.clone()), to);
}
Ok(())
}
/// Bind a key to an action.
pub fn bind(&mut self, key: Key, action: P::Action) {
self.bindings.bind(key, action);
}
/// Process a frame with a key input.
#[cfg(feature = "alloc")]
pub fn process_frame(&mut self, key: Key) -> Result<Vec<P::Event>, ComponentError> {
if !self.running {
return Ok(Vec::new());
}
let mut events = Vec::new();
if self.command_handler.is_active() {
match self.command_handler.handle(key) {
CommandResult::Resolved(action) => {
if let Some(event) = self.handle_action(action)? {
events.push(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);
if let Some(event) = self.handle_action(resolved_action)? {
events.push(event);
}
}
}
for event in &events {
self.event_bus.emit(event.clone());
}
Ok(events)
}
/// Process a frame with a key input (no_std version).
#[cfg(not(feature = "alloc"))]
pub fn process_frame(&mut self, key: Key) -> Result<Option<P::Event>, 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<Option<P::Event>, 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 page = self.router.current_mut().ok_or(ComponentError::NoComponent)?;
page.handle(&focus, action)?
};
let new_focus = self.router.focus_manager().current().cloned();
if old_focus != new_focus {
let _ = self.state_coordinator.on_focus_change(old_focus, new_focus);
}
Ok(result)
} else {
Ok(None)
}
}
/// Get current page reference.
pub fn current(&self) -> Option<&P> {
self.router.current()
}
/// Get current page mutable reference.
pub fn current_mut(&mut self) -> Option<&mut P> {
self.router.current_mut()
}
/// Check if currently on a specific page variant.
pub fn is_on<F>(&self, check: F) -> bool
where
F: Fn(&P) -> bool,
{
self.router.is_on(check)
}
pub fn focus_manager(&self) -> &FocusManager<P::Focus, FOCUS> {
self.router.focus_manager()
}
pub fn focus_manager_mut(&mut self) -> &mut FocusManager<P::Focus, FOCUS> {
self.router.focus_manager_mut()
}
pub fn modes(&self) -> &ModeStack<MODES> {
&self.modes
}
pub fn modes_mut(&mut self) -> &mut ModeStack<MODES> {
&mut self.modes
}
pub fn push_mode(&mut self, mode: ModeName) -> bool {
self.modes.push(mode)
}
pub fn pop_mode(&mut self) -> Option<ModeName> {
self.modes.pop()
}
#[cfg(feature = "alloc")]
pub fn event_bus(&self) -> &EventBus<P::Event> {
&self.event_bus
}
#[cfg(feature = "alloc")]
pub fn event_bus_mut(&mut self) -> &mut EventBus<P::Event> {
&mut self.event_bus
}
#[cfg(not(feature = "alloc"))]
pub fn event_bus(&self) -> &EventBus<P::Event, EVENTS> {
&self.event_bus
}
#[cfg(not(feature = "alloc"))]
pub fn event_bus_mut(&mut self) -> &mut EventBus<P::Event, EVENTS> {
&mut self.event_bus
}
/// Set custom action resolver (alloc only).
#[cfg(feature = "alloc")]
pub fn set_action_resolver<R: ActionResolver<P> + 'static>(&mut self, resolver: R) {
self.action_resolver = Box::new(resolver);
}
/// Set custom command handler (alloc only).
#[cfg(feature = "alloc")]
pub fn set_command_handler<H: CommandHandler<P::Action> + 'static>(&mut self, handler: H) {
self.command_handler = Box::new(handler);
}
/// Set custom state coordinator (alloc only).
#[cfg(feature = "alloc")]
pub fn set_state_coordinator<S: StateCoordinator<P> + 'static>(&mut self, coordinator: S) {
self.state_coordinator = Box::new(coordinator);
}
pub fn is_running(&self) -> bool {
self.running
}
pub fn stop(&mut self) {
self.running = false;
}
pub fn start(&mut self) {
self.running = true;
}
pub fn router(&self) -> &Router<P, PAGES, HISTORY, FOCUS> {
&self.router
}
pub fn router_mut(&mut self) -> &mut Router<P, PAGES, HISTORY, FOCUS> {
&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<P::Action, BINDINGS> {
&self.bindings
}
pub fn bindings_mut(&mut self) -> &mut Bindings<P::Action, BINDINGS> {
&mut self.bindings
}
}

View File

@@ -0,0 +1,80 @@
// src/orchestrator/event_bus.rs
#[cfg(feature = "alloc")]
extern crate alloc;
pub trait EventHandler<E> {
fn handle(&mut self, event: E);
}
#[cfg(feature = "alloc")]
pub struct EventBus<E: Clone, const N: usize = 8> {
handlers: alloc::vec::Vec<alloc::boxed::Box<dyn EventHandler<E>>>,
}
#[cfg(not(feature = "alloc"))]
pub struct EventBus<E: Clone, const N: usize = 8> {
queue: heapless::Deque<E, N>,
}
impl<E: Clone, const N: usize> Default for EventBus<E, N> {
fn default() -> Self {
Self::new()
}
}
impl<E: Clone, const N: usize> EventBus<E, N> {
pub fn new() -> Self {
#[cfg(feature = "alloc")]
{
Self { handlers: alloc::vec::Vec::new() }
}
#[cfg(not(feature = "alloc"))]
{
Self { queue: heapless::Deque::new() }
}
}
#[cfg(feature = "alloc")]
pub fn register(&mut self, handler: alloc::boxed::Box<dyn EventHandler<E>>) {
self.handlers.push(handler);
}
pub fn emit(&mut self, event: E) {
#[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<E> {
self.queue.pop_front()
}
#[cfg(feature = "alloc")]
pub fn handler_count(&self) -> usize {
self.handlers.len()
}
pub fn is_empty(&self) -> bool {
#[cfg(feature = "alloc")]
{
self.handlers.is_empty()
}
#[cfg(not(feature = "alloc"))]
{
self.queue.is_empty()
}
}
}

17
src/orchestrator/mod.rs Normal file
View File

@@ -0,0 +1,17 @@
// path_from_the_root: src/orchestrator/mod.rs
pub mod action_resolver;
pub mod command_handler;
pub mod core;
pub mod event_bus;
pub mod mode;
pub mod router;
pub mod state_coordinator;
pub use action_resolver::{ActionResolver, DefaultActionResolver, ResolveContext};
pub use command_handler::{CommandHandler, CommandResult, DefaultCommandHandler};
pub use core::Orchestrator;
pub use event_bus::{EventBus, EventHandler};
pub use mode::{ModeName, ModeStack};
pub use router::{Router, RouterEvent};
pub use state_coordinator::{DefaultStateCoordinator, StateCoordinator, StateSync};

105
src/orchestrator/mode.rs Normal file
View File

@@ -0,0 +1,105 @@
// src/orchestrator/mode.rs
#[cfg(feature = "alloc")]
extern crate alloc;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ModeName {
General,
Navigation,
Editing,
Command,
Custom(heapless::String<32>),
}
#[derive(Debug, Clone)]
pub struct ModeStack<const N: usize = 8> {
#[cfg(feature = "alloc")]
modes: alloc::vec::Vec<ModeName>,
#[cfg(not(feature = "alloc"))]
modes: heapless::Vec<ModeName, N>,
}
impl<const N: usize> Default for ModeStack<N> {
fn default() -> Self {
Self::new()
}
}
impl<const N: usize> ModeStack<N> {
pub fn new() -> Self {
Self {
#[cfg(feature = "alloc")]
modes: alloc::vec::Vec::new(),
#[cfg(not(feature = "alloc"))]
modes: heapless::Vec::new(),
}
}
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<ModeName> {
self.modes.pop()
}
pub fn current(&self) -> Option<&ModeName> {
self.modes.last()
}
pub fn is_empty(&self) -> bool {
self.modes.is_empty()
}
pub fn len(&self) -> usize {
self.modes.len()
}
pub fn contains(&self, mode: &ModeName) -> bool {
self.modes.contains(mode)
}
pub fn clear(&mut self) {
self.modes.clear();
}
pub fn reset(&mut self, mode: ModeName) -> bool {
self.modes.clear();
self.push(mode)
}
}
#[cfg(feature = "alloc")]
#[derive(Debug, Clone, Default)]
pub struct ModeResolver {
mappings: alloc::collections::BTreeMap<alloc::string::String, alloc::vec::Vec<ModeName>>,
}
#[cfg(feature = "alloc")]
impl ModeResolver {
pub fn new() -> Self {
Self { mappings: alloc::collections::BTreeMap::new() }
}
pub fn register(&mut self, key: alloc::string::String, modes: alloc::vec::Vec<ModeName>) {
self.mappings.insert(key, modes);
}
pub fn resolve(&self, key: &str) -> Option<&alloc::vec::Vec<ModeName>> {
self.mappings.get(key)
}
pub fn is_empty(&self) -> bool {
self.mappings.is_empty()
}
}

362
src/orchestrator/router.rs Normal file
View File

@@ -0,0 +1,362 @@
// path_from_the_root: src/orchestrator/router.rs
#[cfg(feature = "alloc")]
use alloc::vec::Vec;
use crate::focus::{FocusError, FocusManager};
use crate::page::{same_page, Page};
/// Events emitted by the router during navigation.
#[derive(Debug, Clone)]
pub enum RouterEvent<P: Page> {
/// Navigated from one page to another.
Navigated { from: Option<P>, to: P },
/// Went back in history.
Back { to: P },
/// Went forward in history.
Forward { to: P },
}
/// 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<P: Page, const PAGES: usize = 8, const HISTORY: usize = 16, const FOCUS: usize = 16> {
#[cfg(feature = "alloc")]
pages: Vec<P>,
#[cfg(not(feature = "alloc"))]
pages: heapless::Vec<P, PAGES>,
current_index: Option<usize>,
#[cfg(feature = "alloc")]
history: Vec<usize>,
#[cfg(not(feature = "alloc"))]
history: heapless::Vec<usize, HISTORY>,
#[cfg(feature = "alloc")]
future: Vec<usize>,
#[cfg(not(feature = "alloc"))]
future: heapless::Vec<usize, HISTORY>,
focus: FocusManager<P::Focus, FOCUS>,
}
impl<P: Page, const PAGES: usize, const HISTORY: usize, const FOCUS: usize> Default
for Router<P, PAGES, HISTORY, FOCUS>
{
fn default() -> Self {
Self::new()
}
}
impl<P: Page, const PAGES: usize, const HISTORY: usize, const FOCUS: usize>
Router<P, PAGES, HISTORY, FOCUS>
{
pub fn new() -> Self {
Self {
#[cfg(feature = "alloc")]
pages: Vec::new(),
#[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(),
}
}
/// Find page index by discriminant match.
fn find_index(&self, target: &P) -> Option<usize> {
self.pages.iter().position(|p| same_page(p, target))
}
/// 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());
}
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
}
}
/// 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<Option<RouterEvent<P>>, 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);
}
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);
}
#[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(self.pages[current_idx].clone()),
to: self.pages[target_idx].clone(),
})
} else {
Some(RouterEvent::Navigated {
from: None,
to: self.pages[target_idx].clone(),
})
};
// 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<Option<RouterEvent<P>>, 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<Option<RouterEvent<P>>, FocusError> {
let current_idx = match self.current_index {
Some(idx) => idx,
None => return Ok(None),
};
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);
}
#[cfg(not(feature = "alloc"))]
{
if self.future.is_full() {
self.future.remove(0);
}
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(),
}))
}
/// Go forward in history.
pub fn forward(&mut self) -> Result<Option<RouterEvent<P>>, FocusError> {
let current_idx = match self.current_index {
Some(idx) => idx,
None => return Ok(None),
};
let next_idx = match self.future.pop() {
Some(idx) => idx,
None => return 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);
}
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(),
}))
}
/// Get current page reference.
pub fn current(&self) -> Option<&P> {
self.current_index.map(|idx| &self.pages[idx])
}
/// Get current page mutable reference.
pub fn current_mut(&mut self) -> Option<&mut P> {
self.current_index.map(|idx| &mut self.pages[idx])
}
/// Get a clone of the current page (useful for matching variants).
pub fn current_page(&self) -> Option<P> {
self.current().cloned()
}
pub fn focus_manager(&self) -> &FocusManager<P::Focus, FOCUS> {
&self.focus
}
pub fn focus_manager_mut(&mut self) -> &mut FocusManager<P::Focus, FOCUS> {
&mut self.focus
}
/// Check if on a specific page variant.
pub fn is_on<F>(&self, check: F) -> bool
where
F: Fn(&P) -> bool,
{
self.current().map(|p| check(p)).unwrap_or(false)
}
/// 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<Item = &P> {
self.pages.iter()
}
/// Iterate mutably over all registered pages.
pub fn pages_mut(&mut self) -> impl Iterator<Item = &mut P> {
self.pages.iter_mut()
}
/// Capacity info (only meaningful in no_std mode).
pub fn pages_capacity(&self) -> usize {
#[cfg(feature = "alloc")]
{
self.pages.capacity()
}
#[cfg(not(feature = "alloc"))]
{
PAGES
}
}
pub fn history_capacity(&self) -> usize {
#[cfg(feature = "alloc")]
{
self.history.capacity()
}
#[cfg(not(feature = "alloc"))]
{
HISTORY
}
}
}

View File

@@ -0,0 +1,63 @@
// src/orchestrator/state_coordinator.rs
use crate::orchestrator::ModeName;
use crate::page::Page;
#[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: StateError },
}
pub trait StateCoordinator<P: Page> {
fn on_navigate(&mut self, from: Option<P>, to: P) -> Result<StateSync, StateError>;
fn on_focus_change(
&mut self,
old: Option<P::Focus>,
new: Option<P::Focus>,
) -> Result<StateSync, StateError>;
fn on_mode_change(&mut self, _old: &[ModeName], _new: &[ModeName])
-> Result<StateSync, StateError>;
}
pub struct DefaultStateCoordinator<P: Page> {
_p: core::marker::PhantomData<P>,
}
impl<P: Page> Default for DefaultStateCoordinator<P> {
fn default() -> Self {
Self { _p: core::marker::PhantomData }
}
}
impl<P: Page> StateCoordinator<P> for DefaultStateCoordinator<P> {
fn on_navigate(&mut self, _from: Option<P>, _to: P) -> Result<StateSync, StateError> {
Ok(StateSync::Synced)
}
fn on_focus_change(
&mut self,
_old: Option<P::Focus>,
_new: Option<P::Focus>,
) -> Result<StateSync, StateError> {
Ok(StateSync::Synced)
}
fn on_mode_change(
&mut self,
_old: &[ModeName],
_new: &[ModeName],
) -> Result<StateSync, StateError> {
Ok(StateSync::Synced)
}
}

17
src/page/mod.rs Normal file
View File

@@ -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<T> Page for T where T: Component + Clone + core::fmt::Debug {}
/// Compare pages by discriminant (variant), ignoring associated data.
#[inline]
pub fn same_page<P: Page>(a: &P, b: &P) -> bool {
core::mem::discriminant(a) == core::mem::discriminant(b)
}

View File

@@ -5,3 +5,6 @@ pub use crate::component::error::ComponentError;
pub use crate::component::Component; pub use crate::component::Component;
pub use crate::focus::*; pub use crate::focus::*;
pub use crate::input::*; pub use crate::input::*;
pub use crate::page::Page;
pub use crate::orchestrator::{Orchestrator, Router, RouterEvent};