use std::io; use crate::block::Block; use crate::component::{RunError, Update}; use crate::input::{self, Key, RawMode}; use crate::style::{Line, Span, Style}; use crate::terminal; use crate::view::View; // --------------------------------------------------------------------------- // Trait // --------------------------------------------------------------------------- /// Outcome of processing a keypress inside a `FocusItem`. pub enum FocusEvent { /// Item handled the key and updated internal state. Continue, /// Item finished (e.g. user pressed Enter on a text field). Done, /// User pressed Escape/Ctrl+C — entire group should cancel. Cancel, /// Item did not handle this key (focus group may intercept for Tab). Ignore, } /// A component that can participate in a `FocusGroup`. /// /// Because focus items are held as mutable references, the user retains /// ownership of each item and can inspect results after `FocusGroup::run`. pub trait FocusItem { fn render(&self) -> Block; fn handle_key(&mut self, key: &Key) -> FocusEvent; } // --------------------------------------------------------------------------- // FocusSlot — wraps a Component as a FocusItem // --------------------------------------------------------------------------- /// Wraps a `Component`-style state machine as a `FocusItem`, storing /// the final result so it can be retrieved after `FocusGroup::run`. pub struct FocusSlot { state: S, render_fn: Box Block>, update_fn: Box Update>, pub result: Option, pub done: bool, } impl FocusSlot { pub fn new( state: S, render: impl Fn(&S) -> Block + 'static, update: impl Fn(&S, &Key) -> Update + 'static, ) -> Self { FocusSlot { state, render_fn: Box::new(render), update_fn: Box::new(update), result: None, done: false, } } pub fn state(&self) -> &S { &self.state } } impl FocusItem for FocusSlot { fn render(&self) -> Block { (self.render_fn)(&self.state) } fn handle_key(&mut self, key: &Key) -> FocusEvent { match (self.update_fn)(&self.state, key) { Update::Continue(s) => { self.state = s; FocusEvent::Continue } Update::Emit(s, r) => { self.state = s; self.result = Some(r); FocusEvent::Continue } Update::Finish(r) => { self.result = Some(r); self.done = true; FocusEvent::Done } Update::Cancel => FocusEvent::Cancel, Update::Ignore => FocusEvent::Ignore, } } } // --------------------------------------------------------------------------- // FocusGroup // --------------------------------------------------------------------------- /// Style options for the focus indicator shown next to each item. pub struct FocusGroupStyle { /// Prefix shown next to the focused item (e.g. `"▶ "`). pub focused_prefix: String, /// Prefix shown next to unfocused items (spaces to match width). pub unfocused_prefix: String, pub focused_prefix_style: Style, pub unfocused_prefix_style: Style, } impl Default for FocusGroupStyle { fn default() -> Self { use crate::color::Color; FocusGroupStyle { focused_prefix: "▶ ".into(), unfocused_prefix: " ".into(), focused_prefix_style: Style::new().bold().fg(Color::Cyan), unfocused_prefix_style: Style::new(), } } } /// Manages a list of `FocusItem` mutable references, routing keypresses to /// the focused item and handling `Tab` / `BackTab` to move focus. /// /// Returns when: /// - `Ctrl+Enter` is pressed (all items are considered submitted as-is). /// - `Escape` or `Ctrl+C` is received from any item. /// - All items have signalled `Done`. pub struct FocusGroup<'a> { items: Vec<&'a mut dyn FocusItem>, focused: usize, style: FocusGroupStyle, } impl<'a> FocusGroup<'a> { pub fn new(items: Vec<&'a mut dyn FocusItem>) -> Self { FocusGroup { items, focused: 0, style: FocusGroupStyle::default(), } } pub fn style(self, style: FocusGroupStyle) -> Self { Self { style, ..self } } /// Run the interactive event loop. /// /// - `Tab` / `BackTab` moves focus between items. /// - `Ctrl+Enter` (sent as `Ctrl('j')` or `Ctrl('m')`) submits the group. /// - Escape / Ctrl+C from any item cancels the entire group. pub fn run(mut self, mode: &RawMode) -> Result<(), RunError> { let width = terminal::get_size().map(|(c, _)| c as usize).unwrap_or(80); let initial_block = self.render_all(); let mut view = View::create(&initial_block, width); let result = self.event_loop(mode, &mut view); match &result { Ok(_) => view.finish(), Err(RunError::Cancelled) => view.erase(), Err(RunError::InputError(_)) => view.finish(), } result } fn render_all(&self) -> Block { let n = self.items.len(); let blocks: Vec = self .items .iter() .enumerate() .map(|(i, item)| { let is_focused = i == self.focused; let prefix_str = if is_focused { &self.style.focused_prefix } else { &self.style.unfocused_prefix }; let prefix_style = if is_focused { &self.style.focused_prefix_style } else { &self.style.unfocused_prefix_style }; let prefix_line: Line = vec![Span::styled(prefix_str.clone(), prefix_style.clone())]; let prefix_block = Block::text(vec![prefix_line]); let content_block = item.render(); Block::hstack(vec![prefix_block, content_block], 0) }) .collect(); if n == 0 { Block::text(vec![]) } else { Block::vstack(blocks, 0) } } fn event_loop(&mut self, mode: &RawMode, view: &mut View) -> Result<(), RunError> { loop { let key = input::read_key(mode).map_err(RunError::InputError)?; // Ctrl+Enter submits the whole group. if matches!(key, Key::Ctrl('j') | Key::Ctrl('m')) { return Ok(()); } // Tab / BackTab move focus. if matches!(key, Key::Tab) { self.focus_next(); view.update(&self.render_all()); continue; } if matches!(key, Key::BackTab) { self.focus_prev(); view.update(&self.render_all()); continue; } // Route to focused item. let event = self.items[self.focused].handle_key(&key); match event { FocusEvent::Continue => { view.update(&self.render_all()); } FocusEvent::Done => { // Auto-advance focus to next unfocused item. if !self.focus_next_undone() { // All items done — finish. return Ok(()); } view.update(&self.render_all()); } FocusEvent::Cancel => return Err(RunError::Cancelled), FocusEvent::Ignore => {} } } } fn focus_next(&mut self) { if !self.items.is_empty() { self.focused = (self.focused + 1) % self.items.len(); } } fn focus_prev(&mut self) { if !self.items.is_empty() { let n = self.items.len(); self.focused = (self.focused + n - 1) % n; } } /// Advance focus to the next item that hasn't signalled Done. /// Returns `true` if such an item was found. fn focus_next_undone(&mut self) -> bool { let n = self.items.len(); if let Some(offset) = (1..=n).next() { let idx = (self.focused + offset) % n; // We can't inspect `done` without downcasting unless FocusItem exposes it. // For simplicity: always advance to next item. self.focused = idx; return true; } false } } // --------------------------------------------------------------------------- // RunError impls // --------------------------------------------------------------------------- impl From for RunError { fn from(e: io::Error) -> Self { RunError::InputError(e) } } #[cfg(test)] mod tests { use super::*; struct CounterItem { count: usize, done_at: usize, pub final_count: Option, } impl CounterItem { fn new(done_at: usize) -> Self { CounterItem { count: 0, done_at, final_count: None, } } } impl FocusItem for CounterItem { fn render(&self) -> Block { Block::text(vec![vec![Span::plain(format!("count={}", self.count))]]) } fn handle_key(&mut self, key: &Key) -> FocusEvent { match key { Key::Char(' ') => { self.count += 1; if self.count >= self.done_at { self.final_count = Some(self.count); FocusEvent::Done } else { FocusEvent::Continue } } Key::Escape | Key::CtrlC => FocusEvent::Cancel, _ => FocusEvent::Ignore, } } } #[test] fn focus_slot_stores_result() { use crate::component::Update; let mut slot: FocusSlot = FocusSlot::new( "hello".to_string(), |s| Block::text(vec![vec![Span::plain(s.clone())]]), |s, key| match key { Key::Enter => Update::Finish(s.clone()), Key::CtrlC => Update::Cancel, _ => Update::Ignore, }, ); assert!(slot.result.is_none()); assert!(!slot.done); // Enter → Done let event = slot.handle_key(&Key::Enter); assert!(matches!(event, FocusEvent::Done)); assert_eq!(slot.result.as_deref(), Some("hello")); assert!(slot.done); } #[test] fn focus_slot_cancel() { use crate::component::Update; let mut slot: FocusSlot = FocusSlot::new( "".to_string(), |s| Block::text(vec![vec![Span::plain(s.clone())]]), |_, key| match key { Key::CtrlC => Update::Cancel, _ => Update::Ignore, }, ); assert!(matches!(slot.handle_key(&Key::CtrlC), FocusEvent::Cancel)); } #[test] fn focus_slot_continue() { use crate::component::Update; let mut slot: FocusSlot = FocusSlot::new( 0usize, |n| Block::text(vec![vec![Span::plain(n.to_string())]]), |&n, key| match key { Key::Char(' ') => Update::Continue(n + 1), _ => Update::Ignore, }, ); slot.handle_key(&Key::Char(' ')); assert_eq!(*slot.state(), 1); } #[test] fn counter_item_done_event() { let mut item = CounterItem::new(2); let e1 = item.handle_key(&Key::Char(' ')); assert!(matches!(e1, FocusEvent::Continue)); assert!(item.final_count.is_none()); let e2 = item.handle_key(&Key::Char(' ')); assert!(matches!(e2, FocusEvent::Done)); assert_eq!(item.final_count, Some(2)); } }