diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-30 15:20:31 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-30 15:20:31 -0700 |
| commit | 0478a05320ff7bc4257633bc945bd750864d68d4 (patch) | |
| tree | 2e1abf5b0a49fe0270b70bd6385062ea81fb0ba1 /crates/atuin-ai/src/tui/components/input_box.rs | |
| parent | fix: replace `e>|` with `|` in nushell integration to restore history recordi... (diff) | |
| download | atuin-0478a05320ff7bc4257633bc945bd750864d68d4.zip | |
chore: Update to eye-declare 0.3.0 (#3365)
Diffstat (limited to 'crates/atuin-ai/src/tui/components/input_box.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/components/input_box.rs | 175 |
1 files changed, 80 insertions, 95 deletions
diff --git a/crates/atuin-ai/src/tui/components/input_box.rs b/crates/atuin-ai/src/tui/components/input_box.rs index 3167ecc1..f5e0fe2b 100644 --- a/crates/atuin-ai/src/tui/components/input_box.rs +++ b/crates/atuin-ai/src/tui/components/input_box.rs @@ -6,13 +6,12 @@ //! //! On Enter, sends `AiTuiEvent::SubmitInput` via the context-provided channel. -use std::sync::{Mutex, mpsc}; +use std::sync::{Arc, Mutex, mpsc}; use crossterm::event::KeyModifiers; -use eye_declare::{Component, EventResult, Hooks, Tracked}; +use eye_declare::{Canvas, Elements, EventResult, Hooks, component, element, props}; use ratatui::widgets::{Block, Borders, Padding}; use ratatui_core::{ - buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, text::Line, @@ -26,8 +25,8 @@ use crate::tui::events::AiTuiEvent; /// /// Props configure the chrome (title, footer). The TextArea itself lives /// in the component's State so it owns cursor, wrapping, and rendering. -#[derive(Default)] -pub struct InputBox { +#[props] +pub(crate) struct InputBox { /// Title shown in top-left border pub title: String, /// Right-side label in top border @@ -38,8 +37,8 @@ pub struct InputBox { pub active: bool, } -pub struct InputBoxState { - textarea: Mutex<TextArea<'static>>, +pub(crate) struct InputBoxState { + textarea: Arc<Mutex<TextArea<'static>>>, tx: Option<mpsc::Sender<AiTuiEvent>>, } @@ -55,109 +54,58 @@ impl Default for InputBoxState { .add_modifier(ratatui::style::Modifier::ITALIC), ); Self { - textarea: Mutex::new(textarea), + textarea: Arc::new(Mutex::new(textarea)), tx: None, } } } -impl InputBox { - /// Build the ratatui Block with current titles/footer. - fn make_block(&self) -> Block<'_> { - let border_style = Style::default().fg(Color::DarkGray); - let title_style = Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::BOLD); +fn make_block(props: &InputBox) -> Block<'static> { + let border_style = Style::default().fg(Color::DarkGray); + let title_style = Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::BOLD); - let mut block = Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .padding(Padding::horizontal(1)); + let mut block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .padding(Padding::horizontal(1)); - if !self.title.is_empty() { - block = block - .title_top(Line::styled(format!(" {} ", self.title), title_style).left_aligned()); - } - if !self.title_right.is_empty() { - block = block.title_top( - Line::styled(format!(" {} ", self.title_right), border_style).right_aligned(), - ); - } - if !self.footer.is_empty() { - block = block.title_bottom( - Line::styled(format!(" {} ", self.footer), border_style).right_aligned(), - ); - } - - block + if !props.title.is_empty() { + block = + block.title_top(Line::styled(format!(" {} ", props.title), title_style).left_aligned()); } -} - -impl Component for InputBox { - type State = InputBoxState; - - fn initial_state(&self) -> Option<InputBoxState> { - Some(InputBoxState::default()) + if !props.title_right.is_empty() { + block = block.title_top( + Line::styled(format!(" {} ", props.title_right), border_style).right_aligned(), + ); } - - fn lifecycle(&self, hooks: &mut Hooks<Self::State>, _state: &Self::State) { - if self.active { - hooks.use_autofocus(); - } - hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, state| { - state.tx = tx.cloned(); - }); + if !props.footer.is_empty() { + block = block.title_bottom( + Line::styled(format!(" {} ", props.footer), border_style).right_aligned(), + ); } - fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) { - if area.height < 3 || area.width < 4 { - return; - } - // Configure the block on each render so titles/footer stay current. - // Note: set_block takes ownership, but the block is cheap to rebuild. - // We can't call set_block here since we only have &self/&state, - // so we render block + textarea separately. - let block = self.make_block(); - let inner = block.inner(area); - block.render(area, buf); - - let mut textarea = state.textarea.lock().unwrap(); - if self.active { - textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); - textarea.set_placeholder_text("Type a message..."); - } else { - textarea.set_cursor_style(Style::default()); - textarea.set_placeholder_text(""); - } - - // Render textarea into the inner area - textarea.render(inner, buf); - } + block +} - fn desired_height(&self, width: u16, state: &Self::State) -> u16 { - if width < 4 { - return 3; - } - // TextArea handles scrolling internally if content overflows. - let block = self.make_block(); - let inner = block.inner(Rect::new(0, 0, width, u16::MAX)); - let chrome = (u16::MAX).saturating_sub(inner.height); - let content = state.textarea.lock().unwrap().measure(width - 4); - chrome + content.preferred_rows - } +#[component(props = InputBox, state = InputBoxState)] +fn input_box( + props: &InputBox, + state: &InputBoxState, + hooks: &mut Hooks<InputBox, InputBoxState>, +) -> Elements { + hooks.use_focusable(props.active); + hooks.use_autofocus(); - fn is_focusable(&self, _state: &Self::State) -> bool { - self.active - } + hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, _, state| { + state.tx = tx.cloned(); + }); - fn handle_event( - &self, - event: &crossterm::event::Event, - state: &mut Tracked<Self::State>, - ) -> EventResult { + hooks.use_event(move |event, props, state| { let state = state.read(); - if !self.active { + if !props.active { return EventResult::Ignored; } @@ -213,5 +161,42 @@ impl Component for InputBox { } EventResult::Ignored - } + }); + + let textarea = state.textarea.clone(); + let block = make_block(props); + let active = props.active; + element!( + Canvas(render_fn: move |area, buf| { + let mut area = area; + + if area.height < 3 || area.width < 4 { + return; + } + + let height = { + // TextArea handles scrolling internally if content overflows. + let inner = block.inner(Rect::new(0, 0, area.width, u16::MAX)); + let chrome = (u16::MAX).saturating_sub(inner.height); + let content = textarea.lock().unwrap().measure(area.width - 4); + chrome + content.preferred_rows + }; + + area.height = height.min(7); + let inner = block.clone().inner(area); + block.clone().render(area, buf); + + let mut textarea = textarea.lock().unwrap(); + if active { + textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); + textarea.set_placeholder_text("Type a message..."); + } else { + textarea.set_cursor_style(Style::default()); + textarea.set_placeholder_text(""); + } + + // Render textarea into the inner area + textarea.render(inner, buf); + }) + ) } |
