Enhanced paith input with cursor navigation and tab completion in settings import/export
This commit is contained in:
150
docs/plans/2026-02-27-enhanced-setting-path-input.md
Normal file
150
docs/plans/2026-02-27-enhanced-setting-path-input.md
Normal file
@@ -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<String>,
|
||||||
|
completion_index: Option<usize>,
|
||||||
|
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<char>, &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
|
||||||
35
src/app.rs
35
src/app.rs
@@ -45,6 +45,7 @@ use crate::store::schema::{
|
|||||||
DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData,
|
DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData,
|
||||||
};
|
};
|
||||||
use crate::ui::components::menu::Menu;
|
use crate::ui::components::menu::Menu;
|
||||||
|
use crate::ui::line_input::{LineInput, PathField};
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -216,7 +217,7 @@ pub struct App {
|
|||||||
pub store: Option<JsonStore>,
|
pub store: Option<JsonStore>,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
pub settings_selected: usize,
|
pub settings_selected: usize,
|
||||||
pub settings_editing_download_dir: bool,
|
pub settings_editing_path: Option<(PathField, LineInput)>,
|
||||||
pub stats_tab: usize,
|
pub stats_tab: usize,
|
||||||
pub depressed_keys: HashSet<char>,
|
pub depressed_keys: HashSet<char>,
|
||||||
pub last_key_time: Option<Instant>,
|
pub last_key_time: Option<Instant>,
|
||||||
@@ -266,8 +267,6 @@ pub struct App {
|
|||||||
pub settings_status_message: Option<StatusMessage>,
|
pub settings_status_message: Option<StatusMessage>,
|
||||||
pub settings_export_path: String,
|
pub settings_export_path: String,
|
||||||
pub settings_import_path: String,
|
pub settings_import_path: String,
|
||||||
pub settings_editing_export_path: bool,
|
|
||||||
pub settings_editing_import_path: bool,
|
|
||||||
pub keyboard_explorer_selected: Option<char>,
|
pub keyboard_explorer_selected: Option<char>,
|
||||||
pub explorer_accuracy_cache_overall: Option<(char, usize, usize)>,
|
pub explorer_accuracy_cache_overall: Option<(char, usize, usize)>,
|
||||||
pub explorer_accuracy_cache_ranked: Option<(char, usize, usize)>,
|
pub explorer_accuracy_cache_ranked: Option<(char, usize, usize)>,
|
||||||
@@ -369,7 +368,7 @@ impl App {
|
|||||||
store,
|
store,
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
settings_selected: 0,
|
settings_selected: 0,
|
||||||
settings_editing_download_dir: false,
|
settings_editing_path: None,
|
||||||
stats_tab: 0,
|
stats_tab: 0,
|
||||||
depressed_keys: HashSet::new(),
|
depressed_keys: HashSet::new(),
|
||||||
last_key_time: None,
|
last_key_time: None,
|
||||||
@@ -419,8 +418,6 @@ impl App {
|
|||||||
settings_status_message: None,
|
settings_status_message: None,
|
||||||
settings_export_path: default_export_path(),
|
settings_export_path: default_export_path(),
|
||||||
settings_import_path: default_export_path(),
|
settings_import_path: default_export_path(),
|
||||||
settings_editing_export_path: false,
|
|
||||||
settings_editing_import_path: false,
|
|
||||||
keyboard_explorer_selected: None,
|
keyboard_explorer_selected: None,
|
||||||
explorer_accuracy_cache_overall: None,
|
explorer_accuracy_cache_overall: None,
|
||||||
explorer_accuracy_cache_ranked: None,
|
explorer_accuracy_cache_ranked: None,
|
||||||
@@ -462,9 +459,23 @@ impl App {
|
|||||||
pub fn clear_settings_modals(&mut self) {
|
pub fn clear_settings_modals(&mut self) {
|
||||||
self.settings_confirm_import = false;
|
self.settings_confirm_import = false;
|
||||||
self.settings_export_conflict = false;
|
self.settings_export_conflict = false;
|
||||||
self.settings_editing_export_path = false;
|
self.settings_editing_path = None;
|
||||||
self.settings_editing_import_path = false;
|
}
|
||||||
self.settings_editing_download_dir = false;
|
|
||||||
|
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) {
|
pub fn arm_post_drill_input_lock(&mut self) {
|
||||||
@@ -1575,7 +1586,7 @@ impl App {
|
|||||||
|
|
||||||
pub fn go_to_settings(&mut self) {
|
pub fn go_to_settings(&mut self) {
|
||||||
self.settings_selected = 0;
|
self.settings_selected = 0;
|
||||||
self.settings_editing_download_dir = false;
|
self.settings_editing_path = None;
|
||||||
self.screen = AppScreen::Settings;
|
self.screen = AppScreen::Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2199,7 +2210,7 @@ impl App {
|
|||||||
store: None,
|
store: None,
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
settings_selected: 0,
|
settings_selected: 0,
|
||||||
settings_editing_download_dir: false,
|
settings_editing_path: None,
|
||||||
stats_tab: 0,
|
stats_tab: 0,
|
||||||
depressed_keys: HashSet::new(),
|
depressed_keys: HashSet::new(),
|
||||||
last_key_time: None,
|
last_key_time: None,
|
||||||
@@ -2249,8 +2260,6 @@ impl App {
|
|||||||
settings_status_message: None,
|
settings_status_message: None,
|
||||||
settings_export_path: default_export_path(),
|
settings_export_path: default_export_path(),
|
||||||
settings_import_path: default_export_path(),
|
settings_import_path: default_export_path(),
|
||||||
settings_editing_export_path: false,
|
|
||||||
settings_editing_import_path: false,
|
|
||||||
keyboard_explorer_selected: None,
|
keyboard_explorer_selected: None,
|
||||||
explorer_accuracy_cache_overall: None,
|
explorer_accuracy_cache_overall: None,
|
||||||
explorer_accuracy_cache_ranked: None,
|
explorer_accuracy_cache_ranked: None,
|
||||||
|
|||||||
287
src/main.rs
287
src/main.rs
@@ -29,6 +29,7 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
||||||
|
|
||||||
use app::{App, AppScreen, DrillMode, MilestoneKind, StatusKind};
|
use app::{App, AppScreen, DrillMode, MilestoneKind, StatusKind};
|
||||||
|
use ui::line_input::{InputResult, LineInput, PathField};
|
||||||
use engine::skill_tree::{DrillScope, find_key_branch};
|
use engine::skill_tree::{DrillScope, find_key_branch};
|
||||||
use event::{AppEvent, EventHandler};
|
use event::{AppEvent, EventHandler};
|
||||||
use generator::code_syntax::{code_language_options, is_language_cached, language_by_key};
|
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
|
// Priority 4: editing a path field
|
||||||
if app.settings_editing_download_dir
|
if let Some((field, ref mut input)) = app.settings_editing_path {
|
||||||
|| app.settings_editing_export_path
|
match input.handle(key) {
|
||||||
|| app.settings_editing_import_path
|
InputResult::Submit => {
|
||||||
{
|
let value = input.value().to_string();
|
||||||
match key.code {
|
match field {
|
||||||
KeyCode::Esc => {
|
PathField::CodeDownloadDir => app.config.code_download_dir = value,
|
||||||
app.clear_settings_modals();
|
PathField::PassageDownloadDir => app.config.passage_download_dir = value,
|
||||||
}
|
PathField::ExportPath => app.settings_export_path = value,
|
||||||
KeyCode::Backspace => {
|
PathField::ImportPath => app.settings_import_path = value,
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
app.settings_editing_path = None;
|
||||||
}
|
}
|
||||||
KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
InputResult::Cancel => {
|
||||||
if app.settings_editing_download_dir {
|
app.settings_editing_path = None;
|
||||||
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::Continue => {}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -603,20 +585,36 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter => match app.settings_selected {
|
KeyCode::Enter => match app.settings_selected {
|
||||||
5 | 9 => {
|
5 => {
|
||||||
app.clear_settings_modals();
|
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(),
|
7 => app.start_code_downloads_from_settings(),
|
||||||
11 => app.start_passage_downloads_from_settings(),
|
11 => app.start_passage_downloads_from_settings(),
|
||||||
12 => {
|
12 => {
|
||||||
app.clear_settings_modals();
|
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(),
|
13 => app.export_data(),
|
||||||
14 => {
|
14 => {
|
||||||
app.clear_settings_modals();
|
app.clear_settings_modals();
|
||||||
app.settings_editing_import_path = true;
|
app.settings_editing_path = Some((
|
||||||
|
PathField::ImportPath,
|
||||||
|
LineInput::new(&app.settings_import_path),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
15 => {
|
15 => {
|
||||||
app.clear_settings_modals();
|
app.clear_settings_modals();
|
||||||
@@ -1788,16 +1786,17 @@ mod review_tests {
|
|||||||
|
|
||||||
/// Helper: count how many settings modal/edit flags are active
|
/// Helper: count how many settings modal/edit flags are active
|
||||||
fn modal_edit_count(app: &App) -> usize {
|
fn modal_edit_count(app: &App) -> usize {
|
||||||
[
|
let mut count = 0;
|
||||||
app.settings_confirm_import,
|
if app.settings_confirm_import {
|
||||||
app.settings_export_conflict,
|
count += 1;
|
||||||
app.settings_editing_export_path,
|
}
|
||||||
app.settings_editing_import_path,
|
if app.settings_export_conflict {
|
||||||
app.settings_editing_download_dir,
|
count += 1;
|
||||||
]
|
}
|
||||||
.iter()
|
if app.is_editing_path() {
|
||||||
.filter(|&&f| f)
|
count += 1;
|
||||||
.count()
|
}
|
||||||
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1818,12 +1817,12 @@ mod review_tests {
|
|||||||
// Enter export path editing
|
// Enter export path editing
|
||||||
app.settings_selected = 12; // Export Path
|
app.settings_selected = 12; // Export Path
|
||||||
handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
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);
|
assert!(modal_edit_count(&app) <= 1);
|
||||||
|
|
||||||
// Esc out
|
// Esc out
|
||||||
handle_settings_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
handle_settings_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
assert!(!app.settings_editing_export_path);
|
assert!(!app.is_editing_path());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1834,14 +1833,14 @@ mod review_tests {
|
|||||||
// Activate export path editing first
|
// Activate export path editing first
|
||||||
app.settings_selected = 12;
|
app.settings_selected = 12;
|
||||||
handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
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
|
// Esc out, then enter import path editing
|
||||||
handle_settings_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
handle_settings_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
app.settings_selected = 14; // Import Path
|
app.settings_selected = 14; // Import Path
|
||||||
handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
handle_settings_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
assert!(app.settings_editing_import_path);
|
assert!(app.is_editing_field(14));
|
||||||
assert!(!app.settings_editing_export_path);
|
assert!(!app.is_editing_field(12));
|
||||||
assert!(modal_edit_count(&app) <= 1);
|
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");
|
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) {
|
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 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 field_height = inner.height.saturating_sub(header_height + footer_height);
|
||||||
|
|
||||||
let layout = Layout::default()
|
let layout = Layout::default()
|
||||||
@@ -2690,28 +2799,44 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
|||||||
colors.text_pending()
|
colors.text_pending()
|
||||||
});
|
});
|
||||||
|
|
||||||
let is_editing_this_path = is_selected
|
let is_editing_this_path = is_selected && *is_path && app.is_editing_field(i);
|
||||||
&& *is_path
|
|
||||||
&& (app.settings_editing_download_dir
|
|
||||||
|| app.settings_editing_export_path
|
|
||||||
|| app.settings_editing_import_path);
|
|
||||||
let lines = if *is_path {
|
let lines = if *is_path {
|
||||||
let path_line = if is_editing_this_path {
|
if is_editing_this_path {
|
||||||
format!(" {value}_")
|
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 {
|
} else {
|
||||||
format!(" {value}")
|
vec![
|
||||||
};
|
Line::from(Span::styled(label_text, label_style)),
|
||||||
vec![
|
Line::from(Span::styled(format!(" {value}"), value_style)),
|
||||||
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)),
|
|
||||||
]
|
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
Line::from(Span::styled(label_text, label_style)),
|
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());
|
Paragraph::new(lines).render(field_layout[row], frame.buffer_mut());
|
||||||
}
|
}
|
||||||
|
|
||||||
let any_path_editing = app.settings_editing_download_dir
|
let footer_lines: Vec<Line> = footer_packed
|
||||||
|| 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<Line> = pack_hint_lines(&footer_hints, layout[2].width as usize)
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent()))))
|
.map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent()))))
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
705
src/ui/line_input.rs
Normal file
705
src/ui/line_input.rs
Normal file
@@ -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<String>,
|
||||||
|
completion_index: Option<usize>,
|
||||||
|
/// 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<char>, &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<char> = 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<String> {
|
||||||
|
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<Item = std::io::Result<(String, bool)>>,
|
||||||
|
dir_str: &str,
|
||||||
|
partial: &str,
|
||||||
|
) -> Vec<String> {
|
||||||
|
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<std::io::Result<(String, bool)>> = 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<std::io::Result<(String, bool)>> = 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<std::io::Result<(String, bool)>> = (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<std::io::Result<(String, bool)>> = (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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod line_input;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
|||||||
Reference in New Issue
Block a user