1use anyhow::{Context, Result};
2use iocraft::prelude::*;
3use std::time::{Duration, Instant};
4use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
5
6const ESCAPE_DOUBLE_MS: u64 = 750;
7
8#[derive(Clone, Default)]
9pub struct IocraftTextStyle {
10 pub color: Option<Color>,
11 pub weight: Weight,
12 pub italic: bool,
13}
14
15impl IocraftTextStyle {
16 pub fn merge_color(mut self, fallback: Option<Color>) -> Self {
17 if self.color.is_none() {
18 self.color = fallback;
19 }
20 self
21 }
22}
23
24#[derive(Clone, Default)]
25pub struct IocraftSegment {
26 pub text: String,
27 pub style: IocraftTextStyle,
28}
29
30#[derive(Clone, Default)]
31struct StyledLine {
32 segments: Vec<IocraftSegment>,
33}
34
35impl StyledLine {
36 fn push_segment(&mut self, segment: IocraftSegment) {
37 if segment.text.is_empty() {
38 return;
39 }
40 self.segments.push(segment);
41 }
42}
43
44#[derive(Clone)]
45pub struct IocraftTheme {
46 pub background: Option<Color>,
47 pub foreground: Option<Color>,
48 pub primary: Option<Color>,
49 pub secondary: Option<Color>,
50}
51
52impl Default for IocraftTheme {
53 fn default() -> Self {
54 Self {
55 background: None,
56 foreground: None,
57 primary: None,
58 secondary: None,
59 }
60 }
61}
62
63pub enum IocraftCommand {
64 AppendLine {
65 segments: Vec<IocraftSegment>,
66 },
67 Inline {
68 segment: IocraftSegment,
69 },
70 SetPrompt {
71 prefix: String,
72 style: IocraftTextStyle,
73 },
74 SetPlaceholder {
75 hint: Option<String>,
76 },
77 SetTheme {
78 theme: IocraftTheme,
79 },
80 Shutdown,
81}
82
83#[derive(Debug, Clone)]
84pub enum IocraftEvent {
85 Submit(String),
86 Cancel,
87 Exit,
88 Interrupt,
89 ScrollLineUp,
90 ScrollLineDown,
91 ScrollPageUp,
92 ScrollPageDown,
93}
94
95#[derive(Clone)]
96pub struct IocraftHandle {
97 sender: UnboundedSender<IocraftCommand>,
98}
99
100impl IocraftHandle {
101 pub fn append_line(&self, segments: Vec<IocraftSegment>) {
102 if segments.is_empty() {
103 let _ = self.sender.send(IocraftCommand::AppendLine {
104 segments: vec![IocraftSegment::default()],
105 });
106 } else {
107 let _ = self.sender.send(IocraftCommand::AppendLine { segments });
108 }
109 }
110
111 pub fn inline(&self, segment: IocraftSegment) {
112 let _ = self.sender.send(IocraftCommand::Inline { segment });
113 }
114
115 pub fn set_prompt(&self, prefix: String, style: IocraftTextStyle) {
116 let _ = self
117 .sender
118 .send(IocraftCommand::SetPrompt { prefix, style });
119 }
120
121 pub fn set_placeholder(&self, hint: Option<String>) {
122 let _ = self.sender.send(IocraftCommand::SetPlaceholder { hint });
123 }
124
125 pub fn set_theme(&self, theme: IocraftTheme) {
126 let _ = self.sender.send(IocraftCommand::SetTheme { theme });
127 }
128
129 pub fn shutdown(&self) {
130 let _ = self.sender.send(IocraftCommand::Shutdown);
131 }
132}
133
134pub struct IocraftSession {
135 pub handle: IocraftHandle,
136 pub events: UnboundedReceiver<IocraftEvent>,
137}
138
139pub fn spawn_session(theme: IocraftTheme, placeholder: Option<String>) -> Result<IocraftSession> {
140 let (command_tx, command_rx) = mpsc::unbounded_channel();
141 let (event_tx, event_rx) = mpsc::unbounded_channel();
142
143 tokio::spawn(async move {
144 if let Err(err) = run_iocraft(command_rx, event_tx, theme, placeholder).await {
145 tracing::error!(error = ?err, "iocraft session terminated unexpectedly");
146 }
147 });
148
149 Ok(IocraftSession {
150 handle: IocraftHandle { sender: command_tx },
151 events: event_rx,
152 })
153}
154
155async fn run_iocraft(
156 commands: UnboundedReceiver<IocraftCommand>,
157 events: UnboundedSender<IocraftEvent>,
158 theme: IocraftTheme,
159 placeholder: Option<String>,
160) -> Result<()> {
161 element! {
162 SessionRoot(
163 commands: commands,
164 events: events,
165 theme: theme,
166 placeholder: placeholder,
167 )
168 }
169 .render_loop()
170 .await
171 .context("iocraft render loop failed")
172}
173
174#[derive(Default, Props)]
175struct SessionRootProps {
176 commands: Option<UnboundedReceiver<IocraftCommand>>,
177 events: Option<UnboundedSender<IocraftEvent>>,
178 theme: IocraftTheme,
179 placeholder: Option<String>,
180}
181
182#[component]
183fn SessionRoot(props: &mut SessionRootProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
184 let mut system = hooks.use_context_mut::<SystemContext>();
185 let lines = hooks.use_state(Vec::<StyledLine>::default);
186 let current_line = hooks.use_state(StyledLine::default);
187 let current_active = hooks.use_state(|| false);
188 let prompt_prefix = hooks.use_state(|| "❯ ".to_string());
189 let prompt_style = hooks.use_state(IocraftTextStyle::default);
190 let input_value = hooks.use_state(|| String::new());
191 let placeholder_hint = hooks.use_state(|| props.placeholder.clone().unwrap_or_default());
192 let show_placeholder = hooks.use_state(|| props.placeholder.is_some());
193 let should_exit = hooks.use_state(|| false);
194 let theme_state = hooks.use_state(|| props.theme.clone());
195 let command_state = hooks.use_state(|| props.commands.take());
196
197 hooks.use_future({
198 let mut command_slot = command_state;
199 let mut lines_state = lines;
200 let mut current_line_state = current_line;
201 let mut current_active_state = current_active;
202 let mut prompt_prefix_state = prompt_prefix;
203 let mut prompt_style_state = prompt_style;
204 let mut placeholder_state = placeholder_hint;
205 let mut placeholder_visible_state = show_placeholder;
206 let mut exit_state = should_exit;
207 async move {
208 let receiver = {
209 let mut guard = command_slot
210 .try_write()
211 .expect("iocraft commands receiver missing");
212 guard.take()
213 };
214
215 let Some(mut rx) = receiver else {
216 return;
217 };
218
219 while let Some(cmd) = rx.recv().await {
220 match cmd {
221 IocraftCommand::AppendLine { segments } => {
222 let was_active = current_active_state.get();
223 flush_current_line(
224 &mut current_line_state,
225 &mut current_active_state,
226 &mut lines_state,
227 was_active,
228 );
229 if let Some(mut lines) = lines_state.try_write() {
230 lines.push(StyledLine { segments });
231 }
232 }
233 IocraftCommand::Inline { segment } => {
234 append_inline_segment(
235 &mut current_line_state,
236 &mut current_active_state,
237 &mut lines_state,
238 segment,
239 );
240 }
241 IocraftCommand::SetPrompt { prefix, style } => {
242 prompt_prefix_state.set(prefix);
243 prompt_style_state.set(style);
244 }
245 IocraftCommand::SetPlaceholder { hint } => {
246 placeholder_state.set(hint.clone().unwrap_or_default());
247 placeholder_visible_state.set(hint.is_some());
248 }
249 IocraftCommand::SetTheme { theme } => {
250 let mut theme_handle = theme_state;
251 theme_handle.set(theme);
252 }
253 IocraftCommand::Shutdown => {
254 exit_state.set(true);
255 break;
256 }
257 }
258 }
259 }
260 });
261
262 if should_exit.get() {
263 system.exit();
264 }
265
266 let events_tx = props.events.clone().expect("iocraft events sender missing");
267 let mut last_escape = hooks.use_state(|| None::<Instant>);
268 let mut placeholder_toggle = show_placeholder;
269
270 hooks.use_terminal_events(move |event| {
271 if let TerminalEvent::Key(KeyEvent {
272 code,
273 kind,
274 modifiers,
275 ..
276 }) = event
277 {
278 if kind == KeyEventKind::Release {
279 return;
280 }
281
282 match code {
283 KeyCode::Enter => {
284 let text = input_value.to_string();
285 let mut input_handle = input_value;
286 input_handle.set(String::new());
287 last_escape.set(None);
288 placeholder_toggle.set(false);
289 let _ = events_tx.send(IocraftEvent::Submit(text));
290 }
291 KeyCode::Esc => {
292 let now = Instant::now();
293 if last_escape
294 .get()
295 .and_then(|prev| now.checked_duration_since(prev))
296 .map(|elapsed| elapsed <= Duration::from_millis(ESCAPE_DOUBLE_MS))
297 .unwrap_or(false)
298 {
299 let _ = events_tx.send(IocraftEvent::Exit);
300 let mut exit_flag = should_exit;
301 exit_flag.set(true);
302 } else {
303 last_escape.set(Some(now));
304 let _ = events_tx.send(IocraftEvent::Cancel);
305 }
306 }
307 KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
308 let _ = events_tx.send(IocraftEvent::Interrupt);
309 let mut exit_flag = should_exit;
310 exit_flag.set(true);
311 }
312 KeyCode::Up => {
313 let _ = events_tx.send(IocraftEvent::ScrollLineUp);
314 }
315 KeyCode::Down => {
316 let _ = events_tx.send(IocraftEvent::ScrollLineDown);
317 }
318 KeyCode::PageUp => {
319 let _ = events_tx.send(IocraftEvent::ScrollPageUp);
320 }
321 KeyCode::PageDown => {
322 let _ = events_tx.send(IocraftEvent::ScrollPageDown);
323 }
324 KeyCode::Char('k')
325 if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
326 {
327 let _ = events_tx.send(IocraftEvent::ScrollLineUp);
328 }
329 KeyCode::Char('j')
330 if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
331 {
332 let _ = events_tx.send(IocraftEvent::ScrollLineDown);
333 }
334 _ => {}
335 }
336 }
337 });
338
339 let mut transcript_lines = lines.read().clone();
340 if let Some(current) = current_line.try_read() {
341 if current_active.get() && (!current.segments.is_empty()) {
342 transcript_lines.push(current.clone());
343 }
344 }
345
346 let prompt_prefix_value = prompt_prefix.to_string();
347 let prompt_style_value = prompt_style.read().clone();
348 let input_value_string = input_value.to_string();
349 let placeholder_text = placeholder_hint.to_string();
350 let placeholder_visible = show_placeholder.get() && !placeholder_text.is_empty();
351
352 let transcript_rows = transcript_lines.into_iter().map(|line| {
353 element! {
354 View(flex_direction: FlexDirection::Row) {
355 #(line
356 .segments
357 .into_iter()
358 .map(|segment| element! {
359 Text(
360 content: segment.text,
361 color: segment.style.color,
362 weight: segment.style.weight,
363 italic: segment.style.italic,
364 wrap: TextWrap::NoWrap,
365 )
366 }))
367 }
368 }
369 });
370
371 let theme_value = theme_state.read().clone();
372
373 let background = theme_value
374 .background
375 .unwrap_or(Color::Rgb { r: 0, g: 0, b: 0 });
376 let foreground = theme_value.foreground.unwrap_or(Color::White);
377
378 let placeholder_color = theme_value.secondary.or(Some(foreground));
379 let placeholder_element = placeholder_visible.then(|| {
380 element! {
381 Text(
382 content: placeholder_text.clone(),
383 color: placeholder_color,
384 italic: true,
385 )
386 }
387 });
388 let input_value_state = input_value;
389
390 element! {
391 View(
392 flex_direction: FlexDirection::Column,
393 padding: 1u16,
394 gap: 1u16,
395 background_color: background,
396 ) {
397 View(
398 flex_direction: FlexDirection::Column,
399 flex_grow: 1.0,
400 gap: 0u16,
401 overflow: Overflow::Hidden,
402 ) {
403 #(transcript_rows)
404 }
405 View(flex_direction: FlexDirection::Column, gap: 1u16) {
406 View(
407 flex_direction: FlexDirection::Row,
408 align_items: AlignItems::Center,
409 gap: 1u16,
410 ) {
411 Text(
412 content: prompt_prefix_value.clone(),
413 color: prompt_style_value.color.or(theme_value.secondary),
414 weight: prompt_style_value.weight,
415 italic: prompt_style_value.italic,
416 wrap: TextWrap::NoWrap,
417 )
418 TextInput(
419 has_focus: true,
420 value: input_value_string.clone(),
421 on_change: move |value| {
422 let mut handle = input_value_state;
423 handle.set(value);
424 },
425 color: theme_value.foreground,
426 )
427 }
428 #(placeholder_element.into_iter())
429 }
430 }
431 }
432}
433
434fn flush_current_line(
435 current_line: &mut State<StyledLine>,
436 current_active: &mut State<bool>,
437 lines_state: &mut State<Vec<StyledLine>>,
438 force: bool,
439) {
440 if !force && !current_active.get() {
441 return;
442 }
443
444 if let Some(cur) = current_line.try_read() {
445 if !cur.segments.is_empty() || force {
446 if let Some(mut lines) = lines_state.try_write() {
447 lines.push(cur.clone());
448 }
449 }
450 }
451
452 if let Some(mut cur) = current_line.try_write() {
453 cur.segments.clear();
454 }
455 current_active.set(false);
456}
457
458fn append_inline_segment(
459 current_line: &mut State<StyledLine>,
460 current_active: &mut State<bool>,
461 lines_state: &mut State<Vec<StyledLine>>,
462 segment: IocraftSegment,
463) {
464 let text = segment.text;
465 let style = segment.style;
466
467 if text.is_empty() {
468 return;
469 }
470
471 let mut parts = text.split('\n').peekable();
472 let ends_with_newline = text.ends_with('\n');
473
474 while let Some(part) = parts.next() {
475 if !part.is_empty() {
476 if let Some(mut cur) = current_line.try_write() {
477 cur.push_segment(IocraftSegment {
478 text: part.to_string(),
479 style: style.clone(),
480 });
481 }
482 current_active.set(true);
483 }
484
485 if parts.peek().is_some() {
486 flush_current_line(current_line, current_active, lines_state, true);
487 }
488 }
489
490 if ends_with_newline {
491 flush_current_line(current_line, current_active, lines_state, true);
492 }
493}
494
495pub fn convert_style(style: anstyle::Style) -> IocraftTextStyle {
496 let color = style.get_fg_color().and_then(|color| convert_color(color));
497 let effects = style.get_effects();
498 let weight = if effects.contains(anstyle::Effects::BOLD) {
499 Weight::Bold
500 } else {
501 Weight::Normal
502 };
503 let italic = effects.contains(anstyle::Effects::ITALIC);
504
505 IocraftTextStyle {
506 color,
507 weight,
508 italic,
509 }
510}
511
512pub fn convert_color(color: anstyle::Color) -> Option<Color> {
513 match color {
514 anstyle::Color::Ansi(ansi) => Some(match ansi {
515 anstyle::AnsiColor::Black => Color::Black,
516 anstyle::AnsiColor::Red => Color::DarkRed,
517 anstyle::AnsiColor::Green => Color::DarkGreen,
518 anstyle::AnsiColor::Yellow => Color::DarkYellow,
519 anstyle::AnsiColor::Blue => Color::DarkBlue,
520 anstyle::AnsiColor::Magenta => Color::DarkMagenta,
521 anstyle::AnsiColor::Cyan => Color::DarkCyan,
522 anstyle::AnsiColor::White => Color::Grey,
523 anstyle::AnsiColor::BrightBlack => Color::DarkGrey,
524 anstyle::AnsiColor::BrightRed => Color::Red,
525 anstyle::AnsiColor::BrightGreen => Color::Green,
526 anstyle::AnsiColor::BrightYellow => Color::Yellow,
527 anstyle::AnsiColor::BrightBlue => Color::Blue,
528 anstyle::AnsiColor::BrightMagenta => Color::Magenta,
529 anstyle::AnsiColor::BrightCyan => Color::Cyan,
530 anstyle::AnsiColor::BrightWhite => Color::White,
531 }),
532 anstyle::Color::Ansi256(value) => Some(Color::AnsiValue(value.index())),
533 anstyle::Color::Rgb(rgb) => Some(Color::Rgb {
534 r: rgb.r(),
535 g: rgb.g(),
536 b: rgb.b(),
537 }),
538 }
539}
540
541pub fn theme_from_styles(styles: &crate::ui::theme::ThemeStyles) -> IocraftTheme {
542 IocraftTheme {
543 background: convert_color(styles.background),
544 foreground: convert_style(styles.output).color,
545 primary: convert_style(styles.primary).color,
546 secondary: convert_style(styles.secondary).color,
547 }
548}