2026-07-21 06:07:05 +07:00
|
|
|
|
//! Chat input box and autocomplete dropdown rendering.
|
|
|
|
|
|
//! Implements the Component trait.
|
|
|
|
|
|
|
|
|
|
|
|
use super::theme::Theme;
|
|
|
|
|
|
use crate::components::Component;
|
|
|
|
|
|
use ratatui::layout::Rect;
|
|
|
|
|
|
use ratatui::style::{Modifier, Style};
|
|
|
|
|
|
use ratatui::text::{Line, Span};
|
|
|
|
|
|
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
|
|
|
|
|
|
use ratatui::Frame;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
pub struct InputComponent;
|
|
|
|
|
|
|
|
|
|
|
|
impl Component for InputComponent {
|
|
|
|
|
|
fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
|
|
|
|
|
if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() {
|
|
|
|
|
|
let n = state.input.autocomplete_candidates.len().min(10) as u16;
|
|
|
|
|
|
let dropdown_height = n + 2;
|
|
|
|
|
|
let dropdown_area = Rect {
|
|
|
|
|
|
x: area.x,
|
|
|
|
|
|
y: area.y.saturating_sub(dropdown_height),
|
|
|
|
|
|
width: area.width.min(45),
|
|
|
|
|
|
height: dropdown_height,
|
|
|
|
|
|
};
|
|
|
|
|
|
let dropdown_title = match state.input.autocomplete_kind {
|
|
|
|
|
|
crate::state::AutocompleteKind::Command => " ⌘ Commands ",
|
|
|
|
|
|
crate::state::AutocompleteKind::FileMention => " 📁 Files ",
|
|
|
|
|
|
};
|
|
|
|
|
|
let dropdown_block = Block::default()
|
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
|
.border_type(BorderType::Rounded)
|
|
|
|
|
|
.border_style(Style::default().fg(Theme::BORDER))
|
|
|
|
|
|
.title(Span::styled(
|
|
|
|
|
|
dropdown_title,
|
2026-08-27 22:10:28 +07:00
|
|
|
|
Style::default()
|
|
|
|
|
|
.fg(Theme::PRIMARY)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD),
|
2026-07-21 06:07:05 +07:00
|
|
|
|
))
|
|
|
|
|
|
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
|
|
|
|
|
|
|
|
|
|
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
|
|
|
|
let selected = state.input.autocomplete_idx;
|
|
|
|
|
|
for (i, candidate) in state
|
|
|
|
|
|
.input
|
|
|
|
|
|
.autocomplete_candidates
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.take(10)
|
|
|
|
|
|
{
|
|
|
|
|
|
let prefix = if i == selected { " ▸ " } else { " " };
|
|
|
|
|
|
let style = if i == selected {
|
|
|
|
|
|
Style::default()
|
|
|
|
|
|
.fg(Theme::TEXT)
|
|
|
|
|
|
.bg(Theme::HIGHLIGHT_DIM)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Style::default().fg(Theme::TEXT)
|
|
|
|
|
|
};
|
|
|
|
|
|
let label = format!("{prefix}{candidate}");
|
|
|
|
|
|
lines.push(Line::from(Span::styled(label, style)));
|
|
|
|
|
|
}
|
|
|
|
|
|
let dropdown = Paragraph::new(lines).block(dropdown_block);
|
|
|
|
|
|
frame.render_widget(ratatui::widgets::Clear, dropdown_area);
|
|
|
|
|
|
frame.render_widget(dropdown, dropdown_area);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let block = Block::default()
|
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
|
.border_type(BorderType::Rounded)
|
|
|
|
|
|
.border_style(Style::default().fg(Theme::BORDER_FOCUSED))
|
|
|
|
|
|
.style(Style::default().bg(Theme::BG));
|
|
|
|
|
|
|
|
|
|
|
|
let input_text = &state.input.buffer;
|
|
|
|
|
|
let cursor_pos = state.input.cursor;
|
|
|
|
|
|
|
|
|
|
|
|
let prompt = Span::styled(
|
|
|
|
|
|
" ❯ ",
|
|
|
|
|
|
Style::default()
|
|
|
|
|
|
.fg(Theme::PRIMARY)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let mut spans = vec![prompt];
|
|
|
|
|
|
|
|
|
|
|
|
if input_text.is_empty() {
|
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
|
"Type a message, @file, or /command...",
|
|
|
|
|
|
Style::default()
|
|
|
|
|
|
.fg(Theme::TEXT_DIM)
|
|
|
|
|
|
.add_modifier(Modifier::ITALIC),
|
|
|
|
|
|
));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let (before, after) = input_text.split_at(cursor_pos);
|
|
|
|
|
|
spans.push(Span::raw(before.to_string()));
|
|
|
|
|
|
let (cursor_char, after_char) = if after.is_empty() {
|
|
|
|
|
|
(" ".to_string(), "")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let c = after.chars().next().unwrap();
|
|
|
|
|
|
let char_len = c.len_utf8();
|
|
|
|
|
|
(c.to_string(), &after[char_len..])
|
|
|
|
|
|
};
|
2026-08-27 22:10:28 +07:00
|
|
|
|
|
2026-07-21 06:07:05 +07:00
|
|
|
|
// Blinking cursor logic based on tick count
|
|
|
|
|
|
let cursor_style = if state.misc.tick_count % 10 < 5 {
|
|
|
|
|
|
Style::default()
|
|
|
|
|
|
.bg(Theme::PRIMARY)
|
|
|
|
|
|
.fg(Theme::BG)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Style::default()
|
|
|
|
|
|
.bg(Theme::TEXT_DIM)
|
|
|
|
|
|
.fg(Theme::BG)
|
|
|
|
|
|
.add_modifier(Modifier::BOLD)
|
|
|
|
|
|
};
|
2026-08-27 22:10:28 +07:00
|
|
|
|
|
2026-07-21 06:07:05 +07:00
|
|
|
|
spans.push(Span::styled(cursor_char, cursor_style));
|
|
|
|
|
|
if !after_char.is_empty() {
|
|
|
|
|
|
spans.push(Span::raw(after_char.to_string()));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let line = Line::from(spans);
|
|
|
|
|
|
let paragraph = Paragraph::new(line).block(block);
|
|
|
|
|
|
frame.render_widget(paragraph, area);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|