Recreate repository due to Git object corruption (all files preserved)
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
komp_ac_client/
|
||||
target/
|
||||
|
||||
32
AGENTS.md
Normal file
32
AGENTS.md
Normal file
@@ -0,0 +1,32 @@
|
||||
## Architecture
|
||||
- Allways follow feature-based structuring
|
||||
- Feature-based tree structure—group by domain, not by type
|
||||
- Each feature is self-contained: handler, logic, types, tests
|
||||
- Functional programming style
|
||||
- Use structs, traits, enums, `impl`, `match` over `if`
|
||||
- Avoid shared mutable state—decouple with enums
|
||||
- Keep it simple: small, decoupled, easy-to-read blocks
|
||||
- Don't invent new states/booleans—reuse existing features
|
||||
- Forbidden to use Arc, Mutex, RefCell and others
|
||||
|
||||
## File Structure
|
||||
- `mod.rs` is for routing only, no logic
|
||||
- Tests live in `tests/` dir equivalent to src/
|
||||
- If a feature exceeds 5–10 files, reconsider the design
|
||||
- Nest features logically: `auth/`, `auth/login/`, `auth/register/`
|
||||
|
||||
## Error Handling
|
||||
- Use `Result<T, E>` everywhere—no `.unwrap()` in production code(tests can use unwraps)
|
||||
- Custom error enums per feature, map to a shared app error at boundaries
|
||||
|
||||
## Naming
|
||||
- Clear, descriptive names—no abbreviations
|
||||
- Types are nouns, functions are verbs
|
||||
- Top of the file should always contain // path_from_the_root
|
||||
|
||||
## Dependencies
|
||||
- Always use the latest stable versions
|
||||
- No legacy or deprecated versions for compatibility
|
||||
|
||||
## Komp_ac
|
||||
Komp_ac_client is a codebase out of the app, we are getting inspired from. We only copy code out of it. Its already in gitignore
|
||||
39
Cargo.lock
generated
Normal file
39
Cargo.lock
generated
Normal file
@@ -0,0 +1,39 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tui_orchestrator"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
16
Cargo.toml
Normal file
16
Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "tui_orchestrator"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
alloc = ["hashbrown"]
|
||||
sequences = ["alloc"]
|
||||
|
||||
[dependencies]
|
||||
hashbrown = { version = "0.15", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
734
INPUT_PIPELINE_MIGRATION.md
Normal file
734
INPUT_PIPELINE_MIGRATION.md
Normal file
@@ -0,0 +1,734 @@
|
||||
# Input Pipeline Migration Guide
|
||||
|
||||
## Goal
|
||||
Migrate `komp_ac_client/src/input_pipeline/` to a generalized `no_std` compatible API in `src/input_pipeline/` that can be used by any TUI application.
|
||||
|
||||
## Current State (komp_ac_client)
|
||||
|
||||
Files in `komp_ac_client/src/input_pipeline/`:
|
||||
- `key_chord.rs` - Uses crossterm::event::{KeyCode, KeyModifiers}
|
||||
- `sequence.rs` - Uses std::time::{Duration, Instant}
|
||||
- `registry.rs` - Uses std::collections::HashMap
|
||||
- `pipeline.rs` - Pure logic
|
||||
- `response.rs` - Pure types
|
||||
|
||||
Dependencies to remove:
|
||||
- `crossterm::event` - Replace with custom types
|
||||
- `std::time` - Replace with alloc-based solution or make optional
|
||||
- `std::collections` - Use alloc or custom structures
|
||||
|
||||
## Target Structure
|
||||
|
||||
```
|
||||
src/input_pipeline/
|
||||
├── mod.rs # Routing only
|
||||
├── key.rs # KeyCode, KeyModifiers enums (no_std)
|
||||
├── chord.rs # KeyChord type (no_std)
|
||||
├── sequence.rs # KeySequence type (no_std)
|
||||
├── key_map.rs # KeyMap: Chord -> Action mapping (no_std)
|
||||
├── key_registry.rs # Registry for storing key bindings (alloc)
|
||||
├── sequence_tracker.rs # Track incomplete sequences (optional with std feature)
|
||||
├── pipeline.rs # Main pipeline logic (no_std)
|
||||
└── response.rs # Response types (no_std)
|
||||
```
|
||||
|
||||
## Step 1: Core Types (no_std)
|
||||
|
||||
### `src/input_pipeline/key.rs`
|
||||
|
||||
Define backend-agnostic KeyCode and KeyModifiers:
|
||||
|
||||
```rust
|
||||
// path_from_the_root: src/input_pipeline/key.rs
|
||||
|
||||
use core::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum KeyCode {
|
||||
Char(char),
|
||||
Enter,
|
||||
Tab,
|
||||
Esc,
|
||||
Backspace,
|
||||
Delete,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
F(u8),
|
||||
Null,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub struct KeyModifiers {
|
||||
pub control: bool,
|
||||
pub alt: bool,
|
||||
pub shift: bool,
|
||||
}
|
||||
|
||||
impl KeyModifiers {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
control: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_control(mut self) -> Self {
|
||||
self.control = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_alt(mut self) -> Self {
|
||||
self.alt = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_shift(mut self) -> Self {
|
||||
self.shift = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn is_empty(&self) -> bool {
|
||||
!self.control && !self.alt && !self.shift
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `src/input_pipeline/chord.rs`
|
||||
|
||||
Define KeyChord using custom types:
|
||||
|
||||
```rust
|
||||
// path_from_the_root: src/input_pipeline/chord.rs
|
||||
|
||||
use super::key::{KeyCode, KeyModifiers};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct KeyChord {
|
||||
pub code: KeyCode,
|
||||
pub modifiers: KeyModifiers,
|
||||
}
|
||||
|
||||
impl KeyChord {
|
||||
pub const fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
|
||||
Self { code, modifiers }
|
||||
}
|
||||
|
||||
pub const fn char(c: char) -> Self {
|
||||
Self {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_string(&self) -> alloc::string::String {
|
||||
let mut out = alloc::string::String::new();
|
||||
if self.modifiers.control {
|
||||
out.push_str("Ctrl+");
|
||||
}
|
||||
if self.modifiers.alt {
|
||||
out.push_str("Alt+");
|
||||
}
|
||||
if self.modifiers.shift {
|
||||
out.push_str("Shift+");
|
||||
}
|
||||
match self.code {
|
||||
KeyCode::Char(c) => out.push(c),
|
||||
KeyCode::Enter => out.push_str("Enter"),
|
||||
KeyCode::Tab => out.push_str("Tab"),
|
||||
KeyCode::Esc => out.push_str("Esc"),
|
||||
KeyCode::Backspace => out.push_str("Backspace"),
|
||||
KeyCode::Delete => out.push_str("Delete"),
|
||||
KeyCode::Up => out.push_str("Up"),
|
||||
KeyCode::Down => out.push_str("Down"),
|
||||
KeyCode::Left => out.push_str("Left"),
|
||||
KeyCode::Right => out.push_str("Right"),
|
||||
KeyCode::F(n) => {
|
||||
out.push('F');
|
||||
out.push(char::from_digit(n as u32, 10).unwrap_or('0'));
|
||||
}
|
||||
KeyCode::Home => out.push_str("Home"),
|
||||
KeyCode::End => out.push_str("End"),
|
||||
KeyCode::PageUp => out.push_str("PageUp"),
|
||||
KeyCode::PageDown => out.push_str("PageDown"),
|
||||
KeyCode::Null => out.push_str("Null"),
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KeyCode> for KeyChord {
|
||||
fn from(code: KeyCode) -> Self {
|
||||
Self {
|
||||
code,
|
||||
modifiers: KeyModifiers::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2: Sequence Types (no_std)
|
||||
|
||||
### `src/input_pipeline/sequence.rs`
|
||||
|
||||
```rust
|
||||
// path_from_the_root: src/input_pipeline/sequence.rs
|
||||
|
||||
use super::chord::KeyChord;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct KeySequence {
|
||||
chords: alloc::vec::Vec<KeyChord>,
|
||||
}
|
||||
|
||||
impl KeySequence {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
chords: alloc::vec::Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_chords(chords: impl IntoIterator<Item = KeyChord>) -> Self {
|
||||
Self {
|
||||
chords: chords.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, chord: KeyChord) {
|
||||
self.chords.push(chord);
|
||||
}
|
||||
|
||||
pub fn chords(&self) -> &[KeyChord] {
|
||||
&self.chords
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.chords.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.chords.is_empty()
|
||||
}
|
||||
|
||||
pub fn starts_with(&self, other: &KeySequence) -> bool {
|
||||
self.chords.starts_with(other.chords())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `src/input_pipeline/key_map.rs`
|
||||
|
||||
```rust
|
||||
// path_from_the_root: src/input_pipeline/key_map.rs
|
||||
|
||||
use super::chord::KeyChord;
|
||||
use super::sequence::KeySequence;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum KeyMapEntry<Action> {
|
||||
Chord(KeyChord, Action),
|
||||
Sequence(KeySequence, Action),
|
||||
}
|
||||
|
||||
impl<Action: Clone> KeyMapEntry<Action> {
|
||||
pub fn chord(chord: KeyChord, action: Action) -> Self {
|
||||
Self::Chord(chord, action)
|
||||
}
|
||||
|
||||
pub fn sequence(sequence: KeySequence, action: Action) -> Self {
|
||||
Self::Sequence(sequence, action)
|
||||
}
|
||||
|
||||
pub fn action(&self) -> &Action {
|
||||
match self {
|
||||
Self::Chord(_, action) | Self::Sequence(_, action) => action,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Response Types (no_std)
|
||||
|
||||
### `src/input_pipeline/response.rs`
|
||||
|
||||
```rust
|
||||
// path_from_the_root: src/input_pipeline/response.rs
|
||||
|
||||
use super::chord::KeyChord;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PipelineResponse<Action> {
|
||||
Execute(Action),
|
||||
Type(KeyChord),
|
||||
Wait(alloc::vec::Vec<InputHint<Action>>),
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InputHint<Action> {
|
||||
pub chord: KeyChord,
|
||||
pub action: Action,
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Key Registry (alloc)
|
||||
|
||||
### `src/input_pipeline/key_registry.rs`
|
||||
|
||||
```rust
|
||||
// path_from_the_root: src/input_pipeline/key_registry.rs
|
||||
|
||||
use super::chord::KeyChord;
|
||||
use super::sequence::KeySequence;
|
||||
use super::key_map::KeyMapEntry;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyRegistry<Action> {
|
||||
chords: alloc::collections::HashMap<KeyChord, Action>,
|
||||
sequences: alloc::vec::Vec<(KeySequence, Action)>,
|
||||
}
|
||||
|
||||
impl<Action: Clone> KeyRegistry<Action> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
chords: alloc::collections::HashMap::new(),
|
||||
sequences: alloc::vec::Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_chord(&mut self, chord: KeyChord, action: Action) {
|
||||
self.chords.insert(chord, action);
|
||||
}
|
||||
|
||||
pub fn register_sequence(&mut self, sequence: KeySequence, action: Action) {
|
||||
self.sequences.push((sequence, action));
|
||||
}
|
||||
|
||||
pub fn get_chord(&self, chord: &KeyChord) -> Option<&Action> {
|
||||
self.chords.get(chord)
|
||||
}
|
||||
|
||||
pub fn find_sequences_starting_with(
|
||||
&self,
|
||||
prefix: &KeySequence,
|
||||
) -> alloc::vec::Vec<&KeySequence> {
|
||||
self.sequences
|
||||
.iter()
|
||||
.filter(|(seq, _)| seq.starts_with(prefix))
|
||||
.map(|(seq, _)| seq)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_sequence(&self, sequence: &KeySequence) -> Option<&Action> {
|
||||
self.sequences
|
||||
.iter()
|
||||
.find(|(seq, _)| seq == sequence)
|
||||
.map(|(_, action)| action)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Action: Clone> Default for KeyRegistry<Action> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Sequence Tracker (optional, with std feature)
|
||||
|
||||
### `src/input_pipeline/sequence_tracker.rs`
|
||||
|
||||
```rust
|
||||
// path_from_the_root: src/input_pipeline/sequence_tracker.rs
|
||||
|
||||
use super::sequence::KeySequence;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SequenceTracker {
|
||||
current: KeySequence,
|
||||
#[cfg(feature = "std")]
|
||||
last_input: Option<std::time::Instant>,
|
||||
#[cfg(feature = "std")]
|
||||
timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl SequenceTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current: KeySequence::new(),
|
||||
#[cfg(feature = "std")]
|
||||
last_input: None,
|
||||
#[cfg(feature = "std")]
|
||||
timeout: std::time::Duration::from_millis(1000),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
pub fn with_timeout(timeout_ms: u64) -> Self {
|
||||
Self {
|
||||
current: KeySequence::new(),
|
||||
last_input: None,
|
||||
timeout: std::time::Duration::from_millis(timeout_ms),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.current = KeySequence::new();
|
||||
#[cfg(feature = "std")]
|
||||
{
|
||||
self.last_input = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, chord: KeyChord) {
|
||||
self.current.push(chord);
|
||||
#[cfg(feature = "std")]
|
||||
{
|
||||
self.last_input = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
pub fn is_expired(&self) -> bool {
|
||||
match self.last_input {
|
||||
None => false,
|
||||
Some(last) => last.elapsed() > self.timeout,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "std"))]
|
||||
pub fn is_expired(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn current(&self) -> &KeySequence {
|
||||
&self.current
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.current.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SequenceTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Pipeline Logic (no_std)
|
||||
|
||||
### `src/input_pipeline/pipeline.rs`
|
||||
|
||||
```rust
|
||||
// path_from_the_root: src/input_pipeline/pipeline.rs
|
||||
|
||||
use super::chord::KeyChord;
|
||||
use super::key_registry::KeyRegistry;
|
||||
use super::sequence::KeySequence;
|
||||
use super::response::{PipelineResponse, InputHint};
|
||||
use super::sequence_tracker::SequenceTracker;
|
||||
|
||||
pub struct KeyPipeline<Action: Clone> {
|
||||
registry: KeyRegistry<Action>,
|
||||
#[cfg(feature = "std")]
|
||||
tracker: SequenceTracker,
|
||||
}
|
||||
|
||||
impl<Action: Clone> KeyPipeline<Action> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
registry: KeyRegistry::new(),
|
||||
#[cfg(feature = "std")]
|
||||
tracker: SequenceTracker::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_chord(&mut self, chord: KeyChord, action: Action) {
|
||||
self.registry.register_chord(chord, action);
|
||||
}
|
||||
|
||||
pub fn register_sequence(&mut self, sequence: KeySequence, action: Action) {
|
||||
self.registry.register_sequence(sequence, action);
|
||||
}
|
||||
|
||||
pub fn process(&mut self, chord: KeyChord) -> PipelineResponse<Action> {
|
||||
#[cfg(feature = "std")]
|
||||
{
|
||||
if self.tracker.is_expired() {
|
||||
self.tracker.reset();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
if !self.tracker.is_empty() {
|
||||
self.tracker.add(chord);
|
||||
let current = self.tracker.current();
|
||||
|
||||
if let Some(action) = self.registry.get_sequence(current) {
|
||||
self.tracker.reset();
|
||||
return PipelineResponse::Execute(action.clone());
|
||||
}
|
||||
|
||||
let matching = self.registry.find_sequences_starting_with(current);
|
||||
if matching.is_empty() {
|
||||
self.tracker.reset();
|
||||
PipelineResponse::Cancel
|
||||
} else {
|
||||
let hints: alloc::vec::Vec<InputHint<Action>> = matching
|
||||
.into_iter()
|
||||
.filter_map(|seq| {
|
||||
if seq.len() > current.len() {
|
||||
let next_chord = seq.chords()[current.len()];
|
||||
self.registry.get_sequence(seq)
|
||||
.map(|action| InputHint {
|
||||
chord: next_chord,
|
||||
action: action.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
PipelineResponse::Wait(hints)
|
||||
}
|
||||
} else {
|
||||
if let Some(action) = self.registry.get_chord(&chord) {
|
||||
PipelineResponse::Execute(action.clone())
|
||||
} else {
|
||||
let one_chord_seq = KeySequence::from_chords([chord]);
|
||||
let matching = self.registry.find_sequences_starting_with(&one_chord_seq);
|
||||
|
||||
if !matching.is_empty() {
|
||||
#[cfg(feature = "std")]
|
||||
{
|
||||
self.tracker.add(chord);
|
||||
let hints: alloc::vec::Vec<InputHint<Action>> = matching
|
||||
.into_iter()
|
||||
.filter_map(|seq| {
|
||||
if seq.len() > 1 {
|
||||
let next_chord = seq.chords()[1];
|
||||
self.registry.get_sequence(seq)
|
||||
.map(|action| InputHint {
|
||||
chord: next_chord,
|
||||
action: action.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
PipelineResponse::Wait(hints)
|
||||
}
|
||||
#[cfg(not(feature = "std"))]
|
||||
{
|
||||
PipelineResponse::Cancel
|
||||
}
|
||||
} else {
|
||||
PipelineResponse::Type(chord)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Action: Clone> Default for KeyPipeline<Action> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 7: Module Routing
|
||||
|
||||
### `src/input_pipeline/mod.rs`
|
||||
|
||||
```rust
|
||||
// path_from_the_root: src/input_pipeline/mod.rs
|
||||
|
||||
pub mod key;
|
||||
pub mod chord;
|
||||
pub mod sequence;
|
||||
pub mod key_map;
|
||||
pub mod key_registry;
|
||||
pub mod sequence_tracker;
|
||||
pub mod pipeline;
|
||||
pub mod response;
|
||||
|
||||
pub use key::{KeyCode, KeyModifiers};
|
||||
pub use chord::KeyChord;
|
||||
pub use sequence::KeySequence;
|
||||
pub use key_map::KeyMapEntry;
|
||||
pub use key_registry::KeyRegistry;
|
||||
pub use sequence_tracker::SequenceTracker;
|
||||
pub use pipeline::KeyPipeline;
|
||||
pub use response::{PipelineResponse, InputHint};
|
||||
```
|
||||
|
||||
## Step 8: Update lib.rs
|
||||
|
||||
```rust
|
||||
// path_from_the_root: src/lib.rs
|
||||
|
||||
#![no_std]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
pub mod input_pipeline;
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::input_pipeline::*;
|
||||
}
|
||||
```
|
||||
|
||||
## Step 9: Update Cargo.toml
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "tui_orchestrator"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
alloc = []
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
```
|
||||
|
||||
## Step 10: Tests
|
||||
|
||||
Create `tests/input_pipeline/` directory with tests for each module:
|
||||
|
||||
```
|
||||
tests/input_pipeline/
|
||||
├── key_tests.rs
|
||||
├── chord_tests.rs
|
||||
├── sequence_tests.rs
|
||||
├── registry_tests.rs
|
||||
└── pipeline_tests.rs
|
||||
```
|
||||
|
||||
Example test file:
|
||||
|
||||
```rust
|
||||
// path_from_the_root: tests/input_pipeline/chord_tests.rs
|
||||
|
||||
use tui_orchestrator::input_pipeline::{KeyChord, KeyCode, KeyModifiers};
|
||||
|
||||
#[test]
|
||||
fn test_chord_creation() {
|
||||
let chord = KeyChord::new(
|
||||
KeyCode::Char('a'),
|
||||
KeyModifiers::new().with_control(),
|
||||
);
|
||||
assert_eq!(chord.code, KeyCode::Char('a'));
|
||||
assert!(chord.modifiers.control);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chord_display() {
|
||||
let chord = KeyChord::new(
|
||||
KeyCode::Char('a'),
|
||||
KeyModifiers::new().with_control().with_shift(),
|
||||
);
|
||||
let display = chord.display_string();
|
||||
assert!(display.contains("Ctrl+"));
|
||||
assert!(display.contains("Shift+"));
|
||||
assert!(display.contains('a'));
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with komp_ac_client
|
||||
|
||||
After migration, update `komp_ac_client/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
tui_orchestrator = { path = "..", features = ["std"] }
|
||||
```
|
||||
|
||||
Then in `komp_ac_client/src/input_pipeline/mod.rs`, replace with:
|
||||
|
||||
```rust
|
||||
// path_from_the_root: komp_ac_client/src/input_pipeline/mod.rs
|
||||
|
||||
pub use tui_orchestrator::input_pipeline::*;
|
||||
|
||||
// Add crossterm conversion trait
|
||||
use crossterm::event::{KeyCode, KeyModifiers as CrosstermModifiers};
|
||||
|
||||
impl From<&crossterm::event::KeyEvent> for KeyChord {
|
||||
fn from(event: &crossterm::event::KeyEvent) -> Self {
|
||||
let code = match event.code {
|
||||
KeyCode::Char(c) => KeyCode::Char(c),
|
||||
KeyCode::Enter => KeyCode::Enter,
|
||||
KeyCode::Tab => KeyCode::Tab,
|
||||
KeyCode::Esc => KeyCode::Esc,
|
||||
KeyCode::Backspace => KeyCode::Backspace,
|
||||
KeyCode::Delete => KeyCode::Delete,
|
||||
KeyCode::Home => KeyCode::Home,
|
||||
KeyCode::End => KeyCode::End,
|
||||
KeyCode::PageUp => KeyCode::PageUp,
|
||||
KeyCode::PageDown => KeyCode::PageDown,
|
||||
KeyCode::Up => KeyCode::Up,
|
||||
KeyCode::Down => KeyCode::Down,
|
||||
KeyCode::Left => KeyCode::Left,
|
||||
KeyCode::Right => KeyCode::Right,
|
||||
KeyCode::F(n) => KeyCode::F(n),
|
||||
KeyCode::Null => KeyCode::Null,
|
||||
};
|
||||
|
||||
let modifiers = KeyModifiers {
|
||||
control: event.modifiers.contains(CrosstermModifiers::CONTROL),
|
||||
alt: event.modifiers.contains(CrosstermModifiers::ALT),
|
||||
shift: event.modifiers.contains(CrosstermModifiers::SHIFT),
|
||||
};
|
||||
|
||||
KeyChord::new(code, modifiers)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Delete the migrated files from `komp_ac_client/src/input_pipeline/`.
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **no_std compatible** - Works on embedded systems and WASM
|
||||
2. **Backend agnostic** - No crossterm/ratatui dependency
|
||||
3. **General purpose** - Any TUI can use this API
|
||||
4. **Type safe** - Strong typing for key codes and modifiers
|
||||
5. **Testable** - Pure functions, easy to unit test
|
||||
6. **Flexible** - Applications define their own Action types
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] Create `src/input_pipeline/key.rs`
|
||||
- [ ] Create `src/input_pipeline/chord.rs`
|
||||
- [ ] Create `src/input_pipeline/sequence.rs`
|
||||
- [ ] Create `src/input_pipeline/key_map.rs`
|
||||
- [ ] Create `src/input_pipeline/key_registry.rs`
|
||||
- [ ] Create `src/input_pipeline/sequence_tracker.rs`
|
||||
- [ ] Create `src/input_pipeline/pipeline.rs`
|
||||
- [ ] Create `src/input_pipeline/response.rs`
|
||||
- [ ] Create `src/input_pipeline/mod.rs`
|
||||
- [ ] Update `src/lib.rs`
|
||||
- [ ] Update `src/key/mod.rs` (remove old placeholders or convert)
|
||||
- [ ] Create tests in `tests/input_pipeline/`
|
||||
- [ ] Run tests: `cargo test --no-default-features`
|
||||
- [ ] Run tests with std: `cargo test`
|
||||
- [ ] Update komp_ac_client to use library
|
||||
- [ ] Delete migrated files from komp_ac_client
|
||||
- [ ] Run komp_ac_client tests
|
||||
582
INTEGRATION_GUIDE.md
Normal file
582
INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,582 @@
|
||||
# TUI Orchestrator Integration Guide
|
||||
|
||||
This guide shows how to use the TUI Orchestrator framework to build terminal user interfaces with minimal boilerplate.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start: Your First TUI App
|
||||
|
||||
### Step 1: Define Your Component
|
||||
|
||||
A component represents a page or UI section with focusable elements:
|
||||
|
||||
```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),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> Result<()> {
|
||||
self.username.clear();
|
||||
self.password.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: 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
|
||||
- Focus management (Tab/Shift+Tab navigation)
|
||||
- Button activation (Enter key)
|
||||
- Text input typing
|
||||
- Page lifecycle (on_enter/on_exit)
|
||||
|
||||
---
|
||||
|
||||
## Component Trait Deep Dive
|
||||
|
||||
### Associated Types
|
||||
|
||||
```rust
|
||||
pub trait Component {
|
||||
type Focus: FocusId + Clone; // What can be focused in this component
|
||||
type Action: Action + Clone; // What actions this component handles
|
||||
type Event: Clone + Debug; // Events this component emits
|
||||
}
|
||||
```
|
||||
|
||||
### Required Methods
|
||||
|
||||
**`targets(&self) -> &[Self::Focus]`**
|
||||
|
||||
Returns all focusable elements. Order determines navigation sequence (next/prev).
|
||||
|
||||
```rust
|
||||
fn targets(&self) -> &[Self::Focus] {
|
||||
&[
|
||||
Focus::Username,
|
||||
Focus::Password,
|
||||
Focus::LoginButton,
|
||||
Focus::CancelButton,
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>>`**
|
||||
|
||||
Called when a bound action occurs. Returns optional event for application to handle.
|
||||
|
||||
```rust
|
||||
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>> {
|
||||
match (focus, action) {
|
||||
(Focus::Submit, ComponentAction::Select) => Ok(Some(Event::Submit)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Optional Methods
|
||||
|
||||
All have default implementations—only override what you need.
|
||||
|
||||
**`on_enter(&mut self) -> Result<()>`**
|
||||
|
||||
Called when component becomes active (page is navigated to). Good for resetting state.
|
||||
|
||||
**`on_exit(&mut self) -> Result<()>`**
|
||||
|
||||
Called when component becomes inactive (page is navigated away). Good for cleanup.
|
||||
|
||||
**`on_focus(&mut self, focus: &Self::Focus) -> Result<()>`**
|
||||
|
||||
Called when a specific focus target gains focus.
|
||||
|
||||
**`on_blur(&mut self, focus: &Self::Focus) -> Result<()>`**
|
||||
|
||||
Called when a specific focus target loses focus.
|
||||
|
||||
**`handle_text(&mut self, focus: &Self::Focus, ch: char) -> Result<Option<Self::Event>>`**
|
||||
|
||||
Called when character is typed (not a bound action). Only called for text-friendly focus targets.
|
||||
|
||||
**`can_navigate_forward(&self, focus: &Self::Focus) -> bool`**
|
||||
|
||||
Return false to prevent Next action from moving focus (useful for boundary detection).
|
||||
|
||||
**`can_navigate_backward(&self, focus: &Self::Focus) -> bool`**
|
||||
|
||||
Return false to prevent Prev action from moving focus.
|
||||
|
||||
---
|
||||
|
||||
## Standard Component Actions
|
||||
|
||||
The library provides these actions automatically bound to keys:
|
||||
|
||||
| Action | Default Key | Description |
|
||||
|--------|--------------|-------------|
|
||||
| `ComponentAction::Next` | Tab | Move focus to next target |
|
||||
| `ComponentAction::Prev` | Shift+Tab | Move focus to previous target |
|
||||
| `ComponentAction::First` | Home | Move focus to first target |
|
||||
| `ComponentAction::Last` | End | Move focus to last target |
|
||||
| `ComponentAction::Select` | Enter | Activate current focus target |
|
||||
| `ComponentAction::Cancel` | Esc | Cancel or close |
|
||||
| `ComponentAction::TypeChar(c)` | Any character | Type character |
|
||||
| `ComponentAction::Backspace` | Backspace | Delete character before cursor |
|
||||
| `ComponentAction::Delete` | Delete | Delete character at cursor |
|
||||
| `ComponentAction::Custom(n)` | None | User-defined action |
|
||||
|
||||
### Customizing Bindings
|
||||
|
||||
```rust
|
||||
let mut orch = Orchestrator::new();
|
||||
|
||||
// Override default bindings
|
||||
orch.bindings().bind(Key::ctrl('s'), ComponentAction::Custom(0)); // Custom save action
|
||||
orch.bindings().bind(Key::char(':'), ComponentAction::Custom(1)); // Enter command mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Orchestrator API
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```rust
|
||||
let mut orch = Orchestrator::new();
|
||||
|
||||
// Register pages
|
||||
orch.register_page("login", LoginPage::new())?;
|
||||
orch.register_page("home", HomePage::new())?;
|
||||
|
||||
// Navigation
|
||||
orch.navigate_to("login")?;
|
||||
```
|
||||
|
||||
### Processing Input
|
||||
|
||||
```rust
|
||||
loop {
|
||||
let key = read_key()?;
|
||||
let events = orch.process_frame(key)?;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
LoginEvent::AttemptLogin => do_login(username, password),
|
||||
LoginEvent::Cancel => return Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
render(&orch)?;
|
||||
}
|
||||
```
|
||||
|
||||
### Reading State
|
||||
|
||||
```rust
|
||||
// Get current page
|
||||
if let Some(page) = orch.current_page() {
|
||||
// Access page...
|
||||
}
|
||||
|
||||
// Get current focus
|
||||
if let Some(focus) = orch.focus().current() {
|
||||
// Check what's focused...
|
||||
}
|
||||
|
||||
// Create query snapshot
|
||||
let query = orch.focus().query();
|
||||
if query.is_focused(&LoginFocus::Username) {
|
||||
// Username field is focused...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multiple Pages Example
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
enum MyPage {
|
||||
Login(LoginPage),
|
||||
Home(HomePage),
|
||||
Settings(SettingsPage),
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut orch = Orchestrator::new();
|
||||
|
||||
orch.register_page("login", LoginPage::new())?;
|
||||
orch.register_page("home", HomePage::new())?;
|
||||
orch.register_page("settings", SettingsPage::new())?;
|
||||
|
||||
orch.navigate_to("login")?;
|
||||
|
||||
orch.run()?;
|
||||
}
|
||||
```
|
||||
|
||||
Navigation with history:
|
||||
```rust
|
||||
orch.navigate_to("home")?;
|
||||
orch.navigate_to("settings")?;
|
||||
orch.back()? // Return to home
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extension: Custom Mode Resolution
|
||||
|
||||
For apps with complex mode systems (like komp_ac):
|
||||
|
||||
```rust
|
||||
pub struct CustomModeResolver {
|
||||
state: AppState,
|
||||
}
|
||||
|
||||
impl ModeResolver for CustomModeResolver {
|
||||
fn resolve(&self, focus: &dyn Any) -> alloc::vec::Vec<ModeName> {
|
||||
match focus.downcast_ref::<FocusTarget>() {
|
||||
Some(FocusTarget::CanvasField(_)) => {
|
||||
// Dynamic mode based on editor state
|
||||
vec![self.state.editor_mode(), ModeName::Common, ModeName::Global]
|
||||
}
|
||||
_ => vec![ModeName::General, ModeName::Global],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut orch = Orchestrator::new();
|
||||
orch.set_mode_resolver(CustomModeResolver::new(state));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extension: Custom Overlays
|
||||
|
||||
For apps with complex overlay types (command palette, dialogs):
|
||||
|
||||
```rust
|
||||
pub struct CustomOverlayManager {
|
||||
command_palette: CommandPalette,
|
||||
dialogs: Vec<Dialog>,
|
||||
}
|
||||
|
||||
impl OverlayManager for CustomOverlayManager {
|
||||
fn is_active(&self) -> bool {
|
||||
self.command_palette.is_active() || !self.dialogs.is_empty()
|
||||
}
|
||||
|
||||
fn handle_input(&mut self, key: Key) -> Option<OverlayResult> {
|
||||
if let Some(result) = self.command_palette.handle_input(key) {
|
||||
return Some(result);
|
||||
}
|
||||
// Handle dialogs...
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
let mut orch = Orchestrator::new();
|
||||
orch.set_overlay_manager(CustomOverlayManager::new());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with External Libraries
|
||||
|
||||
### Reading Input from crossterm
|
||||
|
||||
```rust
|
||||
use crossterm::event;
|
||||
use tui_orchestrator::input::Key;
|
||||
|
||||
impl InputSource for CrosstermInput {
|
||||
fn read_key(&mut self) -> Result<Key> {
|
||||
match event::read()? {
|
||||
event::Event::Key(key_event) => {
|
||||
let code = match key_event.code {
|
||||
event::KeyCode::Char(c) => KeyCode::Char(c),
|
||||
event::KeyCode::Enter => KeyCode::Enter,
|
||||
event::KeyCode::Tab => KeyCode::Tab,
|
||||
event::KeyCode::Esc => KeyCode::Esc,
|
||||
// ... map all codes ...
|
||||
};
|
||||
|
||||
let modifiers = KeyModifiers {
|
||||
control: key_event.modifiers.contains(event::KeyModifiers::CONTROL),
|
||||
alt: key_event.modifiers.contains(event::KeyModifiers::ALT),
|
||||
shift: key_event.modifiers.contains(event::KeyModifiers::SHIFT),
|
||||
};
|
||||
|
||||
Ok(Key::new(code, modifiers))
|
||||
}
|
||||
_ => Err(Error::NotAKeyEvent),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using with ratatui for Rendering
|
||||
|
||||
The library is backend-agnostic—you can render with any framework:
|
||||
|
||||
```rust
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use tui_orchestrator::prelude::*;
|
||||
|
||||
struct MyApp {
|
||||
orch: Orchestrator<...>,
|
||||
terminal: Terminal<CrosstermBackend<std::io::Stdout>>,
|
||||
}
|
||||
|
||||
impl MyApp {
|
||||
fn render(&mut self) -> Result<()> {
|
||||
self.terminal.draw(|f| {
|
||||
let focus = self.orch.focus().query();
|
||||
let page = self.orch.current_page().unwrap();
|
||||
|
||||
// Render page with focus context
|
||||
page.render(f, &focus);
|
||||
})?;
|
||||
}
|
||||
|
||||
fn run(&mut self) -> Result<()> {
|
||||
loop {
|
||||
let key = self.orch.read_key()?;
|
||||
let events = self.orch.process_frame(key)?;
|
||||
|
||||
for event in events {
|
||||
self.handle_event(event)?;
|
||||
}
|
||||
|
||||
self.render()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Components
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Test component logic in isolation:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_login_button_action() {
|
||||
let mut page = LoginPage::new();
|
||||
let focus = LoginFocus::LoginButton;
|
||||
let action = ComponentAction::Select;
|
||||
|
||||
let event = page.handle(&focus, action).unwrap();
|
||||
assert!(matches!(event, Some(LoginEvent::AttemptLogin { .. })));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test with orchestrator:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_full_login_flow() {
|
||||
let mut orch = Orchestrator::new();
|
||||
orch.register_page("login", LoginPage::new()).unwrap();
|
||||
|
||||
// Simulate tab navigation
|
||||
let _ = orch.process_frame(Key::tab()).unwrap();
|
||||
assert_eq!(orch.focus().current(), Some(&LoginFocus::Password));
|
||||
|
||||
// Simulate typing
|
||||
let _ = orch.process_frame(Key::char('p')).unwrap();
|
||||
let _ = orch.process_frame(Key::char('a')).unwrap();
|
||||
let _ = orch.process_frame(Key::char('s')).unwrap();
|
||||
let _ = orch.process_frame(Key::char('s')).unwrap();
|
||||
|
||||
// Simulate enter
|
||||
let events = orch.process_frame(Key::enter()).unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], LoginEvent::AttemptLogin { .. }));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration from Existing Code
|
||||
|
||||
### Migrating from Manual Wiring
|
||||
|
||||
**Before:**
|
||||
```rust
|
||||
// Manual setup
|
||||
let mut focus = FocusManager::new();
|
||||
let mut bindings = Bindings::new();
|
||||
let mut router = Router::new();
|
||||
let mut page = LoginPage::new();
|
||||
|
||||
focus.set_targets(page.targets());
|
||||
bindings.bind(Key::tab(), MyAction::Next);
|
||||
|
||||
// Manual loop
|
||||
loop {
|
||||
let key = read_key()?;
|
||||
if let Some(action) = bindings.handle(key) {
|
||||
match action {
|
||||
MyAction::Next => focus.next(),
|
||||
MyAction::Select => {
|
||||
let focused = focus.current()?;
|
||||
page.handle_button(focused)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```rust
|
||||
// Framework setup
|
||||
let mut orch = Orchestrator::builder()
|
||||
.with_page("login", LoginPage::new())
|
||||
.build()?;
|
||||
|
||||
orch.run()?;
|
||||
```
|
||||
|
||||
### Keeping Custom Behavior
|
||||
|
||||
If your existing code has custom behavior (like komp_ac's mode resolution), use extension points:
|
||||
|
||||
```rust
|
||||
let mut orch = Orchestrator::new()
|
||||
.with_mode_resolver(CustomModeResolver::new(state))
|
||||
.with_overlay_manager(CustomOverlayManager::new())
|
||||
.with_event_handler(CustomEventHandler::new(router));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Keep Components Focused
|
||||
|
||||
Components should handle their own logic only. Don't directly manipulate other components.
|
||||
|
||||
### 2. Use Events for Communication
|
||||
|
||||
Components should emit events, not directly call methods on other components.
|
||||
|
||||
### 3. Respect Optional Methods
|
||||
|
||||
Only override lifecycle hooks when you need them. Default implementations are fine for most cases.
|
||||
|
||||
### 4. Test Component Isolation
|
||||
|
||||
Test components without orchestrator to ensure logic is correct.
|
||||
|
||||
### 5. Leverage Default Bindings
|
||||
|
||||
Use `with_default_bindings()` unless you have specific keybinding requirements.
|
||||
|
||||
### 6. Use Extension Points Wisely
|
||||
|
||||
Only implement custom resolvers/handlers when default behavior doesn't meet your needs.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The TUI Orchestrator framework provides:
|
||||
|
||||
1. **Zero boilerplate** - Define components, library handles the rest
|
||||
2. **Sensible defaults** - Works without configuration
|
||||
3. **Full extension** - Customize via traits when needed
|
||||
4. **Backend-agnostic** - Works with any rendering library
|
||||
5. **no_std compatible** - Runs on embedded systems and WASM
|
||||
|
||||
Your job: define components with buttons and logic. Our job: make it just work.
|
||||
540
PLAN.md
Normal file
540
PLAN.md
Normal file
@@ -0,0 +1,540 @@
|
||||
# TUI Orchestrator - Complete TUI Framework
|
||||
|
||||
## Overview
|
||||
|
||||
`tui_orchestrator` is a **ready-to-use TUI framework** that provides a complete runtime for building terminal user interfaces. Users define their pages, buttons, and logic—library handles everything else: input routing, focus management, page navigation, lifecycle hooks, and event orchestration.
|
||||
|
||||
### Key Philosophy
|
||||
|
||||
**"Register pages with buttons and logic—it just works."**
|
||||
|
||||
The library is a **complete application framework** where:
|
||||
- User defines components (pages with focusable elements)
|
||||
- Library orchestrates all runtime concerns
|
||||
- Everything is optional—define what you need
|
||||
- Fully extendable for complex apps like komp_ac
|
||||
|
||||
### Zero Boilerplate
|
||||
|
||||
Users write:
|
||||
```rust
|
||||
impl Component for LoginPage {
|
||||
fn targets(&self) -> &[Focus];
|
||||
fn handle(&mut self, focus: &Focus, action: Action) -> Result<Option<Event>> {
|
||||
// What happens when button pressed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Library handles:
|
||||
- Input processing
|
||||
- Focus management
|
||||
- Page navigation
|
||||
- Lifecycle hooks
|
||||
- Event routing
|
||||
- Default keybindings
|
||||
|
||||
### Extension Model
|
||||
|
||||
komp_ac can use the library for 90% of functionality while extending:
|
||||
- Mode resolution (dynamic Canvas-style modes)
|
||||
- Overlay management (command palette, find file, search)
|
||||
- Event routing (global vs page vs canvas actions)
|
||||
- Custom behaviors (boundary detection, navigation rules)
|
||||
|
||||
**Defaults work, override what's different.**
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ User Code (What You Define) │
|
||||
│ │
|
||||
│ Component trait │
|
||||
│ - Page structs/enums │
|
||||
│ - Focus targets (buttons, fields) │
|
||||
│ - Button logic (what happens on press) │
|
||||
│ - Lifecycle hooks (optional) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Orchestrator (Library Runtime) │
|
||||
│ │
|
||||
│ - ComponentRegistry │
|
||||
│ - FocusManager │
|
||||
│ - Bindings (default + custom) │
|
||||
│ - Router + history │
|
||||
│ - ModeStack │
|
||||
│ - OverlayStack │
|
||||
│ - EventBus │
|
||||
└─────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Extension Points (For komp_ac) │
|
||||
│ │
|
||||
│ - ModeResolver (dynamic mode resolution) │
|
||||
│ - OverlayManager (custom overlay types) │
|
||||
│ - EventHandler (custom event routing) │
|
||||
│ - FocusNavigation (boundary detection) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Foundation ✅ COMPLETE
|
||||
|
||||
**Completed:**
|
||||
- `src/input/` - Key types, bindings, sequence handling
|
||||
- `src/focus/` - Focus manager, queries, traits
|
||||
|
||||
**What this provides:**
|
||||
- Backend-agnostic key representation
|
||||
- Key-to-action mappings
|
||||
- Focus tracking with navigation
|
||||
- Generic focus IDs (user-defined enums, `usize`, `String`, etc.)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Component System (CURRENT)
|
||||
|
||||
**Goal:** Unified abstraction for pages/components.
|
||||
|
||||
**Files to create:**
|
||||
- `src/component/mod.rs` - Component trait
|
||||
- `src/component/action.rs` - Standard component actions
|
||||
- `src/component/error.rs` - Component-specific errors
|
||||
|
||||
**Component Trait:**
|
||||
|
||||
```rust
|
||||
pub trait Component {
|
||||
type Focus: FocusId + Clone;
|
||||
type Action: Action + Clone;
|
||||
type Event: Clone + core::fmt::Debug;
|
||||
|
||||
fn targets(&self) -> &[Self::Focus];
|
||||
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>>;
|
||||
|
||||
fn on_enter(&mut self) -> Result<()> { Ok(()) }
|
||||
fn on_exit(&mut self) -> Result<()> { Ok(()) }
|
||||
fn on_focus(&mut self, focus: &Self::Focus) -> Result<()> { Ok(()) }
|
||||
fn on_blur(&mut self, focus: &Self::Focus) -> Result<()> { Ok(()) }
|
||||
fn handle_text(&mut self, focus: &Self::Focus, ch: char) -> Result<Option<Self::Event>> { Ok(None) }
|
||||
fn can_navigate_forward(&self, focus: &Self::Focus) -> bool { true }
|
||||
fn can_navigate_backward(&self, focus: &Self::Focus) -> bool { true }
|
||||
}
|
||||
```
|
||||
|
||||
**Standard Component Actions:**
|
||||
|
||||
```rust
|
||||
pub enum ComponentAction {
|
||||
Next, // Tab by default
|
||||
Prev, // Shift+Tab by default
|
||||
First,
|
||||
Last,
|
||||
Select, // Enter by default
|
||||
Cancel, // Esc by default
|
||||
TypeChar(char),
|
||||
Backspace,
|
||||
Delete,
|
||||
Custom(usize), // User extension
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Router & Lifecycle
|
||||
|
||||
**Goal:** Page navigation with automatic lifecycle hooks.
|
||||
|
||||
**Files to create:**
|
||||
- `src/router/mod.rs` - Router trait and implementation
|
||||
- `src/router/history.rs` - Navigation history
|
||||
|
||||
**Router API:**
|
||||
|
||||
```rust
|
||||
pub struct Router<C: Component> {
|
||||
pages: alloc::collections::HashMap<String, C>,
|
||||
current: Option<String>,
|
||||
history: alloc::vec::Vec<String>,
|
||||
}
|
||||
|
||||
impl<C: Component> Router<C> {
|
||||
pub fn new() -> Self;
|
||||
pub fn navigate(&mut self, id: &str) -> Result<()>;
|
||||
pub fn back(&mut self) -> Result<Option<()>>;
|
||||
pub fn forward(&mut self) -> Result<Option<()>>;
|
||||
pub fn current(&self) -> Option<&C>;
|
||||
}
|
||||
```
|
||||
|
||||
**Automatic behavior:**
|
||||
- `navigate()` calls `old_page.on_exit()` → swaps page → calls `new_page.on_enter()`
|
||||
- `back()`/`forward()` manage history stack
|
||||
- Focus targets auto-updated from `targets()`
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Orchestrator (The Core Runtime)
|
||||
|
||||
**Goal:** Wire everything together into complete TUI runtime.
|
||||
|
||||
**Files to create:**
|
||||
- `src/orchestrator/mod.rs` - Orchestrator struct
|
||||
- `src/orchestrator/modes.rs` - Mode stack and resolution
|
||||
- `src/orchestrator/overlays.rs` - Overlay stack
|
||||
- `src/orchestrator/events.rs` - Event bus
|
||||
- `src/orchestrator/bindings.rs` - Default + custom bindings
|
||||
|
||||
**Orchestrator API:**
|
||||
|
||||
```rust
|
||||
pub struct Orchestrator<C: Component> {
|
||||
registry: ComponentRegistry<C>,
|
||||
focus: FocusManager<C::Focus>,
|
||||
bindings: Bindings<ComponentAction>,
|
||||
router: Router<C>,
|
||||
modes: ModeStack<ModeName>,
|
||||
overlays: OverlayStack<C::Event>,
|
||||
events: EventBus<C::Event>,
|
||||
}
|
||||
|
||||
impl<C: Component> Orchestrator<C> {
|
||||
pub fn new() -> Self;
|
||||
|
||||
pub fn register_page(&mut self, id: &str, page: C) -> Result<()>;
|
||||
pub fn navigate_to(&mut self, id: &str) -> Result<()>;
|
||||
|
||||
pub fn process_frame(&mut self, key: Key) -> Result<alloc::vec::Vec<C::Event>>;
|
||||
pub fn run<I: InputSource>(&mut self, input: I) -> Result<()>;
|
||||
|
||||
// Extension points
|
||||
pub fn set_mode_resolver<R: ModeResolver + 'static>(&mut self, resolver: R);
|
||||
pub fn set_overlay_manager<O: OverlayManager + 'static>(&mut self, manager: O);
|
||||
pub fn set_event_handler<H: EventHandler<C::Event> + 'static>(&mut self, handler: H);
|
||||
}
|
||||
```
|
||||
|
||||
**Process flow:**
|
||||
1. Check overlay active → route to overlay
|
||||
2. Get current mode + focus
|
||||
3. Lookup binding → get action
|
||||
4. Get current component
|
||||
5. Call `component.handle(action, focus)`
|
||||
6. Collect events returned
|
||||
7. Handle internal events (focus changes, page nav)
|
||||
8. Return external events to user
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Extension Traits
|
||||
|
||||
**Goal:** Provide extension points for komp_ac's custom behavior.
|
||||
|
||||
**Files to create:**
|
||||
- `src/extension/mod.rs` - Extension traits
|
||||
- `src/extension/mode.rs` - Mode resolver
|
||||
- `src/extension/overlay.rs` - Overlay manager
|
||||
- `src/extension/event.rs` - Event handler
|
||||
|
||||
**ModeResolver (for dynamic mode resolution):**
|
||||
|
||||
```rust
|
||||
pub trait ModeResolver {
|
||||
fn resolve(&self, focus: &dyn core::any::Any) -> alloc::vec::Vec<ModeName>;
|
||||
}
|
||||
|
||||
pub struct DefaultModeResolver;
|
||||
impl ModeResolver for DefaultModeResolver {
|
||||
fn resolve(&self, _focus: &dyn core::any::Any) -> alloc::vec::Vec<ModeName> {
|
||||
alloc::vec![ModeName::General]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**OverlayManager (for custom overlays):**
|
||||
|
||||
```rust
|
||||
pub trait OverlayManager {
|
||||
fn is_active(&self) -> bool;
|
||||
fn handle_input(&mut self, key: Key) -> Option<OverlayResult>;
|
||||
}
|
||||
|
||||
pub enum OverlayResult {
|
||||
Dismissed,
|
||||
Selected(OverlayData),
|
||||
Continue,
|
||||
}
|
||||
```
|
||||
|
||||
**EventHandler (for custom event routing):**
|
||||
|
||||
```rust
|
||||
pub trait EventHandler<E> {
|
||||
fn handle(&mut self, event: E) -> Result<HandleResult>;
|
||||
}
|
||||
|
||||
pub enum HandleResult {
|
||||
Consumed,
|
||||
Forward,
|
||||
Navigate(String),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Builder & Defaults
|
||||
|
||||
**Goal:** Easy setup with sensible defaults.
|
||||
|
||||
**Files to create:**
|
||||
- `src/builder/mod.rs` - Builder pattern
|
||||
- `src/defaults/bindings.rs` - Preset keybindings
|
||||
|
||||
**Builder API:**
|
||||
|
||||
```rust
|
||||
impl<C: Component> Orchestrator<C> {
|
||||
pub fn builder() -> Builder<C> { Builder::new() }
|
||||
}
|
||||
|
||||
pub struct Builder<C: Component> {
|
||||
orchestrator: Orchestrator<C>,
|
||||
}
|
||||
|
||||
impl<C: Component> Builder<C> {
|
||||
pub fn with_page(mut self, id: &str, page: C) -> Result<Self>;
|
||||
pub fn with_default_bindings(mut self) -> Self;
|
||||
pub fn with_mode_resolver<R: ModeResolver + 'static>(mut self, resolver: R) -> Self;
|
||||
pub fn with_overlay_manager<O: OverlayManager + 'static>(mut self, manager: O) -> Self;
|
||||
pub fn build(self) -> Result<Orchestrator<C>>;
|
||||
}
|
||||
```
|
||||
|
||||
**Default bindings:**
|
||||
|
||||
```rust
|
||||
pub fn default_bindings<A: Action>() -> Bindings<ComponentAction> {
|
||||
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::ctrl('c'), ComponentAction::Custom(0)); // Quit
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Integration (Optional)
|
||||
|
||||
**Goal:** Seamless integration with komp_ac.
|
||||
|
||||
**Files to create:**
|
||||
- `src/integration/mod.rs` - Integration helpers
|
||||
- `src/integration/komp_ac.rs` - komp_ac-specific adapters
|
||||
|
||||
**Adapter pattern:**
|
||||
|
||||
```rust
|
||||
impl Component for komp_ac::LoginPage {
|
||||
type Focus = komp_ac::FocusTarget;
|
||||
type Action = komp_ac::ResolvedAction;
|
||||
type Event = komp_ac::AppEvent;
|
||||
|
||||
fn targets(&self) -> &[Self::Focus] { ... }
|
||||
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib.rs # Routing only, re-exports
|
||||
├── prelude.rs # Common imports
|
||||
│
|
||||
├── input/ # Phase 1 ✅
|
||||
│ ├── mod.rs
|
||||
│ ├── key.rs # KeyCode, KeyModifiers
|
||||
│ ├── bindings.rs # Bindings
|
||||
│ ├── handler.rs # InputHandler
|
||||
│ ├── result.rs # MatchResult
|
||||
│ └── action.rs # Action trait
|
||||
│
|
||||
├── focus/ # Phase 1 ✅
|
||||
│ ├── mod.rs
|
||||
│ ├── id.rs # FocusId trait
|
||||
│ ├── manager.rs # FocusManager
|
||||
│ ├── query.rs # FocusQuery
|
||||
│ ├── error.rs # FocusError
|
||||
│ └── traits.rs # Focusable
|
||||
│
|
||||
├── component/ # Phase 2
|
||||
│ ├── mod.rs
|
||||
│ ├── trait.rs # Component trait
|
||||
│ ├── action.rs # ComponentAction
|
||||
│ └── error.rs # ComponentError
|
||||
│
|
||||
├── router/ # Phase 3
|
||||
│ ├── mod.rs
|
||||
│ ├── router.rs # Router
|
||||
│ └── history.rs # HistoryStack
|
||||
│
|
||||
├── orchestrator/ # Phase 4
|
||||
│ ├── mod.rs
|
||||
│ ├── core.rs # Orchestrator
|
||||
│ ├── modes.rs # ModeStack, ModeResolver
|
||||
│ ├── overlays.rs # OverlayStack
|
||||
│ ├── bindings.rs # Component bindings
|
||||
│ └── events.rs # EventBus
|
||||
│
|
||||
├── extension/ # Phase 5
|
||||
│ ├── mod.rs
|
||||
│ ├── mode.rs # ModeResolver trait
|
||||
│ ├── overlay.rs # OverlayManager trait
|
||||
│ └── event.rs # EventHandler trait
|
||||
│
|
||||
├── builder/ # Phase 6
|
||||
│ ├── mod.rs
|
||||
│ ├── builder.rs # Builder pattern
|
||||
│ └── defaults.rs # Default bindings
|
||||
│
|
||||
└── integration/ # Phase 7
|
||||
├── mod.rs
|
||||
└── komp_ac.rs # komp_ac adapters
|
||||
|
||||
tests/ # Mirror src/ structure
|
||||
├── input/
|
||||
├── focus/
|
||||
├── component/
|
||||
├── router/
|
||||
├── orchestrator/
|
||||
└── integration/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
### From AGENTS.md
|
||||
|
||||
- **Feature-based tree structure**—group by domain
|
||||
- **Each feature is self-contained**—handler, logic, types, tests
|
||||
- **Functional programming style**—pure functions, stateless where possible
|
||||
- **Use structs, traits, enums, impl, match** over if
|
||||
- **No Arc/Mutex/RefCell**
|
||||
- **Result<T, E> everywhere**
|
||||
- **mod.rs is for routing only**
|
||||
- **No comments unless necessary**
|
||||
|
||||
### Additional for Framework
|
||||
|
||||
- **Batteries included**—not just building blocks
|
||||
- **Sensible defaults**—zero configuration works
|
||||
- **Optional everything**—define only what you need
|
||||
- **Extension points**—override defaults when needed
|
||||
- **no_std compatible**—works on embedded, WASM
|
||||
- **Backend-agnostic**—no crossterm/ratatui dependencies
|
||||
- **User-focused**—"register page" not "register_chord"
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core (no_std)
|
||||
|
||||
- `alloc` - For dynamic collections (Vec, HashMap)
|
||||
|
||||
### Optional Features
|
||||
|
||||
- `std` - Enable std library support
|
||||
- `sequences` - Enable multi-key sequences
|
||||
|
||||
---
|
||||
|
||||
## User Experience
|
||||
|
||||
### Before (Building Blocks)
|
||||
|
||||
Users must manually wire everything:
|
||||
- Create focus manager
|
||||
- Create bindings
|
||||
- Create router
|
||||
- Set up components
|
||||
- Write main loop
|
||||
- Handle lifecycle manually
|
||||
|
||||
**Result:** Lots of boilerplate, easy to get wrong.
|
||||
|
||||
### After (Framework)
|
||||
|
||||
Users define components and run:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
struct LoginPage;
|
||||
|
||||
impl Component for LoginPage {
|
||||
fn targets(&self) -> &[Focus] { ... }
|
||||
fn handle(&mut self, focus: &Focus, action: Action) -> Result<Option<Event>> { ... }
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut orch = Orchestrator::new();
|
||||
orch.register_page("login", LoginPage::new())?;
|
||||
orch.run()?;
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Zero boilerplate, everything just works.
|
||||
|
||||
---
|
||||
|
||||
## Migration for komp_ac
|
||||
|
||||
### Before Integration
|
||||
|
||||
komp_ac has:
|
||||
- Custom orchestrator
|
||||
- Custom mode resolution (Canvas-style)
|
||||
- Custom overlays (command bar, find file, search)
|
||||
- Custom action routing (page vs canvas vs global)
|
||||
|
||||
### After Integration
|
||||
|
||||
komp_ac:
|
||||
1. Implements `Component` trait for all pages
|
||||
2. Uses library's `Orchestrator` as runtime
|
||||
3. Extends with custom `ModeResolver`
|
||||
4. Extends with custom `OverlayManager`
|
||||
5. Extends with custom `EventHandler`
|
||||
|
||||
**Result:**
|
||||
- Library handles 90% of runtime
|
||||
- komp_ac keeps all custom behavior
|
||||
- No code duplication
|
||||
- Cleaner, more maintainable codebase
|
||||
|
||||
---
|
||||
|
||||
## Feature Checklist
|
||||
|
||||
- [x] Phase 1: Input handling (keys, bindings, focus)
|
||||
- [ ] Phase 2: Component trait and actions
|
||||
- [ ] Phase 3: Router with lifecycle
|
||||
- [ ] Phase 4: Orchestrator runtime
|
||||
- [ ] Phase 5: Extension traits
|
||||
- [ ] Phase 6: Builder and defaults
|
||||
- [ ] Phase 7: Integration with komp_ac
|
||||
- [ ] Documentation updates
|
||||
- [ ] Example applications
|
||||
- [ ] Full test coverage
|
||||
431
PROGRESS.md
Normal file
431
PROGRESS.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# TUI Orchestrator - Progress Summary
|
||||
|
||||
## Current Status
|
||||
|
||||
### Completed Features ✅
|
||||
|
||||
#### Phase 1: Core Foundation
|
||||
- **Input handling** (`src/input/`)
|
||||
- Key types (KeyCode, KeyModifiers, Key)
|
||||
- Bindings (key → action mappings)
|
||||
- SequenceHandler (multi-key sequences, feature-gated)
|
||||
- MatchResult (action/pending/no-match)
|
||||
|
||||
- **Focus management** (`src/focus/`)
|
||||
- FocusId trait (generic focus identifiers)
|
||||
- FocusManager (focus tracking, navigation, overlays)
|
||||
- FocusQuery (read-only focus state for rendering)
|
||||
- Focusable trait (components declare focusable elements)
|
||||
- FocusError (error types)
|
||||
|
||||
#### Documentation ✅
|
||||
- **PLAN.md** - Complete implementation plan for framework approach
|
||||
- **REDESIGN.md** - Deep dive into framework architecture
|
||||
- **INTEGRATION_GUIDE.md** - User guide for building TUI apps
|
||||
- **README.md** - Project overview and quick start
|
||||
|
||||
#### Tests ✅
|
||||
- Input tests: 12 tests passing
|
||||
- Focus tests: 18 tests passing
|
||||
- Total: 30 tests covering all core functionality
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 2: Component System
|
||||
|
||||
The foundation for the entire framework. This is the highest priority.
|
||||
|
||||
**Files to create:**
|
||||
- `src/component/mod.rs` - Module routing
|
||||
- `src/component/trait.rs` - Component trait definition
|
||||
- `src/component/action.rs` - Standard component actions
|
||||
- `src/component/error.rs` - Component-specific errors
|
||||
|
||||
**Component trait design:**
|
||||
```rust
|
||||
pub trait Component {
|
||||
type Focus: FocusId + Clone;
|
||||
type Action: Action + Clone;
|
||||
type Event: Clone + core::fmt::Debug;
|
||||
|
||||
// REQUIRED
|
||||
fn targets(&self) -> &[Self::Focus];
|
||||
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>>;
|
||||
|
||||
// OPTIONAL (all with defaults)
|
||||
fn on_enter(&mut self) -> Result<()>;
|
||||
fn on_exit(&mut self) -> Result<()>;
|
||||
fn on_focus(&mut self, focus: &Self::Focus) -> Result<()>;
|
||||
fn on_blur(&mut self, focus: &Self::Focus) -> Result<()>;
|
||||
fn handle_text(&mut self, focus: &Self::Focus, ch: char) -> Result<Option<Self::Event>>;
|
||||
fn can_navigate_forward(&self, focus: &Self::Focus) -> bool;
|
||||
fn can_navigate_backward(&self, focus: &Self::Focus) -> bool;
|
||||
}
|
||||
```
|
||||
|
||||
**ComponentAction enum:**
|
||||
```rust
|
||||
pub enum ComponentAction {
|
||||
Next, // Tab → move focus forward
|
||||
Prev, // Shift+Tab → move focus backward
|
||||
First, // Home → move to first
|
||||
Last, // End → move to last
|
||||
Select, // Enter → activate current focus
|
||||
Cancel, // Esc → cancel/close
|
||||
TypeChar(char), // Character → type text
|
||||
Backspace, // Backspace → delete before cursor
|
||||
Delete, // Delete → delete at cursor
|
||||
Custom(usize), // User-defined action
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** HIGHEST - This enables all other functionality
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Router & Lifecycle
|
||||
|
||||
Page navigation with automatic lifecycle hook invocation.
|
||||
|
||||
**Files to create:**
|
||||
- `src/router/mod.rs` - Module routing
|
||||
- `src/router/router.rs` - Router implementation
|
||||
- `src/router/history.rs` - Navigation history
|
||||
|
||||
**Router API:**
|
||||
```rust
|
||||
pub struct Router<C: Component> {
|
||||
pages: alloc::collections::HashMap<String, C>,
|
||||
current: Option<String>,
|
||||
history: alloc::vec::Vec<String>,
|
||||
future: alloc::vec::Vec<String>, // For forward navigation
|
||||
}
|
||||
|
||||
impl<C: Component> Router<C> {
|
||||
pub fn new() -> Self;
|
||||
pub fn navigate(&mut self, id: &str) -> Result<()>;
|
||||
pub fn back(&mut self) -> Result<Option<()>>;
|
||||
pub fn forward(&mut self) -> Result<Option<()>>;
|
||||
pub fn current(&self) -> Option<&C>;
|
||||
}
|
||||
```
|
||||
|
||||
**Automatic behavior:**
|
||||
- `navigate()` calls `old_page.on_exit()` → swaps → calls `new_page.on_enter()`
|
||||
- `back()`/`forward()` manage history stack
|
||||
|
||||
**Priority:** HIGH - Essential for multi-page apps
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Orchestrator Core
|
||||
|
||||
The complete runtime that wires everything together.
|
||||
|
||||
**Files to create:**
|
||||
- `src/orchestrator/mod.rs` - Module routing
|
||||
- `src/orchestrator/core.rs` - Main Orchestrator struct
|
||||
- `src/orchestrator/bindings.rs` - Default + custom bindings
|
||||
- `src/orchestrator/modes.rs` - Mode stack and resolution
|
||||
- `src/orchestrator/overlays.rs` - Overlay stack
|
||||
- `src/orchestrator/events.rs` - Event bus
|
||||
|
||||
**Orchestrator API:**
|
||||
```rust
|
||||
pub struct Orchestrator<C: Component> {
|
||||
registry: ComponentRegistry<C>,
|
||||
focus: FocusManager<C::Focus>,
|
||||
bindings: Bindings<ComponentAction>,
|
||||
router: Router<C>,
|
||||
modes: ModeStack<ModeName>,
|
||||
overlays: OverlayStack<C::Event>,
|
||||
events: EventBus<C::Event>,
|
||||
}
|
||||
|
||||
impl<C: Component> Orchestrator<C> {
|
||||
pub fn new() -> Self;
|
||||
|
||||
pub fn register_page(&mut self, id: &str, page: C) -> Result<()>;
|
||||
pub fn navigate_to(&mut self, id: &str) -> Result<()>;
|
||||
|
||||
pub fn process_frame(&mut self, key: Key) -> Result<alloc::vec::Vec<C::Event>>;
|
||||
|
||||
pub fn run<I: InputSource>(&mut self, input: I) -> Result<()>;
|
||||
|
||||
// Extension points
|
||||
pub fn set_mode_resolver<R: ModeResolver + 'static>(&mut self, resolver: R);
|
||||
pub fn set_overlay_manager<O: OverlayManager + 'static>(&mut self, manager: O);
|
||||
pub fn set_event_handler<H: EventHandler<C::Event> + 'static>(&mut self, handler: H);
|
||||
}
|
||||
```
|
||||
|
||||
**Process flow:**
|
||||
1. Check overlay active → route to overlay
|
||||
2. Get current mode + focus
|
||||
3. Lookup binding → get action
|
||||
4. Get current component
|
||||
5. Call `component.handle(action, focus)`
|
||||
6. Collect events
|
||||
7. Handle internal events (focus changes, page nav)
|
||||
8. Return external events
|
||||
|
||||
**Priority:** HIGH - This makes the framework "ready to use"
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Extension Traits
|
||||
|
||||
Extension points for komp_ac and other complex apps.
|
||||
|
||||
**Files to create:**
|
||||
- `src/extension/mod.rs` - Module routing
|
||||
- `src/extension/mode.rs` - ModeResolver trait
|
||||
- `src/extension/overlay.rs` - OverlayManager trait
|
||||
- `src/extension/event.rs` - EventHandler trait
|
||||
|
||||
**ModeResolver trait:**
|
||||
```rust
|
||||
pub trait ModeResolver {
|
||||
fn resolve(&self, focus: &dyn core::any::Any) -> alloc::vec::Vec<ModeName>;
|
||||
}
|
||||
|
||||
pub struct DefaultModeResolver;
|
||||
impl ModeResolver for DefaultModeResolver { ... }
|
||||
```
|
||||
|
||||
**OverlayManager trait:**
|
||||
```rust
|
||||
pub trait OverlayManager {
|
||||
fn is_active(&self) -> bool;
|
||||
fn handle_input(&mut self, key: Key) -> Option<OverlayResult>;
|
||||
}
|
||||
|
||||
pub enum OverlayResult {
|
||||
Dismissed,
|
||||
Selected(OverlayData),
|
||||
Continue,
|
||||
}
|
||||
```
|
||||
|
||||
**EventHandler trait:**
|
||||
```rust
|
||||
pub trait EventHandler<E> {
|
||||
fn handle(&mut self, event: E) -> Result<HandleResult>;
|
||||
}
|
||||
|
||||
pub enum HandleResult {
|
||||
Consumed,
|
||||
Forward,
|
||||
Navigate(String),
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** MEDIUM - Defaults work for most apps, komp_ac needs these
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Builder & Defaults
|
||||
|
||||
Easy setup pattern with sensible defaults.
|
||||
|
||||
**Files to create:**
|
||||
- `src/builder/mod.rs` - Module routing
|
||||
- `src/builder/builder.rs` - Builder pattern
|
||||
- `src/builder/defaults.rs` - Preset keybindings
|
||||
|
||||
**Builder API:**
|
||||
```rust
|
||||
pub struct Builder<C: Component> {
|
||||
orchestrator: Orchestrator<C>,
|
||||
}
|
||||
|
||||
impl<C: Component> Builder<C> {
|
||||
pub fn new() -> Self;
|
||||
pub fn with_page(mut self, id: &str, page: C) -> Result<Self>;
|
||||
pub fn with_default_bindings(mut self) -> Self;
|
||||
pub fn with_mode_resolver<R: ModeResolver + 'static>(mut self, resolver: R) -> Self;
|
||||
pub fn with_overlay_manager<O: OverlayManager + 'static>(mut self, manager: O) -> Self;
|
||||
pub fn build(self) -> Result<Orchestrator<C>>;
|
||||
}
|
||||
```
|
||||
|
||||
**Default bindings:**
|
||||
```rust
|
||||
pub fn default_bindings() -> Bindings<ComponentAction> {
|
||||
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::ctrl('c'), ComponentAction::Custom(0)); // Common quit
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** MEDIUM - Improves developer experience
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Integration with komp_ac
|
||||
|
||||
Adapters and integration helpers for seamless komp_ac migration.
|
||||
|
||||
**Files to create:**
|
||||
- `src/integration/mod.rs` - Module routing
|
||||
- `src/integration/komp_ac.rs` - komp_ac-specific adapters
|
||||
|
||||
**Integration pattern:**
|
||||
```rust
|
||||
impl Component for komp_ac::LoginPage {
|
||||
type Focus = komp_ac::FocusTarget;
|
||||
type Action = komp_ac::ResolvedAction;
|
||||
type Event = komp_ac::AppEvent;
|
||||
|
||||
fn targets(&self) -> &[Self::Focus] { ... }
|
||||
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**komp_ac setup:**
|
||||
```rust
|
||||
let mut orch = Orchestrator::new()
|
||||
.with_mode_resolver(CanvasModeResolver::new(app_state))
|
||||
.with_overlay_manager(KompAcOverlayManager::new())
|
||||
.with_event_handler(KompAcEventHandler::new(router, focus));
|
||||
```
|
||||
|
||||
**Priority:** LOW - Not needed for general library users, but essential for komp_ac
|
||||
|
||||
---
|
||||
|
||||
## File Structure After All Phases
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib.rs # Routing
|
||||
├── prelude.rs # Common imports
|
||||
│
|
||||
├── input/ # Phase 1 ✅
|
||||
│ ├── mod.rs
|
||||
│ ├── key.rs
|
||||
│ ├── bindings.rs
|
||||
│ ├── handler.rs
|
||||
│ ├── result.rs
|
||||
│ └── action.rs
|
||||
│
|
||||
├── focus/ # Phase 1 ✅
|
||||
│ ├── mod.rs
|
||||
│ ├── id.rs
|
||||
│ ├── manager.rs
|
||||
│ ├── query.rs
|
||||
│ ├── error.rs
|
||||
│ └── traits.rs
|
||||
│
|
||||
├── component/ # Phase 2 (NEXT)
|
||||
│ ├── mod.rs
|
||||
│ ├── trait.rs
|
||||
│ ├── action.rs
|
||||
│ └── error.rs
|
||||
│
|
||||
├── router/ # Phase 3
|
||||
│ ├── mod.rs
|
||||
│ ├── router.rs
|
||||
│ └── history.rs
|
||||
│
|
||||
├── orchestrator/ # Phase 4
|
||||
│ ├── mod.rs
|
||||
│ ├── core.rs
|
||||
│ ├── bindings.rs
|
||||
│ ├── modes.rs
|
||||
│ ├── overlays.rs
|
||||
│ └── events.rs
|
||||
│
|
||||
├── extension/ # Phase 5
|
||||
│ ├── mod.rs
|
||||
│ ├── mode.rs
|
||||
│ ├── overlay.rs
|
||||
│ └── event.rs
|
||||
│
|
||||
├── builder/ # Phase 6
|
||||
│ ├── mod.rs
|
||||
│ ├── builder.rs
|
||||
│ └── defaults.rs
|
||||
│
|
||||
└── integration/ # Phase 7
|
||||
├── mod.rs
|
||||
└── komp_ac.rs
|
||||
|
||||
tests/
|
||||
├── input/
|
||||
├── focus/
|
||||
├── component/
|
||||
├── router/
|
||||
├── orchestrator/
|
||||
└── integration/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
For each phase:
|
||||
|
||||
1. **Write tests first** - Define expected behavior
|
||||
2. **Implement to pass** - Code should make tests pass
|
||||
3. **Run cargo test** - Verify all pass
|
||||
4. **Run cargo clippy** - Ensure code quality
|
||||
5. **Run cargo fmt** - Ensure formatting
|
||||
|
||||
**Target:** 100% test coverage for all public APIs
|
||||
|
||||
---
|
||||
|
||||
## Dependencies Update
|
||||
|
||||
### Cargo.toml (after Phase 4)
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "tui_orchestrator"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
alloc = ["hashbrown"]
|
||||
sequences = ["alloc"]
|
||||
|
||||
[dependencies]
|
||||
hashbrown = { version = "0.15", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Action
|
||||
|
||||
**Implement Phase 2: Component System**
|
||||
|
||||
This is the foundation that enables:
|
||||
- Page/component registration
|
||||
- Button logic definition
|
||||
- Lifecycle hooks
|
||||
- Everything the framework needs
|
||||
|
||||
**Tasks:**
|
||||
1. Create `src/component/mod.rs`
|
||||
2. Create `src/component/trait.rs` with Component trait
|
||||
3. Create `src/component/action.rs` with ComponentAction enum
|
||||
4. Create `src/component/error.rs` with ComponentError enum
|
||||
5. Write tests in `tests/component/`
|
||||
6. Update `src/lib.rs` to export component module
|
||||
7. Update `src/prelude.rs` to include Component types
|
||||
8. Run `cargo test --all-features`
|
||||
9. Run `cargo clippy --all-features`
|
||||
10. Update documentation if needed
|
||||
|
||||
Ready to implement?
|
||||
322
README.md
Normal file
322
README.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# TUI Orchestrator
|
||||
|
||||
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.
|
||||
|
||||
## Features
|
||||
|
||||
- **Zero boilerplate** - Define components, library handles everything else
|
||||
- **Ready to use** - Register pages and run, no manual wiring needed
|
||||
- **Sensible defaults** - Works without configuration
|
||||
- **Fully extendable** - Customize via traits when needed
|
||||
- **no_std compatible** - Works on embedded systems and WebAssembly
|
||||
- **Backend-agnostic** - No crossterm/ratatui dependencies
|
||||
- **Zero unsafe** - Pure Rust, no unsafe code
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Define Your Component
|
||||
|
||||
```rust
|
||||
extern crate alloc;
|
||||
|
||||
use tui_orchestrator::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum LoginFocus {
|
||||
Username,
|
||||
Password,
|
||||
LoginButton,
|
||||
CancelButton,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum LoginEvent {
|
||||
AttemptLogin { username: String, password: String },
|
||||
Cancel,
|
||||
}
|
||||
|
||||
struct LoginPage {
|
||||
username: alloc::string::String,
|
||||
password: alloc::string::String,
|
||||
}
|
||||
|
||||
impl Component for LoginPage {
|
||||
type Focus = LoginFocus;
|
||||
type Action = ComponentAction;
|
||||
type Event = LoginEvent;
|
||||
|
||||
fn targets(&self) -> &[Self::Focus] {
|
||||
&[
|
||||
LoginFocus::Username,
|
||||
LoginFocus::Password,
|
||||
LoginFocus::LoginButton,
|
||||
LoginFocus::CancelButton,
|
||||
]
|
||||
}
|
||||
|
||||
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<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
|
||||
[dependencies]
|
||||
tui_orchestrator = { version = "0.1", features = ["std"] }
|
||||
|
||||
# Optional features
|
||||
sequences = ["alloc"] # Enable multi-key sequences
|
||||
```
|
||||
|
||||
- `default` - No features (pure no_std)
|
||||
- `std` - Enable std library support
|
||||
- `alloc` - Enable alloc support (needed for collections)
|
||||
|
||||
---
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
1. **Plugin-play model** - Library is runtime, components are plugins
|
||||
2. **Sensible defaults** - Zero configuration works
|
||||
3. **Optional everything** - Define only what you need
|
||||
4. **Extension points** - Override defaults when needed
|
||||
5. **User-focused** - "register page" not "bind chord to registry"
|
||||
6. **no_std first** - Works on embedded, opt-in std
|
||||
|
||||
---
|
||||
|
||||
## For komp_ac Integration
|
||||
|
||||
komp_ac can:
|
||||
1. Implement `Component` trait for all pages
|
||||
2. Use library's `Orchestrator` as runtime
|
||||
3. Extend with custom `ModeResolver` for dynamic Canvas-style modes
|
||||
4. Extend with custom `OverlayManager` for command palette, find file, search
|
||||
5. Extend with custom `EventHandler` for page/global/canvas routing
|
||||
|
||||
**Result:** komp_ac uses library's core while keeping all custom behavior.
|
||||
|
||||
See [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you're migrating from a TUI built with manual wiring:
|
||||
|
||||
1. **Identify components** - What are your pages/sections?
|
||||
2. **Implement Component trait** - `targets()`, `handle()`, optional hooks
|
||||
3. **Remove manual orchestration** - Delete manual focus/binding/router setup
|
||||
4. **Use Orchestrator** - Register pages and run
|
||||
5. **Add extensions if needed** - ModeResolver, OverlayManager, EventHandler
|
||||
|
||||
The library handles everything else.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
See `examples/` directory for complete working applications:
|
||||
- `simple_app.rs` - Basic multi-page TUI
|
||||
- `form_app.rs` - Form with text input
|
||||
- `extended_app.rs` - Using extension points
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [PLAN.md](PLAN.md) - Complete implementation plan
|
||||
- [REDESIGN.md](REDESIGN.md) - Framework architecture deep dive
|
||||
- [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) - Integration examples and patterns
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
497
REDESIGN.md
Normal file
497
REDESIGN.md
Normal file
@@ -0,0 +1,497 @@
|
||||
# TUI Orchestrator: Framework-Based Design
|
||||
|
||||
## Philosophy Shift
|
||||
|
||||
### From Building Blocks to Framework
|
||||
|
||||
**Old approach:** Provide individual primitives (keys, bindings, focus) that users wire together manually.
|
||||
|
||||
**New approach:** Provide complete TUI framework where users define components and library handles everything else.
|
||||
|
||||
This is a **plugin-play model**:
|
||||
- Library is the runtime
|
||||
- Components are plugins
|
||||
- Extension points allow customization
|
||||
- Everything else is optional with sensible defaults
|
||||
|
||||
---
|
||||
|
||||
## The "Ready to Use" Vision
|
||||
|
||||
### What Users Should Do
|
||||
|
||||
```rust
|
||||
// 1. Define component
|
||||
#[derive(Debug, Clone)]
|
||||
enum LoginPage {
|
||||
Username,
|
||||
Password,
|
||||
LoginBtn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum LoginEvent {
|
||||
AttemptLogin { username: String, password: String },
|
||||
Cancel,
|
||||
}
|
||||
|
||||
impl Component for LoginPage {
|
||||
type Focus = LoginPage;
|
||||
type Action = ComponentAction;
|
||||
type Event = LoginEvent;
|
||||
|
||||
fn targets(&self) -> &[Self::Focus] { ... }
|
||||
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>> { ... }
|
||||
}
|
||||
|
||||
// 2. Register and run
|
||||
fn main() -> Result<()> {
|
||||
let mut orch = Orchestrator::builder()
|
||||
.with_page("login", LoginPage::new())
|
||||
.with_default_bindings()
|
||||
.build()?;
|
||||
|
||||
orch.run()?;
|
||||
}
|
||||
```
|
||||
|
||||
### What Library Does
|
||||
|
||||
**Automatically:**
|
||||
- Input processing (read keys, route to actions)
|
||||
- Focus management (next, prev, set, clear overlay)
|
||||
- Page navigation (call on_exit, swap, call on_enter)
|
||||
- Lifecycle hooks (on_focus, on_blur called at right time)
|
||||
- Default bindings (Tab=Next, Enter=Select, etc.)
|
||||
- Event collection and routing
|
||||
|
||||
**Never:**
|
||||
- Forces user to write glue code
|
||||
- Requires manual lifecycle management
|
||||
- Makes assumptions about app structure
|
||||
- Requires complex configuration
|
||||
|
||||
---
|
||||
|
||||
## Extension Model
|
||||
|
||||
### Three-Layer Architecture
|
||||
|
||||
```
|
||||
Layer 1: Core Framework (Library)
|
||||
├── Component trait
|
||||
├── Orchestrator runtime
|
||||
├── Default bindings
|
||||
└── Router + lifecycle
|
||||
|
||||
Layer 2: Extension Points (For komp_ac)
|
||||
├── ModeResolver - dynamic mode resolution
|
||||
├── OverlayManager - custom overlay types
|
||||
├── EventHandler - custom event routing
|
||||
└── FocusNavigation - boundary detection
|
||||
|
||||
Layer 3: App Logic (User)
|
||||
├── Page definitions
|
||||
├── Business logic (gRPC, authentication)
|
||||
└── Rendering
|
||||
```
|
||||
|
||||
### Layer 1: What Library Provides
|
||||
|
||||
**Component trait** - The abstraction:
|
||||
- `targets()` - What's focusable
|
||||
- `handle()` - What happens on action
|
||||
- `on_enter/on_exit` - Lifecycle hooks
|
||||
- `on_focus/on_blur` - Focus lifecycle
|
||||
- `handle_text()` - Optional text input
|
||||
- `can_navigate_*()` - Optional boundary detection
|
||||
|
||||
**Orchestrator** - The runtime:
|
||||
- `register_page()` - Add pages
|
||||
- `navigate_to()` - Page navigation
|
||||
- `process_frame()` - Process one input frame
|
||||
- `run()` - Complete main loop
|
||||
|
||||
**Standard actions** - Common patterns:
|
||||
- `Next`, `Prev`, `First`, `Last` - Navigation
|
||||
- `Select`, `Cancel` - Selection
|
||||
- `TypeChar`, `Backspace`, `Delete` - Text input
|
||||
- `Custom(usize)` - User extension
|
||||
|
||||
### Layer 2: Extension Points
|
||||
|
||||
Each extension has a **default implementation** that works for simple apps, and a **trait** that komp_ac implements for custom behavior.
|
||||
|
||||
#### ModeResolver
|
||||
|
||||
**Default:** Static mode stack
|
||||
|
||||
```rust
|
||||
pub struct DefaultModeResolver;
|
||||
impl ModeResolver for DefaultModeResolver {
|
||||
fn resolve(&self, _focus: &dyn Any) -> Vec<ModeName> {
|
||||
vec![ModeName::General]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**komp_ac extension:** Dynamic Canvas-style mode resolution
|
||||
|
||||
```rust
|
||||
pub struct CanvasModeResolver {
|
||||
app_state: AppState,
|
||||
}
|
||||
|
||||
impl ModeResolver for CanvasModeResolver {
|
||||
fn resolve(&self, focus: &dyn Any) -> Vec<ModeName> {
|
||||
// Check if focus is canvas field
|
||||
// Get editor mode (Edit/ReadOnly)
|
||||
// Return mode stack: [EditorMode, Common, Global]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use case:** Simple app doesn't care about modes. komp_ac needs dynamic resolution based on editor state.
|
||||
|
||||
#### OverlayManager
|
||||
|
||||
**Default:** Simple dialog/input overlay
|
||||
|
||||
```rust
|
||||
pub struct DefaultOverlayManager {
|
||||
stack: Vec<Overlay>,
|
||||
}
|
||||
|
||||
impl OverlayManager for DefaultOverlayManager {
|
||||
fn handle_input(&mut self, key: Key) -> Option<OverlayResult> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**komp_ac extension:** Complex overlay types (command palette, find file, search palette)
|
||||
|
||||
```rust
|
||||
pub struct KompAcOverlayManager {
|
||||
command_bar: CommandBar,
|
||||
find_file: FindFilePalette,
|
||||
search: SearchPalette,
|
||||
}
|
||||
|
||||
impl OverlayManager for KompAcOverlayManager {
|
||||
fn handle_input(&mut self, key: Key) -> Option<OverlayResult> {
|
||||
// Route to appropriate overlay
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use case:** Simple app uses built-in dialogs. komp_ac needs custom overlays that integrate with editor, gRPC, etc.
|
||||
|
||||
#### EventHandler
|
||||
|
||||
**Default:** Return events to user
|
||||
|
||||
```rust
|
||||
pub struct DefaultEventHandler<E>;
|
||||
|
||||
impl<E> EventHandler for DefaultEventHandler<E> {
|
||||
fn handle(&mut self, event: E) -> Result<HandleResult> {
|
||||
// Just pass events back to user
|
||||
Ok(HandleResult::Consumed)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**komp_ac extension:** Route to page/global/canvas handlers
|
||||
|
||||
```rust
|
||||
pub struct KompAcEventHandler {
|
||||
router: Router,
|
||||
focus: FocusManager,
|
||||
canvas_handlers: HashMap<Page, Box<dyn CanvasHandler>>,
|
||||
}
|
||||
|
||||
impl EventHandler for KompAcEventHandler {
|
||||
fn handle(&mut self, event: AppEvent) -> Result<HandleResult> {
|
||||
match self.focus.current() {
|
||||
Some(FocusTarget::CanvasField(_)) => self.canvas_handler.handle(event),
|
||||
_ => self.page_handler.handle(event),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use case:** Simple app just processes events. komp_ac needs complex routing based on focus type and context.
|
||||
|
||||
### Layer 3: App Logic
|
||||
|
||||
**This is entirely user-defined:**
|
||||
- Page structs/enums
|
||||
- Business logic
|
||||
- API calls (gRPC, HTTP)
|
||||
- State management
|
||||
- Rendering
|
||||
|
||||
The library never touches this.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Associated Types vs Generics
|
||||
|
||||
**Choice:** Component trait uses associated types
|
||||
|
||||
```rust
|
||||
pub trait Component {
|
||||
type Focus: FocusId;
|
||||
type Action: Action;
|
||||
type Event: Clone + Debug;
|
||||
}
|
||||
```
|
||||
|
||||
**Why:**
|
||||
- One component = one configuration
|
||||
- Type system ensures consistency
|
||||
- Cleaner trait signature
|
||||
|
||||
**Alternative:** Generics `Component<F, A, E>`
|
||||
|
||||
**Why not:**
|
||||
- More verbose
|
||||
- Type inference harder
|
||||
- Less "component feels like a thing"
|
||||
|
||||
### 2. Automatic vs Explicit Navigation
|
||||
|
||||
**Choice:** Library automatically moves focus on Next/Prev actions
|
||||
|
||||
**Why:**
|
||||
- Reduces boilerplate
|
||||
- Consistent behavior across apps
|
||||
- Component only needs to know "button was pressed"
|
||||
|
||||
**Alternative:** Library passes Next/Prev action, component decides what to do
|
||||
|
||||
**Why not:**
|
||||
- Every component implements same logic
|
||||
- Easy to miss patterns
|
||||
- Library already has FocusManager—use it
|
||||
|
||||
**Escape hatch:** Components can override with `can_navigate_forward/backward()`
|
||||
|
||||
### 3. Event Model
|
||||
|
||||
**Choice:** Components return `Option<Event>`, library collects and returns
|
||||
|
||||
**Why:**
|
||||
- Library can handle internal events (focus changes, page nav)
|
||||
- Users get clean list of events to process
|
||||
- Decouples component from application
|
||||
|
||||
**Alternative:** Components emit events directly to channel/bus
|
||||
|
||||
**Why not:**
|
||||
- Requires async or channels
|
||||
- More complex setup
|
||||
- Library can't orchestrate internal events
|
||||
|
||||
### 4. Page vs Component
|
||||
|
||||
**Choice:** Library doesn't distinguish—everything is a Component
|
||||
|
||||
**Why:**
|
||||
- Simpler API
|
||||
- User can nest components if needed
|
||||
- Flat hierarchy, easy to understand
|
||||
|
||||
**Alternative:** Library has `Page` and `Component` concepts
|
||||
|
||||
**Why not:**
|
||||
- Forces app structure
|
||||
- Some apps don't have pages
|
||||
- More concepts to learn
|
||||
|
||||
### 5. Extension Points
|
||||
|
||||
**Choice:** Extension points are trait objects (`Box<dyn Trait> + 'static`)
|
||||
|
||||
**Why:**
|
||||
- Allows komp_ac to pass stateful resolvers
|
||||
- Flexible at runtime
|
||||
- Can be swapped dynamically
|
||||
|
||||
**Alternative:** Generic with bounds (`<R: ModeResolver + Sized>`)
|
||||
|
||||
**Why not:**
|
||||
- Monomorphization bloat
|
||||
- Can't store different implementations
|
||||
- Less flexible
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Building Blocks vs Framework
|
||||
|
||||
### Building Blocks (Old Design)
|
||||
|
||||
**What user writes:**
|
||||
|
||||
```rust
|
||||
// Setup
|
||||
let mut focus = FocusManager::new();
|
||||
let mut bindings = Bindings::new();
|
||||
let mut router = Router::new();
|
||||
|
||||
// Configuration
|
||||
bindings.bind(Key::tab(), MyAction::Next);
|
||||
bindings.bind(Key::enter(), MyAction::Select);
|
||||
focus.set_targets(page.targets());
|
||||
router.navigate(Page::Login);
|
||||
|
||||
// Main loop
|
||||
loop {
|
||||
let key = read_key()?;
|
||||
|
||||
if let Some(action) = bindings.handle(key) {
|
||||
match action {
|
||||
MyAction::Next => focus.next(),
|
||||
MyAction::Select => {
|
||||
let focused = focus.current()?;
|
||||
let result = page.handle_button(focused)?;
|
||||
// Handle result...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(&focus, &router)?;
|
||||
}
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
- Tons of boilerplate
|
||||
- User must understand all systems
|
||||
- Easy to miss lifecycle (forgot to call on_exit?)
|
||||
- Manual wiring everywhere
|
||||
- Every app reinvents same code
|
||||
|
||||
### Framework (New Design)
|
||||
|
||||
**What user writes:**
|
||||
|
||||
```rust
|
||||
impl Component for LoginPage {
|
||||
fn targets(&self) -> &[Focus] { ... }
|
||||
fn handle(&mut self, focus: &Focus, action: Action) -> Result<Option<Event>> { ... }
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut orch = Orchestrator::builder()
|
||||
.with_page("login", LoginPage::new())
|
||||
.build()?;
|
||||
|
||||
orch.run()?;
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Zero boilerplate
|
||||
- Library handles everything
|
||||
- Lifecycle automatic
|
||||
- Consistent behavior
|
||||
- Easy to reason about
|
||||
|
||||
---
|
||||
|
||||
## Extension Strategy for komp_ac
|
||||
|
||||
### What komp_ac Keeps
|
||||
|
||||
komp_ac continues to own:
|
||||
- All page state and logic
|
||||
- gRPC client and authentication
|
||||
- Rendering with ratatui
|
||||
- Canvas editor integration
|
||||
- Command palette logic
|
||||
- Find file palette logic
|
||||
- Business rules
|
||||
|
||||
### What komp_ac Replaces
|
||||
|
||||
komp_ac removes:
|
||||
- `InputOrchestrator` - Uses library's `Orchestrator`
|
||||
- `ActionDecider` routing logic - Uses library's event handler
|
||||
- Manual lifecycle calls - Uses library's automatic hooks
|
||||
- Mode stack assembly - Uses library's `ModeResolver` extension
|
||||
- Overlay management - Uses library's `OverlayManager` extension
|
||||
|
||||
### Integration Pattern
|
||||
|
||||
komp_ac implements `Component` trait for each page:
|
||||
|
||||
```rust
|
||||
impl Component for LoginPage {
|
||||
type Focus = FocusTarget;
|
||||
type Action = ResolvedAction;
|
||||
type Event = AppEvent;
|
||||
|
||||
fn targets(&self) -> &[Self::Focus] {
|
||||
// Return existing focus targets
|
||||
&[FocusTarget::CanvasField(0), FocusTarget::Button(0), ...]
|
||||
}
|
||||
|
||||
fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result<Option<Self::Event>> {
|
||||
// Return existing app events
|
||||
match (focus, action) {
|
||||
(FocusTarget::Button(0), ResolvedAction::Keybind(KeybindAction::Save)) => {
|
||||
Ok(Some(AppEvent::FormSave { path: self.path.clone() }))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
komp_ac uses extension points:
|
||||
|
||||
```rust
|
||||
let mut orch = Orchestrator::new()
|
||||
.with_mode_resolver(CanvasModeResolver::new(app_state))
|
||||
.with_overlay_manager(KompAcOverlayManager::new())
|
||||
.with_event_handler(KompAcEventHandler::new(router, focus));
|
||||
```
|
||||
|
||||
**Result:** komp_ac uses library's core while keeping all custom behavior.
|
||||
|
||||
---
|
||||
|
||||
## Future-Proofing
|
||||
|
||||
### What Can Be Added Without Breaking Changes
|
||||
|
||||
1. **Additional lifecycle hooks:** Add new methods to `Component` trait with default impls
|
||||
2. **More actions:** Add variants to `ComponentAction` enum
|
||||
3. **New overlay types:** Implement `OverlayManager` trait
|
||||
4. **Custom input sources:** Implement `InputSource` trait
|
||||
5. **Animation support:** Add hooks for frame updates
|
||||
6. **Accessibility:** Add hooks for screen readers
|
||||
|
||||
### What Requires Breaking Changes
|
||||
|
||||
1. **Component trait signature:** Changing associated types
|
||||
2. **Orchestrator API:** Major method signature changes
|
||||
3. **Extension point contracts:** Changing trait methods
|
||||
|
||||
**Strategy:** Mark APIs as `#[doc(hidden)]` or `#[deprecated]` before removing.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The redesigned TUI Orchestrator is:
|
||||
|
||||
1. **Complete framework** - Not just building blocks
|
||||
2. **Zero boilerplate** - Users define components, library runs show
|
||||
3. **Sensible defaults** - Works without configuration
|
||||
4. **Fully extendable** - Trait-based extension points
|
||||
5. **komp_ac compatible** - Can replace existing orchestration
|
||||
6. **User-focused** - "register page" not "bind chord to registry"
|
||||
|
||||
The library becomes a **TUI runtime** where users write application logic and library handles everything else.
|
||||
57
examples/focus_example.rs
Normal file
57
examples/focus_example.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
extern crate alloc;
|
||||
|
||||
use tui_orchestrator::focus::{FocusManager, Focusable};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum FormElement {
|
||||
Username,
|
||||
Password,
|
||||
RememberMe,
|
||||
Submit,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct LoginForm {
|
||||
username: String,
|
||||
password: String,
|
||||
remember: bool,
|
||||
}
|
||||
|
||||
impl Focusable<FormElement> for LoginForm {
|
||||
fn focus_targets(&self) -> alloc::vec::Vec<FormElement> {
|
||||
vec![
|
||||
FormElement::Username,
|
||||
FormElement::Password,
|
||||
FormElement::RememberMe,
|
||||
FormElement::Submit,
|
||||
FormElement::Cancel,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let form = LoginForm {
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
remember: false,
|
||||
};
|
||||
|
||||
let mut focus_manager: FocusManager<FormElement> = FocusManager::new();
|
||||
focus_manager.set_targets(form.focus_targets());
|
||||
|
||||
assert_eq!(focus_manager.current(), Some(&FormElement::Username));
|
||||
|
||||
focus_manager.next();
|
||||
assert_eq!(focus_manager.current(), Some(&FormElement::Password));
|
||||
|
||||
focus_manager.set_focus(FormElement::Submit).unwrap();
|
||||
assert_eq!(focus_manager.current(), Some(&FormElement::Submit));
|
||||
|
||||
let query = focus_manager.query();
|
||||
assert!(query.is_focused(&FormElement::Submit));
|
||||
|
||||
focus_manager.set_overlay(FormElement::Cancel);
|
||||
assert!(focus_manager.has_overlay());
|
||||
assert_eq!(focus_manager.current(), Some(&FormElement::Cancel));
|
||||
}
|
||||
19
src/component/action.rs
Normal file
19
src/component/action.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
// path_from_the_root: src/component/action.rs
|
||||
|
||||
use crate::input::Action;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ComponentAction {
|
||||
Next,
|
||||
Prev,
|
||||
First,
|
||||
Last,
|
||||
Select,
|
||||
Cancel,
|
||||
TypeChar(char),
|
||||
Backspace,
|
||||
Delete,
|
||||
Custom(usize),
|
||||
}
|
||||
|
||||
impl Action for ComponentAction {}
|
||||
7
src/component/error.rs
Normal file
7
src/component/error.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// path_from_the_root: src/component/error.rs
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ComponentError {
|
||||
EmptyTargets,
|
||||
InvalidFocus,
|
||||
}
|
||||
9
src/component/mod.rs
Normal file
9
src/component/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// path_from_the_root: src/component/mod.rs
|
||||
|
||||
pub mod action;
|
||||
pub mod error;
|
||||
pub mod r#trait;
|
||||
|
||||
pub use action::ComponentAction;
|
||||
pub use error::ComponentError;
|
||||
pub use r#trait::Component;
|
||||
50
src/component/trait.rs
Normal file
50
src/component/trait.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
// path_from_the_root: src/component/trait.rs
|
||||
|
||||
use super::error::ComponentError;
|
||||
use crate::focus::FocusId;
|
||||
|
||||
pub trait Component {
|
||||
type Focus: FocusId;
|
||||
type Action: core::fmt::Debug + Clone;
|
||||
type Event: Clone + core::fmt::Debug;
|
||||
|
||||
fn targets(&self) -> &[Self::Focus];
|
||||
|
||||
fn handle(
|
||||
&mut self,
|
||||
focus: &Self::Focus,
|
||||
action: Self::Action,
|
||||
) -> Result<Option<Self::Event>, ComponentError>;
|
||||
|
||||
fn on_enter(&mut self) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, _focus: &Self::Focus) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, _focus: &Self::Focus) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_text(
|
||||
&mut self,
|
||||
focus: &Self::Focus,
|
||||
_ch: char,
|
||||
) -> Result<Option<Self::Event>, ComponentError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn can_navigate_forward(&self, _focus: &Self::Focus) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn can_navigate_backward(&self, _focus: &Self::Focus) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
8
src/focus/error.rs
Normal file
8
src/focus/error.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
// path_from_the_root: src/focus/error.rs
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FocusError {
|
||||
TargetNotFound,
|
||||
EmptyTargets,
|
||||
OverlayActive,
|
||||
}
|
||||
5
src/focus/id.rs
Normal file
5
src/focus/id.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
// path_from_the_root: src/focus/id.rs
|
||||
|
||||
pub trait FocusId: Clone + PartialEq + Eq + core::hash::Hash {}
|
||||
|
||||
impl<T: Clone + PartialEq + Eq + core::hash::Hash> FocusId for T {}
|
||||
136
src/focus/manager.rs
Normal file
136
src/focus/manager.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
// path_from_the_root: src/focus/manager.rs
|
||||
|
||||
use super::error::FocusError;
|
||||
use super::id::FocusId;
|
||||
use super::query::FocusQuery;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FocusManager<F: FocusId> {
|
||||
targets: alloc::vec::Vec<F>,
|
||||
index: usize,
|
||||
overlay: Option<F>,
|
||||
}
|
||||
|
||||
impl<F: FocusId> Default for FocusManager<F> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: FocusId> FocusManager<F> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
targets: alloc::vec::Vec::new(),
|
||||
index: 0,
|
||||
overlay: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_targets(&mut self, targets: alloc::vec::Vec<F>) {
|
||||
self.targets = targets;
|
||||
self.index = 0;
|
||||
}
|
||||
|
||||
pub fn add_target(&mut self, id: F) {
|
||||
if !self.targets.contains(&id) {
|
||||
self.targets.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_target(&mut self, id: &F) {
|
||||
if let Some(pos) = self.targets.iter().position(|t| t == id) {
|
||||
self.targets.remove(pos);
|
||||
if self.index >= self.targets.len() && !self.targets.is_empty() {
|
||||
self.index = self.targets.len() - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current(&self) -> Option<&F> {
|
||||
if let Some(overlay) = &self.overlay {
|
||||
return Some(overlay);
|
||||
}
|
||||
|
||||
self.targets.get(self.index)
|
||||
}
|
||||
|
||||
pub fn query(&self) -> FocusQuery<'_, F> {
|
||||
FocusQuery {
|
||||
current: self.current(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_focused(&self, id: &F) -> bool {
|
||||
self.current() == Some(id)
|
||||
}
|
||||
|
||||
pub fn has_overlay(&self) -> bool {
|
||||
self.overlay.is_some()
|
||||
}
|
||||
|
||||
pub fn set_focus(&mut self, id: F) -> Result<(), FocusError> {
|
||||
if self.targets.is_empty() {
|
||||
return Err(FocusError::EmptyTargets);
|
||||
}
|
||||
|
||||
if let Some(pos) = self.targets.iter().position(|t| t == &id) {
|
||||
self.index = pos;
|
||||
self.overlay = None;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FocusError::TargetNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_overlay(&mut self, id: F) {
|
||||
self.overlay = Some(id);
|
||||
}
|
||||
|
||||
pub fn clear_overlay(&mut self) {
|
||||
self.overlay = None;
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if self.overlay.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.targets.is_empty() && self.index < self.targets.len() - 1 {
|
||||
self.index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(&mut self) {
|
||||
if self.overlay.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.targets.is_empty() && self.index > 0 {
|
||||
self.index -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn first(&mut self) {
|
||||
self.index = 0;
|
||||
self.overlay = None;
|
||||
}
|
||||
|
||||
pub fn last(&mut self) {
|
||||
if !self.targets.is_empty() {
|
||||
self.index = self.targets.len() - 1;
|
||||
}
|
||||
self.overlay = None;
|
||||
}
|
||||
|
||||
pub fn targets(&self) -> &[F] {
|
||||
&self.targets
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.targets.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.targets.is_empty()
|
||||
}
|
||||
}
|
||||
13
src/focus/mod.rs
Normal file
13
src/focus/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
// path_from_the_root: src/focus/mod.rs
|
||||
|
||||
pub mod error;
|
||||
pub mod id;
|
||||
pub mod manager;
|
||||
pub mod query;
|
||||
pub mod traits;
|
||||
|
||||
pub use error::FocusError;
|
||||
pub use id::FocusId;
|
||||
pub use manager::FocusManager;
|
||||
pub use query::FocusQuery;
|
||||
pub use traits::Focusable;
|
||||
22
src/focus/query.rs
Normal file
22
src/focus/query.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
// path_from_the_root: src/focus/query.rs
|
||||
|
||||
use super::id::FocusId;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FocusQuery<'a, F: FocusId> {
|
||||
pub current: Option<&'a F>,
|
||||
}
|
||||
|
||||
impl<'a, F: FocusId> FocusQuery<'a, F> {
|
||||
pub fn new(current: Option<&'a F>) -> Self {
|
||||
Self { current }
|
||||
}
|
||||
|
||||
pub fn is_focused(&self, id: &F) -> bool {
|
||||
self.current == Some(id)
|
||||
}
|
||||
|
||||
pub fn has_focus(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
}
|
||||
12
src/focus/traits.rs
Normal file
12
src/focus/traits.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// path_from_the_root: src/focus/traits.rs
|
||||
|
||||
use super::error::FocusError;
|
||||
use super::id::FocusId;
|
||||
|
||||
pub trait Focusable<F: FocusId> {
|
||||
fn focus_targets(&self) -> alloc::vec::Vec<F>;
|
||||
|
||||
fn on_focus_change(&mut self, _id: &F) -> Result<(), FocusError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
5
src/input/action.rs
Normal file
5
src/input/action.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
// path_from_the_root: src/input/action.rs
|
||||
|
||||
pub trait Action: Clone + PartialEq + Eq + core::fmt::Debug {}
|
||||
|
||||
impl Action for ComponentAction {}
|
||||
70
src/input/bindings.rs
Normal file
70
src/input/bindings.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
// path_from_the_root: src/input/bindings.rs
|
||||
|
||||
use super::action::Action;
|
||||
use super::key::Key;
|
||||
|
||||
#[cfg(feature = "alloc")]
|
||||
use hashbrown::HashSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Bindings<A: Action> {
|
||||
bindings: alloc::vec::Vec<(Key, A)>,
|
||||
}
|
||||
|
||||
impl<A: Action> Bindings<A> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
bindings: alloc::vec::Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bind(&mut self, key: Key, action: A) {
|
||||
self.bindings.push((key, action));
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &Key) -> Option<&A> {
|
||||
self.bindings.iter().find(|(k, _)| k == key).map(|(_, a)| a)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &Key) {
|
||||
self.bindings.retain(|(k, _)| k != key);
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.bindings.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.bindings.len()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &(Key, A)> {
|
||||
self.bindings.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: Action> Default for Bindings<A> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sequences")]
|
||||
impl<A: Action + core::hash::Hash + Eq> Bindings<A> {
|
||||
pub fn bind_sequence(&mut self, keys: alloc::vec::Vec<Key>, action: A) {
|
||||
for key in keys {
|
||||
self.bindings.push((key, action.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_sequences(&self) -> alloc::vec::Vec<&A> {
|
||||
let mut actions = alloc::vec::Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for (_, action) in &self.bindings {
|
||||
if seen.insert(action) {
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
actions
|
||||
}
|
||||
}
|
||||
100
src/input/handler.rs
Normal file
100
src/input/handler.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
// path_from_the_root: src/input/handler.rs
|
||||
|
||||
#[cfg(feature = "sequences")]
|
||||
use super::action::Action;
|
||||
|
||||
#[cfg(feature = "sequences")]
|
||||
use super::key::Key;
|
||||
|
||||
#[cfg(feature = "sequences")]
|
||||
use super::result::MatchResult;
|
||||
|
||||
#[cfg(feature = "sequences")]
|
||||
pub struct SequenceHandler<A: Action> {
|
||||
sequences: alloc::vec::Vec<(alloc::vec::Vec<Key>, A)>,
|
||||
current: alloc::vec::Vec<Key>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sequences")]
|
||||
impl<A: Action> SequenceHandler<A> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sequences: alloc::vec::Vec::new(),
|
||||
current: alloc::vec::Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bind(&mut self, keys: impl IntoIterator<Item = Key>, action: A) {
|
||||
let vec: alloc::vec::Vec<Key> = keys.into_iter().collect();
|
||||
self.sequences.push((vec, action));
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, key: Key) -> MatchResult<A> {
|
||||
self.current.push(key);
|
||||
|
||||
for (seq, action) in &self.sequences {
|
||||
if seq == &self.current {
|
||||
let action = action.clone();
|
||||
self.current.clear();
|
||||
return MatchResult::Match(action);
|
||||
}
|
||||
}
|
||||
|
||||
let is_prefix = self
|
||||
.sequences
|
||||
.iter()
|
||||
.any(|(seq, _)| seq.len() > self.current.len() && seq.starts_with(&self.current));
|
||||
|
||||
if is_prefix {
|
||||
MatchResult::Pending
|
||||
} else {
|
||||
self.current.clear();
|
||||
MatchResult::NoMatch
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.current.clear();
|
||||
}
|
||||
|
||||
pub fn current_sequence(&self) -> &[Key] {
|
||||
&self.current
|
||||
}
|
||||
|
||||
pub fn in_sequence(&self) -> bool {
|
||||
!self.current.is_empty()
|
||||
}
|
||||
|
||||
pub fn continuations(&self) -> alloc::vec::Vec<(&Key, &[Key], &A)> {
|
||||
if self.current.is_empty() {
|
||||
return alloc::vec::Vec::new();
|
||||
}
|
||||
|
||||
let current = &self.current;
|
||||
let current_len = current.len();
|
||||
|
||||
self.sequences
|
||||
.iter()
|
||||
.filter_map(move |(seq, action)| {
|
||||
if seq.len() > current_len && seq.starts_with(current) {
|
||||
let next_key = &seq[current_len];
|
||||
let remaining = &seq[current_len + 1..];
|
||||
Some((next_key, remaining, action))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn all_sequences(&self) -> impl Iterator<Item = (&[Key], &A)> {
|
||||
self.sequences.iter().map(|(k, a)| (k.as_slice(), a))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sequences")]
|
||||
impl<A: Action> Default for SequenceHandler<A> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
127
src/input/key.rs
Normal file
127
src/input/key.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
// path_from_the_root: src/input/key.rs
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum KeyCode {
|
||||
Char(char),
|
||||
Enter,
|
||||
Tab,
|
||||
Esc,
|
||||
Backspace,
|
||||
Delete,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
F(u8),
|
||||
Null,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub struct KeyModifiers {
|
||||
pub control: bool,
|
||||
pub alt: bool,
|
||||
pub shift: bool,
|
||||
}
|
||||
|
||||
impl KeyModifiers {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
control: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_control(mut self) -> Self {
|
||||
self.control = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_alt(mut self) -> Self {
|
||||
self.alt = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_shift(mut self) -> Self {
|
||||
self.shift = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn is_empty(&self) -> bool {
|
||||
!self.control && !self.alt && !self.shift
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct Key {
|
||||
pub code: KeyCode,
|
||||
pub modifiers: KeyModifiers,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
pub const fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
|
||||
Self { code, modifiers }
|
||||
}
|
||||
|
||||
pub const fn char(c: char) -> Self {
|
||||
Self {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn ctrl(c: char) -> Self {
|
||||
Self {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::new().with_control(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_string(&self) -> alloc::string::String {
|
||||
let mut out = alloc::string::String::new();
|
||||
if self.modifiers.control {
|
||||
out.push_str("Ctrl+");
|
||||
}
|
||||
if self.modifiers.alt {
|
||||
out.push_str("Alt+");
|
||||
}
|
||||
if self.modifiers.shift {
|
||||
out.push_str("Shift+");
|
||||
}
|
||||
match self.code {
|
||||
KeyCode::Char(c) => out.push(c),
|
||||
KeyCode::Enter => out.push_str("Enter"),
|
||||
KeyCode::Tab => out.push_str("Tab"),
|
||||
KeyCode::Esc => out.push_str("Esc"),
|
||||
KeyCode::Backspace => out.push_str("Backspace"),
|
||||
KeyCode::Delete => out.push_str("Delete"),
|
||||
KeyCode::Up => out.push_str("Up"),
|
||||
KeyCode::Down => out.push_str("Down"),
|
||||
KeyCode::Left => out.push_str("Left"),
|
||||
KeyCode::Right => out.push_str("Right"),
|
||||
KeyCode::F(n) => {
|
||||
out.push('F');
|
||||
out.push(char::from_digit(n as u32, 10).unwrap_or('0'));
|
||||
}
|
||||
KeyCode::Home => out.push_str("Home"),
|
||||
KeyCode::End => out.push_str("End"),
|
||||
KeyCode::PageUp => out.push_str("PageUp"),
|
||||
KeyCode::PageDown => out.push_str("PageDown"),
|
||||
KeyCode::Null => out.push_str("Null"),
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KeyCode> for Key {
|
||||
fn from(code: KeyCode) -> Self {
|
||||
Self {
|
||||
code,
|
||||
modifiers: KeyModifiers::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/input/mod.rs
Normal file
15
src/input/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
// path_from_the_root: src/input/mod.rs
|
||||
|
||||
pub mod action;
|
||||
pub mod bindings;
|
||||
pub mod handler;
|
||||
pub mod key;
|
||||
pub mod result;
|
||||
|
||||
pub use action::Action;
|
||||
pub use bindings::Bindings;
|
||||
pub use key::{Key, KeyCode, KeyModifiers};
|
||||
pub use result::MatchResult;
|
||||
|
||||
#[cfg(feature = "sequences")]
|
||||
pub use handler::SequenceHandler;
|
||||
29
src/input/result.rs
Normal file
29
src/input/result.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
// path_from_the_root: src/input/result.rs
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MatchResult<A> {
|
||||
Match(A),
|
||||
Pending,
|
||||
NoMatch,
|
||||
}
|
||||
|
||||
impl<A> MatchResult<A> {
|
||||
pub fn matched(&self) -> bool {
|
||||
matches!(self, Self::Match(_))
|
||||
}
|
||||
|
||||
pub fn pending(&self) -> bool {
|
||||
matches!(self, Self::Pending)
|
||||
}
|
||||
|
||||
pub fn no_match(&self) -> bool {
|
||||
matches!(self, Self::NoMatch)
|
||||
}
|
||||
|
||||
pub fn into_match(self) -> Option<A> {
|
||||
match self {
|
||||
Self::Match(a) => Some(a),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/lib.rs
Normal file
13
src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
#![no_std]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
pub mod component;
|
||||
pub mod focus;
|
||||
pub mod input;
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::component::*;
|
||||
pub use crate::focus::*;
|
||||
pub use crate::input::*;
|
||||
}
|
||||
7
src/prelude.rs
Normal file
7
src/prelude.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// path_from_the_root: src/prelude.rs
|
||||
|
||||
pub use crate::component::action::ComponentAction;
|
||||
pub use crate::component::error::ComponentError;
|
||||
pub use crate::component::Component;
|
||||
pub use crate::focus::*;
|
||||
pub use crate::input::*;
|
||||
73
tests/bindings.rs
Normal file
73
tests/bindings.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use tui_orchestrator::input::{Bindings, Key};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[allow(dead_code)]
|
||||
enum TestAction {
|
||||
Quit,
|
||||
Save,
|
||||
Open,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bindings_new() {
|
||||
let _bindings: Bindings<TestAction> = Bindings::new();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bindings_bind() {
|
||||
let mut bindings: Bindings<TestAction> = Bindings::new();
|
||||
bindings.bind(Key::char('q'), TestAction::Quit);
|
||||
assert_eq!(bindings.get(&Key::char('q')), Some(&TestAction::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bindings_get_not_found() {
|
||||
let mut bindings: Bindings<TestAction> = Bindings::new();
|
||||
bindings.bind(Key::char('q'), TestAction::Quit);
|
||||
assert_eq!(bindings.get(&Key::char('x')), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bindings_remove() {
|
||||
let mut bindings: Bindings<TestAction> = Bindings::new();
|
||||
bindings.bind(Key::char('q'), TestAction::Quit);
|
||||
bindings.remove(&Key::char('q'));
|
||||
assert_eq!(bindings.get(&Key::char('q')), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bindings_is_empty() {
|
||||
let mut bindings: Bindings<TestAction> = Bindings::new();
|
||||
assert!(bindings.is_empty());
|
||||
|
||||
bindings.bind(Key::char('q'), TestAction::Quit);
|
||||
assert!(!bindings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bindings_len() {
|
||||
let mut bindings: Bindings<TestAction> = Bindings::new();
|
||||
assert_eq!(bindings.len(), 0);
|
||||
|
||||
bindings.bind(Key::char('q'), TestAction::Quit);
|
||||
assert_eq!(bindings.len(), 1);
|
||||
|
||||
bindings.bind(Key::char('s'), TestAction::Save);
|
||||
assert_eq!(bindings.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bindings_iter() {
|
||||
let mut bindings: Bindings<TestAction> = Bindings::new();
|
||||
bindings.bind(Key::char('q'), TestAction::Quit);
|
||||
bindings.bind(Key::char('s'), TestAction::Save);
|
||||
|
||||
let actions: Vec<_> = bindings.iter().map(|(_, a)| *a).collect();
|
||||
assert!(actions.contains(&TestAction::Quit));
|
||||
assert!(actions.contains(&TestAction::Save));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bindings_default() {
|
||||
let _bindings: Bindings<TestAction> = Bindings::default();
|
||||
}
|
||||
122
tests/component_tests.rs
Normal file
122
tests/component_tests.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
extern crate alloc;
|
||||
|
||||
use tui_orchestrator::component::{Component, ComponentAction};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum TestFocus {
|
||||
FieldA,
|
||||
FieldB,
|
||||
ButtonC,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum TestEvent {
|
||||
ButtonCPressed,
|
||||
TextTyped(char),
|
||||
}
|
||||
|
||||
struct TestComponent {
|
||||
field_a: alloc::string::String,
|
||||
field_b: alloc::string::String,
|
||||
}
|
||||
|
||||
impl Component for TestComponent {
|
||||
type Focus = TestFocus;
|
||||
type Action = ComponentAction;
|
||||
type Event = TestEvent;
|
||||
|
||||
fn targets(&self) -> &[Self::Focus] {
|
||||
&[
|
||||
Self::Focus::FieldA,
|
||||
Self::Focus::FieldB,
|
||||
Self::Focus::ButtonC,
|
||||
]
|
||||
}
|
||||
|
||||
fn handle(
|
||||
&mut self,
|
||||
focus: &Self::Focus,
|
||||
action: Self::Action,
|
||||
) -> Result<Option<Self::Event>, tui_orchestrator::component::error::ComponentError> {
|
||||
match (focus, action) {
|
||||
(Self::Focus::ButtonC, ComponentAction::Select) => {
|
||||
Ok(Some(Self::Event::ButtonCPressed))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> Result<(), tui_orchestrator::component::error::ComponentError> {
|
||||
self.field_a.clear();
|
||||
self.field_b.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_targets() {
|
||||
let mut component = TestComponent {
|
||||
field_a: alloc::string::String::new(),
|
||||
field_b: alloc::string::String::new(),
|
||||
};
|
||||
|
||||
let targets = component.targets();
|
||||
assert_eq!(targets.len(), 3);
|
||||
assert_eq!(targets[0], TestFocus::FieldA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_handle_select() {
|
||||
let mut component = TestComponent {
|
||||
field_a: alloc::string::String::new(),
|
||||
field_b: alloc::string::String::new(),
|
||||
};
|
||||
|
||||
let focus = TestFocus::ButtonC;
|
||||
let action = ComponentAction::Select;
|
||||
|
||||
let event = component.handle(&focus, action);
|
||||
assert!(event.is_ok());
|
||||
assert!(matches!(event.unwrap(), Some(TestEvent::ButtonCPressed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_handle_text() {
|
||||
let mut component = TestComponent {
|
||||
field_a: alloc::string::String::new(),
|
||||
field_b: alloc::string::String::new(),
|
||||
};
|
||||
|
||||
let focus = TestFocus::FieldA;
|
||||
let ch = 'x';
|
||||
|
||||
let event = component.handle_text(&focus, ch);
|
||||
assert!(event.is_ok());
|
||||
assert!(matches!(event.unwrap(), Some(TestEvent::TextTyped('x'))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_on_enter_clears() {
|
||||
let mut component = TestComponent {
|
||||
field_a: alloc::string::String::from("test"),
|
||||
field_b: alloc::string::String::from("test"),
|
||||
};
|
||||
|
||||
component.on_enter().unwrap();
|
||||
assert_eq!(component.field_a.as_str(), "");
|
||||
assert_eq!(component.field_b.as_str(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_defaults() {
|
||||
let component = TestComponent {
|
||||
field_a: alloc::string::String::new(),
|
||||
field_b: alloc::string::String::new(),
|
||||
};
|
||||
|
||||
assert!(component.on_exit().is_ok());
|
||||
assert!(component.on_focus(&TestFocus::FieldA).is_ok());
|
||||
assert!(component.on_blur(&TestFocus::FieldA).is_ok());
|
||||
assert!(component.can_navigate_forward(&TestFocus::FieldA));
|
||||
assert!(component.can_navigate_backward(&TestFocus::FieldA));
|
||||
}
|
||||
283
tests/focus.rs
Normal file
283
tests/focus.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
extern crate alloc;
|
||||
|
||||
use tui_orchestrator::focus::{FocusError, FocusManager, Focusable};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[allow(dead_code)]
|
||||
enum TestId {
|
||||
Field(usize),
|
||||
Button(&'static str),
|
||||
Menu,
|
||||
Dialog,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_focus_id_trait() {
|
||||
let id1 = TestId::Button("save");
|
||||
let id2 = TestId::Button("save");
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_new() {
|
||||
let manager: FocusManager<TestId> = FocusManager::new();
|
||||
assert!(manager.is_empty());
|
||||
assert_eq!(manager.current(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_set_targets() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
manager.set_targets(vec![
|
||||
TestId::Field(0),
|
||||
TestId::Field(1),
|
||||
TestId::Button("save"),
|
||||
]);
|
||||
|
||||
assert_eq!(manager.len(), 3);
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_navigation() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
manager.set_targets(vec![
|
||||
TestId::Field(0),
|
||||
TestId::Field(1),
|
||||
TestId::Button("save"),
|
||||
]);
|
||||
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(0)));
|
||||
|
||||
manager.next();
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(1)));
|
||||
|
||||
manager.next();
|
||||
assert_eq!(manager.current(), Some(&TestId::Button("save")));
|
||||
|
||||
manager.next();
|
||||
assert_eq!(manager.current(), Some(&TestId::Button("save")));
|
||||
|
||||
manager.prev();
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(1)));
|
||||
|
||||
manager.first();
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(0)));
|
||||
|
||||
manager.last();
|
||||
assert_eq!(manager.current(), Some(&TestId::Button("save")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_prev_at_start() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
manager.set_targets(vec![
|
||||
TestId::Field(0),
|
||||
TestId::Field(1),
|
||||
TestId::Button("save"),
|
||||
]);
|
||||
|
||||
manager.prev();
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_set_focus() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
manager.set_targets(vec![
|
||||
TestId::Field(0),
|
||||
TestId::Field(1),
|
||||
TestId::Button("save"),
|
||||
]);
|
||||
|
||||
let result = manager.set_focus(TestId::Button("save"));
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(manager.current(), Some(&TestId::Button("save")));
|
||||
|
||||
let result = manager.set_focus(TestId::Field(0));
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_set_focus_not_found() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
manager.set_targets(vec![
|
||||
TestId::Field(0),
|
||||
TestId::Field(1),
|
||||
TestId::Button("save"),
|
||||
]);
|
||||
|
||||
let result = manager.set_focus(TestId::Menu);
|
||||
assert_eq!(result, Err(FocusError::TargetNotFound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_set_focus_empty() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
let result = manager.set_focus(TestId::Menu);
|
||||
assert_eq!(result, Err(FocusError::EmptyTargets));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_overlay() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
manager.set_targets(vec![
|
||||
TestId::Field(0),
|
||||
TestId::Field(1),
|
||||
TestId::Button("save"),
|
||||
]);
|
||||
|
||||
manager.set_overlay(TestId::Menu);
|
||||
assert!(manager.has_overlay());
|
||||
assert_eq!(manager.current(), Some(&TestId::Menu));
|
||||
|
||||
manager.next();
|
||||
assert_eq!(manager.current(), Some(&TestId::Menu));
|
||||
|
||||
manager.clear_overlay();
|
||||
assert!(!manager.has_overlay());
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_overlay_with_focus() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
manager.set_targets(vec![
|
||||
TestId::Field(0),
|
||||
TestId::Field(1),
|
||||
TestId::Button("save"),
|
||||
]);
|
||||
|
||||
manager.set_focus(TestId::Button("save")).unwrap();
|
||||
assert_eq!(manager.current(), Some(&TestId::Button("save")));
|
||||
|
||||
manager.set_overlay(TestId::Menu);
|
||||
assert_eq!(manager.current(), Some(&TestId::Menu));
|
||||
|
||||
manager.clear_overlay();
|
||||
assert_eq!(manager.current(), Some(&TestId::Button("save")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_add_remove_target() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
manager.add_target(TestId::Field(0));
|
||||
manager.add_target(TestId::Field(1));
|
||||
|
||||
assert_eq!(manager.len(), 2);
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(0)));
|
||||
|
||||
manager.remove_target(&TestId::Field(0));
|
||||
assert_eq!(manager.len(), 1);
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_remove_current_adjusts_index() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
manager.set_targets(vec![TestId::Field(0), TestId::Field(1), TestId::Field(2)]);
|
||||
|
||||
manager.next();
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(1)));
|
||||
|
||||
manager.remove_target(&TestId::Field(1));
|
||||
assert_eq!(manager.len(), 2);
|
||||
assert_eq!(manager.current(), Some(&TestId::Field(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_query() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
manager.set_targets(vec![
|
||||
TestId::Field(0),
|
||||
TestId::Field(1),
|
||||
TestId::Button("save"),
|
||||
]);
|
||||
|
||||
let query = manager.query();
|
||||
assert_eq!(query.current, Some(&TestId::Field(0)));
|
||||
assert!(query.is_focused(&TestId::Field(0)));
|
||||
assert!(!query.is_focused(&TestId::Field(1)));
|
||||
assert!(query.has_focus());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_query_no_focus() {
|
||||
let manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
let query = manager.query();
|
||||
assert_eq!(query.current, None);
|
||||
assert!(!query.has_focus());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manager_is_focused() {
|
||||
let mut manager: FocusManager<TestId> = FocusManager::new();
|
||||
|
||||
manager.set_targets(vec![
|
||||
TestId::Field(0),
|
||||
TestId::Field(1),
|
||||
TestId::Button("save"),
|
||||
]);
|
||||
|
||||
assert!(manager.is_focused(&TestId::Field(0)));
|
||||
assert!(!manager.is_focused(&TestId::Field(1)));
|
||||
|
||||
manager.next();
|
||||
assert!(!manager.is_focused(&TestId::Field(0)));
|
||||
assert!(manager.is_focused(&TestId::Field(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_focusable_trait() {
|
||||
struct TestComponent;
|
||||
|
||||
impl Focusable<TestId> for TestComponent {
|
||||
fn focus_targets(&self) -> alloc::vec::Vec<TestId> {
|
||||
vec![TestId::Field(0), TestId::Field(1), TestId::Button("save")]
|
||||
}
|
||||
}
|
||||
|
||||
let component = TestComponent;
|
||||
let targets = component.focus_targets();
|
||||
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"));
|
||||
}
|
||||
96
tests/key.rs
Normal file
96
tests/key.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use tui_orchestrator::input::{Key, KeyCode, KeyModifiers};
|
||||
|
||||
#[test]
|
||||
fn test_key_char() {
|
||||
let key = Key::char('a');
|
||||
assert_eq!(key.code, KeyCode::Char('a'));
|
||||
assert!(key.modifiers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_ctrl() {
|
||||
let key = Key::ctrl('s');
|
||||
assert_eq!(key.code, KeyCode::Char('s'));
|
||||
assert!(key.modifiers.control);
|
||||
assert!(!key.modifiers.alt);
|
||||
assert!(!key.modifiers.shift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_new() {
|
||||
let key = Key::new(KeyCode::Enter, KeyModifiers::new().with_alt());
|
||||
assert_eq!(key.code, KeyCode::Enter);
|
||||
assert!(key.modifiers.alt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_from_keycode() {
|
||||
let key = Key::from(KeyCode::Esc);
|
||||
assert_eq!(key.code, KeyCode::Esc);
|
||||
assert!(key.modifiers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_display_char() {
|
||||
let key = Key::char('x');
|
||||
let display = key.display_string();
|
||||
assert!(display.contains('x'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_display_ctrl() {
|
||||
let key = Key::ctrl('c');
|
||||
let display = key.display_string();
|
||||
assert!(display.contains("Ctrl+"));
|
||||
assert!(display.contains('c'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_display_all_modifiers() {
|
||||
let key = Key::new(
|
||||
KeyCode::Char('a'),
|
||||
KeyModifiers::new().with_control().with_alt().with_shift(),
|
||||
);
|
||||
let display = key.display_string();
|
||||
assert!(display.contains("Ctrl+"));
|
||||
assert!(display.contains("Alt+"));
|
||||
assert!(display.contains("Shift+"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_display_special() {
|
||||
let key = Key::new(KeyCode::F(5), KeyModifiers::new());
|
||||
let display = key.display_string();
|
||||
assert!(display.contains("F5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_modifiers_new() {
|
||||
let mods = KeyModifiers::new();
|
||||
assert!(!mods.control);
|
||||
assert!(!mods.alt);
|
||||
assert!(!mods.shift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_modifiers_builders() {
|
||||
let mods = KeyModifiers::new().with_control().with_shift();
|
||||
assert!(mods.control);
|
||||
assert!(!mods.alt);
|
||||
assert!(mods.shift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_modifiers_is_empty() {
|
||||
assert!(KeyModifiers::new().is_empty());
|
||||
assert!(!KeyModifiers::new().with_control().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_equality() {
|
||||
let k1 = Key::char('a');
|
||||
let k2 = Key::char('a');
|
||||
let k3 = Key::ctrl('a');
|
||||
assert_eq!(k1, k2);
|
||||
assert_ne!(k1, k3);
|
||||
}
|
||||
Reference in New Issue
Block a user