Compare commits
10 Commits
d872b3d786
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f30a2a2758 | ||
|
|
feb22d270c | ||
|
|
d7f35690e3 | ||
|
|
33002f89a6 | ||
|
|
ad9bb78fc8 | ||
|
|
91ac418bc0 | ||
|
|
1044003179 | ||
|
|
e3e2d64b2a | ||
|
|
0926bbee46 | ||
|
|
d41182b13b |
50
Cargo.lock
generated
50
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
12
Cargo.toml
12
Cargo.toml
@@ -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
397
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
|
## 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
|
||||||
|
|
||||||
|
|||||||
51
examples/focus_advanced.rs
Normal file
51
examples/focus_advanced.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
extern crate alloc;
|
||||||
|
|
||||||
|
use tui_orchestrator::focus::{FocusId, FocusManager, Focusable};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
enum FormElement {
|
||||||
|
Username,
|
||||||
|
Password,
|
||||||
|
RememberMe,
|
||||||
|
Submit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FocusId for FormElement {}
|
||||||
|
|
||||||
|
struct FormPage {
|
||||||
|
username: alloc::string::String,
|
||||||
|
password: alloc::string::String,
|
||||||
|
remember: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Focusable<FormElement> for FormPage {
|
||||||
|
fn focus_targets(&self) -> alloc::vec::Vec<FormElement> {
|
||||||
|
alloc::vec![
|
||||||
|
FormElement::Username,
|
||||||
|
FormElement::Password,
|
||||||
|
FormElement::RememberMe,
|
||||||
|
FormElement::Submit,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let form = FormPage {
|
||||||
|
username: alloc::string::String::new(),
|
||||||
|
password: alloc::string::String::new(),
|
||||||
|
remember: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut focus_manager = FocusManager::new();
|
||||||
|
focus_manager.set_targets(form.focus_targets());
|
||||||
|
println!("Current focus: {:?}", focus_manager.current());
|
||||||
|
|
||||||
|
focus_manager.next();
|
||||||
|
println!("After next: {:?}", focus_manager.current());
|
||||||
|
|
||||||
|
focus_manager.last();
|
||||||
|
println!("After last: {:?}", focus_manager.current());
|
||||||
|
|
||||||
|
println!("Is first: {}", focus_manager.is_first());
|
||||||
|
println!("Is last: {}", focus_manager.is_last());
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
extern crate alloc;
|
extern crate alloc;
|
||||||
|
|
||||||
use tui_orchestrator::focus::{FocusManager, Focusable};
|
use tui_orchestrator::focus::{FocusId, FocusManager, Focusable};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
enum FormElement {
|
enum FormElement {
|
||||||
@@ -11,6 +11,8 @@ enum FormElement {
|
|||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FocusId for FormElement {}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
struct LoginForm {
|
struct LoginForm {
|
||||||
username: String,
|
username: String,
|
||||||
|
|||||||
132
examples/simple.rs
Normal file
132
examples/simple.rs
Normal 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");
|
||||||
|
}
|
||||||
55
examples/simple_input.rs
Normal file
55
examples/simple_input.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
extern crate alloc;
|
||||||
|
|
||||||
|
use tui_orchestrator::input::{default_bindings, ComponentAction, InputError, InputSource, Key};
|
||||||
|
|
||||||
|
struct MockInput {
|
||||||
|
keys: alloc::vec::Vec<Key>,
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockInput {
|
||||||
|
fn new(keys: alloc::vec::Vec<Key>) -> Self {
|
||||||
|
Self { keys, index: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputSource for MockInput {
|
||||||
|
fn read_key(&mut self) -> Result<Key, InputError> {
|
||||||
|
if self.index < self.keys.len() {
|
||||||
|
let key = self.keys[self.index];
|
||||||
|
self.index += 1;
|
||||||
|
Ok(key)
|
||||||
|
} else {
|
||||||
|
Err(InputError::BackendError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let bindings = default_bindings();
|
||||||
|
let mut input = MockInput::new(alloc::vec![Key::tab(), Key::enter(), Key::esc(),]);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match input.read_key() {
|
||||||
|
Ok(key) => {
|
||||||
|
if let Some(action) = bindings.get(&key) {
|
||||||
|
match action {
|
||||||
|
ComponentAction::Next => println!("Next"),
|
||||||
|
ComponentAction::Select => println!("Select"),
|
||||||
|
ComponentAction::Cancel => {
|
||||||
|
println!("Cancel - exiting");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => println!("Other action: {:?}", action),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("No binding for key: {:?}", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Error: {:?}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
examples/simple_usage.rs
Normal file
75
examples/simple_usage.rs
Normal 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
61
flake.lock
generated
Normal 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
49
flake.nix
Normal 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"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// path_from_the_root: src/component/mod.rs
|
|
||||||
|
|
||||||
pub mod action;
|
pub mod action;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod r#trait;
|
pub mod r#trait;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
// path_from_the_root: src/component/trait.rs
|
|
||||||
|
|
||||||
use super::error::ComponentError;
|
use super::error::ComponentError;
|
||||||
use crate::focus::FocusId;
|
use crate::focus::FocusId;
|
||||||
|
use crate::input::Action;
|
||||||
|
|
||||||
pub trait Component {
|
pub trait Component {
|
||||||
type Focus: FocusId;
|
type Focus: FocusId;
|
||||||
type Action: core::fmt::Debug + Clone;
|
type Action: Action;
|
||||||
type Event: Clone + core::fmt::Debug;
|
type Event: Clone + core::fmt::Debug;
|
||||||
|
|
||||||
fn targets(&self) -> &[Self::Focus];
|
fn targets(&self) -> &[Self::Focus];
|
||||||
@@ -34,7 +33,7 @@ pub trait Component {
|
|||||||
|
|
||||||
fn handle_text(
|
fn handle_text(
|
||||||
&mut self,
|
&mut self,
|
||||||
focus: &Self::Focus,
|
_focus: &Self::Focus,
|
||||||
_ch: char,
|
_ch: char,
|
||||||
) -> Result<Option<Self::Event>, ComponentError> {
|
) -> Result<Option<Self::Event>, ComponentError> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
|
|||||||
26
src/focus/builder.rs
Normal file
26
src/focus/builder.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FocusBuilder<F: super::FocusId> {
|
||||||
|
targets: alloc::vec::Vec<F>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F: super::FocusId> FocusBuilder<F> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
targets: alloc::vec::Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target(mut self, target: F) -> Self {
|
||||||
|
self.targets.push(target);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn targets(mut self, targets: &[F]) -> Self {
|
||||||
|
self.targets.extend_from_slice(targets);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> alloc::vec::Vec<F> {
|
||||||
|
self.targets
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// path_from_the_root: src/focus/id.rs
|
// path_from_the_root: src/focus/id.rs
|
||||||
|
|
||||||
pub trait FocusId: Clone + PartialEq + Eq + core::hash::Hash {}
|
pub trait FocusId: Clone + PartialEq + Eq + core::hash::Hash {}
|
||||||
|
|
||||||
impl<T: Clone + PartialEq + Eq + core::hash::Hash> FocusId for T {}
|
|
||||||
|
|||||||
@@ -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,12 +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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
9
src/focus/mode.rs
Normal file
9
src/focus/mode.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pub trait FocusModeHint<F: super::FocusId> {
|
||||||
|
fn focus_modes(&self) -> &[&'static str];
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F: super::FocusId> FocusModeHint<F> for super::FocusManager<F> {
|
||||||
|
fn focus_modes(&self) -> &[&'static str] {
|
||||||
|
&["general"]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/focus/navigation.rs
Normal file
11
src/focus/navigation.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
pub trait FocusNavigation<F: super::FocusId> {
|
||||||
|
type Error;
|
||||||
|
|
||||||
|
fn can_navigate_forward(&self, from: &F) -> bool;
|
||||||
|
|
||||||
|
fn can_navigate_backward(&self, from: &F) -> bool;
|
||||||
|
|
||||||
|
fn navigate_forward(&mut self) -> Result<Option<F>, Self::Error>;
|
||||||
|
|
||||||
|
fn navigate_backward(&mut self) -> Result<Option<F>, Self::Error>;
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
// path_from_the_root: src/input/action.rs
|
/// Marker trait for actions that can be bound to keys.
|
||||||
|
///
|
||||||
|
/// Actions must be cloneable, comparable, and debuggable.
|
||||||
pub trait Action: Clone + PartialEq + Eq + core::fmt::Debug {}
|
pub trait Action: Clone + PartialEq + Eq + core::fmt::Debug {}
|
||||||
|
|
||||||
|
/// Default component actions for common TUI patterns.
|
||||||
|
///
|
||||||
|
/// These actions cover the most common TUI interactions:
|
||||||
|
/// - Navigation (next, prev, first, last, directional)
|
||||||
|
/// - Interaction (select, cancel)
|
||||||
|
/// - Text input (type character, backspace, delete)
|
||||||
|
/// - Custom actions via `Custom(usize)`
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum ComponentAction {
|
||||||
|
Next,
|
||||||
|
Prev,
|
||||||
|
First,
|
||||||
|
Last,
|
||||||
|
Select,
|
||||||
|
Cancel,
|
||||||
|
TypeChar(char),
|
||||||
|
Backspace,
|
||||||
|
Delete,
|
||||||
|
Home,
|
||||||
|
End,
|
||||||
|
PageUp,
|
||||||
|
PageDown,
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Custom(usize),
|
||||||
|
}
|
||||||
|
|
||||||
impl Action for ComponentAction {}
|
impl Action for ComponentAction {}
|
||||||
|
|||||||
@@ -1,33 +1,57 @@
|
|||||||
// path_from_the_root: src/input/bindings.rs
|
use super::action::{Action, ComponentAction};
|
||||||
|
|
||||||
use super::action::Action;
|
|
||||||
use super::key::Key;
|
use super::key::Key;
|
||||||
|
|
||||||
#[cfg(feature = "alloc")]
|
#[cfg(feature = "alloc")]
|
||||||
use hashbrown::HashSet;
|
use hashbrown::HashSet;
|
||||||
|
|
||||||
|
/// Maps keys to actions.
|
||||||
|
///
|
||||||
|
/// When `alloc` feature is enabled, uses HashMap for O(1) 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> {
|
||||||
bindings: alloc::vec::Vec<(Key, A)>,
|
#[cfg(feature = "alloc")]
|
||||||
|
bindings: hashbrown::HashMap<Key, A>,
|
||||||
|
#[cfg(not(feature = "alloc"))]
|
||||||
|
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 {
|
||||||
bindings: alloc::vec::Vec::new(),
|
#[cfg(feature = "alloc")]
|
||||||
|
bindings: hashbrown::HashMap::new(),
|
||||||
|
#[cfg(not(feature = "alloc"))]
|
||||||
|
bindings: heapless::LinearMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bind(&mut self, key: Key, action: A) {
|
pub fn bind(&mut self, key: Key, action: A) {
|
||||||
self.bindings.push((key, action));
|
#[cfg(feature = "alloc")]
|
||||||
|
{
|
||||||
|
self.bindings.insert(key, action);
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "alloc"))]
|
||||||
|
{
|
||||||
|
// 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> {
|
||||||
self.bindings.iter().find(|(k, _)| k == key).map(|(_, a)| a)
|
self.bindings.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove(&mut self, key: &Key) {
|
pub fn remove(&mut self, key: &Key) {
|
||||||
self.bindings.retain(|(k, _)| k != key);
|
#[cfg(feature = "alloc")]
|
||||||
|
{
|
||||||
|
self.bindings.remove(key);
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "alloc"))]
|
||||||
|
{
|
||||||
|
self.bindings.remove(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
@@ -38,25 +62,48 @@ impl<A: Action> Bindings<A> {
|
|||||||
self.bindings.len()
|
self.bindings.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = &(Key, A)> {
|
#[cfg(feature = "alloc")]
|
||||||
|
pub fn keys(&self) -> alloc::vec::Vec<&Key> {
|
||||||
|
self.bindings.keys().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "alloc"))]
|
||||||
|
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> {
|
||||||
|
#[cfg(feature = "alloc")]
|
||||||
pub fn bind_sequence(&mut self, keys: alloc::vec::Vec<Key>, action: A) {
|
pub fn bind_sequence(&mut self, keys: alloc::vec::Vec<Key>, action: A) {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
self.bindings.push((key, action.clone()));
|
self.bindings.insert(key, action.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "alloc"))]
|
||||||
|
pub fn bind_sequence<const K: usize>(&mut self, keys: heapless::Vec<Key, K>, action: A) {
|
||||||
|
for key in keys {
|
||||||
|
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();
|
||||||
@@ -67,4 +114,36 @@ impl<A: Action + core::hash::Hash + Eq> Bindings<A> {
|
|||||||
}
|
}
|
||||||
actions
|
actions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
if !actions.iter().any(|&a| a == action) {
|
||||||
|
let _ = actions.push(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_bindings() -> Bindings<ComponentAction, 16> {
|
||||||
|
let mut bindings = Bindings::new();
|
||||||
|
bindings.bind(Key::tab(), ComponentAction::Next);
|
||||||
|
bindings.bind(Key::shift_tab(), ComponentAction::Prev);
|
||||||
|
bindings.bind(Key::enter(), ComponentAction::Select);
|
||||||
|
bindings.bind(Key::esc(), ComponentAction::Cancel);
|
||||||
|
bindings.bind(Key::home(), ComponentAction::Home);
|
||||||
|
bindings.bind(Key::end(), ComponentAction::End);
|
||||||
|
bindings.bind(Key::page_up(), ComponentAction::PageUp);
|
||||||
|
bindings.bind(Key::page_down(), ComponentAction::PageDown);
|
||||||
|
bindings.bind(Key::up(), ComponentAction::Up);
|
||||||
|
bindings.bind(Key::down(), ComponentAction::Down);
|
||||||
|
bindings.bind(Key::left(), ComponentAction::Left);
|
||||||
|
bindings.bind(Key::right(), ComponentAction::Right);
|
||||||
|
bindings.bind(Key::backspace(), ComponentAction::Backspace);
|
||||||
|
bindings.bind(Key::delete(), ComponentAction::Delete);
|
||||||
|
bindings.bind(Key::ctrl('c'), ComponentAction::Custom(0));
|
||||||
|
bindings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// path_from_the_root: src/input/handler.rs
|
|
||||||
|
|
||||||
#[cfg(feature = "sequences")]
|
#[cfg(feature = "sequences")]
|
||||||
use super::action::Action;
|
use super::action::Action;
|
||||||
|
|
||||||
@@ -9,10 +7,20 @@ use super::key::Key;
|
|||||||
#[cfg(feature = "sequences")]
|
#[cfg(feature = "sequences")]
|
||||||
use super::result::MatchResult;
|
use super::result::MatchResult;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "sequences", feature = "std"))]
|
||||||
|
extern crate std;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "sequences", feature = "std"))]
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[cfg(feature = "sequences")]
|
#[cfg(feature = "sequences")]
|
||||||
pub struct SequenceHandler<A: Action> {
|
pub struct SequenceHandler<A: Action> {
|
||||||
sequences: alloc::vec::Vec<(alloc::vec::Vec<Key>, A)>,
|
sequences: alloc::vec::Vec<(alloc::vec::Vec<Key>, A)>,
|
||||||
current: alloc::vec::Vec<Key>,
|
current: alloc::vec::Vec<Key>,
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
last_timestamp: Option<Instant>,
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "sequences")]
|
#[cfg(feature = "sequences")]
|
||||||
@@ -21,6 +29,17 @@ impl<A: Action> SequenceHandler<A> {
|
|||||||
Self {
|
Self {
|
||||||
sequences: alloc::vec::Vec::new(),
|
sequences: alloc::vec::Vec::new(),
|
||||||
current: alloc::vec::Vec::new(),
|
current: alloc::vec::Vec::new(),
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
last_timestamp: None,
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
timeout: Duration::from_millis(500),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_timeout_ms(&mut self, ms: u64) {
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
{
|
||||||
|
self.timeout = Duration::from_millis(ms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +49,16 @@ impl<A: Action> SequenceHandler<A> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle(&mut self, key: Key) -> MatchResult<A> {
|
pub fn handle(&mut self, key: Key) -> MatchResult<A> {
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
{
|
||||||
|
if let Some(last) = self.last_timestamp {
|
||||||
|
if last.elapsed() > self.timeout {
|
||||||
|
self.current.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_timestamp = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
self.current.push(key);
|
self.current.push(key);
|
||||||
|
|
||||||
for (seq, action) in &self.sequences {
|
for (seq, action) in &self.sequences {
|
||||||
|
|||||||
204
src/input/key.rs
204
src/input/key.rs
@@ -1,5 +1,13 @@
|
|||||||
// path_from_the_root: src/input/key.rs
|
// 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.),
|
||||||
|
/// and function keys F1-F255.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum KeyCode {
|
pub enum KeyCode {
|
||||||
Char(char),
|
Char(char),
|
||||||
@@ -20,6 +28,9 @@ pub enum KeyCode {
|
|||||||
Null,
|
Null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents key modifier flags (Control, Alt, Shift).
|
||||||
|
///
|
||||||
|
/// These modifiers can be combined with any key code.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||||
pub struct KeyModifiers {
|
pub struct KeyModifiers {
|
||||||
pub control: bool,
|
pub control: bool,
|
||||||
@@ -56,6 +67,9 @@ impl KeyModifiers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents a complete key press with modifiers.
|
||||||
|
///
|
||||||
|
/// This is the main type used throughout the input system.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub struct Key {
|
pub struct Key {
|
||||||
pub code: KeyCode,
|
pub code: KeyCode,
|
||||||
@@ -81,39 +95,123 @@ impl Key {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn display_string(&self) -> alloc::string::String {
|
pub const fn shift_tab() -> Self {
|
||||||
let mut out = alloc::string::String::new();
|
Self {
|
||||||
if self.modifiers.control {
|
code: KeyCode::Tab,
|
||||||
out.push_str("Ctrl+");
|
modifiers: KeyModifiers::new().with_shift(),
|
||||||
}
|
}
|
||||||
if self.modifiers.alt {
|
|
||||||
out.push_str("Alt+");
|
|
||||||
}
|
}
|
||||||
if self.modifiers.shift {
|
|
||||||
out.push_str("Shift+");
|
pub const fn alt_char(c: char) -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Char(c),
|
||||||
|
modifiers: KeyModifiers::new().with_alt(),
|
||||||
}
|
}
|
||||||
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"),
|
pub const fn ctrl_shift_char(c: char) -> Self {
|
||||||
KeyCode::PageUp => out.push_str("PageUp"),
|
Self {
|
||||||
KeyCode::PageDown => out.push_str("PageDown"),
|
code: KeyCode::Char(c),
|
||||||
KeyCode::Null => out.push_str("Null"),
|
modifiers: KeyModifiers::new().with_control().with_shift(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn alt_ctrl_char(c: char) -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Char(c),
|
||||||
|
modifiers: KeyModifiers::new().with_alt().with_control(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn enter() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Enter,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn esc() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Esc,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn backspace() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Backspace,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn delete() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Delete,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn tab() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Tab,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn page_up() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::PageUp,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn page_down() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::PageDown,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn up() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Up,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn down() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Down,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn left() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Left,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn right() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Right,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn home() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::Home,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn end() -> Self {
|
||||||
|
Self {
|
||||||
|
code: KeyCode::End,
|
||||||
|
modifiers: KeyModifiers::new(),
|
||||||
}
|
}
|
||||||
out
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ pub mod bindings;
|
|||||||
pub mod handler;
|
pub mod handler;
|
||||||
pub mod key;
|
pub mod key;
|
||||||
pub mod result;
|
pub mod result;
|
||||||
|
pub mod source;
|
||||||
|
|
||||||
pub use action::Action;
|
pub use action::{Action, ComponentAction};
|
||||||
pub use bindings::Bindings;
|
pub use bindings::{default_bindings, Bindings};
|
||||||
pub use key::{Key, KeyCode, KeyModifiers};
|
pub use key::{Key, KeyCode, KeyModifiers};
|
||||||
pub use result::MatchResult;
|
pub use result::MatchResult;
|
||||||
|
pub use source::{InputError, InputSource};
|
||||||
|
|
||||||
#[cfg(feature = "sequences")]
|
#[cfg(feature = "sequences")]
|
||||||
pub use handler::SequenceHandler;
|
pub use handler::SequenceHandler;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// path_from_the_root: src/input/result.rs
|
/// Result of matching a key or sequence.
|
||||||
|
///
|
||||||
|
/// - `Match(action)` - Key or sequence matched, returns the action
|
||||||
|
/// - `Pending` - Sequence in progress, waiting for more keys
|
||||||
|
/// - `NoMatch` - No binding found
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum MatchResult<A> {
|
pub enum MatchResult<A> {
|
||||||
Match(A),
|
Match(A),
|
||||||
|
|||||||
38
src/input/source.rs
Normal file
38
src/input/source.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use super::key::Key;
|
||||||
|
|
||||||
|
/// Errors that can occur when reading input.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum InputError {
|
||||||
|
NotAKeyEvent,
|
||||||
|
BackendError,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trait for reading keys from a backend.
|
||||||
|
///
|
||||||
|
/// Users implement this trait to bridge to crossterm, termion,
|
||||||
|
/// or any other terminal backend.
|
||||||
|
///
|
||||||
|
/// Example with crossterm:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// use crossterm::event;
|
||||||
|
///
|
||||||
|
/// struct CrosstermInput;
|
||||||
|
///
|
||||||
|
/// impl InputSource for CrosstermInput {
|
||||||
|
/// fn read_key(&mut self) -> Result<Key, InputError> {
|
||||||
|
/// match event::read()? {
|
||||||
|
/// event::Event::Key(key_event) => {
|
||||||
|
/// Ok(Key::new(
|
||||||
|
/// KeyCode::from(key_event.code),
|
||||||
|
/// KeyModifiers::from(key_event.modifiers),
|
||||||
|
/// ))
|
||||||
|
/// }
|
||||||
|
/// _ => Err(InputError::NotAKeyEvent),
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub trait InputSource {
|
||||||
|
fn read_key(&mut self) -> Result<Key, InputError>;
|
||||||
|
}
|
||||||
16
src/lib.rs
16
src/lib.rs
@@ -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::*;
|
pub use focus::{FocusError, FocusId, FocusManager, FocusQuery, Focusable};
|
||||||
pub use crate::focus::*;
|
pub use input::{Action, Bindings, Key, KeyCode, KeyModifiers};
|
||||||
pub use crate::input::*;
|
pub use orchestrator::{Orchestrator, Router, RouterEvent};
|
||||||
}
|
pub use page::Page;
|
||||||
|
|||||||
22
src/orchestrator/action_resolver.rs
Normal file
22
src/orchestrator/action_resolver.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
47
src/orchestrator/command_handler.rs
Normal file
47
src/orchestrator/command_handler.rs
Normal 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
411
src/orchestrator/core.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/orchestrator/event_bus.rs
Normal file
80
src/orchestrator/event_bus.rs
Normal 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
17
src/orchestrator/mod.rs
Normal 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
105
src/orchestrator/mode.rs
Normal 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
362
src/orchestrator/router.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/orchestrator/state_coordinator.rs
Normal file
63
src/orchestrator/state_coordinator.rs
Normal 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
17
src/page/mod.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use tui_orchestrator::input::{Bindings, Key};
|
extern crate alloc;
|
||||||
|
|
||||||
|
use tui_orchestrator::input::{Action, Bindings, Key};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -8,6 +10,8 @@ enum TestAction {
|
|||||||
Open,
|
Open,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Action for TestAction {}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_bindings_new() {
|
fn test_bindings_new() {
|
||||||
let _bindings: Bindings<TestAction> = Bindings::new();
|
let _bindings: Bindings<TestAction> = Bindings::new();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
extern crate alloc;
|
extern crate alloc;
|
||||||
|
|
||||||
use tui_orchestrator::component::{Component, ComponentAction};
|
use tui_orchestrator::component::{Component, ComponentAction};
|
||||||
|
use tui_orchestrator::focus::FocusId;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
enum TestFocus {
|
enum TestFocus {
|
||||||
@@ -9,6 +10,8 @@ enum TestFocus {
|
|||||||
ButtonC,
|
ButtonC,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FocusId for TestFocus {}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum TestEvent {
|
enum TestEvent {
|
||||||
ButtonCPressed,
|
ButtonCPressed,
|
||||||
@@ -51,11 +54,22 @@ impl Component for TestComponent {
|
|||||||
self.field_b.clear();
|
self.field_b.clear();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_text(
|
||||||
|
&mut self,
|
||||||
|
focus: &Self::Focus,
|
||||||
|
ch: char,
|
||||||
|
) -> Result<Option<Self::Event>, tui_orchestrator::component::error::ComponentError> {
|
||||||
|
match focus {
|
||||||
|
Self::Focus::FieldA | Self::Focus::FieldB => Ok(Some(Self::Event::TextTyped(ch))),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_component_targets() {
|
fn test_component_targets() {
|
||||||
let mut component = TestComponent {
|
let component = TestComponent {
|
||||||
field_a: alloc::string::String::new(),
|
field_a: alloc::string::String::new(),
|
||||||
field_b: alloc::string::String::new(),
|
field_b: alloc::string::String::new(),
|
||||||
};
|
};
|
||||||
@@ -109,7 +123,7 @@ fn test_component_on_enter_clears() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_component_defaults() {
|
fn test_component_defaults() {
|
||||||
let component = TestComponent {
|
let mut component = TestComponent {
|
||||||
field_a: alloc::string::String::new(),
|
field_a: alloc::string::String::new(),
|
||||||
field_b: alloc::string::String::new(),
|
field_b: alloc::string::String::new(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
extern crate alloc;
|
extern crate alloc;
|
||||||
|
|
||||||
use tui_orchestrator::focus::{FocusError, FocusManager, Focusable};
|
use tui_orchestrator::focus::{FocusError, FocusId, FocusManager, Focusable};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -11,6 +11,8 @@ enum TestId {
|
|||||||
Dialog,
|
Dialog,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FocusId for TestId {}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_focus_id_trait() {
|
fn test_focus_id_trait() {
|
||||||
let id1 = TestId::Button("save");
|
let id1 = TestId::Button("save");
|
||||||
@@ -253,31 +255,3 @@ fn test_focusable_trait() {
|
|||||||
let targets = component.focus_targets();
|
let targets = component.focus_targets();
|
||||||
assert_eq!(targets.len(), 3);
|
assert_eq!(targets.len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_usize_focus_id() {
|
|
||||||
let mut manager: FocusManager<usize> = FocusManager::new();
|
|
||||||
|
|
||||||
manager.set_targets(vec![0, 1, 2, 3]);
|
|
||||||
assert_eq!(manager.current(), Some(&0));
|
|
||||||
|
|
||||||
manager.next();
|
|
||||||
assert_eq!(manager.current(), Some(&1));
|
|
||||||
|
|
||||||
manager.set_focus(3).unwrap();
|
|
||||||
assert_eq!(manager.current(), Some(&3));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_string_focus_id() {
|
|
||||||
let mut manager: FocusManager<&str> = FocusManager::new();
|
|
||||||
|
|
||||||
manager.set_targets(vec!["input1", "input2", "button_save"]);
|
|
||||||
assert_eq!(manager.current(), Some(&"input1"));
|
|
||||||
|
|
||||||
manager.next();
|
|
||||||
assert_eq!(manager.current(), Some(&"input2"));
|
|
||||||
|
|
||||||
manager.set_focus("button_save").unwrap();
|
|
||||||
assert_eq!(manager.current(), Some(&"button_save"));
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user