1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
|
//! Bordered input box component for the AI TUI.
//!
//! Wraps tui-textarea's TextArea, which handles rendering, wrapping, cursor
//! positioning, and height measurement natively. The component configures the
//! TextArea's block (border + titles) and forwards events to it.
//!
//! On Enter, sends `AiTuiEvent::SubmitInput` via the context-provided channel.
use std::sync::{Arc, Mutex};
use crossterm::event::KeyModifiers;
use eye_declare::{Canvas, Elements, EventResult, Hooks, component, element, props};
use ratatui::widgets::{Block, Borders, Padding};
use ratatui_core::{
layout::Rect,
style::{Color, Modifier, Style},
text::Line,
widgets::Widget,
};
use tui_textarea::TextArea;
use crate::commands::inline::DriverEventSender;
use crate::tui::{events::AiTuiEvent, slash::SlashCommandSearchResult};
/// A bordered text input box backed by tui-textarea.
///
/// Props configure the chrome (title, footer). The TextArea itself lives
/// in the component's State so it owns cursor, wrapping, and rendering.
#[props]
pub(crate) struct InputBox {
/// Title shown in top-left border
pub title: String,
/// Right-side label in top border
pub title_right: String,
/// Footer text shown in bottom border (keybinding hints)
pub footer: String,
/// Whether the input is currently active (shows cursor, accepts input)
pub active: bool,
/// If the user has typed a slash command, this holds the best match for it.
pub slash_suggestion: Option<SlashCommandSearchResult>,
}
pub(crate) struct InputBoxState {
textarea: Arc<Mutex<TextArea<'static>>>,
tx: Option<DriverEventSender>,
}
impl Default for InputBoxState {
fn default() -> Self {
let mut textarea = TextArea::default();
textarea.set_cursor_line_style(ratatui::style::Style::default());
textarea.set_wrap_mode(tui_textarea::WrapMode::Word);
textarea.set_placeholder_text("Type a message...");
textarea.set_placeholder_style(
ratatui::style::Style::default()
.fg(ratatui::style::Color::DarkGray)
.add_modifier(ratatui::style::Modifier::ITALIC),
);
Self {
textarea: Arc::new(Mutex::new(textarea)),
tx: None,
}
}
}
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));
if !props.title.is_empty() {
block =
block.title_top(Line::styled(format!(" {} ", props.title), title_style).left_aligned());
}
if !props.title_right.is_empty() {
block = block.title_top(
Line::styled(format!(" {} ", props.title_right), border_style).right_aligned(),
);
}
if !props.footer.is_empty() {
block = block.title_bottom(
Line::styled(format!(" {} ", props.footer), border_style).right_aligned(),
);
}
block
}
#[component(props = InputBox, state = InputBoxState)]
fn input_box(
props: &InputBox,
state: &InputBoxState,
hooks: &mut Hooks<InputBox, InputBoxState>,
) -> Elements {
// Always focusable so focus isn't lost when the permission Select is
// removed from the tree. The `active` prop controls visual state and
// whether keystrokes are processed, not focusability.
hooks.use_focusable(true);
hooks.use_autofocus();
hooks.use_context::<DriverEventSender>(|tx, _, state| {
state.tx = tx.cloned();
});
hooks.use_event(move |event, props, state| {
let state = state.read();
if !props.active {
return EventResult::Ignored;
}
if let crossterm::event::Event::Paste(text) = event {
let mut textarea = state.textarea.lock().unwrap();
textarea.insert_str(text);
return EventResult::Consumed;
}
if let crossterm::event::Event::Key(key) = event {
if key.kind != crossterm::event::KeyEventKind::Press {
return EventResult::Ignored;
}
let mut textarea = state.textarea.lock().unwrap();
match key.code {
crossterm::event::KeyCode::Char('j')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
textarea.insert_newline();
return EventResult::Consumed;
}
crossterm::event::KeyCode::Tab if props.slash_suggestion.is_some() => {
// If there's a slash command suggestion, Tab accepts it.
if let Some(suggestion) = &props.slash_suggestion {
textarea.clear();
textarea.insert_str(format!("/{}", suggestion.command.name));
// Manually trigger an input update event so the slash suggestion box can update immediately
if let Some(ref tx) = state.tx {
let _ = tx.send(AiTuiEvent::InputUpdated(textarea.lines().join("\n")));
}
return EventResult::Consumed;
}
}
crossterm::event::KeyCode::Enter => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
textarea.insert_newline();
return EventResult::Consumed;
} else {
let text = textarea.lines().join("\n");
if text.trim().is_empty() {
return EventResult::Ignored;
}
textarea.clear();
if let Some(ref tx) = state.tx {
let _ = tx.send(AiTuiEvent::SubmitInput(text));
}
return EventResult::Consumed;
}
}
_ => {}
}
// All other keys: forward to textarea.
// tui-textarea can convert crossterm events itself.
textarea.input(*key);
if let Some(ref tx) = state.tx {
let _ = tx.send(AiTuiEvent::InputUpdated(textarea.lines().join("\n")));
}
return EventResult::Consumed;
}
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);
})
)
}
|