diff --git a/docs/plans/2026-02-27-enhanced-setting-path-input.md b/docs/plans/2026-02-27-enhanced-setting-path-input.md new file mode 100644 index 0000000..cdd4446 --- /dev/null +++ b/docs/plans/2026-02-27-enhanced-setting-path-input.md @@ -0,0 +1,150 @@ +# Plan: Enhanced Path Input with Cursor Navigation and Tab Completion + +## Context + +Settings page path fields (code download dir, passage download dir, export path, import path) currently only support appending characters and backspace — no cursor movement, no arrow keys, no tab completion. Users can't easily correct a typo in the middle of a path or navigate to an existing file for import. + +## Approach: Custom `LineInput` struct + filesystem tab completion + +Neither `tui-input` nor `tui-textarea` provide tab/path completion, and both have crossterm version mismatches with our deps (ratatui 0.30 + crossterm 0.28). A custom struct avoids dependency churn and gives us exactly the features we need. + +## New file: `src/ui/line_input.rs` + +### Struct + +```rust +/// Which settings path field is being edited. +pub enum PathField { + CodeDownloadDir, + PassageDownloadDir, + ExportPath, + ImportPath, +} + +pub enum InputResult { + Continue, + Submit, + Cancel, +} + +pub struct LineInput { + text: String, + cursor: usize, // char index (NOT byte offset) + completions: Vec, + completion_index: Option, + completion_seed: String, // text snapshot when Tab first pressed + completion_error: bool, // true if last read_dir failed +} +``` + +Cursor is stored as a **char index** (0 = before first char, `text.chars().count()` = after last char). Conversion to byte offset happens only at mutation boundaries via `text.char_indices()`. + +### Keyboard handling + +| Key | Action | Resets completion? | +|-----|--------|--------------------| +| Left | Move cursor one char left | Yes | +| Right | Move cursor one char right | Yes | +| Home / Ctrl+A | Move cursor to start | Yes | +| End / Ctrl+E | Move cursor to end | Yes | +| Backspace | Delete char before cursor | Yes | +| Delete | Delete char at cursor | Yes | +| Ctrl+U | Clear entire line | Yes | +| Ctrl+W | Delete word before cursor (see semantics below) | Yes | +| Tab | Cycle completions forward (end-of-line only) | No | +| BackTab | Cycle completions backward | No | +| Printable char | Insert at cursor | Yes | +| Esc | Return `InputResult::Cancel` | — | +| Enter | Return `InputResult::Submit` | — | + +**Only Tab/BackTab preserve** the completion session. All other keys reset it. + +**Ctrl+W semantics**: From cursor position, first skip any consecutive whitespace to the left, then delete the contiguous non-whitespace run. This matches standard readline/bash `unix-word-rubout` behavior. Example: `"foo bar |"` → Ctrl+W → `"foo |"`. + +### Tab completion + +Tab completion **only activates when cursor is at end-of-line**. If cursor is in the middle, Tab is a no-op. + +1. **First Tab** (`completion_index` is `None`): Snapshot `text` as `completion_seed`. Split into (directory, partial_filename) using the last path separator. Expand leading `~` to `dirs::home_dir()` for the `read_dir` call only — preserve `~` in output text. Call `std::fs::read_dir` with a **scan budget of 1000 entries** (iterate at most 1000 `DirEntry` results). From the scanned entries, filter those whose name starts with `partial_filename` (always case-sensitive — this is an intentional simplification; case-insensitive matching on macOS HFS+/Windows NTFS is not in scope). Hidden files (names starting with `.`) only included when `partial_filename` starts with `.`. Sort matching candidates: **directories first, then files, alphabetical within each group**. Cap the final candidate list at 100. On any `read_dir` or entry I/O error, produce zero completions and set `completion_error = true` (renders `"(cannot read directory)"` in footer). +2. **Cycling**: Increment/decrement `completion_index`, wrapping. Replace `text` with selected completion. Directories get a trailing `std::path::MAIN_SEPARATOR`. Cursor moves to end. +3. **Reset**: Any non-Tab/BackTab key clears completion state **and** clears `completion_error`. This means the error hint disappears on the next keystroke (text mutation, cursor move, submit, or cancel). +4. **Mixed paths** like `~/../tmp` are not normalized — they're passed through as-is. +5. **Hidden-file filtering** (`.-prefix only` rule) applies identically on all platforms. + +### Rendering + +```rust +impl LineInput { + /// Returns (before_cursor, cursor_char, after_cursor) for styled rendering. + pub fn render_parts(&self) -> (&str, Option, &str); + pub fn value(&self) -> &str; +} +``` + +When cursor is at end of text, `cursor_char` is `None` and a **space with inverted background** is rendered as the cursor (avoids font/glyph compatibility issues with block characters across terminals). When cursor is in the middle, the character at cursor position is rendered with inverted colors (swapped fg/bg). + +## Changes to existing files + +### `src/ui/mod.rs` +- Add `pub mod line_input;` + +### `src/app.rs` +- Replace three booleans (`settings_editing_download_dir`, `settings_editing_export_path`, `settings_editing_import_path`) with: + ```rust + pub settings_editing_path: Option<(PathField, LineInput)>, + ``` +- `settings_export_path` and `settings_import_path` remain as `String`. On editing start, `LineInput` is initialized from current value. On `Submit`, value is written back to the field identified by `PathField`. +- `clear_settings_modals()` sets `settings_editing_path` to `None`. +- Add `is_editing_path(&self) -> bool` and `is_editing_field(&self, index: usize) -> bool` helpers. + +**Migration checklist** — all sites referencing the old booleans must be updated. Verify by grepping for the removed field names (`settings_editing_download_dir`, `settings_editing_export_path`, `settings_editing_import_path`) — zero hits after migration: +- `src/main.rs` handle_settings_key priority 4 block (~line 550) +- `src/main.rs` Enter handler for fields 5, 9, 12, 14 (~line 605) +- `src/main.rs` render_settings `is_editing_this_path` check (~line 2693) +- `src/main.rs` `any_path_editing` footer check (~line 2724) +- `src/app.rs` field declarations (~line 218) +- `src/app.rs` `clear_settings_modals` (~line 462) +- `src/app.rs` `Default` / `new` initialization + +### `src/main.rs` — `handle_settings_key()` +- **Priority 4 block**: Replace with `if let Some((field, ref mut input)) = app.settings_editing_path`. Call `input.handle(key)` and match on result. `Submit` writes value back via `field`, `Cancel` discards. +- **Enter on path fields**: Construct `LineInput::new(current_value)` paired with appropriate `PathField` variant. + +### `src/main.rs` — `render_settings()` +- When editing, render via `input.render_parts()` with cursor char in inverted style. +- Footer hints in editing state: `"[←→] Move [Tab] Complete (at end) [Enter] Confirm [Esc] Cancel"` +- If `input.completion_error` is true, append `"(cannot read directory)"` to footer. Clears on next keystroke. + +## Key files + +- `src/ui/line_input.rs` — new +- `src/ui/mod.rs` — add module +- `src/app.rs` — state fields, `clear_settings_modals()`, helper methods +- `src/main.rs` — key handling, rendering, footer + +## Verification + +1. `cargo build` — no warnings +2. `cargo test` — all existing + new unit tests pass +3. **Unit tests for `LineInput`** (in `line_input.rs`): + - Insert char at start, middle, end + - Delete/backspace at boundaries (start of line, end, empty string) + - Ctrl+W: `"foo bar "` → `"foo "`, `" foo"` → `" "`, `""` → `""` + - Cursor: left at 0 stays 0, right at end stays at end + - Home/End position correctly + - Ctrl+U clears text and cursor to 0 + - Tab at end-of-line with no match → no completions, no panic + - Tab at mid-line → no-op + - Tab cycling wraps around; BackTab cycles reverse + - Non-Tab key resets completion state + - `render_parts()` returns correct slices at start, middle, end positions +4. **Grep verification**: `grep -rn 'settings_editing_download_dir\|settings_editing_export_path\|settings_editing_import_path' src/` returns zero hits +5. Manual testing: + - Navigate to Export Path, press Enter → cursor appears at end + - Arrow left/right moves cursor, Home/End work + - Backspace/Delete at cursor position, Ctrl+U/Ctrl+W + - Type partial path, Tab → completions cycle; Shift+Tab reverses + - Tab on directory appends separator, allows continued completion + - Tab on nonexistent path → footer shows "(cannot read directory)" + - Enter confirms, Esc cancels (value reverts) + - All four path fields (code dir, passage dir, export, import) work identically diff --git a/src/app.rs b/src/app.rs index b7e8c79..aad898d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -45,6 +45,7 @@ use crate::store::schema::{ DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData, }; use crate::ui::components::menu::Menu; +use crate::ui::line_input::{LineInput, PathField}; use crate::ui::theme::Theme; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -216,7 +217,7 @@ pub struct App { pub store: Option, pub should_quit: bool, pub settings_selected: usize, - pub settings_editing_download_dir: bool, + pub settings_editing_path: Option<(PathField, LineInput)>, pub stats_tab: usize, pub depressed_keys: HashSet, pub last_key_time: Option, @@ -266,8 +267,6 @@ pub struct App { pub settings_status_message: Option, pub settings_export_path: String, pub settings_import_path: String, - pub settings_editing_export_path: bool, - pub settings_editing_import_path: bool, pub keyboard_explorer_selected: Option, pub explorer_accuracy_cache_overall: Option<(char, usize, usize)>, pub explorer_accuracy_cache_ranked: Option<(char, usize, usize)>, @@ -369,7 +368,7 @@ impl App { store, should_quit: false, settings_selected: 0, - settings_editing_download_dir: false, + settings_editing_path: None, stats_tab: 0, depressed_keys: HashSet::new(), last_key_time: None, @@ -419,8 +418,6 @@ impl App { settings_status_message: None, settings_export_path: default_export_path(), settings_import_path: default_export_path(), - settings_editing_export_path: false, - settings_editing_import_path: false, keyboard_explorer_selected: None, explorer_accuracy_cache_overall: None, explorer_accuracy_cache_ranked: None, @@ -462,9 +459,23 @@ impl App { pub fn clear_settings_modals(&mut self) { self.settings_confirm_import = false; self.settings_export_conflict = false; - self.settings_editing_export_path = false; - self.settings_editing_import_path = false; - self.settings_editing_download_dir = false; + self.settings_editing_path = None; + } + + pub fn is_editing_path(&self) -> bool { + self.settings_editing_path.is_some() + } + + pub fn is_editing_field(&self, index: usize) -> bool { + self.settings_editing_path + .as_ref() + .map(|(field, _)| match field { + PathField::CodeDownloadDir => index == 5, + PathField::PassageDownloadDir => index == 9, + PathField::ExportPath => index == 12, + PathField::ImportPath => index == 14, + }) + .unwrap_or(false) } pub fn arm_post_drill_input_lock(&mut self) { @@ -1575,7 +1586,7 @@ impl App { pub fn go_to_settings(&mut self) { self.settings_selected = 0; - self.settings_editing_download_dir = false; + self.settings_editing_path = None; self.screen = AppScreen::Settings; } @@ -2199,7 +2210,7 @@ impl App { store: None, should_quit: false, settings_selected: 0, - settings_editing_download_dir: false, + settings_editing_path: None, stats_tab: 0, depressed_keys: HashSet::new(), last_key_time: None, @@ -2249,8 +2260,6 @@ impl App { settings_status_message: None, settings_export_path: default_export_path(), settings_import_path: default_export_path(), - settings_editing_export_path: false, - settings_editing_import_path: false, keyboard_explorer_selected: None, explorer_accuracy_cache_overall: None, explorer_accuracy_cache_ranked: None, diff --git a/src/main.rs b/src/main.rs index ba5d70a..3e83ef7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; use app::{App, AppScreen, DrillMode, MilestoneKind, StatusKind}; +use ui::line_input::{InputResult, LineInput, PathField}; use engine::skill_tree::{DrillScope, find_key_branch}; use event::{AppEvent, EventHandler}; use generator::code_syntax::{code_language_options, is_language_cached, language_by_key}; @@ -548,41 +549,22 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) { } // Priority 4: editing a path field - if app.settings_editing_download_dir - || app.settings_editing_export_path - || app.settings_editing_import_path - { - match key.code { - KeyCode::Esc => { - app.clear_settings_modals(); - } - KeyCode::Backspace => { - if app.settings_editing_download_dir { - if app.settings_selected == 5 { - app.config.code_download_dir.pop(); - } else if app.settings_selected == 9 { - app.config.passage_download_dir.pop(); - } - } else if app.settings_editing_export_path { - app.settings_export_path.pop(); - } else if app.settings_editing_import_path { - app.settings_import_path.pop(); + if let Some((field, ref mut input)) = app.settings_editing_path { + match input.handle(key) { + InputResult::Submit => { + let value = input.value().to_string(); + match field { + PathField::CodeDownloadDir => app.config.code_download_dir = value, + PathField::PassageDownloadDir => app.config.passage_download_dir = value, + PathField::ExportPath => app.settings_export_path = value, + PathField::ImportPath => app.settings_import_path = value, } + app.settings_editing_path = None; } - KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - if app.settings_editing_download_dir { - if app.settings_selected == 5 { - app.config.code_download_dir.push(ch); - } else if app.settings_selected == 9 { - app.config.passage_download_dir.push(ch); - } - } else if app.settings_editing_export_path { - app.settings_export_path.push(ch); - } else if app.settings_editing_import_path { - app.settings_import_path.push(ch); - } + InputResult::Cancel => { + app.settings_editing_path = None; } - _ => {} + InputResult::Continue => {} } return; } @@ -603,20 +585,36 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) { } } KeyCode::Enter => match app.settings_selected { - 5 | 9 => { + 5 => { app.clear_settings_modals(); - app.settings_editing_download_dir = true; + app.settings_editing_path = Some(( + PathField::CodeDownloadDir, + LineInput::new(&app.config.code_download_dir), + )); + } + 9 => { + app.clear_settings_modals(); + app.settings_editing_path = Some(( + PathField::PassageDownloadDir, + LineInput::new(&app.config.passage_download_dir), + )); } 7 => app.start_code_downloads_from_settings(), 11 => app.start_passage_downloads_from_settings(), 12 => { app.clear_settings_modals(); - app.settings_editing_export_path = true; + app.settings_editing_path = Some(( + PathField::ExportPath, + LineInput::new(&app.settings_export_path), + )); } 13 => app.export_data(), 14 => { app.clear_settings_modals(); - app.settings_editing_import_path = true; + app.settings_editing_path = Some(( + PathField::ImportPath, + LineInput::new(&app.settings_import_path), + )); } 15 => { app.clear_settings_modals(); @@ -1788,16 +1786,17 @@ mod review_tests { /// Helper: count how many settings modal/edit flags are active fn modal_edit_count(app: &App) -> usize { - [ - app.settings_confirm_import, - app.settings_export_conflict, - app.settings_editing_export_path, - app.settings_editing_import_path, - app.settings_editing_download_dir, - ] - .iter() - .filter(|&&f| f) - .count() + let mut count = 0; + if app.settings_confirm_import { + count += 1; + } + if app.settings_export_conflict { + count += 1; + } + if app.is_editing_path() { + count += 1; + } + count } #[test] @@ -1818,12 +1817,12 @@ mod review_tests { // Enter export path editing app.settings_selected = 12; // Export Path handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.settings_editing_export_path); + assert!(app.is_editing_field(12)); assert!(modal_edit_count(&app) <= 1); // Esc out handle_settings_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(!app.settings_editing_export_path); + assert!(!app.is_editing_path()); } #[test] @@ -1834,14 +1833,14 @@ mod review_tests { // Activate export path editing first app.settings_selected = 12; handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.settings_editing_export_path); + assert!(app.is_editing_field(12)); // Esc out, then enter import path editing handle_settings_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); app.settings_selected = 14; // Import Path handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.settings_editing_import_path); - assert!(!app.settings_editing_export_path); + assert!(app.is_editing_field(14)); + assert!(!app.is_editing_field(12)); assert!(modal_edit_count(&app) <= 1); } @@ -2387,6 +2386,85 @@ mod review_tests { assert!(app.should_quit, "Ctrl+C should set should_quit even during input lock"); } + + /// Helper: render settings to a test buffer and return its text content. + fn render_settings_to_string(app: &App) -> String { + let backend = ratatui::backend::TestBackend::new(80, 40); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| render_settings(frame, app)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut text = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + text.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + text.push('\n'); + } + text + } + + #[test] + fn footer_shows_completion_error_and_clears_on_keystroke() { + let mut app = test_app(); + app.screen = AppScreen::Settings; + app.settings_selected = 12; // Export Path + + // Enter editing mode + handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.is_editing_field(12)); + + // Set path to nonexistent dir and trigger tab completion error + if let Some((_, ref mut input)) = app.settings_editing_path { + input.handle(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); // clear + for ch in "/nonexistent_zzz_dir/".chars() { + input.handle(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + input.handle(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(input.completion_error); + } + + // Render and check footer contains the error hint + let output = render_settings_to_string(&app); + assert!( + output.contains("(cannot read directory)"), + "Footer should show completion error hint" + ); + + // Press a non-tab key to clear the error + handle_settings_key( + &mut app, + KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + ); + + // Render again — error hint should be gone + let output_after = render_settings_to_string(&app); + assert!( + !output_after.contains("(cannot read directory)"), + "Footer error hint should clear after non-Tab keystroke" + ); + } + + #[test] + fn footer_shows_editing_hints_when_path_editing() { + let mut app = test_app(); + app.screen = AppScreen::Settings; + app.settings_selected = 12; + + // Before editing: shows default hints + let output_before = render_settings_to_string(&app); + assert!(output_before.contains("[ESC] Save & back")); + + // Enter editing mode + handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // After editing: shows editing hints + let output_during = render_settings_to_string(&app); + assert!(output_during.contains("[Enter] Confirm")); + assert!(output_during.contains("[Esc] Cancel")); + assert!(output_during.contains("[Tab] Complete")); + } } fn render_result(frame: &mut ratatui::Frame, app: &App) { @@ -2621,7 +2699,38 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { ]; let header_height = if inner.height > 0 { 1 } else { 0 }; - let footer_height = if inner.height > header_height { 1 } else { 0 }; + + // Compute footer hints early so we know how many lines they need. + let completion_error = app + .settings_editing_path + .as_ref() + .map(|(_, input)| input.completion_error) + .unwrap_or(false); + let footer_hints: Vec<&str> = if app.is_editing_path() { + let mut hints = vec![ + "[←→] Move", + "[Tab] Complete (at end)", + "[Enter] Confirm", + "[Esc] Cancel", + ]; + if completion_error { + hints.push("(cannot read directory)"); + } + hints + } else { + vec![ + "[ESC] Save & back", + "[Enter/arrows] Change value", + "[Enter on path] Edit", + ] + }; + let footer_packed = pack_hint_lines(&footer_hints, inner.width as usize); + let footer_height = if inner.height > header_height { + (footer_packed.len() as u16).max(1) + } else { + 0 + }; + let field_height = inner.height.saturating_sub(header_height + footer_height); let layout = Layout::default() @@ -2690,28 +2799,44 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { colors.text_pending() }); - let is_editing_this_path = is_selected - && *is_path - && (app.settings_editing_download_dir - || app.settings_editing_export_path - || app.settings_editing_import_path); + let is_editing_this_path = is_selected && *is_path && app.is_editing_field(i); let lines = if *is_path { - let path_line = if is_editing_this_path { - format!(" {value}_") + if is_editing_this_path { + if let Some((_, ref input)) = app.settings_editing_path { + let (before, cursor_ch, after) = input.render_parts(); + let cursor_style = Style::default() + .fg(colors.bg()) + .bg(colors.focused_key()); + let path_spans = match cursor_ch { + Some(ch) => vec![ + Span::styled(format!(" {before}"), value_style), + Span::styled(ch.to_string(), cursor_style), + Span::styled(after.to_string(), value_style), + ], + None => vec![ + Span::styled(format!(" {before}"), value_style), + Span::styled(" ", cursor_style), + ], + }; + vec![ + Line::from(Span::styled( + format!("{indicator}{label}: (editing)"), + label_style, + )), + Line::from(path_spans), + ] + } else { + vec![ + Line::from(Span::styled(label_text, label_style)), + Line::from(Span::styled(format!(" {value}"), value_style)), + ] + } } else { - format!(" {value}") - }; - vec![ - Line::from(Span::styled( - if is_editing_this_path { - format!("{indicator}{label}: (editing)") - } else { - label_text - }, - label_style, - )), - Line::from(Span::styled(path_line, value_style)), - ] + vec![ + Line::from(Span::styled(label_text, label_style)), + Line::from(Span::styled(format!(" {value}"), value_style)), + ] + } } else { vec![ Line::from(Span::styled(label_text, label_style)), @@ -2721,23 +2846,7 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { Paragraph::new(lines).render(field_layout[row], frame.buffer_mut()); } - let any_path_editing = app.settings_editing_download_dir - || app.settings_editing_export_path - || app.settings_editing_import_path; - let footer_hints: Vec<&str> = if any_path_editing { - vec![ - "Editing path:", - "[Type/Backspace] Modify", - "[ESC] Done editing", - ] - } else { - vec![ - "[ESC] Save & back", - "[Enter/arrows] Change value", - "[Enter on path] Edit", - ] - }; - let footer_lines: Vec = pack_hint_lines(&footer_hints, layout[2].width as usize) + let footer_lines: Vec = footer_packed .into_iter() .map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent())))) .collect(); diff --git a/src/ui/line_input.rs b/src/ui/line_input.rs new file mode 100644 index 0000000..27adec8 --- /dev/null +++ b/src/ui/line_input.rs @@ -0,0 +1,705 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Which settings path field is being edited. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PathField { + CodeDownloadDir, + PassageDownloadDir, + ExportPath, + ImportPath, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InputResult { + Continue, + Submit, + Cancel, +} + +pub struct LineInput { + text: String, + /// Cursor position as a char index (0 = before first char). + cursor: usize, + completions: Vec, + completion_index: Option, + /// Text snapshot when Tab was first pressed. + completion_seed: String, + /// True if last read_dir call failed. + pub completion_error: bool, +} + +impl LineInput { + pub fn new(text: &str) -> Self { + let cursor = text.chars().count(); + Self { + text: text.to_string(), + cursor, + completions: Vec::new(), + completion_index: None, + completion_seed: String::new(), + completion_error: false, + } + } + + pub fn value(&self) -> &str { + &self.text + } + + /// Returns (before_cursor, cursor_char, after_cursor) for styled rendering. + /// When cursor is at end of text, cursor_char is None. + pub fn render_parts(&self) -> (&str, Option, &str) { + let byte_offset = self.char_to_byte(self.cursor); + if self.cursor >= self.text.chars().count() { + (&self.text, None, "") + } else { + let ch = self.text[byte_offset..].chars().next().unwrap(); + let next_byte = byte_offset + ch.len_utf8(); + (&self.text[..byte_offset], Some(ch), &self.text[next_byte..]) + } + } + + pub fn handle(&mut self, key: KeyEvent) -> InputResult { + match key.code { + KeyCode::Esc => return InputResult::Cancel, + KeyCode::Enter => return InputResult::Submit, + + KeyCode::Left => { + self.reset_completion(); + if self.cursor > 0 { + self.cursor -= 1; + } + } + KeyCode::Right => { + self.reset_completion(); + let len = self.text.chars().count(); + if self.cursor < len { + self.cursor += 1; + } + } + KeyCode::Home => { + self.reset_completion(); + self.cursor = 0; + } + KeyCode::End => { + self.reset_completion(); + self.cursor = self.text.chars().count(); + } + KeyCode::Backspace => { + self.reset_completion(); + if self.cursor > 0 { + let byte_offset = self.char_to_byte(self.cursor - 1); + let ch = self.text[byte_offset..].chars().next().unwrap(); + self.text.replace_range(byte_offset..byte_offset + ch.len_utf8(), ""); + self.cursor -= 1; + } + } + KeyCode::Delete => { + self.reset_completion(); + let len = self.text.chars().count(); + if self.cursor < len { + let byte_offset = self.char_to_byte(self.cursor); + let ch = self.text[byte_offset..].chars().next().unwrap(); + self.text.replace_range(byte_offset..byte_offset + ch.len_utf8(), ""); + } + } + KeyCode::Tab => { + self.tab_complete(true); + } + KeyCode::BackTab => { + self.tab_complete(false); + } + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.reset_completion(); + self.cursor = 0; + } + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.reset_completion(); + self.cursor = self.text.chars().count(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.reset_completion(); + self.text.clear(); + self.cursor = 0; + } + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.reset_completion(); + self.delete_word_back(); + } + KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + self.reset_completion(); + let byte_offset = self.char_to_byte(self.cursor); + self.text.insert(byte_offset, ch); + self.cursor += 1; + } + _ => {} + } + InputResult::Continue + } + + /// Convert char index to byte offset. + fn char_to_byte(&self, char_idx: usize) -> usize { + self.text + .char_indices() + .nth(char_idx) + .map(|(b, _)| b) + .unwrap_or(self.text.len()) + } + + /// Delete word before cursor (unix-word-rubout: skip whitespace, then non-whitespace). + fn delete_word_back(&mut self) { + if self.cursor == 0 { + return; + } + let chars: Vec = self.text.chars().collect(); + let mut pos = self.cursor; + + // Skip trailing whitespace + while pos > 0 && chars[pos - 1].is_whitespace() { + pos -= 1; + } + // Skip non-whitespace + while pos > 0 && !chars[pos - 1].is_whitespace() { + pos -= 1; + } + + let start_byte = self.char_to_byte(pos); + let end_byte = self.char_to_byte(self.cursor); + self.text.replace_range(start_byte..end_byte, ""); + self.cursor = pos; + } + + fn reset_completion(&mut self) { + self.completions.clear(); + self.completion_index = None; + self.completion_seed.clear(); + self.completion_error = false; + } + + fn tab_complete(&mut self, forward: bool) { + // Only activate when cursor is at end of line + let len = self.text.chars().count(); + if self.cursor < len { + return; + } + + if self.completion_index.is_none() { + // First tab press: build completions + self.completion_seed = self.text.clone(); + self.completion_error = false; + self.completions = self.build_completions(); + if self.completions.is_empty() { + return; + } + self.completion_index = Some(0); + self.apply_completion(0); + } else if !self.completions.is_empty() { + // Cycle + let idx = self.completion_index.unwrap(); + let count = self.completions.len(); + let next = if forward { + (idx + 1) % count + } else { + (idx + count - 1) % count + }; + self.completion_index = Some(next); + self.apply_completion(next); + } + } + + fn apply_completion(&mut self, idx: usize) { + self.text = self.completions[idx].clone(); + self.cursor = self.text.chars().count(); + } + + fn build_completions(&mut self) -> Vec { + let seed = self.completion_seed.clone(); + + // Split seed into (dir_part, partial_filename) by last path separator. + // Accept both '/' and '\\' so user-typed alternate separators work on any platform. + let last_sep_pos = seed + .rfind('/') + .into_iter() + .chain(seed.rfind('\\')) + .max(); + let (dir_str, partial) = if let Some(pos) = last_sep_pos { + (&seed[..=pos], &seed[pos + 1..]) + } else { + ("", seed.as_str()) + }; + + // Expand ~ for read_dir, but keep ~ in output + let expanded_dir = if dir_str.starts_with('~') { + if let Some(home) = dirs::home_dir() { + let home_str = home.to_string_lossy().to_string(); + format!("{}{}", home_str, &dir_str[1..]) + } else { + dir_str.to_string() + } + } else if dir_str.is_empty() { + ".".to_string() + } else { + dir_str.to_string() + }; + + let read_result = std::fs::read_dir(&expanded_dir); + let entries = match read_result { + Ok(rd) => rd, + Err(_) => { + self.completion_error = true; + return Vec::new(); + } + }; + + let entry_iter = entries.map(|result| { + result.map(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); + (name, is_dir) + }) + }); + + self.collect_completions(entry_iter, dir_str, partial) + } + + /// Scan an iterator of (name, is_dir) results, filter/sort, and return completions. + /// Extracted so tests can inject synthetic iterators with errors. + fn collect_completions( + &mut self, + entries: impl Iterator>, + dir_str: &str, + partial: &str, + ) -> Vec { + let sep = std::path::MAIN_SEPARATOR; + let include_hidden = partial.starts_with('.'); + + let mut candidates: Vec<(bool, String)> = Vec::new(); // (is_dir, full_text) + let mut scanned = 0usize; + for entry_result in entries { + if scanned >= 1000 { + break; + } + scanned += 1; + + let (name_str, is_dir) = match entry_result { + Ok(pair) => pair, + Err(_) => { + self.completion_error = true; + return Vec::new(); + } + }; + + // Skip hidden files unless partial starts with '.' + if !include_hidden && name_str.starts_with('.') { + continue; + } + + // Filter by prefix + if !name_str.starts_with(partial) { + continue; + } + + let full = if is_dir { + format!("{}{}{}", dir_str, name_str, sep) + } else { + format!("{}{}", dir_str, name_str) + }; + + candidates.push((is_dir, full)); + } + + // Sort: directories first, then files, alphabetical within each group + candidates.sort_by(|a, b| { + b.0.cmp(&a.0) // true (dir) before false (file) + .then_with(|| a.1.cmp(&b.1)) + }); + + // Cap at 100 + candidates.truncate(100); + + candidates.into_iter().map(|(_, path)| path).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn ctrl(ch: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL) + } + + #[test] + fn insert_at_start_middle_end() { + let mut input = LineInput::new("ac"); + // Cursor at end (2), insert 'd' -> "acd" + input.handle(key(KeyCode::Char('d'))); + assert_eq!(input.value(), "acd"); + + // Move to start, insert 'z' -> "zacd" + input.handle(key(KeyCode::Home)); + input.handle(key(KeyCode::Char('z'))); + assert_eq!(input.value(), "zacd"); + assert_eq!(input.cursor, 1); + + // Move right once (past 'a'), insert 'b' -> "zabcd" + input.handle(key(KeyCode::Right)); + input.handle(key(KeyCode::Char('b'))); + assert_eq!(input.value(), "zabcd"); + assert_eq!(input.cursor, 3); + } + + #[test] + fn backspace_at_boundaries() { + let mut input = LineInput::new("ab"); + // Backspace at end -> "a" + input.handle(key(KeyCode::Backspace)); + assert_eq!(input.value(), "a"); + + // Backspace again -> "" + input.handle(key(KeyCode::Backspace)); + assert_eq!(input.value(), ""); + + // Backspace on empty -> no panic + input.handle(key(KeyCode::Backspace)); + assert_eq!(input.value(), ""); + assert_eq!(input.cursor, 0); + } + + #[test] + fn delete_at_boundaries() { + let mut input = LineInput::new("ab"); + // Move to start, delete -> "b" + input.handle(key(KeyCode::Home)); + input.handle(key(KeyCode::Delete)); + assert_eq!(input.value(), "b"); + assert_eq!(input.cursor, 0); + + // Delete at end -> no change + input.handle(key(KeyCode::End)); + input.handle(key(KeyCode::Delete)); + assert_eq!(input.value(), "b"); + + // Empty string delete -> no panic + let mut empty = LineInput::new(""); + empty.handle(key(KeyCode::Delete)); + assert_eq!(empty.value(), ""); + } + + #[test] + fn ctrl_w_word_delete() { + // "foo bar " -> "foo " + let mut input = LineInput::new("foo bar "); + input.handle(ctrl('w')); + assert_eq!(input.value(), "foo "); + + // " foo" cursor at end -> " " + let mut input2 = LineInput::new(" foo"); + input2.handle(ctrl('w')); + assert_eq!(input2.value(), " "); + + // empty -> empty + let mut input3 = LineInput::new(""); + input3.handle(ctrl('w')); + assert_eq!(input3.value(), ""); + } + + #[test] + fn cursor_left_at_zero_stays() { + let mut input = LineInput::new("a"); + input.handle(key(KeyCode::Home)); + assert_eq!(input.cursor, 0); + input.handle(key(KeyCode::Left)); + assert_eq!(input.cursor, 0); + } + + #[test] + fn cursor_right_at_end_stays() { + let mut input = LineInput::new("a"); + assert_eq!(input.cursor, 1); + input.handle(key(KeyCode::Right)); + assert_eq!(input.cursor, 1); + } + + #[test] + fn home_end_position() { + let mut input = LineInput::new("hello"); + input.handle(key(KeyCode::Home)); + assert_eq!(input.cursor, 0); + input.handle(key(KeyCode::End)); + assert_eq!(input.cursor, 5); + } + + #[test] + fn ctrl_a_and_ctrl_e() { + let mut input = LineInput::new("test"); + input.handle(ctrl('a')); + assert_eq!(input.cursor, 0); + input.handle(ctrl('e')); + assert_eq!(input.cursor, 4); + } + + #[test] + fn ctrl_u_clears() { + let mut input = LineInput::new("hello world"); + input.handle(ctrl('u')); + assert_eq!(input.value(), ""); + assert_eq!(input.cursor, 0); + } + + #[test] + fn tab_at_midline_is_noop() { + let mut input = LineInput::new("hello"); + input.handle(key(KeyCode::Home)); + input.handle(key(KeyCode::Right)); // cursor at 1 + let result = input.handle(key(KeyCode::Tab)); + assert_eq!(result, InputResult::Continue); + assert_eq!(input.value(), "hello"); + assert_eq!(input.cursor, 1); + } + + #[test] + fn tab_no_match_sets_error_state() { + let mut input = LineInput::new("/nonexistent_path_zzzzz/"); + let result = input.handle(key(KeyCode::Tab)); + assert_eq!(result, InputResult::Continue); + assert!(input.completions.is_empty()); + assert!(input.completion_index.is_none()); + assert!(input.completion_error); + } + + #[test] + fn non_tab_key_resets_completion() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("aaa.txt"), "").unwrap(); + let path = format!("{}/", dir.path().display()); + let mut input = LineInput::new(&path); + + // Tab to start completion + input.handle(key(KeyCode::Tab)); + assert!(!input.completions.is_empty()); + assert!(input.completion_index.is_some()); + + // Any non-tab key resets + input.handle(key(KeyCode::Char('x'))); + assert!(input.completions.is_empty()); + assert!(input.completion_index.is_none()); + assert!(input.value().ends_with('x')); + } + + #[test] + fn render_parts_at_start() { + let input = LineInput::new("abc"); + let mut input = input; + input.cursor = 0; + let (before, ch, after) = input.render_parts(); + assert_eq!(before, ""); + assert_eq!(ch, Some('a')); + assert_eq!(after, "bc"); + } + + #[test] + fn render_parts_at_middle() { + let mut input = LineInput::new("abc"); + input.cursor = 1; + let (before, ch, after) = input.render_parts(); + assert_eq!(before, "a"); + assert_eq!(ch, Some('b')); + assert_eq!(after, "c"); + } + + #[test] + fn render_parts_at_end() { + let input = LineInput::new("abc"); + let (before, ch, after) = input.render_parts(); + assert_eq!(before, "abc"); + assert_eq!(ch, None); + assert_eq!(after, ""); + } + + #[test] + fn submit_and_cancel() { + let mut input = LineInput::new("test"); + assert_eq!(input.handle(key(KeyCode::Enter)), InputResult::Submit); + + let mut input2 = LineInput::new("test"); + assert_eq!(input2.handle(key(KeyCode::Esc)), InputResult::Cancel); + } + + #[test] + fn tab_completion_cycles_and_backtab_reverses() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("alpha.txt"), "").unwrap(); + std::fs::write(dir.path().join("beta.txt"), "").unwrap(); + std::fs::create_dir(dir.path().join("gamma_dir")).unwrap(); + let path = format!("{}/", dir.path().display()); + + let mut input = LineInput::new(&path); + input.handle(key(KeyCode::Tab)); + + // Should have 3 completions: gamma_dir/ first (dirs first), then alpha.txt, beta.txt + assert_eq!(input.completions.len(), 3); + assert!(input.completions[0].ends_with("gamma_dir/")); + assert!(input.completions[1].ends_with("alpha.txt")); + assert!(input.completions[2].ends_with("beta.txt")); + + // First tab selects gamma_dir/ + assert!(input.value().ends_with("gamma_dir/")); + + // Second tab cycles to alpha.txt + input.handle(key(KeyCode::Tab)); + assert!(input.value().ends_with("alpha.txt")); + + // Third tab cycles to beta.txt + input.handle(key(KeyCode::Tab)); + assert!(input.value().ends_with("beta.txt")); + + // Fourth tab wraps to gamma_dir/ + input.handle(key(KeyCode::Tab)); + assert!(input.value().ends_with("gamma_dir/")); + + // BackTab reverses to beta.txt + input.handle(key(KeyCode::BackTab)); + assert!(input.value().ends_with("beta.txt")); + } + + #[test] + fn completion_error_on_bad_dir() { + let mut input = LineInput::new("/nonexistent_zzz_dir/"); + input.handle(key(KeyCode::Tab)); + assert!(input.completion_error); + assert!(input.completions.is_empty()); + assert!(input.completion_index.is_none()); + + // Any key clears the error + input.handle(key(KeyCode::Char('x'))); + assert!(!input.completion_error); + } + + #[test] + fn completion_hidden_file_filtering() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(".hidden"), "").unwrap(); + std::fs::write(dir.path().join("visible"), "").unwrap(); + + // Without dot prefix: hidden files excluded + let path = format!("{}/", dir.path().display()); + let mut input = LineInput::new(&path); + input.handle(key(KeyCode::Tab)); + assert_eq!(input.completions.len(), 1); + assert!(input.completions[0].ends_with("visible")); + + // With dot prefix: hidden files included + let path_dot = format!("{}/.h", dir.path().display()); + let mut input2 = LineInput::new(&path_dot); + input2.handle(key(KeyCode::Tab)); + assert_eq!(input2.completions.len(), 1); + assert!(input2.completions[0].ends_with(".hidden")); + } + + #[test] + fn completion_prefix_filtering() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("foo_bar"), "").unwrap(); + std::fs::write(dir.path().join("foo_baz"), "").unwrap(); + std::fs::write(dir.path().join("other"), "").unwrap(); + + let path = format!("{}/foo_", dir.path().display()); + let mut input = LineInput::new(&path); + input.handle(key(KeyCode::Tab)); + assert_eq!(input.completions.len(), 2); + assert!(input.completions[0].ends_with("foo_bar")); + assert!(input.completions[1].ends_with("foo_baz")); + } + + #[test] + fn completion_directories_get_trailing_separator() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("subdir")).unwrap(); + std::fs::write(dir.path().join("file.txt"), "").unwrap(); + + let path = format!("{}/", dir.path().display()); + let mut input = LineInput::new(&path); + input.handle(key(KeyCode::Tab)); + + // First completion is the directory (sorted first) + let sep = std::path::MAIN_SEPARATOR; + assert!(input.completions[0].ends_with(&format!("subdir{sep}"))); + // File does not have trailing separator + assert!(input.completions[1].ends_with("file.txt")); + assert!(!input.completions[1].ends_with(&sep.to_string())); + } + + #[test] + fn collect_completions_entry_error_sets_error_and_returns_empty() { + let mut input = LineInput::new(""); + let entries: Vec> = vec![ + Ok(("alpha.txt".to_string(), false)), + Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "mock")), + Ok(("beta.txt".to_string(), false)), + ]; + + let result = input.collect_completions(entries.into_iter(), "/some/dir/", ""); + assert!(result.is_empty()); + assert!(input.completion_error); + } + + #[test] + fn collect_completions_ok_entries_no_error() { + let mut input = LineInput::new(""); + let entries: Vec> = vec![ + Ok(("zeta".to_string(), false)), + Ok(("alpha_dir".to_string(), true)), + Ok(("beta".to_string(), false)), + ]; + + let result = input.collect_completions(entries.into_iter(), "pfx/", ""); + assert!(!input.completion_error); + // dirs first, then files, alphabetical within each group + assert_eq!(result.len(), 3); + let sep = std::path::MAIN_SEPARATOR; + assert_eq!(result[0], format!("pfx/alpha_dir{sep}")); + assert_eq!(result[1], "pfx/beta"); + assert_eq!(result[2], "pfx/zeta"); + } + + #[test] + fn collect_completions_scan_budget_caps_at_1000() { + let mut input = LineInput::new(""); + // Create 1200 entries; only first 1000 should be scanned + let entries: Vec> = (0..1200) + .map(|i| Ok((format!("file_{i:04}"), false))) + .collect(); + + let result = input.collect_completions(entries.into_iter(), "", ""); + // Should have at most 100 (candidate cap) from the first 1000 scanned + assert!(result.len() <= 100); + } + + #[test] + fn collect_completions_candidate_cap_at_100() { + let mut input = LineInput::new(""); + // Create 200 matching entries + let entries: Vec> = (0..200) + .map(|i| Ok((format!("item_{i:03}"), false))) + .collect(); + + let result = input.collect_completions(entries.into_iter(), "", ""); + assert_eq!(result.len(), 100); + } + + #[test] + fn completion_error_clears_on_non_tab_key() { + // Trigger a completion error + let mut input = LineInput::new("/nonexistent_zzz_dir/"); + input.handle(key(KeyCode::Tab)); + assert!(input.completion_error); + + // Non-tab key clears it + input.handle(key(KeyCode::Left)); + assert!(!input.completion_error); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9beedbd..c93e024 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ pub mod components; pub mod layout; +pub mod line_input; pub mod theme;