zeph_tui/widgets/
input.rs1use ratatui::Frame;
5use ratatui::layout::Rect;
6use ratatui::text::Span;
7use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
8use unicode_width::UnicodeWidthStr;
9
10use crate::app::{App, InputMode};
11use crate::theme::Theme;
12
13pub fn render(app: &App, frame: &mut Frame, area: Rect) {
14 let theme = Theme::default();
15
16 let title = match app.input_mode() {
17 InputMode::Normal => " Press 'i' to type ",
18 InputMode::Insert => " Input (Esc to cancel) ",
19 };
20
21 let mut block = Block::default()
22 .borders(Borders::ALL)
23 .border_style(theme.panel_border)
24 .title(title);
25
26 if app.queued_count() > 0 {
27 let badge = format!(" [+{} queued] ", app.queued_count());
28 block = block.title_bottom(Span::styled(badge, theme.highlight));
29 }
30
31 if app.editing_queued() {
32 block = block.title_bottom(Span::styled(" [editing queued] ", theme.highlight));
33 }
34
35 let visible_lines = area.height.saturating_sub(2);
36 let cursor_line = u16::try_from(
37 app.input()[..app
38 .input()
39 .char_indices()
40 .nth(app.cursor_position())
41 .map_or(app.input().len(), |(idx, _)| idx)]
42 .matches('\n')
43 .count(),
44 )
45 .unwrap_or(u16::MAX);
46 let scroll = cursor_line.saturating_sub(visible_lines.saturating_sub(1));
47
48 let paragraph = if let Some(ps) = app.paste_state() {
49 let size_label = if ps.byte_len >= 1024 {
52 #[allow(clippy::cast_precision_loss)]
54 let kb = ps.byte_len as f64 / 1024.0;
55 format!("{kb:.1} KB")
56 } else {
57 format!("{} B", ps.byte_len)
58 };
59 let indicator = format!("[Pasted: {} lines · {}]", ps.line_count, size_label);
60 Paragraph::new(indicator)
61 .block(block)
62 .style(theme.system_message)
63 .scroll((scroll, 0))
64 .wrap(Wrap { trim: false })
65 } else if app.input().is_empty() && matches!(app.input_mode(), InputMode::Insert) {
66 Paragraph::new("Type a message, / for commands, @ to mention")
67 .block(block)
68 .style(theme.system_message)
69 .scroll((scroll, 0))
70 .wrap(Wrap { trim: false })
71 } else {
72 Paragraph::new(app.input())
73 .block(block)
74 .style(theme.input_text)
75 .scroll((scroll, 0))
76 .wrap(Wrap { trim: false })
77 };
78
79 frame.render_widget(paragraph, area);
80
81 if app.paste_state().is_none() && matches!(app.input_mode(), InputMode::Insert) {
84 let prefix: String = app.input().chars().take(app.cursor_position()).collect();
85 let last_line = prefix.rsplit('\n').next().unwrap_or(&prefix);
86 #[allow(clippy::cast_possible_truncation)]
87 let cursor_x = area.x + last_line.width() as u16 + 1;
88 let line_count = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX);
89 #[allow(clippy::cast_possible_truncation)]
90 let cursor_y = area.y + 1 + line_count.saturating_sub(scroll);
91 frame.set_cursor_position((cursor_x, cursor_y));
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use insta::assert_snapshot;
98 use tokio::sync::mpsc;
99
100 use crate::app::App;
101 use crate::test_utils::render_to_string;
102
103 fn make_app() -> App {
104 let (user_tx, _) = mpsc::channel(1);
105 let (_, agent_rx) = mpsc::channel(1);
106 App::new(user_tx, agent_rx)
107 }
108
109 #[test]
110 fn input_insert_mode() {
111 let app = make_app();
112 let output = render_to_string(40, 5, |frame, area| {
113 super::render(&app, frame, area);
114 });
115 assert_snapshot!(output);
116 }
117
118 #[test]
119 fn input_normal_mode() {
120 let mut app = make_app();
121 app.handle_event(crate::event::AppEvent::Key(
122 crossterm::event::KeyEvent::new(
123 crossterm::event::KeyCode::Esc,
124 crossterm::event::KeyModifiers::NONE,
125 ),
126 ));
127 let output = render_to_string(40, 5, |frame, area| {
128 super::render(&app, frame, area);
129 });
130 assert_snapshot!(output);
131 }
132}