151 lines
8.3 KiB
Markdown
151 lines
8.3 KiB
Markdown
# 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
|