From b7eab6b22c0a6166227a9ebb59aef6cec60d7ffc Mon Sep 17 00:00:00 2001 From: filipriec_vm Date: Sun, 11 Jan 2026 09:53:37 +0100 Subject: [PATCH] Recreate repository due to Git object corruption (all files preserved) --- .gitignore | 3 + AGENTS.md | 32 ++ Cargo.lock | 39 ++ Cargo.toml | 16 + INPUT_PIPELINE_MIGRATION.md | 734 ++++++++++++++++++++++++++++++++++++ INTEGRATION_GUIDE.md | 582 ++++++++++++++++++++++++++++ PLAN.md | 540 ++++++++++++++++++++++++++ PROGRESS.md | 431 +++++++++++++++++++++ README.md | 322 ++++++++++++++++ REDESIGN.md | 497 ++++++++++++++++++++++++ examples/focus_example.rs | 57 +++ src/component/action.rs | 19 + src/component/error.rs | 7 + src/component/mod.rs | 9 + src/component/trait.rs | 50 +++ src/focus/error.rs | 8 + src/focus/id.rs | 5 + src/focus/manager.rs | 136 +++++++ src/focus/mod.rs | 13 + src/focus/query.rs | 22 ++ src/focus/traits.rs | 12 + src/input/action.rs | 5 + src/input/bindings.rs | 70 ++++ src/input/handler.rs | 100 +++++ src/input/key.rs | 127 +++++++ src/input/mod.rs | 15 + src/input/result.rs | 29 ++ src/lib.rs | 13 + src/prelude.rs | 7 + tests/bindings.rs | 73 ++++ tests/component_tests.rs | 122 ++++++ tests/focus.rs | 283 ++++++++++++++ tests/key.rs | 96 +++++ 33 files changed, 4474 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 INPUT_PIPELINE_MIGRATION.md create mode 100644 INTEGRATION_GUIDE.md create mode 100644 PLAN.md create mode 100644 PROGRESS.md create mode 100644 README.md create mode 100644 REDESIGN.md create mode 100644 examples/focus_example.rs create mode 100644 src/component/action.rs create mode 100644 src/component/error.rs create mode 100644 src/component/mod.rs create mode 100644 src/component/trait.rs create mode 100644 src/focus/error.rs create mode 100644 src/focus/id.rs create mode 100644 src/focus/manager.rs create mode 100644 src/focus/mod.rs create mode 100644 src/focus/query.rs create mode 100644 src/focus/traits.rs create mode 100644 src/input/action.rs create mode 100644 src/input/bindings.rs create mode 100644 src/input/handler.rs create mode 100644 src/input/key.rs create mode 100644 src/input/mod.rs create mode 100644 src/input/result.rs create mode 100644 src/lib.rs create mode 100644 src/prelude.rs create mode 100644 tests/bindings.rs create mode 100644 tests/component_tests.rs create mode 100644 tests/focus.rs create mode 100644 tests/key.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..923fea3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +komp_ac_client/ +target/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9b0968c --- /dev/null +++ b/AGENTS.md @@ -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` 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 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..40be7fa --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2fc400e --- /dev/null +++ b/Cargo.toml @@ -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] diff --git a/INPUT_PIPELINE_MIGRATION.md b/INPUT_PIPELINE_MIGRATION.md new file mode 100644 index 0000000..061a14c --- /dev/null +++ b/INPUT_PIPELINE_MIGRATION.md @@ -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 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, +} + +impl KeySequence { + pub fn new() -> Self { + Self { + chords: alloc::vec::Vec::new(), + } + } + + pub fn from_chords(chords: impl IntoIterator) -> 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 { + Chord(KeyChord, Action), + Sequence(KeySequence, Action), +} + +impl KeyMapEntry { + 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 { + Execute(Action), + Type(KeyChord), + Wait(alloc::vec::Vec>), + Cancel, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InputHint { + 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 { + chords: alloc::collections::HashMap, + sequences: alloc::vec::Vec<(KeySequence, Action)>, +} + +impl KeyRegistry { + 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 Default for KeyRegistry { + 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, + #[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 { + registry: KeyRegistry, + #[cfg(feature = "std")] + tracker: SequenceTracker, +} + +impl KeyPipeline { + 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 { + #[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> = 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> = 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 Default for KeyPipeline { + 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 diff --git a/INTEGRATION_GUIDE.md b/INTEGRATION_GUIDE.md new file mode 100644 index 0000000..86ad79d --- /dev/null +++ b/INTEGRATION_GUIDE.md @@ -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> { + match (focus, action) { + (LoginFocus::LoginButton, ComponentAction::Select) => { + Ok(Some(LoginEvent::AttemptLogin { + username: self.username.clone(), + password: self.password.clone(), + })) + } + (LoginFocus::CancelButton, ComponentAction::Select) => { + Ok(Some(LoginEvent::Cancel)) + } + _ => Ok(None), + } + } + + fn handle_text(&mut self, focus: &Self::Focus, ch: char) -> Result> { + match focus { + LoginFocus::Username => { + self.username.push(ch); + Ok(None) + } + LoginFocus::Password => { + self.password.push(ch); + Ok(None) + } + _ => Ok(None), + } + } + + 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>`** + +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> { + 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>`** + +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 { + match focus.downcast_ref::() { + 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, +} + +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 { + 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 { + 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>, +} + +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. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..399a9d9 --- /dev/null +++ b/PLAN.md @@ -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> { + // 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>; + + 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> { 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 { + pages: alloc::collections::HashMap, + current: Option, + history: alloc::vec::Vec, +} + +impl Router { + pub fn new() -> Self; + pub fn navigate(&mut self, id: &str) -> Result<()>; + pub fn back(&mut self) -> Result>; + pub fn forward(&mut self) -> Result>; + 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 { + registry: ComponentRegistry, + focus: FocusManager, + bindings: Bindings, + router: Router, + modes: ModeStack, + overlays: OverlayStack, + events: EventBus, +} + +impl Orchestrator { + 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>; + pub fn run(&mut self, input: I) -> Result<()>; + + // Extension points + pub fn set_mode_resolver(&mut self, resolver: R); + pub fn set_overlay_manager(&mut self, manager: O); + pub fn set_event_handler + '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; +} + +pub struct DefaultModeResolver; +impl ModeResolver for DefaultModeResolver { + fn resolve(&self, _focus: &dyn core::any::Any) -> alloc::vec::Vec { + 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; +} + +pub enum OverlayResult { + Dismissed, + Selected(OverlayData), + Continue, +} +``` + +**EventHandler (for custom event routing):** + +```rust +pub trait EventHandler { + fn handle(&mut self, event: E) -> Result; +} + +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 Orchestrator { + pub fn builder() -> Builder { Builder::new() } +} + +pub struct Builder { + orchestrator: Orchestrator, +} + +impl Builder { + pub fn with_page(mut self, id: &str, page: C) -> Result; + pub fn with_default_bindings(mut self) -> Self; + pub fn with_mode_resolver(mut self, resolver: R) -> Self; + pub fn with_overlay_manager(mut self, manager: O) -> Self; + pub fn build(self) -> Result>; +} +``` + +**Default bindings:** + +```rust +pub fn default_bindings() -> Bindings { + 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> { ... } +} +``` + +--- + +## 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 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> { ... } +} + +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 diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..1f908c8 --- /dev/null +++ b/PROGRESS.md @@ -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>; + + // 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>; + 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 { + pages: alloc::collections::HashMap, + current: Option, + history: alloc::vec::Vec, + future: alloc::vec::Vec, // For forward navigation +} + +impl Router { + pub fn new() -> Self; + pub fn navigate(&mut self, id: &str) -> Result<()>; + pub fn back(&mut self) -> Result>; + pub fn forward(&mut self) -> Result>; + 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 { + registry: ComponentRegistry, + focus: FocusManager, + bindings: Bindings, + router: Router, + modes: ModeStack, + overlays: OverlayStack, + events: EventBus, +} + +impl Orchestrator { + 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>; + + pub fn run(&mut self, input: I) -> Result<()>; + + // Extension points + pub fn set_mode_resolver(&mut self, resolver: R); + pub fn set_overlay_manager(&mut self, manager: O); + pub fn set_event_handler + '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; +} + +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; +} + +pub enum OverlayResult { + Dismissed, + Selected(OverlayData), + Continue, +} +``` + +**EventHandler trait:** +```rust +pub trait EventHandler { + fn handle(&mut self, event: E) -> Result; +} + +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 { + orchestrator: Orchestrator, +} + +impl Builder { + pub fn new() -> Self; + pub fn with_page(mut self, id: &str, page: C) -> Result; + pub fn with_default_bindings(mut self) -> Self; + pub fn with_mode_resolver(mut self, resolver: R) -> Self; + pub fn with_overlay_manager(mut self, manager: O) -> Self; + pub fn build(self) -> Result>; +} +``` + +**Default bindings:** +```rust +pub fn default_bindings() -> Bindings { + 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> { ... } +} +``` + +**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? diff --git a/README.md b/README.md new file mode 100644 index 0000000..71f1875 --- /dev/null +++ b/README.md @@ -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> { + match (focus, action) { + (LoginFocus::LoginButton, ComponentAction::Select) => { + Ok(Some(LoginEvent::AttemptLogin { + username: self.username.clone(), + password: self.password.clone(), + })) + } + (LoginFocus::CancelButton, ComponentAction::Select) => { + Ok(Some(LoginEvent::Cancel)) + } + _ => Ok(None), + } + } + + fn handle_text(&mut self, focus: &Self::Focus, ch: char) -> Result> { + match focus { + LoginFocus::Username => { + self.username.push(ch); + Ok(None) + } + LoginFocus::Password => { + self.password.push(ch); + Ok(None) + } + _ => Ok(None), + } + } +} +``` + +### Register and Run + +```rust +use tui_orchestrator::prelude::*; + +fn main() -> Result<()> { + let mut orch = Orchestrator::builder() + .with_page("login", LoginPage::new()) + .with_default_bindings() + .build()?; + + orch.navigate_to("login")?; + + orch.run(&mut MyInputSource)?; +} +``` + +**That's it.** The library handles: +- Input processing (read keys, route to actions) +- Focus management (next/prev navigation) +- Page navigation (on_exit, swap, on_enter) +- Default keybindings (Tab=Next, Enter=Select) +- Event collection and routing + +--- + +## Core Concepts + +### Component + +The main abstraction in tui_orchestrator. A component represents a page or UI section with focusable elements. + +```rust +pub trait Component { + type Focus: FocusId; // What can receive focus + type Action: Action; // What actions this handles + type Event: Clone + Debug; // Events this component emits + + fn targets(&self) -> &[Self::Focus]; + fn handle(&mut self, focus: &Self::Focus, action: Self::Action) -> Result>; +} +``` + +**Optional methods** (all have defaults): +- `on_enter()` - Called when component becomes active +- `on_exit()` - Called when component becomes inactive +- `on_focus()` - Called when a focus target gains focus +- `on_blur()` - Called when a focus target loses focus +- `handle_text()` - Called when character is typed +- `can_navigate_forward/backward()` - Control focus movement + +### Component Actions + +Standard actions the library provides: + +```rust +pub enum ComponentAction { + Next, // Tab + Prev, // Shift+Tab + First, // Home + Last, // End + Select, // Enter + Cancel, // Esc + TypeChar(char), // Any character + Backspace, // Backspace + Delete, // Delete + Custom(usize), // User-defined +} +``` + +### Focus Management + +Focus tracks which element is currently active. The library provides: + +- `FocusManager` - Generic focus tracking +- `FocusQuery` - Read-only focus state for rendering +- Automatic navigation (next, prev, first, last) + +### Orchestrator + +The complete TUI runtime that wires everything together: + +- `Orchestrator` - Main framework struct +- `process_frame()` - Process one input frame +- `run()` - Complete main loop +- Extension points for custom behavior + +--- + +## Extension Points + +For complex applications (like komp_ac), the library provides extension points to customize behavior: + +### ModeResolver + +Customize how modes are resolved (dynamic vs static). + +```rust +impl ModeResolver for CustomResolver { + fn resolve(&self, focus: &dyn Any) -> Vec { ... } +} +``` + +### OverlayManager + +Customize overlay types (dialogs, command palettes, search). + +```rust +impl OverlayManager for CustomOverlayManager { + fn is_active(&self) -> bool { ... } + fn handle_input(&mut self, key: Key) -> Option { ... } +} +``` + +### EventHandler + +Customize how events are routed to handlers. + +```rust +impl EventHandler for CustomHandler { + fn handle(&mut self, event: AppEvent) -> Result { ... } +} +``` + +--- + +## Example: Multi-Page App + +```rust +#[derive(Debug, Clone)] +enum MyPage { + Login(LoginPage), + Home(HomePage), + Settings(SettingsPage), +} + +fn main() -> Result<()> { + let mut orch = Orchestrator::builder() + .with_page("login", LoginPage::new()) + .with_page("home", HomePage::new()) + .with_page("settings", SettingsPage::new()) + .with_default_bindings() + .build()?; + + orch.navigate_to("login")?; + + orch.run()?; +} +``` + +Navigation with history: +```rust +orch.navigate_to("home")?; +orch.navigate_to("settings")?; +orch.back()? // Return to home +``` + +--- + +## Feature Flags + +```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 diff --git a/REDESIGN.md b/REDESIGN.md new file mode 100644 index 0000000..4e418f2 --- /dev/null +++ b/REDESIGN.md @@ -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> { ... } +} + +// 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 { + 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 { + // 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, +} + +impl OverlayManager for DefaultOverlayManager { + fn handle_input(&mut self, key: Key) -> Option { ... } +} +``` + +**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 { + // 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; + +impl EventHandler for DefaultEventHandler { + fn handle(&mut self, event: E) -> Result { + // 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>, +} + +impl EventHandler for KompAcEventHandler { + fn handle(&mut self, event: AppEvent) -> Result { + 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` + +**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`, 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 + 'static`) + +**Why:** +- Allows komp_ac to pass stateful resolvers +- Flexible at runtime +- Can be swapped dynamically + +**Alternative:** Generic with bounds (``) + +**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> { ... } +} + +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> { + // 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. diff --git a/examples/focus_example.rs b/examples/focus_example.rs new file mode 100644 index 0000000..67720be --- /dev/null +++ b/examples/focus_example.rs @@ -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 for LoginForm { + fn focus_targets(&self) -> alloc::vec::Vec { + 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 = 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)); +} diff --git a/src/component/action.rs b/src/component/action.rs new file mode 100644 index 0000000..fcc79ad --- /dev/null +++ b/src/component/action.rs @@ -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 {} diff --git a/src/component/error.rs b/src/component/error.rs new file mode 100644 index 0000000..d5c9bd7 --- /dev/null +++ b/src/component/error.rs @@ -0,0 +1,7 @@ +// path_from_the_root: src/component/error.rs + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ComponentError { + EmptyTargets, + InvalidFocus, +} diff --git a/src/component/mod.rs b/src/component/mod.rs new file mode 100644 index 0000000..2f84b0e --- /dev/null +++ b/src/component/mod.rs @@ -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; diff --git a/src/component/trait.rs b/src/component/trait.rs new file mode 100644 index 0000000..651a7d4 --- /dev/null +++ b/src/component/trait.rs @@ -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, 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, ComponentError> { + Ok(None) + } + + fn can_navigate_forward(&self, _focus: &Self::Focus) -> bool { + true + } + + fn can_navigate_backward(&self, _focus: &Self::Focus) -> bool { + true + } +} diff --git a/src/focus/error.rs b/src/focus/error.rs new file mode 100644 index 0000000..6180567 --- /dev/null +++ b/src/focus/error.rs @@ -0,0 +1,8 @@ +// path_from_the_root: src/focus/error.rs + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FocusError { + TargetNotFound, + EmptyTargets, + OverlayActive, +} diff --git a/src/focus/id.rs b/src/focus/id.rs new file mode 100644 index 0000000..68afad6 --- /dev/null +++ b/src/focus/id.rs @@ -0,0 +1,5 @@ +// path_from_the_root: src/focus/id.rs + +pub trait FocusId: Clone + PartialEq + Eq + core::hash::Hash {} + +impl FocusId for T {} diff --git a/src/focus/manager.rs b/src/focus/manager.rs new file mode 100644 index 0000000..b90dac1 --- /dev/null +++ b/src/focus/manager.rs @@ -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 { + targets: alloc::vec::Vec, + index: usize, + overlay: Option, +} + +impl Default for FocusManager { + fn default() -> Self { + Self::new() + } +} + +impl FocusManager { + pub fn new() -> Self { + Self { + targets: alloc::vec::Vec::new(), + index: 0, + overlay: None, + } + } + + pub fn set_targets(&mut self, targets: alloc::vec::Vec) { + 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() + } +} diff --git a/src/focus/mod.rs b/src/focus/mod.rs new file mode 100644 index 0000000..4ca6c4b --- /dev/null +++ b/src/focus/mod.rs @@ -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; diff --git a/src/focus/query.rs b/src/focus/query.rs new file mode 100644 index 0000000..0b7d779 --- /dev/null +++ b/src/focus/query.rs @@ -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() + } +} diff --git a/src/focus/traits.rs b/src/focus/traits.rs new file mode 100644 index 0000000..aff6752 --- /dev/null +++ b/src/focus/traits.rs @@ -0,0 +1,12 @@ +// path_from_the_root: src/focus/traits.rs + +use super::error::FocusError; +use super::id::FocusId; + +pub trait Focusable { + fn focus_targets(&self) -> alloc::vec::Vec; + + fn on_focus_change(&mut self, _id: &F) -> Result<(), FocusError> { + Ok(()) + } +} diff --git a/src/input/action.rs b/src/input/action.rs new file mode 100644 index 0000000..94121a3 --- /dev/null +++ b/src/input/action.rs @@ -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 {} diff --git a/src/input/bindings.rs b/src/input/bindings.rs new file mode 100644 index 0000000..ddcd351 --- /dev/null +++ b/src/input/bindings.rs @@ -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 { + bindings: alloc::vec::Vec<(Key, A)>, +} + +impl Bindings { + 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 { + self.bindings.iter() + } +} + +impl Default for Bindings { + fn default() -> Self { + Self::new() + } +} + +#[cfg(feature = "sequences")] +impl Bindings { + pub fn bind_sequence(&mut self, keys: alloc::vec::Vec, 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 + } +} diff --git a/src/input/handler.rs b/src/input/handler.rs new file mode 100644 index 0000000..26fd8dc --- /dev/null +++ b/src/input/handler.rs @@ -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 { + sequences: alloc::vec::Vec<(alloc::vec::Vec, A)>, + current: alloc::vec::Vec, +} + +#[cfg(feature = "sequences")] +impl SequenceHandler { + pub fn new() -> Self { + Self { + sequences: alloc::vec::Vec::new(), + current: alloc::vec::Vec::new(), + } + } + + pub fn bind(&mut self, keys: impl IntoIterator, action: A) { + let vec: alloc::vec::Vec = keys.into_iter().collect(); + self.sequences.push((vec, action)); + } + + pub fn handle(&mut self, key: Key) -> MatchResult { + 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 { + self.sequences.iter().map(|(k, a)| (k.as_slice(), a)) + } +} + +#[cfg(feature = "sequences")] +impl Default for SequenceHandler { + fn default() -> Self { + Self::new() + } +} diff --git a/src/input/key.rs b/src/input/key.rs new file mode 100644 index 0000000..11ab017 --- /dev/null +++ b/src/input/key.rs @@ -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 for Key { + fn from(code: KeyCode) -> Self { + Self { + code, + modifiers: KeyModifiers::new(), + } + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs new file mode 100644 index 0000000..c0770ed --- /dev/null +++ b/src/input/mod.rs @@ -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; diff --git a/src/input/result.rs b/src/input/result.rs new file mode 100644 index 0000000..3d4b285 --- /dev/null +++ b/src/input/result.rs @@ -0,0 +1,29 @@ +// path_from_the_root: src/input/result.rs + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MatchResult { + Match(A), + Pending, + NoMatch, +} + +impl MatchResult { + 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 { + match self { + Self::Match(a) => Some(a), + _ => None, + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1b19820 --- /dev/null +++ b/src/lib.rs @@ -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::*; +} diff --git a/src/prelude.rs b/src/prelude.rs new file mode 100644 index 0000000..b65aec1 --- /dev/null +++ b/src/prelude.rs @@ -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::*; diff --git a/tests/bindings.rs b/tests/bindings.rs new file mode 100644 index 0000000..f5042a9 --- /dev/null +++ b/tests/bindings.rs @@ -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 = Bindings::new(); +} + +#[test] +fn test_bindings_bind() { + let mut bindings: Bindings = 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 = 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 = 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 = 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 = 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 = 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 = Bindings::default(); +} diff --git a/tests/component_tests.rs b/tests/component_tests.rs new file mode 100644 index 0000000..549e630 --- /dev/null +++ b/tests/component_tests.rs @@ -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, 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)); +} diff --git a/tests/focus.rs b/tests/focus.rs new file mode 100644 index 0000000..4da24f4 --- /dev/null +++ b/tests/focus.rs @@ -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 = FocusManager::new(); + assert!(manager.is_empty()); + assert_eq!(manager.current(), None); +} + +#[test] +fn test_manager_set_targets() { + let mut manager: FocusManager = 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 = 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 = 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 = 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 = 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 = FocusManager::new(); + + let result = manager.set_focus(TestId::Menu); + assert_eq!(result, Err(FocusError::EmptyTargets)); +} + +#[test] +fn test_manager_overlay() { + let mut manager: FocusManager = 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 = 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 = 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 = 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 = 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 = 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 = 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 for TestComponent { + fn focus_targets(&self) -> alloc::vec::Vec { + 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 = 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")); +} diff --git a/tests/key.rs b/tests/key.rs new file mode 100644 index 0000000..bc05adf --- /dev/null +++ b/tests/key.rs @@ -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); +}