Skip to main content

zeph_tui/widgets/
input.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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        // Show compact indicator while multiline paste is pending in the buffer.
50        // Cursor is not shown — the user cannot edit within the indicator display.
51        let size_label = if ps.byte_len >= 1024 {
52            // Integer KB with one decimal place; precision loss at >4 PB is acceptable.
53            #[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    // Do not show cursor when paste indicator is active — the user interacts
82    // with the indicator as a whole unit, not individual characters.
83    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}