Cleanup
This commit is contained in:
parent
7b9525ef95
commit
daa4add5cc
3 changed files with 73 additions and 93 deletions
132
src/tui/mod.rs
132
src/tui/mod.rs
|
|
@ -22,7 +22,7 @@ use ratatui::backend::CrosstermBackend;
|
|||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
||||
use ratatui::{Frame, Terminal};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
|
|
@ -43,17 +43,16 @@ pub enum TuiError {
|
|||
/// Mode transitions:
|
||||
/// Insert --Esc--> Normal
|
||||
/// Normal --i--> Insert
|
||||
/// Normal --:--> Command(buffer)
|
||||
/// Normal --:--> Command
|
||||
/// Command --Esc/Enter--> Normal
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// Navigation and command entry. Keys are interpreted as commands, not text.
|
||||
Normal,
|
||||
/// Text input. Printable keys append to the input buffer.
|
||||
Insert,
|
||||
/// Ex-style command entry. The `String` holds the partial command buffer
|
||||
/// (without the leading `:`).
|
||||
Command(String),
|
||||
/// Ex-style command entry. The command buffer lives in `AppState::command_buffer`.
|
||||
Command,
|
||||
}
|
||||
|
||||
/// The UI-layer view of a conversation: rendered messages and the current input buffer.
|
||||
|
|
@ -66,6 +65,8 @@ pub struct AppState {
|
|||
pub scroll: u16,
|
||||
/// Current vim-style editing mode.
|
||||
pub mode: Mode,
|
||||
/// Partial command text in Command mode (without the leading `:`).
|
||||
pub command_buffer: String,
|
||||
/// Buffered keystrokes for multi-key chord resolution in Normal mode (e.g. `gg`).
|
||||
pub pending_keys: Vec<char>,
|
||||
/// Last-known viewport height (output pane lines). Updated each frame by `update_scroll`.
|
||||
|
|
@ -81,6 +82,7 @@ impl AppState {
|
|||
input: String::new(),
|
||||
scroll: 0,
|
||||
mode: Mode::Insert,
|
||||
command_buffer: String::new(),
|
||||
pending_keys: Vec::new(),
|
||||
viewport_height: 0,
|
||||
status_error: None,
|
||||
|
|
@ -154,7 +156,7 @@ fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl
|
|||
match &state.mode {
|
||||
Mode::Normal => handle_normal_key(key, state),
|
||||
Mode::Insert => handle_insert_key(key, state),
|
||||
Mode::Command(_) => handle_command_key(key, state),
|
||||
Mode::Command => handle_command_key(key, state),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -208,7 +210,8 @@ fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl>
|
|||
state.mode = Mode::Insert;
|
||||
}
|
||||
KeyCode::Char(':') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||
state.mode = Mode::Command(String::new());
|
||||
state.command_buffer.clear();
|
||||
state.mode = Mode::Command;
|
||||
}
|
||||
KeyCode::Char('j') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||
state.scroll = state.scroll.saturating_add(1);
|
||||
|
|
@ -255,7 +258,7 @@ fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl>
|
|||
state.mode = Mode::Normal;
|
||||
None
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
state.input.push(c);
|
||||
None
|
||||
}
|
||||
|
|
@ -307,30 +310,21 @@ fn execute_command(buf: &str, state: &mut AppState) -> Option<LoopControl> {
|
|||
/// | Backspace | Pop from buffer; if empty, return to Normal |
|
||||
/// | Char(c) | Append to command buffer |
|
||||
fn handle_command_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl> {
|
||||
// Extract the command buffer. We need ownership to mutate it.
|
||||
let mut buf = match std::mem::replace(&mut state.mode, Mode::Normal) {
|
||||
Mode::Command(b) => b,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
// Already set to Normal above.
|
||||
state.mode = Mode::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
return execute_command(&buf, state);
|
||||
state.mode = Mode::Normal;
|
||||
return execute_command(&state.command_buffer.clone(), state);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
buf.pop();
|
||||
state.mode = Mode::Command(buf);
|
||||
state.command_buffer.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
buf.push(c);
|
||||
state.mode = Mode::Command(buf);
|
||||
}
|
||||
_ => {
|
||||
state.mode = Mode::Command(buf);
|
||||
state.command_buffer.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
@ -407,6 +401,20 @@ fn update_scroll(state: &mut AppState, area: Rect) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Compute the overlay rectangle for the command palette, centered on `output_area`.
|
||||
fn command_overlay_rect(output_area: Rect) -> Rect {
|
||||
let overlay_w = (output_area.width / 2).max(80).min(output_area.width);
|
||||
let overlay_h: u16 = 3; // border + content + border
|
||||
let overlay_x = output_area.x + (output_area.width.saturating_sub(overlay_w)) / 2;
|
||||
let overlay_y = output_area.y + 2;
|
||||
Rect {
|
||||
x: overlay_x,
|
||||
y: overlay_y,
|
||||
width: overlay_w,
|
||||
height: overlay_h.min(output_area.height),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the full TUI into `frame`.
|
||||
///
|
||||
/// Layout (top to bottom):
|
||||
|
|
@ -452,22 +460,11 @@ fn render(frame: &mut Frame, state: &AppState) {
|
|||
frame.render_widget(output, chunks[0]);
|
||||
|
||||
// --- Command overlay (floating box centered on output pane) ---
|
||||
if let Mode::Command(ref buf) = state.mode {
|
||||
let out = chunks[0];
|
||||
let overlay_w = (out.width / 2).max(80).min(out.width);
|
||||
let overlay_h = 3; // border + content + border
|
||||
let overlay_x = out.x + (out.width.saturating_sub(overlay_w)) / 2;
|
||||
let overlay_y = out.y + (out.height.saturating_sub(overlay_h)) / 2;
|
||||
let overlay_area = Rect {
|
||||
x: overlay_x,
|
||||
y: overlay_y,
|
||||
width: overlay_w,
|
||||
height: overlay_h.min(out.height),
|
||||
};
|
||||
if state.mode == Mode::Command {
|
||||
let overlay_area = command_overlay_rect(chunks[0]);
|
||||
// Clear the area behind the overlay so it appears floating.
|
||||
let clear = Paragraph::new("");
|
||||
frame.render_widget(clear, overlay_area);
|
||||
let overlay = Paragraph::new(format!(":{buf}")).block(
|
||||
frame.render_widget(Clear, overlay_area);
|
||||
let overlay = Paragraph::new(format!(":{}", state.command_buffer)).block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(Color::Yellow))
|
||||
.title("Command"),
|
||||
|
|
@ -477,7 +474,7 @@ fn render(frame: &mut Frame, state: &AppState) {
|
|||
|
||||
// --- Input pane ---
|
||||
let (input_title, input_style) = match state.mode {
|
||||
Mode::Normal | Mode::Command(_) => (
|
||||
Mode::Normal | Mode::Command => (
|
||||
"Input (normal)",
|
||||
Style::default()
|
||||
.fg(Color::DarkGray)
|
||||
|
|
@ -499,15 +496,12 @@ fn render(frame: &mut Frame, state: &AppState) {
|
|||
let cursor_y = chunks[1].y + 1; // inside the border
|
||||
frame.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
Mode::Command(ref buf) => {
|
||||
// Cursor in the floating overlay: recalculate overlay position
|
||||
let out = chunks[0];
|
||||
let overlay_w = (out.width / 2).max(80).min(out.width);
|
||||
let overlay_x = out.x + (out.width.saturating_sub(overlay_w)) / 2;
|
||||
let overlay_y = out.y + (out.height.saturating_sub(3)) / 2;
|
||||
Mode::Command => {
|
||||
// Cursor in the floating overlay
|
||||
let overlay = command_overlay_rect(chunks[0]);
|
||||
// border(1) + ":" (1) + buf len
|
||||
let cursor_x = overlay_x + 1 + 1 + buf.len() as u16;
|
||||
let cursor_y = overlay_y + 1; // inside the border
|
||||
let cursor_x = overlay.x + 1 + 1 + state.command_buffer.len() as u16;
|
||||
let cursor_y = overlay.y + 1; // inside the border
|
||||
frame.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
Mode::Normal => {} // no cursor
|
||||
|
|
@ -523,7 +517,7 @@ fn render(frame: &mut Frame, state: &AppState) {
|
|||
" INSERT ",
|
||||
Style::default().bg(Color::Green).fg(Color::White),
|
||||
),
|
||||
Mode::Command(_) => (
|
||||
Mode::Command => (
|
||||
" COMMAND ",
|
||||
Style::default().bg(Color::Yellow).fg(Color::Black),
|
||||
),
|
||||
|
|
@ -687,6 +681,13 @@ mod tests {
|
|||
assert!(matches!(result, Some(LoopControl::Quit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_ctrl_char_is_noop() {
|
||||
let mut state = AppState::new();
|
||||
handle_key(ctrl_key('a'), &mut state);
|
||||
assert_eq!(state.input, "", "Ctrl+A should not insert 'a'");
|
||||
}
|
||||
|
||||
// --- drain_ui_events tests ---
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -801,13 +802,14 @@ mod tests {
|
|||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
handle_key(make_key(KeyCode::Char(':')), &mut state);
|
||||
assert_eq!(state.mode, Mode::Command(String::new()));
|
||||
assert_eq!(state.mode, Mode::Command);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_esc_enters_normal() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command("q".to_string());
|
||||
state.mode = Mode::Command;
|
||||
state.command_buffer = "q".to_string();
|
||||
handle_key(make_key(KeyCode::Esc), &mut state);
|
||||
assert_eq!(state.mode, Mode::Normal);
|
||||
}
|
||||
|
|
@ -815,7 +817,7 @@ mod tests {
|
|||
#[test]
|
||||
fn command_enter_enters_normal() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command("q".to_string());
|
||||
state.mode = Mode::Command;
|
||||
handle_key(make_key(KeyCode::Enter), &mut state);
|
||||
assert_eq!(state.mode, Mode::Normal);
|
||||
}
|
||||
|
|
@ -823,23 +825,25 @@ mod tests {
|
|||
#[test]
|
||||
fn command_chars_append_to_buffer() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command(String::new());
|
||||
state.mode = Mode::Command;
|
||||
handle_key(make_key(KeyCode::Char('q')), &mut state);
|
||||
assert_eq!(state.mode, Mode::Command("q".to_string()));
|
||||
assert_eq!(state.mode, Mode::Command);
|
||||
assert_eq!(state.command_buffer, "q");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_backspace_empty_stays_in_command() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command("ab".to_string());
|
||||
state.mode = Mode::Command;
|
||||
state.command_buffer = "ab".to_string();
|
||||
handle_key(make_key(KeyCode::Backspace), &mut state);
|
||||
assert_eq!(state.mode, Mode::Command("a".to_string()));
|
||||
assert_eq!(state.command_buffer, "a");
|
||||
handle_key(make_key(KeyCode::Backspace), &mut state);
|
||||
assert_eq!(state.mode, Mode::Command(String::new()));
|
||||
assert_eq!(state.command_buffer, "");
|
||||
handle_key(make_key(KeyCode::Backspace), &mut state);
|
||||
assert_eq!(state.mode, Mode::Command(String::new()));
|
||||
assert_eq!(state.command_buffer, "");
|
||||
handle_key(make_key(KeyCode::Backspace), &mut state);
|
||||
assert_eq!(state.mode, Mode::Command(String::new()));
|
||||
assert_eq!(state.command_buffer, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -900,7 +904,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn ctrl_c_quits_from_any_mode() {
|
||||
for mode in [Mode::Normal, Mode::Insert, Mode::Command("q".to_string())] {
|
||||
for mode in [Mode::Normal, Mode::Insert, Mode::Command] {
|
||||
let mut state = AppState::new();
|
||||
state.mode = mode;
|
||||
let result = handle_key(ctrl_key('c'), &mut state);
|
||||
|
|
@ -993,7 +997,8 @@ mod tests {
|
|||
#[test]
|
||||
fn command_enter_executes_quit() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command("q".to_string());
|
||||
state.mode = Mode::Command;
|
||||
state.command_buffer = "q".to_string();
|
||||
let result = handle_key(make_key(KeyCode::Enter), &mut state);
|
||||
assert!(matches!(result, Some(LoopControl::Quit)));
|
||||
assert_eq!(state.mode, Mode::Normal);
|
||||
|
|
@ -1041,7 +1046,7 @@ mod tests {
|
|||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command("quit".to_string());
|
||||
state.mode = Mode::Command;
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let all_text: String = buf
|
||||
|
|
@ -1057,7 +1062,8 @@ mod tests {
|
|||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command("quit".to_string());
|
||||
state.mode = Mode::Command;
|
||||
state.command_buffer = "quit".to_string();
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let all_text: String = buf
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue