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
|
||||
Reference in New Issue
Block a user