Inital commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
.DS_Store
|
||||
Generated
+1313
File diff suppressed because it is too large
Load Diff
+34
@@ -0,0 +1,34 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/lime-core",
|
||||
"crates/lime-syntax",
|
||||
"crates/lime-ui",
|
||||
"crates/lime-cli",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/owen/lime"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ropey = "1"
|
||||
crossterm = "0.28"
|
||||
ratatui = "0.29"
|
||||
unicode-segmentation = "1"
|
||||
unicode-width = "0.2"
|
||||
tree-sitter = "0.24"
|
||||
tree-sitter-rust = "0.23"
|
||||
tree-sitter-javascript = "0.23"
|
||||
tree-sitter-typescript = "0.23"
|
||||
tree-sitter-python = "0.23"
|
||||
tree-sitter-json = "0.24"
|
||||
ignore = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
dirs = "5"
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Owen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,963 @@
|
||||
# Lime Implementation Plan
|
||||
|
||||
Lime is a clean, modern terminal/TUI text editor written in Rust. It should feel approachable like Nano, efficient and clean like Vim, and easy to use like VS Code, while using a standard non-modal cursor editing model.
|
||||
|
||||
This plan intentionally avoids time estimates so it can be handed directly to an implementation agent.
|
||||
|
||||
## 1. Product Direction
|
||||
|
||||
Initial product goal:
|
||||
|
||||
```bash
|
||||
lime path/to/file.rs
|
||||
```
|
||||
|
||||
opens a clean single-file terminal editor with:
|
||||
|
||||
- Standard typing/editing behavior
|
||||
- Save and quit shortcuts
|
||||
- Unsaved-changes confirmation
|
||||
- Syntax highlighting
|
||||
- Line numbers
|
||||
- Status bar and help bar
|
||||
- File search/open popup with `Ctrl-F`
|
||||
- Recursive file discovery from the current working directory
|
||||
- Large-file warning before opening very large files
|
||||
- Good behavior on macOS and Linux
|
||||
|
||||
Lime should not be modal like Vim. It should be keyboard-first, but obvious and friendly.
|
||||
|
||||
## 2. Recommended Technical Stack
|
||||
|
||||
Use a Rust-native terminal stack:
|
||||
|
||||
- `crossterm` for terminal control and input events
|
||||
- `ratatui` for layout, widgets, status bars, popups, and drawing
|
||||
- `ropey` for text storage
|
||||
- `tree-sitter` for syntax highlighting
|
||||
- `ignore` for recursive file scanning with `.gitignore` support
|
||||
- `nucleo-matcher` for fuzzy file matching
|
||||
|
||||
Do not build a custom terminal renderer at first. `ratatui + crossterm` gives enough control for a polished TUI while keeping implementation manageable.
|
||||
|
||||
Suggested dependencies:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ropey = "1"
|
||||
crossterm = "0.28"
|
||||
ratatui = "0.29"
|
||||
unicode-segmentation = "1"
|
||||
unicode-width = "0.2"
|
||||
tree-sitter = "0.24"
|
||||
tree-sitter-rust = "0.23"
|
||||
tree-sitter-javascript = "0.23"
|
||||
tree-sitter-typescript = "0.23"
|
||||
tree-sitter-python = "0.23"
|
||||
tree-sitter-json = "0.23"
|
||||
tree-sitter-toml-ng = "0.6"
|
||||
tree-sitter-md = "0.3"
|
||||
ignore = "0.4"
|
||||
nucleo-matcher = "0.3"
|
||||
notify = "7"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
dirs = "5"
|
||||
```
|
||||
|
||||
Optional later:
|
||||
|
||||
```toml
|
||||
arboard = "3"
|
||||
similar = "2"
|
||||
tempfile = "3"
|
||||
insta = "1"
|
||||
```
|
||||
|
||||
## 3. Repository Structure
|
||||
|
||||
Use a Cargo workspace from the beginning:
|
||||
|
||||
```txt
|
||||
lime/
|
||||
Cargo.toml
|
||||
README.md
|
||||
LICENSE
|
||||
PLAN.md
|
||||
crates/
|
||||
lime-core/
|
||||
Cargo.toml
|
||||
src/
|
||||
lib.rs
|
||||
buffer.rs
|
||||
cursor.rs
|
||||
selection.rs
|
||||
edit.rs
|
||||
history.rs
|
||||
movement.rs
|
||||
search.rs
|
||||
file.rs
|
||||
command.rs
|
||||
lime-syntax/
|
||||
Cargo.toml
|
||||
src/
|
||||
lib.rs
|
||||
language.rs
|
||||
highlighter.rs
|
||||
theme.rs
|
||||
lime-ui/
|
||||
Cargo.toml
|
||||
src/
|
||||
lib.rs
|
||||
app.rs
|
||||
terminal.rs
|
||||
layout.rs
|
||||
editor_view.rs
|
||||
status_bar.rs
|
||||
command_bar.rs
|
||||
file_picker.rs
|
||||
prompt.rs
|
||||
theme.rs
|
||||
input.rs
|
||||
lime-cli/
|
||||
Cargo.toml
|
||||
src/
|
||||
main.rs
|
||||
```
|
||||
|
||||
Root `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/lime-core",
|
||||
"crates/lime-syntax",
|
||||
"crates/lime-ui",
|
||||
"crates/lime-cli"
|
||||
]
|
||||
resolver = "2"
|
||||
```
|
||||
|
||||
The installed binary should be named `lime`.
|
||||
|
||||
## 4. Crate Responsibilities
|
||||
|
||||
### `lime-core`
|
||||
|
||||
Pure editor logic.
|
||||
|
||||
Must not depend on terminal UI, Ratatui, themes, syntax highlighting, or input events.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Text buffer
|
||||
- Cursor state
|
||||
- Selections, even if selection editing comes later
|
||||
- Editing operations
|
||||
- Undo/redo
|
||||
- File load/save
|
||||
- Dirty state
|
||||
- Search inside the current buffer
|
||||
- Line/column movement
|
||||
- Large-file metadata and open policy types
|
||||
|
||||
### `lime-syntax`
|
||||
|
||||
Syntax parsing and highlighting.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Language detection
|
||||
- Tree-sitter parser setup
|
||||
- Highlight spans for visible ranges
|
||||
- Theme scopes
|
||||
- Graceful fallback for unknown languages or parser failures
|
||||
|
||||
### `lime-ui`
|
||||
|
||||
Terminal application.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Raw mode
|
||||
- Alternate screen
|
||||
- Event loop
|
||||
- Keybindings
|
||||
- Rendering
|
||||
- Editor viewport
|
||||
- Popups
|
||||
- File picker
|
||||
- Status bar
|
||||
- Help bar
|
||||
- Prompts and warnings
|
||||
|
||||
### `lime-cli`
|
||||
|
||||
CLI entry point.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Parse CLI arguments
|
||||
- Resolve starting path
|
||||
- Load config
|
||||
- Start app
|
||||
- Ensure terminal cleanup on error or panic
|
||||
|
||||
## 5. Core Data Model
|
||||
|
||||
Use `ropey::Rope` for buffer storage.
|
||||
|
||||
```rust
|
||||
pub struct TextBuffer {
|
||||
rope: Rope,
|
||||
path: Option<PathBuf>,
|
||||
dirty: bool,
|
||||
line_ending: LineEnding,
|
||||
}
|
||||
```
|
||||
|
||||
Track positions as line/column character positions, not byte offsets.
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Position {
|
||||
pub line: usize,
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Cursor {
|
||||
pub position: Position,
|
||||
pub preferred_column: Option<usize>,
|
||||
}
|
||||
```
|
||||
|
||||
Use `preferred_column` for vertical movement so up/down preserve the intended visual column across lines of different lengths.
|
||||
|
||||
Also define:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TextRange {
|
||||
pub start: Position,
|
||||
pub end: Position,
|
||||
}
|
||||
|
||||
pub enum LineEnding {
|
||||
Lf,
|
||||
Crlf,
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Editing Commands
|
||||
|
||||
Create a command abstraction in `lime-core`:
|
||||
|
||||
```rust
|
||||
pub enum EditorCommand {
|
||||
InsertChar(char),
|
||||
InsertText(String),
|
||||
Newline,
|
||||
Backspace,
|
||||
Delete,
|
||||
MoveLeft,
|
||||
MoveRight,
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
MoveLineStart,
|
||||
MoveLineEnd,
|
||||
MoveFileStart,
|
||||
MoveFileEnd,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Save,
|
||||
Undo,
|
||||
Redo,
|
||||
Search(String),
|
||||
}
|
||||
```
|
||||
|
||||
The UI should convert terminal key events into app actions or editor commands. The core applies commands and returns effects.
|
||||
|
||||
```rust
|
||||
pub enum CommandResult {
|
||||
None,
|
||||
Modified,
|
||||
Saved,
|
||||
CursorMoved,
|
||||
NeedsPath,
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Undo/Redo
|
||||
|
||||
Implement undo/redo early.
|
||||
|
||||
Represent edits as reversible transactions:
|
||||
|
||||
```rust
|
||||
pub struct EditTransaction {
|
||||
pub edits: Vec<TextEdit>,
|
||||
pub before_cursor: Cursor,
|
||||
pub after_cursor: Cursor,
|
||||
}
|
||||
|
||||
pub enum TextEdit {
|
||||
Insert {
|
||||
at: Position,
|
||||
text: String,
|
||||
},
|
||||
Delete {
|
||||
range: TextRange,
|
||||
deleted_text: String,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Typing consecutive characters should be grouped into one undo transaction until one of these happens:
|
||||
|
||||
- Cursor movement
|
||||
- Newline
|
||||
- Backspace/delete
|
||||
- Save
|
||||
- Different command type
|
||||
- Focus changes to a popup/prompt
|
||||
|
||||
## 8. Large File Handling
|
||||
|
||||
Before reading a file, inspect metadata.
|
||||
|
||||
Recommended thresholds:
|
||||
|
||||
```txt
|
||||
<= 5 MB: open normally
|
||||
5-25 MB: open with warning prompt
|
||||
> 25 MB: require explicit confirmation
|
||||
> 100 MB: refuse by default unless --force
|
||||
```
|
||||
|
||||
CLI/TUI warning example:
|
||||
|
||||
```txt
|
||||
This file is 84 MB. Lime may be slower with very large files.
|
||||
Open anyway? [y/N]
|
||||
```
|
||||
|
||||
Core policy type:
|
||||
|
||||
```rust
|
||||
pub struct FileOpenPolicy {
|
||||
pub warn_threshold_bytes: u64,
|
||||
pub confirm_threshold_bytes: u64,
|
||||
pub hard_threshold_bytes: u64,
|
||||
pub force: bool,
|
||||
}
|
||||
```
|
||||
|
||||
The file picker should also use this policy before replacing the current buffer.
|
||||
|
||||
## 9. Terminal UI Layout
|
||||
|
||||
Default layout:
|
||||
|
||||
```txt
|
||||
┌────────────────────────────────────────────┐
|
||||
│ 1 │ fn main() { │
|
||||
│ 2 │ println!("hello"); │
|
||||
│ 3 │ } │
|
||||
│ │ │
|
||||
│ │ │
|
||||
├────────────────────────────────────────────┤
|
||||
│ lime file.rs rust Ln 2, Col 5 modified │
|
||||
│ Ctrl-S Save Ctrl-F Files Ctrl-Q Quit │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Main regions:
|
||||
|
||||
1. Editor viewport
|
||||
2. Status bar
|
||||
3. Help/action bar
|
||||
4. Optional popup overlay
|
||||
|
||||
Use `ratatui::Layout` for the main shell.
|
||||
|
||||
## 10. Rendering Strategy
|
||||
|
||||
Render only visible lines.
|
||||
|
||||
UI viewport state:
|
||||
|
||||
```rust
|
||||
pub struct Viewport {
|
||||
pub top_line: usize,
|
||||
pub left_col: usize,
|
||||
pub height: usize,
|
||||
pub width: usize,
|
||||
}
|
||||
```
|
||||
|
||||
Editor rendering should:
|
||||
|
||||
- Render visible lines only
|
||||
- Draw line numbers
|
||||
- Draw cursor
|
||||
- Draw selected text later
|
||||
- Apply syntax spans when available
|
||||
- Horizontally scroll for long lines
|
||||
- Vertically scroll when the cursor nears viewport edges
|
||||
- Avoid allocating the entire file each frame
|
||||
|
||||
Gutter width:
|
||||
|
||||
```rust
|
||||
let gutter_width = total_lines.to_string().len() + 2;
|
||||
```
|
||||
|
||||
## 11. Input Model
|
||||
|
||||
Recommended shortcuts:
|
||||
|
||||
```txt
|
||||
Ctrl-S Save
|
||||
Ctrl-Q Quit
|
||||
Ctrl-F File picker
|
||||
Ctrl-G Go to line
|
||||
Ctrl-Z Undo
|
||||
Ctrl-Y Redo
|
||||
Ctrl-A Start of line
|
||||
Ctrl-E End of line
|
||||
Ctrl-R Search in current file
|
||||
Esc Close popup/cancel prompt
|
||||
Arrow keys Move cursor
|
||||
PageUp Page up
|
||||
PageDown Page down
|
||||
Home Start of line
|
||||
End End of line
|
||||
Backspace Delete backward
|
||||
Delete Delete forward
|
||||
Enter Newline
|
||||
Tab Insert spaces according to config
|
||||
```
|
||||
|
||||
Use an app-level action enum:
|
||||
|
||||
```rust
|
||||
pub enum AppAction {
|
||||
Editor(EditorCommand),
|
||||
Save,
|
||||
Quit,
|
||||
OpenFilePicker,
|
||||
OpenGoToLine,
|
||||
OpenSearch,
|
||||
ClosePopup,
|
||||
Confirm,
|
||||
Cancel,
|
||||
}
|
||||
```
|
||||
|
||||
Do not hardcode behavior directly inside the event loop. Route input through a mapping layer.
|
||||
|
||||
## 12. File Picker
|
||||
|
||||
`Ctrl-F` opens a centered popup listing files recursively from the current directory.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Scan current working directory or selected workspace root
|
||||
- Respect `.gitignore`
|
||||
- Skip hidden files by default
|
||||
- Skip common bulky directories:
|
||||
- `.git`
|
||||
- `target`
|
||||
- `node_modules`
|
||||
- `dist`
|
||||
- `.next`
|
||||
- `build`
|
||||
- Fuzzy filter as the user types
|
||||
- Arrow up/down changes selection
|
||||
- Enter opens selected file
|
||||
- Escape closes picker
|
||||
- Warn if current file has unsaved changes before replacing it
|
||||
- Warn before opening very large files
|
||||
|
||||
Popup layout:
|
||||
|
||||
```txt
|
||||
╭─ Open File ─────────────────────────────╮
|
||||
│ search: main │
|
||||
│ │
|
||||
│ > crates/lime-cli/src/main.rs │
|
||||
│ crates/lime-core/src/buffer.rs │
|
||||
│ README.md │
|
||||
╰─────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
State:
|
||||
|
||||
```rust
|
||||
pub struct FilePickerState {
|
||||
pub query: String,
|
||||
pub all_files: Vec<PathBuf>,
|
||||
pub matches: Vec<FileMatch>,
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
pub struct FileMatch {
|
||||
pub path: PathBuf,
|
||||
pub score: u32,
|
||||
}
|
||||
```
|
||||
|
||||
## 13. Syntax Highlighting
|
||||
|
||||
Start with these languages:
|
||||
|
||||
- Rust
|
||||
- JavaScript
|
||||
- TypeScript
|
||||
- Python
|
||||
- JSON
|
||||
- TOML
|
||||
- Markdown
|
||||
|
||||
Language detection:
|
||||
|
||||
```rust
|
||||
pub enum Language {
|
||||
Rust,
|
||||
JavaScript,
|
||||
TypeScript,
|
||||
Python,
|
||||
Json,
|
||||
Toml,
|
||||
Markdown,
|
||||
PlainText,
|
||||
}
|
||||
```
|
||||
|
||||
Detection should use file extension first.
|
||||
|
||||
Highlighting should be resilient:
|
||||
|
||||
- Unknown language falls back to plain text
|
||||
- Parser failure falls back to plain text
|
||||
- Initial implementation may highlight visible ranges only
|
||||
- Incremental parsing can come later
|
||||
|
||||
Theme model:
|
||||
|
||||
```rust
|
||||
pub struct Theme {
|
||||
pub background: Color,
|
||||
pub foreground: Color,
|
||||
pub gutter: Color,
|
||||
pub cursor: Color,
|
||||
pub selection: Color,
|
||||
pub keyword: Color,
|
||||
pub string: Color,
|
||||
pub comment: Color,
|
||||
pub function: Color,
|
||||
pub type_name: Color,
|
||||
pub number: Color,
|
||||
pub error: Color,
|
||||
}
|
||||
```
|
||||
|
||||
Use a clean default dark theme with calm contrast.
|
||||
|
||||
## 14. Save/Quit Behavior
|
||||
|
||||
If there are no unsaved changes, `Ctrl-Q` exits immediately.
|
||||
|
||||
If the buffer is dirty:
|
||||
|
||||
```txt
|
||||
Unsaved changes. Save before quitting? [s]ave [d]iscard [c]ancel
|
||||
```
|
||||
|
||||
If saving an unnamed buffer:
|
||||
|
||||
```txt
|
||||
Save as: _
|
||||
```
|
||||
|
||||
Save operation should:
|
||||
|
||||
- Write to a temporary file
|
||||
- Flush data
|
||||
- Rename over original path
|
||||
- Preserve line endings where practical
|
||||
- Clear dirty flag after success
|
||||
- Show a status message after success or failure
|
||||
|
||||
## 15. Config
|
||||
|
||||
Keep config simple at first.
|
||||
|
||||
Locations:
|
||||
|
||||
```txt
|
||||
macOS: ~/Library/Application Support/lime/config.toml
|
||||
Linux: ~/.config/lime/config.toml
|
||||
```
|
||||
|
||||
Initial config:
|
||||
|
||||
```toml
|
||||
theme = "lime-dark"
|
||||
tab_width = 4
|
||||
insert_spaces = true
|
||||
show_line_numbers = true
|
||||
confirm_large_files = true
|
||||
```
|
||||
|
||||
Rust model:
|
||||
|
||||
```rust
|
||||
pub struct Config {
|
||||
pub theme: String,
|
||||
pub tab_width: usize,
|
||||
pub insert_spaces: bool,
|
||||
pub show_line_numbers: bool,
|
||||
pub confirm_large_files: bool,
|
||||
}
|
||||
```
|
||||
|
||||
If config is missing or invalid, use defaults and show a non-blocking warning.
|
||||
|
||||
## 16. Error Handling
|
||||
|
||||
Use `anyhow` at app/CLI boundaries.
|
||||
|
||||
Use `thiserror` for library errors.
|
||||
|
||||
Example:
|
||||
|
||||
```rust
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum LimeError {
|
||||
#[error("file is too large: {size} bytes")]
|
||||
FileTooLarge { size: u64 },
|
||||
|
||||
#[error("buffer has no file path")]
|
||||
MissingPath,
|
||||
|
||||
#[error("invalid cursor position")]
|
||||
InvalidPosition,
|
||||
}
|
||||
```
|
||||
|
||||
Terminal cleanup must always happen.
|
||||
|
||||
Use a guard:
|
||||
|
||||
```rust
|
||||
pub struct TerminalGuard;
|
||||
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
// disable raw mode
|
||||
// leave alternate screen
|
||||
// show cursor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Never leave the terminal in raw mode after an error or panic.
|
||||
|
||||
## 17. CLI Design
|
||||
|
||||
Basic usage:
|
||||
|
||||
```bash
|
||||
lime
|
||||
lime file.rs
|
||||
lime .
|
||||
lime --force huge.log
|
||||
lime --config path/to/config.toml
|
||||
```
|
||||
|
||||
CLI args:
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
pub struct Cli {
|
||||
pub path: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
|
||||
#[arg(long)]
|
||||
pub config: Option<PathBuf>,
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- No path: open empty unnamed buffer
|
||||
- File path: open file
|
||||
- Directory path: open empty buffer with file picker rooted at that directory
|
||||
- Nonexistent file: create a new buffer with that path
|
||||
- Huge file: warn/confirm unless `--force`
|
||||
|
||||
## 18. Testing Strategy
|
||||
|
||||
### `lime-core`
|
||||
|
||||
Most tests should live here.
|
||||
|
||||
Test:
|
||||
|
||||
- Insert characters
|
||||
- Insert text
|
||||
- Insert newline
|
||||
- Backspace in middle of line
|
||||
- Backspace at start of line joins lines
|
||||
- Delete in middle of line
|
||||
- Delete at end of line joins lines
|
||||
- Cursor movement left/right/up/down
|
||||
- Line start/end
|
||||
- File start/end
|
||||
- Undo/redo
|
||||
- Search
|
||||
- Save/load roundtrip
|
||||
- Unicode text
|
||||
- Tabs
|
||||
- Long lines
|
||||
- Empty file behavior
|
||||
|
||||
Example test:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn backspace_joins_lines() {
|
||||
let mut editor = Editor::from_text("hello\nworld");
|
||||
editor.set_cursor(Position { line: 1, column: 0 });
|
||||
editor.backspace();
|
||||
|
||||
assert_eq!(editor.text(), "helloworld");
|
||||
assert_eq!(editor.cursor().position, Position { line: 0, column: 5 });
|
||||
}
|
||||
```
|
||||
|
||||
### `lime-syntax`
|
||||
|
||||
Test:
|
||||
|
||||
- Language detection
|
||||
- Parser setup does not panic
|
||||
- Highlight spans are valid
|
||||
- Unknown files fall back to plain text
|
||||
|
||||
### `lime-ui`
|
||||
|
||||
Test pure/non-terminal pieces:
|
||||
|
||||
- Keybinding mapping
|
||||
- Viewport scrolling
|
||||
- File picker filtering
|
||||
- Status string formatting
|
||||
- Layout calculations
|
||||
- Prompt state transitions
|
||||
|
||||
Avoid depending heavily on full terminal snapshot tests in the first version.
|
||||
|
||||
## 19. Implementation Order
|
||||
|
||||
### Step 1: Create workspace
|
||||
|
||||
Create all workspace crates and make `cargo test` pass.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Root `Cargo.toml`
|
||||
- `lime-core`
|
||||
- `lime-syntax`
|
||||
- `lime-ui`
|
||||
- `lime-cli`
|
||||
- Minimal compiling binary named `lime`
|
||||
|
||||
### Step 2: Implement `lime-core`
|
||||
|
||||
Implement:
|
||||
|
||||
- `TextBuffer`
|
||||
- `Position`
|
||||
- `TextRange`
|
||||
- `Cursor`
|
||||
- Load from string
|
||||
- Load from file
|
||||
- Save to file
|
||||
- Insert char/text
|
||||
- Newline
|
||||
- Backspace
|
||||
- Delete
|
||||
- Cursor movement
|
||||
- Dirty flag
|
||||
|
||||
Add comprehensive tests.
|
||||
|
||||
### Step 3: Implement undo/redo
|
||||
|
||||
Add:
|
||||
|
||||
- `EditTransaction`
|
||||
- Undo stack
|
||||
- Redo stack
|
||||
- Transaction grouping for typing
|
||||
- Tests for edit operations and grouped undo behavior
|
||||
|
||||
### Step 4: Implement basic terminal app
|
||||
|
||||
In `lime-ui`, implement:
|
||||
|
||||
- Raw mode
|
||||
- Alternate screen
|
||||
- Panic-safe cleanup
|
||||
- Event loop
|
||||
- Render empty editor
|
||||
- Render file content
|
||||
- Status bar
|
||||
- Help bar
|
||||
- Cursor rendering
|
||||
|
||||
At this point:
|
||||
|
||||
```bash
|
||||
cargo run -p lime-cli -- README.md
|
||||
```
|
||||
|
||||
should open a navigable file.
|
||||
|
||||
### Step 5: Wire editing input
|
||||
|
||||
Add keyboard handling for:
|
||||
|
||||
- Text input
|
||||
- Enter
|
||||
- Tab
|
||||
- Backspace
|
||||
- Delete
|
||||
- Arrow movement
|
||||
- Home/end
|
||||
- Page up/down
|
||||
- Save
|
||||
- Quit
|
||||
|
||||
After this step, Lime should be a usable single-file editor.
|
||||
|
||||
### Step 6: Add viewport scrolling
|
||||
|
||||
Implement:
|
||||
|
||||
- Vertical scroll
|
||||
- Horizontal scroll
|
||||
- Cursor-follow behavior
|
||||
- Page up/down
|
||||
- Long-line handling
|
||||
|
||||
Do not render the whole file.
|
||||
|
||||
### Step 7: Add file open policy
|
||||
|
||||
Before reading files:
|
||||
|
||||
- Check size
|
||||
- Show warning prompt in TUI
|
||||
- Support `--force`
|
||||
- Refuse extreme files by default
|
||||
|
||||
### Step 8: Add file picker
|
||||
|
||||
Implement:
|
||||
|
||||
- Recursive file scan
|
||||
- Fuzzy matching
|
||||
- Popup rendering
|
||||
- Query input
|
||||
- Selection movement
|
||||
- Opening selected files
|
||||
- Dirty-buffer confirmation before replacing current file
|
||||
|
||||
Bind to `Ctrl-F`.
|
||||
|
||||
### Step 9: Add syntax highlighting
|
||||
|
||||
Implement `lime-syntax`.
|
||||
|
||||
Start with:
|
||||
|
||||
- Language detection
|
||||
- Rust highlighting
|
||||
- Plain-text fallback
|
||||
|
||||
Then add:
|
||||
|
||||
- JavaScript
|
||||
- TypeScript
|
||||
- Python
|
||||
- JSON
|
||||
- TOML
|
||||
- Markdown
|
||||
|
||||
Integrate highlighting into editor rendering.
|
||||
|
||||
### Step 10: Add quality-of-life features
|
||||
|
||||
Add:
|
||||
|
||||
- Go to line popup
|
||||
- Search in current file
|
||||
- Unsaved quit prompt
|
||||
- Save-as prompt
|
||||
- Status messages
|
||||
- Error display
|
||||
- Config loading
|
||||
- Tab width setting
|
||||
- Insert spaces setting
|
||||
|
||||
## 20. Minimum Definition of Done
|
||||
|
||||
The first complete version should support:
|
||||
|
||||
```bash
|
||||
lime file.rs
|
||||
```
|
||||
|
||||
and include:
|
||||
|
||||
- Clean terminal UI
|
||||
- Standard text editing
|
||||
- Natural cursor movement
|
||||
- Save with `Ctrl-S`
|
||||
- Quit with `Ctrl-Q`
|
||||
- Unsaved changes warning
|
||||
- Syntax highlighting for known languages
|
||||
- File picker with `Ctrl-F`
|
||||
- Recursive current-directory browsing
|
||||
- Fuzzy file filtering
|
||||
- Moderately large file handling without freezing
|
||||
- Warning before opening very large files
|
||||
- Passing core editing tests
|
||||
- macOS and Linux support
|
||||
|
||||
## 21. Explicit Non-Goals For First Version
|
||||
|
||||
Do not implement these yet:
|
||||
|
||||
- Plugin system
|
||||
- Extension marketplace
|
||||
- LSP
|
||||
- Multi-pane editor
|
||||
- Integrated terminal
|
||||
- Git UI
|
||||
- Debugger
|
||||
- AI features
|
||||
- Remote editing
|
||||
- Modal Vim emulation
|
||||
|
||||
These can be added later only after the core editor feels excellent.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Lime
|
||||
|
||||
Lime is a clean, modern terminal text editor written in Rust.
|
||||
|
||||
It aims to be approachable like Nano, efficient and clean like Vim, and easy to use like VS Code, while keeping a standard non-modal editing model.
|
||||
|
||||
## Status
|
||||
|
||||
This repository contains the first usable implementation:
|
||||
|
||||
- Rust Cargo workspace
|
||||
- Rope-backed editor core
|
||||
- Cursor movement and editing
|
||||
- Undo/redo
|
||||
- Save and save-as
|
||||
- Unsaved-change prompts
|
||||
- Ratatui/Crossterm terminal UI
|
||||
- Status/help bars
|
||||
- Recursive file picker with `Ctrl-F`
|
||||
- Fuzzy file filtering
|
||||
- Large-file warning policy
|
||||
- Syntax highlighting for common languages with Tree-sitter/lexical fallback
|
||||
- Simple TOML config loading
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo run -p lime-cli -- path/to/file.rs
|
||||
```
|
||||
|
||||
or after installing/building the binary:
|
||||
|
||||
```bash
|
||||
lime path/to/file.rs
|
||||
lime .
|
||||
lime --force huge.log
|
||||
```
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
| `Ctrl-S` | Save |
|
||||
| `Ctrl-Q` | Quit |
|
||||
| `Ctrl-F` | Open file picker |
|
||||
| `Ctrl-R` | Search current file |
|
||||
| `Ctrl-G` | Go to line |
|
||||
| `Ctrl-Z` | Undo |
|
||||
| `Ctrl-Y` | Redo |
|
||||
| `Ctrl-A` | Start of line |
|
||||
| `Ctrl-E` | End of line |
|
||||
| `Esc` | Close popup/cancel prompt |
|
||||
|
||||
## Config
|
||||
|
||||
Lime looks for config at:
|
||||
|
||||
- macOS: `~/Library/Application Support/lime/config.toml`
|
||||
- Linux: `~/.config/lime/config.toml`
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
theme = "lime-dark"
|
||||
tab_width = 4
|
||||
insert_spaces = true
|
||||
show_line_numbers = true
|
||||
confirm_large_files = true
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo test
|
||||
```
|
||||
|
||||
Workspace crates:
|
||||
|
||||
- `lime-core`: pure editor model and editing operations
|
||||
- `lime-syntax`: language detection and highlighting
|
||||
- `lime-ui`: terminal UI, file picker, prompts, keybindings
|
||||
- `lime-cli`: binary entry point
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "lime-cli"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "lime"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
lime-core = { path = "../lime-core" }
|
||||
lime-ui = { path = "../lime-ui" }
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use lime_ui::{run_app, AppOptions};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "lime")]
|
||||
#[command(about = "A clean, modern terminal text editor")]
|
||||
struct Cli {
|
||||
/// File or directory to open
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Open very large files without confirmation/refusal
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
|
||||
/// Path to a Lime config file
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
run_app(AppOptions::new(cli.path, cli.force, cli.config))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "lime-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ropey.workspace = true
|
||||
thiserror.workspace = true
|
||||
unicode-segmentation.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,270 @@
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ropey::Rope;
|
||||
|
||||
use crate::cursor::Position;
|
||||
use crate::edit::TextRange;
|
||||
use crate::error::{LimeError, Result};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LineEnding {
|
||||
Lf,
|
||||
Crlf,
|
||||
}
|
||||
|
||||
impl LineEnding {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Lf => "\n",
|
||||
Self::Crlf => "\r\n",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect(text: &str) -> Self {
|
||||
if text.contains("\r\n") {
|
||||
Self::Crlf
|
||||
} else {
|
||||
Self::Lf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextBuffer {
|
||||
rope: Rope,
|
||||
path: Option<PathBuf>,
|
||||
dirty: bool,
|
||||
line_ending: LineEnding,
|
||||
}
|
||||
|
||||
impl Default for TextBuffer {
|
||||
fn default() -> Self {
|
||||
Self::from_text("")
|
||||
}
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
pub fn from_text(text: &str) -> Self {
|
||||
Self {
|
||||
rope: Rope::from_str(text),
|
||||
path: None,
|
||||
dirty: false,
|
||||
line_ending: LineEnding::detect(text),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_text_with_path(text: &str, path: impl Into<PathBuf>) -> Self {
|
||||
let mut buffer = Self::from_text(text);
|
||||
buffer.path = Some(path.into());
|
||||
buffer
|
||||
}
|
||||
|
||||
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let path = path.as_ref();
|
||||
let text = fs::read_to_string(path)?;
|
||||
Ok(Self {
|
||||
rope: Rope::from_str(&text),
|
||||
path: Some(path.to_path_buf()),
|
||||
dirty: false,
|
||||
line_ending: LineEnding::detect(&text),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_path(&mut self, path: impl Into<PathBuf>) {
|
||||
self.path = Some(path.into());
|
||||
}
|
||||
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
|
||||
pub fn set_dirty(&mut self, dirty: bool) {
|
||||
self.dirty = dirty;
|
||||
}
|
||||
|
||||
pub fn line_ending(&self) -> LineEnding {
|
||||
self.line_ending
|
||||
}
|
||||
|
||||
pub fn set_line_ending(&mut self, line_ending: LineEnding) {
|
||||
self.line_ending = line_ending;
|
||||
}
|
||||
|
||||
pub fn len_chars(&self) -> usize {
|
||||
self.rope.len_chars()
|
||||
}
|
||||
|
||||
pub fn len_bytes(&self) -> usize {
|
||||
self.rope.len_bytes()
|
||||
}
|
||||
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.rope.len_lines().max(1)
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
self.rope.to_string()
|
||||
}
|
||||
|
||||
pub fn char(&self, char_idx: usize) -> Option<char> {
|
||||
if char_idx < self.len_chars() {
|
||||
Some(self.rope.char(char_idx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn line_to_string(&self, line_idx: usize) -> String {
|
||||
if line_idx >= self.line_count() {
|
||||
return String::new();
|
||||
}
|
||||
self.rope.line(line_idx).to_string()
|
||||
}
|
||||
|
||||
pub fn line_text_without_ending(&self, line_idx: usize) -> String {
|
||||
let mut line = self.line_to_string(line_idx);
|
||||
if line.ends_with('\n') {
|
||||
line.pop();
|
||||
if line.ends_with('\r') {
|
||||
line.pop();
|
||||
}
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
pub fn line_len_chars(&self, line_idx: usize) -> usize {
|
||||
if line_idx >= self.line_count() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let line = self.rope.line(line_idx);
|
||||
let mut len = line.len_chars();
|
||||
if len > 0 && line.char(len - 1) == '\n' {
|
||||
len -= 1;
|
||||
if len > 0 && line.char(len - 1) == '\r' {
|
||||
len -= 1;
|
||||
}
|
||||
}
|
||||
len
|
||||
}
|
||||
|
||||
pub fn clamp_position(&self, position: Position) -> Position {
|
||||
let line = position.line.min(self.line_count().saturating_sub(1));
|
||||
let column = position.column.min(self.line_len_chars(line));
|
||||
Position { line, column }
|
||||
}
|
||||
|
||||
pub fn position_to_char(&self, position: Position) -> usize {
|
||||
let position = self.clamp_position(position);
|
||||
self.rope.line_to_char(position.line) + position.column
|
||||
}
|
||||
|
||||
pub fn char_to_position(&self, char_idx: usize) -> Position {
|
||||
let char_idx = char_idx.min(self.len_chars());
|
||||
let line = self.rope.char_to_line(char_idx);
|
||||
let line_start = self.rope.line_to_char(line);
|
||||
let column = (char_idx - line_start).min(self.line_len_chars(line));
|
||||
Position { line, column }
|
||||
}
|
||||
|
||||
pub fn range_to_chars(&self, range: TextRange) -> (usize, usize) {
|
||||
let normalized = range.normalized();
|
||||
(
|
||||
self.position_to_char(normalized.start),
|
||||
self.position_to_char(normalized.end),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn slice_range(&self, range: TextRange) -> String {
|
||||
let (start, end) = self.range_to_chars(range);
|
||||
self.rope.slice(start..end).to_string()
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, position: Position, text: &str) -> Position {
|
||||
let position = self.clamp_position(position);
|
||||
let char_idx = self.position_to_char(position);
|
||||
self.rope.insert(char_idx, text);
|
||||
self.dirty = true;
|
||||
advance_position(position, text)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, range: TextRange) -> String {
|
||||
let normalized = range.normalized();
|
||||
let (start, end) = self.range_to_chars(normalized);
|
||||
if start >= end {
|
||||
return String::new();
|
||||
}
|
||||
let deleted = self.rope.slice(start..end).to_string();
|
||||
self.rope.remove(start..end);
|
||||
self.dirty = true;
|
||||
deleted
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<()> {
|
||||
let path = self.path.clone().ok_or(LimeError::MissingPath)?;
|
||||
self.save_as(path)
|
||||
}
|
||||
|
||||
pub fn save_as(&mut self, path: impl AsRef<Path>) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
fs::create_dir_all(parent)?;
|
||||
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("lime-buffer");
|
||||
let temp_path = parent.join(format!(".{}.{}.lime-tmp", file_name, std::process::id()));
|
||||
|
||||
{
|
||||
let file = File::create(&temp_path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
for chunk in self.rope.chunks() {
|
||||
writer.write_all(chunk.as_bytes())?;
|
||||
}
|
||||
writer.flush()?;
|
||||
writer.get_ref().sync_all()?;
|
||||
}
|
||||
|
||||
fs::rename(&temp_path, path)?;
|
||||
self.path = Some(path.to_path_buf());
|
||||
self.dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn advance_position(start: Position, text: &str) -> Position {
|
||||
let mut line = start.line;
|
||||
let mut column = start.column;
|
||||
let mut saw_cr = false;
|
||||
|
||||
for ch in text.chars() {
|
||||
match ch {
|
||||
'\r' => saw_cr = true,
|
||||
'\n' => {
|
||||
line += 1;
|
||||
column = 0;
|
||||
saw_cr = false;
|
||||
}
|
||||
_ => {
|
||||
if saw_cr {
|
||||
column += 1;
|
||||
saw_cr = false;
|
||||
}
|
||||
column += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if saw_cr {
|
||||
column += 1;
|
||||
}
|
||||
|
||||
Position { line, column }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditorCommand {
|
||||
InsertChar(char),
|
||||
InsertText(String),
|
||||
Newline,
|
||||
Backspace,
|
||||
Delete,
|
||||
MoveLeft,
|
||||
MoveRight,
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
MoveLineStart,
|
||||
MoveLineEnd,
|
||||
MoveFileStart,
|
||||
MoveFileEnd,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Save,
|
||||
Undo,
|
||||
Redo,
|
||||
Search(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandResult {
|
||||
None,
|
||||
Modified,
|
||||
Saved,
|
||||
CursorMoved,
|
||||
NeedsPath,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
pub struct Position {
|
||||
pub line: usize,
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
impl Position {
|
||||
pub const fn new(line: usize, column: usize) -> Self {
|
||||
Self { line, column }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Cursor {
|
||||
pub position: Position,
|
||||
pub preferred_column: Option<usize>,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
pub const fn new(position: Position) -> Self {
|
||||
Self {
|
||||
position,
|
||||
preferred_column: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_position(mut self, position: Position) -> Self {
|
||||
self.position = position;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn clear_preferred_column(&mut self) {
|
||||
self.preferred_column = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::buffer::{advance_position, TextBuffer};
|
||||
use crate::command::{CommandResult, EditorCommand};
|
||||
use crate::cursor::{Cursor, Position};
|
||||
use crate::error::{LimeError, Result};
|
||||
use crate::history::EditTransaction;
|
||||
use crate::movement::DEFAULT_PAGE_LINES;
|
||||
use crate::search::SearchMatch;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TextRange {
|
||||
pub start: Position,
|
||||
pub end: Position,
|
||||
}
|
||||
|
||||
impl TextRange {
|
||||
pub const fn new(start: Position, end: Position) -> Self {
|
||||
Self { start, end }
|
||||
}
|
||||
|
||||
pub fn normalized(self) -> Self {
|
||||
if self.start <= self.end {
|
||||
self
|
||||
} else {
|
||||
Self {
|
||||
start: self.end,
|
||||
end: self.start,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(self) -> bool {
|
||||
self.start == self.end
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TextEdit {
|
||||
Insert {
|
||||
at: Position,
|
||||
text: String,
|
||||
},
|
||||
Delete {
|
||||
range: TextRange,
|
||||
deleted_text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Editor {
|
||||
buffer: TextBuffer,
|
||||
cursor: Cursor,
|
||||
undo_stack: Vec<EditTransaction>,
|
||||
redo_stack: Vec<EditTransaction>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
impl Default for Editor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn new() -> Self {
|
||||
Self::from_buffer(TextBuffer::default())
|
||||
}
|
||||
|
||||
pub fn from_text(text: &str) -> Self {
|
||||
Self::from_buffer(TextBuffer::from_text(text))
|
||||
}
|
||||
|
||||
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Ok(Self::from_buffer(TextBuffer::from_file(path)?))
|
||||
}
|
||||
|
||||
pub fn from_buffer(buffer: TextBuffer) -> Self {
|
||||
Self {
|
||||
cursor: Cursor::default(),
|
||||
buffer,
|
||||
undo_stack: Vec::new(),
|
||||
redo_stack: Vec::new(),
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace_buffer(&mut self, buffer: TextBuffer) {
|
||||
self.buffer = buffer;
|
||||
self.cursor = Cursor::default();
|
||||
self.undo_stack.clear();
|
||||
self.redo_stack.clear();
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn buffer(&self) -> &TextBuffer {
|
||||
&self.buffer
|
||||
}
|
||||
|
||||
pub fn buffer_mut(&mut self) -> &mut TextBuffer {
|
||||
&mut self.buffer
|
||||
}
|
||||
|
||||
pub fn cursor(&self) -> Cursor {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
pub fn set_cursor(&mut self, position: Position) {
|
||||
self.cursor.position = self.buffer.clamp_position(position);
|
||||
self.cursor.preferred_column = None;
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.buffer.is_dirty()
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
self.buffer.text()
|
||||
}
|
||||
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.buffer.line_count()
|
||||
}
|
||||
|
||||
pub fn line_len_chars(&self, line: usize) -> usize {
|
||||
self.buffer.line_len_chars(line)
|
||||
}
|
||||
|
||||
pub fn line_text_without_ending(&self, line: usize) -> String {
|
||||
self.buffer.line_text_without_ending(line)
|
||||
}
|
||||
|
||||
pub fn apply_command(&mut self, command: EditorCommand) -> Result<CommandResult> {
|
||||
match command {
|
||||
EditorCommand::InsertChar(ch) => {
|
||||
self.insert_char(ch);
|
||||
Ok(CommandResult::Modified)
|
||||
}
|
||||
EditorCommand::InsertText(text) => {
|
||||
self.insert_text(&text);
|
||||
Ok(CommandResult::Modified)
|
||||
}
|
||||
EditorCommand::Newline => {
|
||||
self.newline();
|
||||
Ok(CommandResult::Modified)
|
||||
}
|
||||
EditorCommand::Backspace => Ok(if self.backspace() {
|
||||
CommandResult::Modified
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::Delete => Ok(if self.delete() {
|
||||
CommandResult::Modified
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::MoveLeft => Ok(if self.move_left() {
|
||||
CommandResult::CursorMoved
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::MoveRight => Ok(if self.move_right() {
|
||||
CommandResult::CursorMoved
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::MoveUp => Ok(if self.move_up() {
|
||||
CommandResult::CursorMoved
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::MoveDown => Ok(if self.move_down() {
|
||||
CommandResult::CursorMoved
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::MoveLineStart => Ok(if self.move_line_start() {
|
||||
CommandResult::CursorMoved
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::MoveLineEnd => Ok(if self.move_line_end() {
|
||||
CommandResult::CursorMoved
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::MoveFileStart => Ok(if self.move_file_start() {
|
||||
CommandResult::CursorMoved
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::MoveFileEnd => Ok(if self.move_file_end() {
|
||||
CommandResult::CursorMoved
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::PageUp => Ok(if self.page_up(DEFAULT_PAGE_LINES) {
|
||||
CommandResult::CursorMoved
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::PageDown => Ok(if self.page_down(DEFAULT_PAGE_LINES) {
|
||||
CommandResult::CursorMoved
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::Save => match self.save() {
|
||||
Ok(()) => Ok(CommandResult::Saved),
|
||||
Err(LimeError::MissingPath) => Ok(CommandResult::NeedsPath),
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
EditorCommand::Undo => Ok(if self.undo() {
|
||||
CommandResult::Modified
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::Redo => Ok(if self.redo() {
|
||||
CommandResult::Modified
|
||||
} else {
|
||||
CommandResult::None
|
||||
}),
|
||||
EditorCommand::Search(_) => Ok(CommandResult::None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_char(&mut self, ch: char) {
|
||||
let mut buf = [0; 4];
|
||||
self.insert_text_grouped(ch.encode_utf8(&mut buf), true);
|
||||
}
|
||||
|
||||
pub fn insert_text(&mut self, text: &str) {
|
||||
self.insert_text_grouped(text, false);
|
||||
}
|
||||
|
||||
fn insert_text_grouped(&mut self, text: &str, allow_grouping: bool) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let before_cursor = self.cursor;
|
||||
let at = self.cursor.position;
|
||||
let after = self.buffer.insert(at, text);
|
||||
self.cursor = Cursor::new(self.buffer.clamp_position(after));
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
|
||||
let tx = EditTransaction::new(
|
||||
vec![TextEdit::Insert {
|
||||
at,
|
||||
text: text.to_string(),
|
||||
}],
|
||||
before_cursor,
|
||||
self.cursor,
|
||||
);
|
||||
self.record_transaction(tx, allow_grouping && !text.contains(['\n', '\r']));
|
||||
}
|
||||
|
||||
pub fn newline(&mut self) {
|
||||
let line_ending = self.buffer.line_ending().as_str().to_string();
|
||||
self.insert_text_grouped(&line_ending, false);
|
||||
}
|
||||
|
||||
pub fn backspace(&mut self) -> bool {
|
||||
let current = self.buffer.position_to_char(self.cursor.position);
|
||||
if current == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let start = self.buffer.char_to_position(current - 1);
|
||||
self.delete_range_with_cursor(
|
||||
TextRange::new(start, self.cursor.position),
|
||||
self.cursor,
|
||||
start,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete(&mut self) -> bool {
|
||||
let current = self.buffer.position_to_char(self.cursor.position);
|
||||
if current >= self.buffer.len_chars() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut end_idx = current + 1;
|
||||
if self.buffer.char(current) == Some('\r') && self.buffer.char(current + 1) == Some('\n') {
|
||||
end_idx = current + 2;
|
||||
}
|
||||
let end = self.buffer.char_to_position(end_idx);
|
||||
self.delete_range_with_cursor(
|
||||
TextRange::new(self.cursor.position, end),
|
||||
self.cursor,
|
||||
self.cursor.position,
|
||||
)
|
||||
}
|
||||
|
||||
fn delete_range_with_cursor(
|
||||
&mut self,
|
||||
range: TextRange,
|
||||
before_cursor: Cursor,
|
||||
after_position: Position,
|
||||
) -> bool {
|
||||
let normalized = range.normalized();
|
||||
if normalized.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let deleted_text = self.buffer.remove(normalized);
|
||||
if deleted_text.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.cursor = Cursor::new(self.buffer.clamp_position(after_position));
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
let tx = EditTransaction::new(
|
||||
vec![TextEdit::Delete {
|
||||
range: normalized,
|
||||
deleted_text,
|
||||
}],
|
||||
before_cursor,
|
||||
self.cursor,
|
||||
);
|
||||
self.record_transaction(tx, false);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn move_left(&mut self) -> bool {
|
||||
let pos = self.cursor.position;
|
||||
let next = if pos.column > 0 {
|
||||
Position::new(pos.line, pos.column - 1)
|
||||
} else if pos.line > 0 {
|
||||
let previous_line = pos.line - 1;
|
||||
Position::new(previous_line, self.buffer.line_len_chars(previous_line))
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
self.set_cursor(next);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn move_right(&mut self) -> bool {
|
||||
let pos = self.cursor.position;
|
||||
let line_len = self.buffer.line_len_chars(pos.line);
|
||||
let next = if pos.column < line_len {
|
||||
Position::new(pos.line, pos.column + 1)
|
||||
} else if pos.line + 1 < self.buffer.line_count() {
|
||||
Position::new(pos.line + 1, 0)
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
self.set_cursor(next);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn move_up(&mut self) -> bool {
|
||||
let pos = self.cursor.position;
|
||||
if pos.line == 0 {
|
||||
return false;
|
||||
}
|
||||
let preferred = self.cursor.preferred_column.unwrap_or(pos.column);
|
||||
let target_line = pos.line - 1;
|
||||
self.cursor.position = Position::new(
|
||||
target_line,
|
||||
preferred.min(self.buffer.line_len_chars(target_line)),
|
||||
);
|
||||
self.cursor.preferred_column = Some(preferred);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self) -> bool {
|
||||
let pos = self.cursor.position;
|
||||
if pos.line + 1 >= self.buffer.line_count() {
|
||||
return false;
|
||||
}
|
||||
let preferred = self.cursor.preferred_column.unwrap_or(pos.column);
|
||||
let target_line = pos.line + 1;
|
||||
self.cursor.position = Position::new(
|
||||
target_line,
|
||||
preferred.min(self.buffer.line_len_chars(target_line)),
|
||||
);
|
||||
self.cursor.preferred_column = Some(preferred);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn move_line_start(&mut self) -> bool {
|
||||
let pos = self.cursor.position;
|
||||
if pos.column == 0 {
|
||||
return false;
|
||||
}
|
||||
self.set_cursor(Position::new(pos.line, 0));
|
||||
true
|
||||
}
|
||||
|
||||
pub fn move_line_end(&mut self) -> bool {
|
||||
let pos = self.cursor.position;
|
||||
let end = self.buffer.line_len_chars(pos.line);
|
||||
if pos.column == end {
|
||||
return false;
|
||||
}
|
||||
self.set_cursor(Position::new(pos.line, end));
|
||||
true
|
||||
}
|
||||
|
||||
pub fn move_file_start(&mut self) -> bool {
|
||||
if self.cursor.position == Position::new(0, 0) {
|
||||
return false;
|
||||
}
|
||||
self.set_cursor(Position::new(0, 0));
|
||||
true
|
||||
}
|
||||
|
||||
pub fn move_file_end(&mut self) -> bool {
|
||||
let last_line = self.buffer.line_count().saturating_sub(1);
|
||||
let end = Position::new(last_line, self.buffer.line_len_chars(last_line));
|
||||
if self.cursor.position == end {
|
||||
return false;
|
||||
}
|
||||
self.set_cursor(end);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self, lines: usize) -> bool {
|
||||
let pos = self.cursor.position;
|
||||
if pos.line == 0 {
|
||||
return false;
|
||||
}
|
||||
let target_line = pos.line.saturating_sub(lines.max(1));
|
||||
let preferred = self.cursor.preferred_column.unwrap_or(pos.column);
|
||||
self.cursor.position = Position::new(
|
||||
target_line,
|
||||
preferred.min(self.buffer.line_len_chars(target_line)),
|
||||
);
|
||||
self.cursor.preferred_column = Some(preferred);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self, lines: usize) -> bool {
|
||||
let pos = self.cursor.position;
|
||||
let last_line = self.buffer.line_count().saturating_sub(1);
|
||||
if pos.line >= last_line {
|
||||
return false;
|
||||
}
|
||||
let target_line = (pos.line + lines.max(1)).min(last_line);
|
||||
let preferred = self.cursor.preferred_column.unwrap_or(pos.column);
|
||||
self.cursor.position = Position::new(
|
||||
target_line,
|
||||
preferred.min(self.buffer.line_len_chars(target_line)),
|
||||
);
|
||||
self.cursor.preferred_column = Some(preferred);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<()> {
|
||||
self.buffer.save()
|
||||
}
|
||||
|
||||
pub fn save_as(&mut self, path: impl AsRef<Path>) -> Result<()> {
|
||||
self.buffer.save_as(path)
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some(tx) = self.undo_stack.pop() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
for edit in tx.edits.iter().rev() {
|
||||
self.apply_inverse_edit(edit);
|
||||
}
|
||||
self.cursor = tx.before_cursor;
|
||||
self.buffer.set_dirty(true);
|
||||
self.redo_stack.push(tx);
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> bool {
|
||||
let Some(tx) = self.redo_stack.pop() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
for edit in &tx.edits {
|
||||
self.apply_edit(edit);
|
||||
}
|
||||
self.cursor = tx.after_cursor;
|
||||
self.buffer.set_dirty(true);
|
||||
self.undo_stack.push(tx);
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
true
|
||||
}
|
||||
|
||||
fn apply_edit(&mut self, edit: &TextEdit) {
|
||||
match edit {
|
||||
TextEdit::Insert { at, text } => {
|
||||
self.buffer.insert(*at, text);
|
||||
}
|
||||
TextEdit::Delete { range, .. } => {
|
||||
self.buffer.remove(*range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_inverse_edit(&mut self, edit: &TextEdit) {
|
||||
match edit {
|
||||
TextEdit::Insert { at, text } => {
|
||||
let end = advance_position(*at, text);
|
||||
self.buffer.remove(TextRange::new(*at, end));
|
||||
}
|
||||
TextEdit::Delete {
|
||||
range,
|
||||
deleted_text,
|
||||
} => {
|
||||
self.buffer.insert(range.start, deleted_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_transaction(&mut self, tx: EditTransaction, allow_grouping: bool) {
|
||||
self.redo_stack.clear();
|
||||
|
||||
if allow_grouping {
|
||||
if let Some(last) = self.undo_stack.last_mut() {
|
||||
if let (
|
||||
[TextEdit::Insert {
|
||||
at: _,
|
||||
text: last_text,
|
||||
}],
|
||||
[TextEdit::Insert { at: _, text }],
|
||||
) = (last.edits.as_mut_slice(), tx.edits.as_slice())
|
||||
{
|
||||
if last.after_cursor == tx.before_cursor
|
||||
&& !last_text.contains(['\n', '\r'])
|
||||
&& !text.contains(['\n', '\r'])
|
||||
{
|
||||
last_text.push_str(text);
|
||||
last.after_cursor = tx.after_cursor;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.undo_stack.push(tx);
|
||||
}
|
||||
|
||||
pub fn find_all(&self, query: &str) -> Vec<SearchMatch> {
|
||||
if query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let text = self.buffer.text();
|
||||
text.match_indices(query)
|
||||
.map(|(byte_start, matched)| {
|
||||
let start_char = text[..byte_start].chars().count();
|
||||
let end_char = start_char + matched.chars().count();
|
||||
SearchMatch {
|
||||
range: TextRange::new(
|
||||
self.buffer.char_to_position(start_char),
|
||||
self.buffer.char_to_position(end_char),
|
||||
),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn search_next(&self, query: &str) -> Option<SearchMatch> {
|
||||
let cursor_char = self.buffer.position_to_char(self.cursor.position);
|
||||
let matches = self.find_all(query);
|
||||
matches
|
||||
.iter()
|
||||
.find(|m| self.buffer.position_to_char(m.range.start) > cursor_char)
|
||||
.cloned()
|
||||
.or_else(|| matches.first().cloned())
|
||||
}
|
||||
|
||||
pub fn go_to_line(&mut self, one_based_line: usize) -> bool {
|
||||
let target = one_based_line.saturating_sub(1);
|
||||
let target = target.min(self.buffer.line_count().saturating_sub(1));
|
||||
self.set_cursor(Position::new(target, 0));
|
||||
true
|
||||
}
|
||||
|
||||
pub fn undo_len(&self) -> usize {
|
||||
self.undo_stack.len()
|
||||
}
|
||||
|
||||
pub fn redo_len(&self) -> usize {
|
||||
self.redo_stack.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn insert_characters_groups_undo() {
|
||||
let mut editor = Editor::new();
|
||||
editor.insert_char('h');
|
||||
editor.insert_char('i');
|
||||
|
||||
assert_eq!(editor.text(), "hi");
|
||||
assert_eq!(editor.undo_len(), 1);
|
||||
assert!(editor.undo());
|
||||
assert_eq!(editor.text(), "");
|
||||
assert_eq!(editor.cursor().position, Position::new(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_newline() {
|
||||
let mut editor = Editor::from_text("hello");
|
||||
editor.move_file_end();
|
||||
editor.newline();
|
||||
editor.insert_text("world");
|
||||
|
||||
assert_eq!(editor.text(), "hello\nworld");
|
||||
assert_eq!(editor.cursor().position, Position::new(1, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_joins_lines() {
|
||||
let mut editor = Editor::from_text("hello\nworld");
|
||||
editor.set_cursor(Position { line: 1, column: 0 });
|
||||
editor.backspace();
|
||||
|
||||
assert_eq!(editor.text(), "helloworld");
|
||||
assert_eq!(editor.cursor().position, Position { line: 0, column: 5 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_joins_lines() {
|
||||
let mut editor = Editor::from_text("hello\nworld");
|
||||
editor.set_cursor(Position::new(0, 5));
|
||||
editor.delete();
|
||||
|
||||
assert_eq!(editor.text(), "helloworld");
|
||||
assert_eq!(editor.cursor().position, Position::new(0, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_moves_across_lines() {
|
||||
let mut editor = Editor::from_text("a\nbc");
|
||||
assert!(editor.move_right());
|
||||
assert_eq!(editor.cursor().position, Position::new(0, 1));
|
||||
assert!(editor.move_right());
|
||||
assert_eq!(editor.cursor().position, Position::new(1, 0));
|
||||
assert!(editor.move_left());
|
||||
assert_eq!(editor.cursor().position, Position::new(0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_movement_preserves_preferred_column() {
|
||||
let mut editor = Editor::from_text("abcd\nx\nabcdef");
|
||||
editor.set_cursor(Position::new(0, 4));
|
||||
editor.move_down();
|
||||
assert_eq!(editor.cursor().position, Position::new(1, 1));
|
||||
editor.move_down();
|
||||
assert_eq!(editor.cursor().position, Position::new(2, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_redo_delete() {
|
||||
let mut editor = Editor::from_text("abc");
|
||||
editor.set_cursor(Position::new(0, 1));
|
||||
editor.delete();
|
||||
assert_eq!(editor.text(), "ac");
|
||||
assert!(editor.undo());
|
||||
assert_eq!(editor.text(), "abc");
|
||||
assert!(editor.redo());
|
||||
assert_eq!(editor.text(), "ac");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_matches() {
|
||||
let editor = Editor::from_text("one two one");
|
||||
let matches = editor.find_all("one");
|
||||
assert_eq!(matches.len(), 2);
|
||||
assert_eq!(matches[0].range.start, Position::new(0, 0));
|
||||
assert_eq!(matches[1].range.start, Position::new(0, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_load_roundtrip() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("test.txt");
|
||||
let mut editor = Editor::from_text("hello\nworld");
|
||||
editor.save_as(&path).unwrap();
|
||||
assert!(!editor.is_dirty());
|
||||
|
||||
let loaded = Editor::from_file(&path).unwrap();
|
||||
assert_eq!(loaded.text(), "hello\nworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_text_moves_and_deletes() {
|
||||
let mut editor = Editor::from_text("åß∂");
|
||||
editor.move_file_end();
|
||||
assert_eq!(editor.cursor().position, Position::new(0, 3));
|
||||
editor.backspace();
|
||||
assert_eq!(editor.text(), "åß");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::io;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum LimeError {
|
||||
#[error("file is too large: {size} bytes")]
|
||||
FileTooLarge { size: u64 },
|
||||
|
||||
#[error("buffer has no file path")]
|
||||
MissingPath,
|
||||
|
||||
#[error("invalid cursor position")]
|
||||
InvalidPosition,
|
||||
|
||||
#[error("invalid text range")]
|
||||
InvalidRange,
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, LimeError>;
|
||||
@@ -0,0 +1,94 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FileOpenPolicy {
|
||||
pub warn_threshold_bytes: u64,
|
||||
pub confirm_threshold_bytes: u64,
|
||||
pub hard_threshold_bytes: u64,
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
impl Default for FileOpenPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
warn_threshold_bytes: 5 * 1024 * 1024,
|
||||
confirm_threshold_bytes: 25 * 1024 * 1024,
|
||||
hard_threshold_bytes: 100 * 1024 * 1024,
|
||||
force: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileOpenDecision {
|
||||
Open { size: u64 },
|
||||
Warn { size: u64 },
|
||||
Confirm { size: u64 },
|
||||
Refuse { size: u64 },
|
||||
}
|
||||
|
||||
impl FileOpenPolicy {
|
||||
pub fn evaluate_size(&self, size: u64) -> FileOpenDecision {
|
||||
if self.force {
|
||||
return FileOpenDecision::Open { size };
|
||||
}
|
||||
|
||||
if size > self.hard_threshold_bytes {
|
||||
FileOpenDecision::Refuse { size }
|
||||
} else if size > self.confirm_threshold_bytes {
|
||||
FileOpenDecision::Confirm { size }
|
||||
} else if size > self.warn_threshold_bytes {
|
||||
FileOpenDecision::Warn { size }
|
||||
} else {
|
||||
FileOpenDecision::Open { size }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evaluate_path(&self, path: &Path) -> Result<FileOpenDecision> {
|
||||
let metadata = fs::metadata(path)?;
|
||||
Ok(self.evaluate_size(metadata.len()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn evaluates_thresholds() {
|
||||
let policy = FileOpenPolicy::default();
|
||||
assert_eq!(
|
||||
policy.evaluate_size(1024),
|
||||
FileOpenDecision::Open { size: 1024 }
|
||||
);
|
||||
assert!(matches!(
|
||||
policy.evaluate_size(6 * 1024 * 1024),
|
||||
FileOpenDecision::Warn { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
policy.evaluate_size(30 * 1024 * 1024),
|
||||
FileOpenDecision::Confirm { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
policy.evaluate_size(101 * 1024 * 1024),
|
||||
FileOpenDecision::Refuse { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_opens_any_size() {
|
||||
let policy = FileOpenPolicy {
|
||||
force: true,
|
||||
..FileOpenPolicy::default()
|
||||
};
|
||||
assert_eq!(
|
||||
policy.evaluate_size(500 * 1024 * 1024),
|
||||
FileOpenDecision::Open {
|
||||
size: 500 * 1024 * 1024
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use crate::cursor::Cursor;
|
||||
use crate::edit::TextEdit;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EditTransaction {
|
||||
pub edits: Vec<TextEdit>,
|
||||
pub before_cursor: Cursor,
|
||||
pub after_cursor: Cursor,
|
||||
}
|
||||
|
||||
impl EditTransaction {
|
||||
pub fn new(edits: Vec<TextEdit>, before_cursor: Cursor, after_cursor: Cursor) -> Self {
|
||||
Self {
|
||||
edits,
|
||||
before_cursor,
|
||||
after_cursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
pub mod buffer;
|
||||
pub mod command;
|
||||
pub mod cursor;
|
||||
pub mod edit;
|
||||
pub mod error;
|
||||
pub mod file;
|
||||
pub mod history;
|
||||
pub mod movement;
|
||||
pub mod search;
|
||||
pub mod selection;
|
||||
|
||||
pub use buffer::{LineEnding, TextBuffer};
|
||||
pub use command::{CommandResult, EditorCommand};
|
||||
pub use cursor::{Cursor, Position};
|
||||
pub use edit::{Editor, TextEdit, TextRange};
|
||||
pub use error::{LimeError, Result};
|
||||
pub use file::{FileOpenDecision, FileOpenPolicy};
|
||||
pub use history::EditTransaction;
|
||||
pub use search::SearchMatch;
|
||||
pub use selection::Selection;
|
||||
@@ -0,0 +1 @@
|
||||
pub const DEFAULT_PAGE_LINES: usize = 20;
|
||||
@@ -0,0 +1,6 @@
|
||||
use crate::edit::TextRange;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SearchMatch {
|
||||
pub range: TextRange,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::edit::TextRange;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Selection {
|
||||
pub range: TextRange,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub const fn new(range: TextRange) -> Self {
|
||||
Self { range }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "lime-syntax"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tree-sitter.workspace = true
|
||||
tree-sitter-rust.workspace = true
|
||||
tree-sitter-javascript.workspace = true
|
||||
tree-sitter-typescript.workspace = true
|
||||
tree-sitter-python.workspace = true
|
||||
tree-sitter-json.workspace = true
|
||||
@@ -0,0 +1,524 @@
|
||||
use tree_sitter::{Node, Parser};
|
||||
|
||||
use crate::language::Language;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum HighlightKind {
|
||||
Normal,
|
||||
Keyword,
|
||||
String,
|
||||
Comment,
|
||||
Function,
|
||||
TypeName,
|
||||
Number,
|
||||
Error,
|
||||
Punctuation,
|
||||
Heading,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HighlightSpan {
|
||||
pub line_start: usize,
|
||||
pub column_start: usize,
|
||||
pub line_end: usize,
|
||||
pub column_end: usize,
|
||||
pub kind: HighlightKind,
|
||||
}
|
||||
|
||||
impl HighlightSpan {
|
||||
pub const fn new(
|
||||
line_start: usize,
|
||||
column_start: usize,
|
||||
line_end: usize,
|
||||
column_end: usize,
|
||||
kind: HighlightKind,
|
||||
) -> Self {
|
||||
Self {
|
||||
line_start,
|
||||
column_start,
|
||||
line_end,
|
||||
column_end,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn intersects_line(&self, line: usize) -> bool {
|
||||
self.line_start <= line && line <= self.line_end
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Highlighter {
|
||||
cache: Option<HighlightCache>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HighlightCache {
|
||||
revision: u64,
|
||||
language: Language,
|
||||
spans: Vec<HighlightSpan>,
|
||||
}
|
||||
|
||||
impl Highlighter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.cache = None;
|
||||
}
|
||||
|
||||
pub fn highlight(
|
||||
&mut self,
|
||||
text: &str,
|
||||
language: Language,
|
||||
revision: u64,
|
||||
) -> Vec<HighlightSpan> {
|
||||
if let Some(cache) = &self.cache {
|
||||
if cache.revision == revision && cache.language == language {
|
||||
return cache.spans.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let mut spans = if text.len() <= 2_000_000 {
|
||||
self.highlight_with_tree_sitter(text, language)
|
||||
.unwrap_or_else(|| lexical_highlight(text, language))
|
||||
} else {
|
||||
lexical_highlight(text, language)
|
||||
};
|
||||
|
||||
spans.sort_by_key(|span| {
|
||||
(
|
||||
span.line_start,
|
||||
span.column_start,
|
||||
span.line_end,
|
||||
span.column_end,
|
||||
)
|
||||
});
|
||||
self.cache = Some(HighlightCache {
|
||||
revision,
|
||||
language,
|
||||
spans: spans.clone(),
|
||||
});
|
||||
spans
|
||||
}
|
||||
|
||||
fn highlight_with_tree_sitter(
|
||||
&self,
|
||||
text: &str,
|
||||
language: Language,
|
||||
) -> Option<Vec<HighlightSpan>> {
|
||||
let mut parser = Parser::new();
|
||||
let grammar = grammar_for(language)?;
|
||||
parser.set_language(&grammar).ok()?;
|
||||
let tree = parser.parse(text, None)?;
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut spans = Vec::new();
|
||||
collect_node_spans(tree.root_node(), &lines, &mut spans);
|
||||
|
||||
if spans.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(spans)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn grammar_for(language: Language) -> Option<tree_sitter::Language> {
|
||||
match language {
|
||||
Language::Rust => Some(tree_sitter_rust::LANGUAGE.into()),
|
||||
Language::JavaScript => Some(tree_sitter_javascript::LANGUAGE.into()),
|
||||
Language::TypeScript => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
|
||||
Language::Python => Some(tree_sitter_python::LANGUAGE.into()),
|
||||
Language::Json => Some(tree_sitter_json::LANGUAGE.into()),
|
||||
Language::Toml | Language::Markdown | Language::PlainText => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_node_spans(node: Node<'_>, lines: &[&str], spans: &mut Vec<HighlightSpan>) {
|
||||
if let Some(kind) = kind_for_node(node.kind()) {
|
||||
if let Some(span) = span_from_node(node, lines, kind) {
|
||||
spans.push(span);
|
||||
}
|
||||
}
|
||||
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
collect_node_spans(child, lines, spans);
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_for_node(kind: &str) -> Option<HighlightKind> {
|
||||
if kind == "ERROR" {
|
||||
return Some(HighlightKind::Error);
|
||||
}
|
||||
|
||||
if kind.contains("comment") {
|
||||
return Some(HighlightKind::Comment);
|
||||
}
|
||||
|
||||
if kind.contains("string") || kind == "template_string" || kind == "raw_string_literal" {
|
||||
return Some(HighlightKind::String);
|
||||
}
|
||||
|
||||
if kind.contains("number")
|
||||
|| kind.contains("integer")
|
||||
|| kind.contains("float")
|
||||
|| kind == "true"
|
||||
|| kind == "false"
|
||||
|| kind == "null"
|
||||
|| kind == "none"
|
||||
{
|
||||
return Some(HighlightKind::Number);
|
||||
}
|
||||
|
||||
if kind == "type_identifier"
|
||||
|| kind == "primitive_type"
|
||||
|| kind == "scoped_type_identifier"
|
||||
|| kind == "generic_type"
|
||||
{
|
||||
return Some(HighlightKind::TypeName);
|
||||
}
|
||||
|
||||
if is_keyword(kind) {
|
||||
return Some(HighlightKind::Keyword);
|
||||
}
|
||||
|
||||
if matches!(
|
||||
kind,
|
||||
"(" | ")" | "{" | "}" | "[" | "]" | ":" | ";" | "," | "." | "->" | "=>"
|
||||
) {
|
||||
return Some(HighlightKind::Punctuation);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn is_keyword(kind: &str) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
"as" | "async"
|
||||
| "await"
|
||||
| "break"
|
||||
| "class"
|
||||
| "const"
|
||||
| "continue"
|
||||
| "def"
|
||||
| "delete"
|
||||
| "do"
|
||||
| "else"
|
||||
| "enum"
|
||||
| "export"
|
||||
| "extends"
|
||||
| "false"
|
||||
| "fn"
|
||||
| "for"
|
||||
| "from"
|
||||
| "function"
|
||||
| "if"
|
||||
| "impl"
|
||||
| "import"
|
||||
| "in"
|
||||
| "interface"
|
||||
| "let"
|
||||
| "loop"
|
||||
| "match"
|
||||
| "mod"
|
||||
| "mut"
|
||||
| "new"
|
||||
| "null"
|
||||
| "pub"
|
||||
| "return"
|
||||
| "self"
|
||||
| "Self"
|
||||
| "static"
|
||||
| "struct"
|
||||
| "super"
|
||||
| "trait"
|
||||
| "true"
|
||||
| "try"
|
||||
| "type"
|
||||
| "use"
|
||||
| "var"
|
||||
| "while"
|
||||
| "yield"
|
||||
| "where"
|
||||
)
|
||||
}
|
||||
|
||||
fn span_from_node(node: Node<'_>, lines: &[&str], kind: HighlightKind) -> Option<HighlightSpan> {
|
||||
let start = node.start_position();
|
||||
let end = node.end_position();
|
||||
if start.row >= lines.len() || end.row >= lines.len().max(1) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start_col = byte_col_to_char_col(lines.get(start.row).copied().unwrap_or(""), start.column);
|
||||
let end_col = byte_col_to_char_col(lines.get(end.row).copied().unwrap_or(""), end.column);
|
||||
|
||||
if start.row == end.row && start_col == end_col {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(HighlightSpan::new(
|
||||
start.row, start_col, end.row, end_col, kind,
|
||||
))
|
||||
}
|
||||
|
||||
fn byte_col_to_char_col(line: &str, byte_col: usize) -> usize {
|
||||
let mut byte = byte_col.min(line.len());
|
||||
while byte > 0 && !line.is_char_boundary(byte) {
|
||||
byte -= 1;
|
||||
}
|
||||
line[..byte].chars().count()
|
||||
}
|
||||
|
||||
fn lexical_highlight(text: &str, language: Language) -> Vec<HighlightSpan> {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
for (line_idx, line) in text.lines().enumerate() {
|
||||
if language == Language::Markdown {
|
||||
highlight_markdown_line(line_idx, line, &mut spans);
|
||||
continue;
|
||||
}
|
||||
|
||||
highlight_comments(line_idx, line, language, &mut spans);
|
||||
highlight_strings(line_idx, line, &mut spans);
|
||||
highlight_numbers(line_idx, line, &mut spans);
|
||||
highlight_keywords(line_idx, line, language, &mut spans);
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
fn highlight_markdown_line(line_idx: usize, line: &str, spans: &mut Vec<HighlightSpan>) {
|
||||
let trimmed = line.trim_start();
|
||||
let indent = line.len() - trimmed.len();
|
||||
if trimmed.starts_with('#') {
|
||||
spans.push(HighlightSpan::new(
|
||||
line_idx,
|
||||
indent,
|
||||
line_idx,
|
||||
line.chars().count(),
|
||||
HighlightKind::Heading,
|
||||
));
|
||||
} else if trimmed.starts_with('>') {
|
||||
spans.push(HighlightSpan::new(
|
||||
line_idx,
|
||||
indent,
|
||||
line_idx,
|
||||
line.chars().count(),
|
||||
HighlightKind::Comment,
|
||||
));
|
||||
}
|
||||
|
||||
highlight_strings(line_idx, line, spans);
|
||||
}
|
||||
|
||||
fn highlight_comments(
|
||||
line_idx: usize,
|
||||
line: &str,
|
||||
language: Language,
|
||||
spans: &mut Vec<HighlightSpan>,
|
||||
) {
|
||||
let marker = match language {
|
||||
Language::Rust | Language::JavaScript | Language::TypeScript => Some("//"),
|
||||
Language::Python | Language::Toml => Some("#"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(marker) = marker {
|
||||
if let Some(byte_idx) = line.find(marker) {
|
||||
let col = line[..byte_idx].chars().count();
|
||||
spans.push(HighlightSpan::new(
|
||||
line_idx,
|
||||
col,
|
||||
line_idx,
|
||||
line.chars().count(),
|
||||
HighlightKind::Comment,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_strings(line_idx: usize, line: &str, spans: &mut Vec<HighlightSpan>) {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let quote = chars[i];
|
||||
if quote != '"' && quote != '\'' && quote != '`' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let start = i;
|
||||
i += 1;
|
||||
let mut escaped = false;
|
||||
while i < chars.len() {
|
||||
let ch = chars[i];
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == quote {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
spans.push(HighlightSpan::new(
|
||||
line_idx,
|
||||
start,
|
||||
line_idx,
|
||||
i.min(chars.len()),
|
||||
HighlightKind::String,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_numbers(line_idx: usize, line: &str, spans: &mut Vec<HighlightSpan>) {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if !chars[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < chars.len()
|
||||
&& (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '.')
|
||||
{
|
||||
i += 1;
|
||||
}
|
||||
spans.push(HighlightSpan::new(
|
||||
line_idx,
|
||||
start,
|
||||
line_idx,
|
||||
i,
|
||||
HighlightKind::Number,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_keywords(
|
||||
line_idx: usize,
|
||||
line: &str,
|
||||
language: Language,
|
||||
spans: &mut Vec<HighlightSpan>,
|
||||
) {
|
||||
let keywords = keywords_for(language);
|
||||
if keywords.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if !is_word_start(chars[i]) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < chars.len() && is_word_continue(chars[i]) {
|
||||
i += 1;
|
||||
}
|
||||
let word: String = chars[start..i].iter().collect();
|
||||
if keywords.contains(&word.as_str()) {
|
||||
spans.push(HighlightSpan::new(
|
||||
line_idx,
|
||||
start,
|
||||
line_idx,
|
||||
i,
|
||||
HighlightKind::Keyword,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word_start(ch: char) -> bool {
|
||||
ch == '_' || ch.is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
fn is_word_continue(ch: char) -> bool {
|
||||
ch == '_' || ch.is_ascii_alphanumeric()
|
||||
}
|
||||
|
||||
fn keywords_for(language: Language) -> &'static [&'static str] {
|
||||
match language {
|
||||
Language::Rust => &[
|
||||
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
|
||||
"extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod",
|
||||
"move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super",
|
||||
"trait", "true", "type", "unsafe", "use", "where", "while",
|
||||
],
|
||||
Language::JavaScript | Language::TypeScript => &[
|
||||
"async",
|
||||
"await",
|
||||
"break",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"else",
|
||||
"export",
|
||||
"extends",
|
||||
"false",
|
||||
"finally",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"interface",
|
||||
"let",
|
||||
"new",
|
||||
"null",
|
||||
"return",
|
||||
"super",
|
||||
"switch",
|
||||
"this",
|
||||
"throw",
|
||||
"true",
|
||||
"try",
|
||||
"type",
|
||||
"typeof",
|
||||
"var",
|
||||
"void",
|
||||
"while",
|
||||
"yield",
|
||||
],
|
||||
Language::Python => &[
|
||||
"and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del",
|
||||
"elif", "else", "except", "False", "finally", "for", "from", "global", "if", "import",
|
||||
"in", "is", "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "return",
|
||||
"True", "try", "while", "with", "yield",
|
||||
],
|
||||
Language::Json | Language::Toml | Language::Markdown | Language::PlainText => &[],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lexical_rust_keywords() {
|
||||
let spans = lexical_highlight("fn main() { let n = 1; }", Language::Rust);
|
||||
assert!(spans.iter().any(|span| span.kind == HighlightKind::Keyword));
|
||||
assert!(spans.iter().any(|span| span.kind == HighlightKind::Number));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_markdown_heading() {
|
||||
let spans = lexical_highlight("# Hello", Language::Markdown);
|
||||
assert_eq!(spans[0].kind, HighlightKind::Heading);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Language {
|
||||
Rust,
|
||||
JavaScript,
|
||||
TypeScript,
|
||||
Python,
|
||||
Json,
|
||||
Toml,
|
||||
Markdown,
|
||||
PlainText,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub fn detect_path(path: Option<&Path>) -> Self {
|
||||
let Some(path) = path else {
|
||||
return Self::PlainText;
|
||||
};
|
||||
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("");
|
||||
match file_name {
|
||||
"Cargo.toml" | "config.toml" | "lime.toml" => return Self::Toml,
|
||||
"README" | "README.md" => return Self::Markdown,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match path.extension().and_then(|ext| ext.to_str()).unwrap_or("") {
|
||||
"rs" => Self::Rust,
|
||||
"js" | "jsx" | "mjs" | "cjs" => Self::JavaScript,
|
||||
"ts" | "tsx" | "mts" | "cts" => Self::TypeScript,
|
||||
"py" | "pyw" => Self::Python,
|
||||
"json" | "jsonc" => Self::Json,
|
||||
"toml" => Self::Toml,
|
||||
"md" | "markdown" => Self::Markdown,
|
||||
_ => Self::PlainText,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rust => "rust",
|
||||
Self::JavaScript => "javascript",
|
||||
Self::TypeScript => "typescript",
|
||||
Self::Python => "python",
|
||||
Self::Json => "json",
|
||||
Self::Toml => "toml",
|
||||
Self::Markdown => "markdown",
|
||||
Self::PlainText => "text",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn detects_common_extensions() {
|
||||
assert_eq!(
|
||||
Language::detect_path(Some(Path::new("main.rs"))),
|
||||
Language::Rust
|
||||
);
|
||||
assert_eq!(
|
||||
Language::detect_path(Some(Path::new("app.ts"))),
|
||||
Language::TypeScript
|
||||
);
|
||||
assert_eq!(
|
||||
Language::detect_path(Some(Path::new("script.py"))),
|
||||
Language::Python
|
||||
);
|
||||
assert_eq!(
|
||||
Language::detect_path(Some(Path::new("README.md"))),
|
||||
Language::Markdown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_is_plain_text() {
|
||||
assert_eq!(
|
||||
Language::detect_path(Some(Path::new("unknown.blob"))),
|
||||
Language::PlainText
|
||||
);
|
||||
assert_eq!(Language::detect_path(None), Language::PlainText);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod highlighter;
|
||||
pub mod language;
|
||||
pub mod theme;
|
||||
|
||||
pub use highlighter::{HighlightKind, HighlightSpan, Highlighter};
|
||||
pub use language::Language;
|
||||
pub use theme::SyntaxTheme;
|
||||
@@ -0,0 +1,10 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SyntaxTheme {
|
||||
pub name: &'static str,
|
||||
}
|
||||
|
||||
impl Default for SyntaxTheme {
|
||||
fn default() -> Self {
|
||||
Self { name: "lime-dark" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "lime-ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
crossterm.workspace = true
|
||||
ratatui.workspace = true
|
||||
ignore.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
dirs.workspace = true
|
||||
unicode-width.workspace = true
|
||||
lime-core = { path = "../lime-core" }
|
||||
lime-syntax = { path = "../lime-syntax" }
|
||||
@@ -0,0 +1,756 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use lime_core::{
|
||||
CommandResult, Editor, EditorCommand, FileOpenDecision, FileOpenPolicy, TextBuffer,
|
||||
};
|
||||
use lime_syntax::{HighlightSpan, Highlighter, Language};
|
||||
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::command_bar::render_help_bar;
|
||||
use crate::config::Config;
|
||||
use crate::editor_view::{gutter_width, render_editor, Viewport};
|
||||
use crate::file_picker::FilePickerState;
|
||||
use crate::input::{map_editing_key, AppAction};
|
||||
use crate::layout::{app_layout, centered_rect};
|
||||
use crate::prompt::{ConfirmKind, ConfirmState, PromptKind, PromptState};
|
||||
use crate::status_bar::{render_status_bar, StatusInfo};
|
||||
use crate::terminal::TerminalSession;
|
||||
use crate::theme::UiTheme;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppOptions {
|
||||
pub path: Option<PathBuf>,
|
||||
pub force: bool,
|
||||
pub config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl AppOptions {
|
||||
pub fn new(path: Option<PathBuf>, force: bool, config_path: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
path,
|
||||
force,
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Mode {
|
||||
Editing,
|
||||
FilePicker(FilePickerState),
|
||||
Prompt(PromptState),
|
||||
Confirm(ConfirmState),
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
editor: Editor,
|
||||
config: Config,
|
||||
theme: UiTheme,
|
||||
mode: Mode,
|
||||
viewport: Viewport,
|
||||
root: PathBuf,
|
||||
should_quit: bool,
|
||||
status_message: Option<String>,
|
||||
status_at: Option<Instant>,
|
||||
file_policy: FileOpenPolicy,
|
||||
highlighter: Highlighter,
|
||||
language: Language,
|
||||
highlights: Vec<HighlightSpan>,
|
||||
highlighted_revision: Option<u64>,
|
||||
highlighted_language: Language,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(options: AppOptions) -> Result<Self> {
|
||||
let (config, config_warning) = Config::load(options.config_path.as_deref());
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let mut app = Self {
|
||||
editor: Editor::new(),
|
||||
config,
|
||||
theme: UiTheme::default(),
|
||||
mode: Mode::Editing,
|
||||
viewport: Viewport::default(),
|
||||
root: cwd.clone(),
|
||||
should_quit: false,
|
||||
status_message: config_warning,
|
||||
status_at: Some(Instant::now()),
|
||||
file_policy: FileOpenPolicy {
|
||||
force: options.force,
|
||||
..FileOpenPolicy::default()
|
||||
},
|
||||
highlighter: Highlighter::new(),
|
||||
language: Language::PlainText,
|
||||
highlights: Vec::new(),
|
||||
highlighted_revision: None,
|
||||
highlighted_language: Language::PlainText,
|
||||
};
|
||||
|
||||
if let Some(path) = options.path {
|
||||
app.open_initial_path(path, &cwd)?;
|
||||
}
|
||||
|
||||
app.refresh_language();
|
||||
app.refresh_highlights();
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
fn open_initial_path(&mut self, path: PathBuf, cwd: &Path) -> Result<()> {
|
||||
if path.is_dir() {
|
||||
self.root = path.canonicalize().unwrap_or(path);
|
||||
self.mode = Mode::FilePicker(FilePickerState::new(&self.root));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
if parent.exists() {
|
||||
self.root = cwd.to_path_buf();
|
||||
}
|
||||
}
|
||||
|
||||
if !path.exists() {
|
||||
let mut buffer = TextBuffer::from_text("");
|
||||
buffer.set_path(path);
|
||||
self.editor.replace_buffer(buffer);
|
||||
self.set_status("New file");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match self.evaluate_open_path(&path)? {
|
||||
OpenEvaluation::Open => self.open_file_unchecked(&path),
|
||||
OpenEvaluation::Prompt(size) => {
|
||||
self.mode = Mode::Confirm(ConfirmState::new(
|
||||
ConfirmKind::OpenLarge { path, size },
|
||||
format!(
|
||||
"This file is {}. Lime may be slower with very large files. Open anyway? [y/N]",
|
||||
format_bytes(size)
|
||||
),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
OpenEvaluation::Refuse(size) => {
|
||||
self.set_status(format!(
|
||||
"Refused to open {} file. Re-run with --force to open it.",
|
||||
format_bytes(size)
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_status(&mut self, message: impl Into<String>) {
|
||||
self.status_message = Some(message.into());
|
||||
self.status_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
fn maybe_clear_status(&mut self) {
|
||||
if self
|
||||
.status_at
|
||||
.is_some_and(|instant| instant.elapsed() > Duration::from_secs(5))
|
||||
{
|
||||
self.status_message = None;
|
||||
self.status_at = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(&mut self) -> Result<()> {
|
||||
let mut terminal = TerminalSession::enter()?;
|
||||
|
||||
while !self.should_quit {
|
||||
self.maybe_clear_status();
|
||||
self.refresh_highlights();
|
||||
terminal.terminal_mut().draw(|frame| self.draw(frame))?;
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_key(key)?,
|
||||
Event::Resize(_, _) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame<'_>) {
|
||||
let area = frame.area();
|
||||
let layout = app_layout(area);
|
||||
let total_lines = self.editor.line_count();
|
||||
let gutter = gutter_width(total_lines, self.config.show_line_numbers);
|
||||
let text_width = layout.editor.width.saturating_sub(gutter) as usize;
|
||||
self.viewport.ensure_cursor_visible(
|
||||
self.editor.cursor().position,
|
||||
total_lines,
|
||||
layout.editor.height as usize,
|
||||
text_width,
|
||||
);
|
||||
|
||||
render_editor(
|
||||
frame,
|
||||
layout.editor,
|
||||
&self.editor,
|
||||
&self.viewport,
|
||||
&self.config,
|
||||
&self.theme,
|
||||
&self.highlights,
|
||||
);
|
||||
|
||||
let file_name = self.file_label();
|
||||
render_status_bar(
|
||||
frame,
|
||||
layout.status,
|
||||
&self.theme,
|
||||
StatusInfo {
|
||||
file_name: &file_name,
|
||||
language: self.language.name(),
|
||||
position: self.editor.cursor().position,
|
||||
dirty: self.editor.is_dirty(),
|
||||
message: self.status_message.as_deref(),
|
||||
},
|
||||
);
|
||||
render_help_bar(frame, layout.help, &self.theme);
|
||||
|
||||
match &self.mode {
|
||||
Mode::Editing => {}
|
||||
Mode::FilePicker(state) => self.render_file_picker(frame, area, state),
|
||||
Mode::Prompt(state) => self.render_prompt(frame, area, state),
|
||||
Mode::Confirm(state) => self.render_confirm(frame, area, state),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Editing => self.handle_editing_key(key),
|
||||
Mode::FilePicker(_) => self.handle_file_picker_key(key),
|
||||
Mode::Prompt(_) => self.handle_prompt_key(key),
|
||||
Mode::Confirm(_) => self.handle_confirm_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_editing_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let action = map_editing_key(key);
|
||||
self.handle_action(action)
|
||||
}
|
||||
|
||||
fn handle_action(&mut self, action: AppAction) -> Result<()> {
|
||||
match action {
|
||||
AppAction::Editor(command) => self.apply_editor_command(command),
|
||||
AppAction::Save => self.save_current(),
|
||||
AppAction::Quit => self.request_quit(),
|
||||
AppAction::OpenFilePicker => {
|
||||
self.mode = Mode::FilePicker(FilePickerState::new(&self.root));
|
||||
Ok(())
|
||||
}
|
||||
AppAction::OpenGoToLine => {
|
||||
self.mode = Mode::Prompt(PromptState::new(PromptKind::GoToLine, "Go to line"));
|
||||
Ok(())
|
||||
}
|
||||
AppAction::OpenSearch => {
|
||||
self.mode = Mode::Prompt(PromptState::new(PromptKind::Search, "Search"));
|
||||
Ok(())
|
||||
}
|
||||
AppAction::ClosePopup | AppAction::Cancel => {
|
||||
self.mode = Mode::Editing;
|
||||
Ok(())
|
||||
}
|
||||
AppAction::Confirm | AppAction::None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_editor_command(&mut self, command: EditorCommand) -> Result<()> {
|
||||
let command = match command {
|
||||
EditorCommand::InsertText(text) if text == "\t" && self.config.insert_spaces => {
|
||||
EditorCommand::InsertText(" ".repeat(self.config.tab_width))
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
|
||||
match self.editor.apply_command(command)? {
|
||||
CommandResult::Saved => self.set_status("Saved"),
|
||||
CommandResult::NeedsPath => {
|
||||
self.mode = Mode::Prompt(PromptState::new(PromptKind::SaveAs, "Save as"));
|
||||
}
|
||||
CommandResult::Modified => {
|
||||
self.refresh_language();
|
||||
}
|
||||
CommandResult::CursorMoved | CommandResult::None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_current(&mut self) -> Result<()> {
|
||||
match self.editor.save() {
|
||||
Ok(()) => {
|
||||
self.set_status("Saved");
|
||||
Ok(())
|
||||
}
|
||||
Err(lime_core::LimeError::MissingPath) => {
|
||||
self.mode = Mode::Prompt(PromptState::new(PromptKind::SaveAs, "Save as"));
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
self.set_status(format!("Save failed: {err}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_then_quit(&mut self) -> Result<()> {
|
||||
match self.editor.save() {
|
||||
Ok(()) => {
|
||||
self.set_status("Saved");
|
||||
self.should_quit = true;
|
||||
}
|
||||
Err(lime_core::LimeError::MissingPath) => {
|
||||
self.mode = Mode::Prompt(PromptState::new(PromptKind::SaveAsThenQuit, "Save as"));
|
||||
}
|
||||
Err(err) => self.set_status(format!("Save failed: {err}")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_then_open(&mut self, path: PathBuf) -> Result<()> {
|
||||
match self.editor.save() {
|
||||
Ok(()) => {
|
||||
self.set_status("Saved");
|
||||
self.open_with_policy_or_prompt(path)?;
|
||||
}
|
||||
Err(lime_core::LimeError::MissingPath) => {
|
||||
self.mode = Mode::Prompt(PromptState::new(
|
||||
PromptKind::SaveAsThenOpen { path },
|
||||
"Save as",
|
||||
));
|
||||
}
|
||||
Err(err) => self.set_status(format!("Save failed: {err}")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request_quit(&mut self) -> Result<()> {
|
||||
if self.editor.is_dirty() {
|
||||
self.mode = Mode::Confirm(ConfirmState::new(
|
||||
ConfirmKind::QuitDirty,
|
||||
"Unsaved changes. Save before quitting? [s]ave [d]iscard [c]ancel",
|
||||
));
|
||||
} else {
|
||||
self.should_quit = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_file_picker_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let mut open_path = None;
|
||||
let mut close = false;
|
||||
|
||||
if let Mode::FilePicker(state) = &mut self.mode {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Esc => close = true,
|
||||
KeyCode::Char('c') if ctrl => close = true,
|
||||
KeyCode::Char(ch) if !ctrl => state.input_char(ch),
|
||||
KeyCode::Backspace => state.backspace(),
|
||||
KeyCode::Up => state.move_up(),
|
||||
KeyCode::Down => state.move_down(),
|
||||
KeyCode::Enter => open_path = state.selected_path(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if close {
|
||||
self.mode = Mode::Editing;
|
||||
}
|
||||
|
||||
if let Some(path) = open_path {
|
||||
self.request_open_file(path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_prompt_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let mut submit = false;
|
||||
let mut cancel = false;
|
||||
|
||||
if let Mode::Prompt(prompt) = &mut self.mode {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Esc => cancel = true,
|
||||
KeyCode::Char('c') if ctrl => cancel = true,
|
||||
KeyCode::Enter => submit = true,
|
||||
KeyCode::Backspace => {
|
||||
prompt.input.pop();
|
||||
}
|
||||
KeyCode::Char(ch) if !ctrl => prompt.input.push(ch),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if cancel {
|
||||
self.mode = Mode::Editing;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if submit {
|
||||
let prompt = match std::mem::replace(&mut self.mode, Mode::Editing) {
|
||||
Mode::Prompt(prompt) => prompt,
|
||||
other => {
|
||||
self.mode = other;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
self.submit_prompt(prompt)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn submit_prompt(&mut self, prompt: PromptState) -> Result<()> {
|
||||
match prompt.kind {
|
||||
PromptKind::SaveAs => {
|
||||
self.submit_save_as(&prompt.input)?;
|
||||
}
|
||||
PromptKind::SaveAsThenQuit => {
|
||||
if self.submit_save_as(&prompt.input)? {
|
||||
self.should_quit = true;
|
||||
}
|
||||
}
|
||||
PromptKind::SaveAsThenOpen { path } => {
|
||||
if self.submit_save_as(&prompt.input)? {
|
||||
self.open_with_policy_or_prompt(path)?;
|
||||
}
|
||||
}
|
||||
PromptKind::GoToLine => {
|
||||
if let Ok(line) = prompt.input.trim().parse::<usize>() {
|
||||
self.editor.go_to_line(line);
|
||||
} else {
|
||||
self.set_status("Invalid line number");
|
||||
}
|
||||
}
|
||||
PromptKind::Search => {
|
||||
let query = prompt.input;
|
||||
if let Some(found) = self.editor.search_next(&query) {
|
||||
self.editor.set_cursor(found.range.start);
|
||||
self.set_status(format!("Found: {query}"));
|
||||
} else {
|
||||
self.set_status(format!("Not found: {query}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn submit_save_as(&mut self, input: &str) -> Result<bool> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
self.set_status("Save cancelled: empty path");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let path = expand_tilde(input);
|
||||
match self.editor.save_as(&path) {
|
||||
Ok(()) => {
|
||||
self.refresh_language();
|
||||
self.set_status(format!("Saved {}", path.display()));
|
||||
Ok(true)
|
||||
}
|
||||
Err(err) => {
|
||||
self.set_status(format!("Save failed: {err}"));
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_confirm_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let confirm = match &self.mode {
|
||||
Mode::Confirm(confirm) => confirm.clone(),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
match confirm.kind {
|
||||
ConfirmKind::QuitDirty => match key.code {
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => {
|
||||
self.mode = Mode::Editing;
|
||||
self.save_then_quit()?;
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Char('D') => self.should_quit = true,
|
||||
KeyCode::Char('c') | KeyCode::Char('C') | KeyCode::Esc => self.mode = Mode::Editing,
|
||||
_ => {}
|
||||
},
|
||||
ConfirmKind::OpenDirty { path } => match key.code {
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => {
|
||||
self.mode = Mode::Editing;
|
||||
self.save_then_open(path)?;
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Char('D') => {
|
||||
self.mode = Mode::Editing;
|
||||
self.open_with_policy_or_prompt(path)?;
|
||||
}
|
||||
KeyCode::Char('c') | KeyCode::Char('C') | KeyCode::Esc => self.mode = Mode::Editing,
|
||||
_ => {}
|
||||
},
|
||||
ConfirmKind::OpenLarge { path, .. } => match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
self.mode = Mode::Editing;
|
||||
self.open_file_unchecked(&path)?;
|
||||
}
|
||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => self.mode = Mode::Editing,
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request_open_file(&mut self, path: PathBuf) -> Result<()> {
|
||||
if self.editor.is_dirty() {
|
||||
self.mode = Mode::Confirm(ConfirmState::new(
|
||||
ConfirmKind::OpenDirty { path },
|
||||
"Unsaved changes. Save before opening another file? [s]ave [d]iscard [c]ancel",
|
||||
));
|
||||
} else {
|
||||
self.open_with_policy_or_prompt(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_with_policy_or_prompt(&mut self, path: PathBuf) -> Result<()> {
|
||||
match self.evaluate_open_path(&path)? {
|
||||
OpenEvaluation::Open => self.open_file_unchecked(&path)?,
|
||||
OpenEvaluation::Prompt(size) => {
|
||||
self.mode = Mode::Confirm(ConfirmState::new(
|
||||
ConfirmKind::OpenLarge { path, size },
|
||||
format!(
|
||||
"This file is {}. Lime may be slower with very large files. Open anyway? [y/N]",
|
||||
format_bytes(size)
|
||||
),
|
||||
));
|
||||
}
|
||||
OpenEvaluation::Refuse(size) => {
|
||||
self.mode = Mode::Editing;
|
||||
self.set_status(format!(
|
||||
"Refused to open {} file. Re-run with --force to open it.",
|
||||
format_bytes(size)
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn evaluate_open_path(&self, path: &Path) -> Result<OpenEvaluation> {
|
||||
if !path.exists() {
|
||||
return Ok(OpenEvaluation::Open);
|
||||
}
|
||||
|
||||
match self.file_policy.evaluate_path(path)? {
|
||||
FileOpenDecision::Open { .. } => Ok(OpenEvaluation::Open),
|
||||
FileOpenDecision::Warn { size } | FileOpenDecision::Confirm { size } => {
|
||||
if self.config.confirm_large_files {
|
||||
Ok(OpenEvaluation::Prompt(size))
|
||||
} else {
|
||||
Ok(OpenEvaluation::Open)
|
||||
}
|
||||
}
|
||||
FileOpenDecision::Refuse { size } => Ok(OpenEvaluation::Refuse(size)),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_file_unchecked(&mut self, path: &Path) -> Result<()> {
|
||||
let buffer = if path.exists() {
|
||||
match TextBuffer::from_file(path) {
|
||||
Ok(buffer) => buffer,
|
||||
Err(err) => {
|
||||
self.set_status(format!("Could not open {}: {err}", path.display()));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TextBuffer::from_text_with_path("", path)
|
||||
};
|
||||
self.editor.replace_buffer(buffer);
|
||||
self.mode = Mode::Editing;
|
||||
self.viewport = Viewport::default();
|
||||
self.refresh_language();
|
||||
self.highlighter.clear();
|
||||
self.refresh_highlights();
|
||||
self.set_status(format!("Opened {}", path.display()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_language(&mut self) {
|
||||
let language = Language::detect_path(self.editor.buffer().path());
|
||||
if language != self.language {
|
||||
self.language = language;
|
||||
self.highlighted_revision = None;
|
||||
self.highlighter.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_highlights(&mut self) {
|
||||
let revision = self.editor.revision();
|
||||
if self.highlighted_revision == Some(revision) && self.highlighted_language == self.language
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let text = self.editor.text();
|
||||
self.highlights = self.highlighter.highlight(&text, self.language, revision);
|
||||
self.highlighted_revision = Some(revision);
|
||||
self.highlighted_language = self.language;
|
||||
}
|
||||
|
||||
fn file_label(&self) -> String {
|
||||
self.editor
|
||||
.buffer()
|
||||
.path()
|
||||
.and_then(|path| path.file_name())
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "[untitled]".to_string())
|
||||
}
|
||||
|
||||
fn render_file_picker(&self, frame: &mut Frame<'_>, area: Rect, state: &FilePickerState) {
|
||||
let popup = centered_rect(72, 70, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
let block = Block::default()
|
||||
.title(" Open File ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.theme.popup_border))
|
||||
.style(self.theme.normal().bg(self.theme.popup_bg));
|
||||
let inner = block.inner(popup);
|
||||
frame.render_widget(block, popup);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(2), Constraint::Min(1)])
|
||||
.split(inner);
|
||||
|
||||
let search = Paragraph::new(format!("search: {}", state.query))
|
||||
.style(self.theme.normal().bg(self.theme.popup_bg));
|
||||
frame.render_widget(search, chunks[0]);
|
||||
|
||||
let visible_count = chunks[1].height as usize;
|
||||
let selected = state.selected.min(state.matches.len().saturating_sub(1));
|
||||
let start = selected.saturating_sub(visible_count.saturating_sub(1));
|
||||
let items: Vec<ListItem<'_>> = state
|
||||
.matches
|
||||
.iter()
|
||||
.skip(start)
|
||||
.take(visible_count)
|
||||
.map(|m| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(" ", self.theme.normal().bg(self.theme.popup_bg)),
|
||||
Span::raw(m.display.clone()),
|
||||
]))
|
||||
.style(self.theme.normal().bg(self.theme.popup_bg))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
if !items.is_empty() {
|
||||
list_state.select(Some(selected - start));
|
||||
}
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(self.theme.background)
|
||||
.bg(self.theme.heading)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("> ");
|
||||
frame.render_stateful_widget(list, chunks[1], &mut list_state);
|
||||
}
|
||||
|
||||
fn render_prompt(&self, frame: &mut Frame<'_>, area: Rect, state: &PromptState) {
|
||||
let popup = centered_rect(60, 20, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
let block = Block::default()
|
||||
.title(format!(" {} ", state.title))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.theme.popup_border))
|
||||
.style(self.theme.normal().bg(self.theme.popup_bg));
|
||||
let inner = block.inner(popup);
|
||||
frame.render_widget(block, popup);
|
||||
let label = match state.kind {
|
||||
PromptKind::SaveAs | PromptKind::SaveAsThenQuit | PromptKind::SaveAsThenOpen { .. } => {
|
||||
"path"
|
||||
}
|
||||
PromptKind::GoToLine => "line",
|
||||
PromptKind::Search => "query",
|
||||
};
|
||||
let paragraph = Paragraph::new(format!("{label}: {}", state.input))
|
||||
.style(self.theme.normal().bg(self.theme.popup_bg));
|
||||
frame.render_widget(paragraph, inner);
|
||||
let cursor_x = inner
|
||||
.x
|
||||
.saturating_add(label.len() as u16)
|
||||
.saturating_add(2)
|
||||
.saturating_add(state.input.chars().count() as u16)
|
||||
.min(inner.x.saturating_add(inner.width.saturating_sub(1)));
|
||||
frame.set_cursor_position((cursor_x, inner.y));
|
||||
}
|
||||
|
||||
fn render_confirm(&self, frame: &mut Frame<'_>, area: Rect, state: &ConfirmState) {
|
||||
let popup = centered_rect(64, 24, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
let block = Block::default()
|
||||
.title(" Confirm ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.theme.popup_border))
|
||||
.style(self.theme.normal().bg(self.theme.popup_bg));
|
||||
let inner = block.inner(popup);
|
||||
frame.render_widget(block, popup);
|
||||
let paragraph = Paragraph::new(state.message.clone())
|
||||
.style(self.theme.normal().bg(self.theme.popup_bg))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true });
|
||||
frame.render_widget(paragraph, inner);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum OpenEvaluation {
|
||||
Open,
|
||||
Prompt(u64),
|
||||
Refuse(u64),
|
||||
}
|
||||
|
||||
pub fn run_app(options: AppOptions) -> Result<()> {
|
||||
let mut app = App::new(options)?;
|
||||
app.run()
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
const MB: f64 = KB * 1024.0;
|
||||
const GB: f64 = MB * 1024.0;
|
||||
let bytes_f = bytes as f64;
|
||||
if bytes_f >= GB {
|
||||
format!("{:.1} GB", bytes_f / GB)
|
||||
} else if bytes_f >= MB {
|
||||
format!("{:.1} MB", bytes_f / MB)
|
||||
} else if bytes_f >= KB {
|
||||
format!("{:.1} KB", bytes_f / KB)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_tilde(input: &str) -> PathBuf {
|
||||
if input == "~" {
|
||||
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(input));
|
||||
}
|
||||
if let Some(rest) = input.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(rest);
|
||||
}
|
||||
}
|
||||
PathBuf::from(input)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{layout::Rect, Frame};
|
||||
|
||||
use crate::theme::UiTheme;
|
||||
|
||||
pub fn render_help_bar(frame: &mut Frame<'_>, area: Rect, theme: &UiTheme) {
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
" Ctrl-S ",
|
||||
theme.normal().fg(theme.heading).bg(theme.help_bg),
|
||||
),
|
||||
Span::styled("Save ", theme.normal().fg(theme.help_fg).bg(theme.help_bg)),
|
||||
Span::styled(
|
||||
" Ctrl-F ",
|
||||
theme.normal().fg(theme.heading).bg(theme.help_bg),
|
||||
),
|
||||
Span::styled("Files ", theme.normal().fg(theme.help_fg).bg(theme.help_bg)),
|
||||
Span::styled(
|
||||
" Ctrl-R ",
|
||||
theme.normal().fg(theme.heading).bg(theme.help_bg),
|
||||
),
|
||||
Span::styled(
|
||||
"Search ",
|
||||
theme.normal().fg(theme.help_fg).bg(theme.help_bg),
|
||||
),
|
||||
Span::styled(
|
||||
" Ctrl-G ",
|
||||
theme.normal().fg(theme.heading).bg(theme.help_bg),
|
||||
),
|
||||
Span::styled(
|
||||
"Go line ",
|
||||
theme.normal().fg(theme.help_fg).bg(theme.help_bg),
|
||||
),
|
||||
Span::styled(
|
||||
" Ctrl-Q ",
|
||||
theme.normal().fg(theme.heading).bg(theme.help_bg),
|
||||
),
|
||||
Span::styled("Quit", theme.normal().fg(theme.help_fg).bg(theme.help_bg)),
|
||||
]);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(line).style(theme.normal().bg(theme.help_bg)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub theme: String,
|
||||
pub tab_width: usize,
|
||||
pub insert_spaces: bool,
|
||||
pub show_line_numbers: bool,
|
||||
pub confirm_large_files: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: "lime-dark".to_string(),
|
||||
tab_width: 4,
|
||||
insert_spaces: true,
|
||||
show_line_numbers: true,
|
||||
confirm_large_files: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: Option<&Path>) -> (Self, Option<String>) {
|
||||
let Some(path) = path.map(Path::to_path_buf).or_else(default_config_path) else {
|
||||
return (Self::default(), None);
|
||||
};
|
||||
|
||||
if !path.exists() {
|
||||
return (Self::default(), None);
|
||||
}
|
||||
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(text) => match toml::from_str::<Self>(&text) {
|
||||
Ok(config) => (config.normalized(), None),
|
||||
Err(err) => (
|
||||
Self::default(),
|
||||
Some(format!("Invalid config {}: {err}", path.display())),
|
||||
),
|
||||
},
|
||||
Err(err) => (
|
||||
Self::default(),
|
||||
Some(format!("Could not read config {}: {err}", path.display())),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(mut self) -> Self {
|
||||
if self.tab_width == 0 || self.tab_width > 16 {
|
||||
self.tab_width = 4;
|
||||
}
|
||||
if self.theme.trim().is_empty() {
|
||||
self.theme = "lime-dark".to_string();
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> Option<PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs::home_dir().map(|home| {
|
||||
home.join("Library")
|
||||
.join("Application Support")
|
||||
.join("lime")
|
||||
.join("config.toml")
|
||||
})
|
||||
} else {
|
||||
dirs::config_dir().map(|dir| dir.join("lime").join("config.toml"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use lime_core::{Editor, Position};
|
||||
use lime_syntax::{HighlightKind, HighlightSpan};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::theme::UiTheme;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Viewport {
|
||||
pub top_line: usize,
|
||||
pub left_col: usize,
|
||||
pub height: usize,
|
||||
pub width: usize,
|
||||
}
|
||||
|
||||
impl Viewport {
|
||||
pub fn ensure_cursor_visible(
|
||||
&mut self,
|
||||
cursor: Position,
|
||||
total_lines: usize,
|
||||
viewport_height: usize,
|
||||
text_width: usize,
|
||||
) {
|
||||
self.height = viewport_height;
|
||||
self.width = text_width;
|
||||
|
||||
let margin = 2usize.min(viewport_height.saturating_div(3));
|
||||
if cursor.line < self.top_line + margin {
|
||||
self.top_line = cursor.line.saturating_sub(margin);
|
||||
} else if cursor.line >= self.top_line + viewport_height.saturating_sub(margin) {
|
||||
self.top_line = cursor
|
||||
.line
|
||||
.saturating_sub(viewport_height.saturating_sub(margin + 1));
|
||||
}
|
||||
|
||||
let max_top = total_lines.saturating_sub(viewport_height.max(1));
|
||||
self.top_line = self.top_line.min(max_top);
|
||||
|
||||
if cursor.column < self.left_col {
|
||||
self.left_col = cursor.column;
|
||||
} else if cursor.column >= self.left_col + text_width.max(1) {
|
||||
self.left_col = cursor.column.saturating_sub(text_width.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gutter_width(total_lines: usize, show_line_numbers: bool) -> u16 {
|
||||
if show_line_numbers {
|
||||
total_lines.max(1).to_string().len() as u16 + 3
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_editor(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
editor: &Editor,
|
||||
viewport: &Viewport,
|
||||
config: &Config,
|
||||
theme: &UiTheme,
|
||||
highlights: &[HighlightSpan],
|
||||
) {
|
||||
let total_lines = editor.line_count();
|
||||
let gutter = gutter_width(total_lines, config.show_line_numbers);
|
||||
let text_width = area.width.saturating_sub(gutter) as usize;
|
||||
let mut lines = Vec::with_capacity(area.height as usize);
|
||||
let cursor_line = editor.cursor().position.line;
|
||||
|
||||
for screen_row in 0..area.height as usize {
|
||||
let line_idx = viewport.top_line + screen_row;
|
||||
if line_idx < total_lines {
|
||||
let mut spans = Vec::new();
|
||||
if config.show_line_numbers {
|
||||
let number = format!(
|
||||
"{:>width$} │ ",
|
||||
line_idx + 1,
|
||||
width = (gutter as usize).saturating_sub(3)
|
||||
);
|
||||
let style = if line_idx == cursor_line {
|
||||
Style::default()
|
||||
.fg(theme.gutter_active)
|
||||
.bg(theme.background)
|
||||
} else {
|
||||
Style::default().fg(theme.gutter).bg(theme.background)
|
||||
};
|
||||
spans.push(Span::styled(number, style));
|
||||
}
|
||||
|
||||
let text = editor.line_text_without_ending(line_idx);
|
||||
spans.extend(styled_line_spans(
|
||||
line_idx,
|
||||
&text,
|
||||
viewport.left_col,
|
||||
text_width,
|
||||
highlights,
|
||||
theme,
|
||||
));
|
||||
lines.push(Line::from(spans));
|
||||
} else {
|
||||
let blank = if config.show_line_numbers {
|
||||
" ".repeat(gutter as usize)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
lines.push(Line::from(Span::styled(blank, theme.normal())));
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(Paragraph::new(lines).style(theme.normal()), area);
|
||||
|
||||
let cursor = editor.cursor().position;
|
||||
if cursor.line >= viewport.top_line
|
||||
&& cursor.line < viewport.top_line + area.height as usize
|
||||
&& cursor.column >= viewport.left_col
|
||||
&& cursor.column < viewport.left_col + text_width.max(1)
|
||||
{
|
||||
let x = area.x + gutter + (cursor.column - viewport.left_col) as u16;
|
||||
let y = area.y + (cursor.line - viewport.top_line) as u16;
|
||||
if x < area.x + area.width && y < area.y + area.height {
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn styled_line_spans(
|
||||
line_idx: usize,
|
||||
text: &str,
|
||||
left_col: usize,
|
||||
width: usize,
|
||||
highlights: &[HighlightSpan],
|
||||
theme: &UiTheme,
|
||||
) -> Vec<Span<'static>> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if width == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut kinds = vec![HighlightKind::Normal; chars.len()];
|
||||
for span in highlights
|
||||
.iter()
|
||||
.filter(|span| span.intersects_line(line_idx))
|
||||
{
|
||||
let start = if line_idx == span.line_start {
|
||||
span.column_start
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end = if line_idx == span.line_end {
|
||||
span.column_end
|
||||
} else {
|
||||
chars.len()
|
||||
};
|
||||
let start = start.min(chars.len());
|
||||
let end = end.min(chars.len());
|
||||
for kind in kinds.iter_mut().take(end).skip(start) {
|
||||
*kind = span.kind;
|
||||
}
|
||||
}
|
||||
|
||||
let start = left_col.min(chars.len());
|
||||
let end = (start + width).min(chars.len());
|
||||
if start >= end {
|
||||
return vec![Span::raw("")];
|
||||
}
|
||||
|
||||
let mut spans = Vec::new();
|
||||
let mut current_kind = kinds[start];
|
||||
let mut current = String::new();
|
||||
|
||||
for idx in start..end {
|
||||
let kind = kinds[idx];
|
||||
if kind != current_kind && !current.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
current.clone(),
|
||||
theme.syntax_style(current_kind),
|
||||
));
|
||||
current.clear();
|
||||
current_kind = kind;
|
||||
}
|
||||
current.push(chars[idx]);
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
spans.push(Span::styled(current, theme.syntax_style(current_kind)));
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn viewport_tracks_cursor() {
|
||||
let mut viewport = Viewport::default();
|
||||
viewport.ensure_cursor_visible(Position::new(50, 10), 100, 10, 20);
|
||||
assert!(viewport.top_line <= 50);
|
||||
assert!(viewport.top_line + 10 > 50);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ignore::{DirEntry, WalkBuilder};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileMatch {
|
||||
pub path: PathBuf,
|
||||
pub display: String,
|
||||
pub score: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FilePickerState {
|
||||
pub root: PathBuf,
|
||||
pub query: String,
|
||||
pub all_files: Vec<PathBuf>,
|
||||
pub matches: Vec<FileMatch>,
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
impl FilePickerState {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
let root = root.into();
|
||||
let all_files = scan_files(&root);
|
||||
let mut state = Self {
|
||||
root,
|
||||
query: String::new(),
|
||||
all_files,
|
||||
matches: Vec::new(),
|
||||
selected: 0,
|
||||
};
|
||||
state.update_matches();
|
||||
state
|
||||
}
|
||||
|
||||
pub fn input_char(&mut self, ch: char) {
|
||||
self.query.push(ch);
|
||||
self.update_matches();
|
||||
}
|
||||
|
||||
pub fn backspace(&mut self) {
|
||||
self.query.pop();
|
||||
self.update_matches();
|
||||
}
|
||||
|
||||
pub fn move_up(&mut self) {
|
||||
if self.matches.is_empty() {
|
||||
self.selected = 0;
|
||||
} else if self.selected == 0 {
|
||||
self.selected = self.matches.len() - 1;
|
||||
} else {
|
||||
self.selected -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self) {
|
||||
if self.matches.is_empty() {
|
||||
self.selected = 0;
|
||||
} else {
|
||||
self.selected = (self.selected + 1) % self.matches.len();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_path(&self) -> Option<PathBuf> {
|
||||
self.matches.get(self.selected).map(|m| m.path.clone())
|
||||
}
|
||||
|
||||
pub fn update_matches(&mut self) {
|
||||
let query = self.query.trim();
|
||||
let root = self.root.clone();
|
||||
let mut matches: Vec<FileMatch> = self
|
||||
.all_files
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let display = path
|
||||
.strip_prefix(&root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
fuzzy_score(query, &display).map(|score| FileMatch {
|
||||
path: path.clone(),
|
||||
display,
|
||||
score,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
matches.sort_by(|a, b| {
|
||||
b.score
|
||||
.cmp(&a.score)
|
||||
.then_with(|| a.display.len().cmp(&b.display.len()))
|
||||
.then_with(|| a.display.cmp(&b.display))
|
||||
});
|
||||
matches.truncate(500);
|
||||
self.matches = matches;
|
||||
self.selected = self.selected.min(self.matches.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_files(root: &Path) -> Vec<PathBuf> {
|
||||
let mut builder = WalkBuilder::new(root);
|
||||
builder
|
||||
.hidden(true)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.parents(true)
|
||||
.filter_entry(|entry| !is_ignored_dir(entry));
|
||||
|
||||
let mut files = Vec::new();
|
||||
for entry in builder.build().filter_map(Result::ok) {
|
||||
if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
|
||||
files.push(entry.path().to_path_buf());
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn is_ignored_dir(entry: &DirEntry) -> bool {
|
||||
if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(name) = entry.file_name().to_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
matches!(
|
||||
name,
|
||||
".git" | "target" | "node_modules" | "dist" | ".next" | "build"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fuzzy_score(query: &str, candidate: &str) -> Option<u32> {
|
||||
if query.is_empty() {
|
||||
return Some(1);
|
||||
}
|
||||
|
||||
let query = query.to_lowercase();
|
||||
let candidate_lower = candidate.to_lowercase();
|
||||
let mut candidate_chars = candidate_lower.chars().enumerate();
|
||||
let mut last_match: Option<usize> = None;
|
||||
let mut score = 0u32;
|
||||
|
||||
for q in query.chars() {
|
||||
let mut found = None;
|
||||
for (idx, ch) in candidate_chars.by_ref() {
|
||||
if ch == q {
|
||||
found = Some(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let idx = found?;
|
||||
score += 10;
|
||||
if let Some(last) = last_match {
|
||||
if idx == last + 1 {
|
||||
score += 15;
|
||||
}
|
||||
} else if idx == 0 {
|
||||
score += 20;
|
||||
}
|
||||
if candidate.as_bytes().get(idx) == Some(&b'/') {
|
||||
score += 3;
|
||||
}
|
||||
last_match = Some(idx);
|
||||
}
|
||||
|
||||
Some(score.saturating_sub(candidate.len() as u32 / 5))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_in_order() {
|
||||
assert!(fuzzy_score("lcore", "crates/lime-core/src/lib.rs").is_some());
|
||||
assert!(fuzzy_score("zz", "crates/lime-core/src/lib.rs").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use lime_core::EditorCommand;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppAction {
|
||||
Editor(EditorCommand),
|
||||
Save,
|
||||
Quit,
|
||||
OpenFilePicker,
|
||||
OpenGoToLine,
|
||||
OpenSearch,
|
||||
ClosePopup,
|
||||
Confirm,
|
||||
Cancel,
|
||||
None,
|
||||
}
|
||||
|
||||
pub fn map_editing_key(key: KeyEvent) -> AppAction {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||
|
||||
if ctrl {
|
||||
return match key.code {
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => AppAction::Save,
|
||||
KeyCode::Char('q') | KeyCode::Char('Q') => AppAction::Quit,
|
||||
KeyCode::Char('f') | KeyCode::Char('F') => AppAction::OpenFilePicker,
|
||||
KeyCode::Char('g') | KeyCode::Char('G') => AppAction::OpenGoToLine,
|
||||
KeyCode::Char('r') | KeyCode::Char('R') => AppAction::OpenSearch,
|
||||
KeyCode::Char('z') | KeyCode::Char('Z') => AppAction::Editor(EditorCommand::Undo),
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => AppAction::Editor(EditorCommand::Redo),
|
||||
KeyCode::Char('a') | KeyCode::Char('A') => {
|
||||
AppAction::Editor(EditorCommand::MoveLineStart)
|
||||
}
|
||||
KeyCode::Char('e') | KeyCode::Char('E') => {
|
||||
AppAction::Editor(EditorCommand::MoveLineEnd)
|
||||
}
|
||||
_ => AppAction::None,
|
||||
};
|
||||
}
|
||||
|
||||
if alt {
|
||||
return AppAction::None;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => AppAction::ClosePopup,
|
||||
KeyCode::Left => AppAction::Editor(EditorCommand::MoveLeft),
|
||||
KeyCode::Right => AppAction::Editor(EditorCommand::MoveRight),
|
||||
KeyCode::Up => AppAction::Editor(EditorCommand::MoveUp),
|
||||
KeyCode::Down => AppAction::Editor(EditorCommand::MoveDown),
|
||||
KeyCode::Home => AppAction::Editor(EditorCommand::MoveLineStart),
|
||||
KeyCode::End => AppAction::Editor(EditorCommand::MoveLineEnd),
|
||||
KeyCode::PageUp => AppAction::Editor(EditorCommand::PageUp),
|
||||
KeyCode::PageDown => AppAction::Editor(EditorCommand::PageDown),
|
||||
KeyCode::Backspace => AppAction::Editor(EditorCommand::Backspace),
|
||||
KeyCode::Delete => AppAction::Editor(EditorCommand::Delete),
|
||||
KeyCode::Enter => AppAction::Editor(EditorCommand::Newline),
|
||||
KeyCode::Tab => AppAction::Editor(EditorCommand::InsertText("\t".to_string())),
|
||||
KeyCode::Char(ch) => AppAction::Editor(EditorCommand::InsertChar(ch)),
|
||||
_ => AppAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
#[test]
|
||||
fn maps_save_shortcut() {
|
||||
let action = map_editing_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
|
||||
assert_eq!(action, AppAction::Save);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_text_input() {
|
||||
let action = map_editing_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||
assert_eq!(action, AppAction::Editor(EditorCommand::InsertChar('x')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AppLayout {
|
||||
pub editor: Rect,
|
||||
pub status: Rect,
|
||||
pub help: Rect,
|
||||
}
|
||||
|
||||
pub fn app_layout(area: Rect) -> AppLayout {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
AppLayout {
|
||||
editor: chunks[0],
|
||||
status: chunks[1],
|
||||
help: chunks[2],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
Constraint::Percentage(percent_y),
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
Constraint::Percentage(percent_x),
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
])
|
||||
.split(popup_layout[1])[1]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub mod app;
|
||||
pub mod command_bar;
|
||||
pub mod config;
|
||||
pub mod editor_view;
|
||||
pub mod file_picker;
|
||||
pub mod input;
|
||||
pub mod layout;
|
||||
pub mod prompt;
|
||||
pub mod status_bar;
|
||||
pub mod terminal;
|
||||
pub mod theme;
|
||||
|
||||
pub use app::{run_app, App, AppOptions};
|
||||
pub use config::Config;
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PromptKind {
|
||||
SaveAs,
|
||||
SaveAsThenQuit,
|
||||
SaveAsThenOpen { path: PathBuf },
|
||||
GoToLine,
|
||||
Search,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PromptState {
|
||||
pub kind: PromptKind,
|
||||
pub title: String,
|
||||
pub input: String,
|
||||
}
|
||||
|
||||
impl PromptState {
|
||||
pub fn new(kind: PromptKind, title: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
title: title.into(),
|
||||
input: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConfirmKind {
|
||||
QuitDirty,
|
||||
OpenDirty { path: PathBuf },
|
||||
OpenLarge { path: PathBuf, size: u64 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfirmState {
|
||||
pub kind: ConfirmKind,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ConfirmState {
|
||||
pub fn new(kind: ConfirmKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use lime_core::Position;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{layout::Rect, Frame};
|
||||
|
||||
use crate::theme::UiTheme;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StatusInfo<'a> {
|
||||
pub file_name: &'a str,
|
||||
pub language: &'a str,
|
||||
pub position: Position,
|
||||
pub dirty: bool,
|
||||
pub message: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn render_status_bar(frame: &mut Frame<'_>, area: Rect, theme: &UiTheme, info: StatusInfo<'_>) {
|
||||
let dirty_mark = if info.dirty { "modified" } else { "saved" };
|
||||
let base = format!(
|
||||
" lime {} {} Ln {}, Col {} {}",
|
||||
info.file_name,
|
||||
info.language,
|
||||
info.position.line + 1,
|
||||
info.position.column + 1,
|
||||
dirty_mark
|
||||
);
|
||||
|
||||
let line = if let Some(message) = info.message {
|
||||
Line::from(vec![
|
||||
Span::raw(base),
|
||||
Span::raw(" │ "),
|
||||
Span::styled(message.to_string(), theme.normal().fg(theme.status_fg)),
|
||||
])
|
||||
} else {
|
||||
Line::from(base)
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(line).style(theme.normal().fg(theme.status_fg).bg(theme.status_bg)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use std::io::{self, Stdout};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::cursor::{Hide, Show};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
pub type LimeTerminal = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
pub struct TerminalSession {
|
||||
terminal: LimeTerminal,
|
||||
}
|
||||
|
||||
impl TerminalSession {
|
||||
pub fn enter() -> Result<Self> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, Hide)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
Ok(Self { terminal })
|
||||
}
|
||||
|
||||
pub fn terminal_mut(&mut self) -> &mut LimeTerminal {
|
||||
&mut self.terminal
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalSession {
|
||||
fn drop(&mut self) {
|
||||
let _ = disable_raw_mode();
|
||||
let mut stdout = io::stdout();
|
||||
let _ = execute!(stdout, Show, LeaveAlternateScreen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use lime_syntax::HighlightKind;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UiTheme {
|
||||
pub background: Color,
|
||||
pub foreground: Color,
|
||||
pub dim: Color,
|
||||
pub gutter: Color,
|
||||
pub gutter_active: Color,
|
||||
pub status_bg: Color,
|
||||
pub status_fg: Color,
|
||||
pub help_bg: Color,
|
||||
pub help_fg: Color,
|
||||
pub popup_bg: Color,
|
||||
pub popup_border: Color,
|
||||
pub selection_bg: Color,
|
||||
pub cursor: Color,
|
||||
pub keyword: Color,
|
||||
pub string: Color,
|
||||
pub comment: Color,
|
||||
pub function: Color,
|
||||
pub type_name: Color,
|
||||
pub number: Color,
|
||||
pub error: Color,
|
||||
pub punctuation: Color,
|
||||
pub heading: Color,
|
||||
}
|
||||
|
||||
impl Default for UiTheme {
|
||||
fn default() -> Self {
|
||||
Self::lime_dark()
|
||||
}
|
||||
}
|
||||
|
||||
impl UiTheme {
|
||||
pub fn lime_dark() -> Self {
|
||||
Self {
|
||||
background: Color::Rgb(18, 20, 18),
|
||||
foreground: Color::Rgb(220, 226, 218),
|
||||
dim: Color::Rgb(112, 121, 108),
|
||||
gutter: Color::Rgb(88, 96, 84),
|
||||
gutter_active: Color::Rgb(174, 210, 118),
|
||||
status_bg: Color::Rgb(53, 83, 45),
|
||||
status_fg: Color::Rgb(236, 244, 222),
|
||||
help_bg: Color::Rgb(28, 32, 27),
|
||||
help_fg: Color::Rgb(177, 187, 169),
|
||||
popup_bg: Color::Rgb(22, 25, 22),
|
||||
popup_border: Color::Rgb(135, 175, 95),
|
||||
selection_bg: Color::Rgb(57, 72, 54),
|
||||
cursor: Color::Rgb(203, 248, 111),
|
||||
keyword: Color::Rgb(145, 205, 255),
|
||||
string: Color::Rgb(177, 219, 139),
|
||||
comment: Color::Rgb(107, 122, 100),
|
||||
function: Color::Rgb(246, 207, 122),
|
||||
type_name: Color::Rgb(129, 221, 199),
|
||||
number: Color::Rgb(230, 172, 117),
|
||||
error: Color::Rgb(255, 111, 111),
|
||||
punctuation: Color::Rgb(157, 167, 148),
|
||||
heading: Color::Rgb(203, 248, 111),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normal(&self) -> Style {
|
||||
Style::default().fg(self.foreground).bg(self.background)
|
||||
}
|
||||
|
||||
pub fn dim(&self) -> Style {
|
||||
Style::default().fg(self.dim).bg(self.background)
|
||||
}
|
||||
|
||||
pub fn syntax_style(&self, kind: HighlightKind) -> Style {
|
||||
let color = match kind {
|
||||
HighlightKind::Normal => self.foreground,
|
||||
HighlightKind::Keyword => self.keyword,
|
||||
HighlightKind::String => self.string,
|
||||
HighlightKind::Comment => self.comment,
|
||||
HighlightKind::Function => self.function,
|
||||
HighlightKind::TypeName => self.type_name,
|
||||
HighlightKind::Number => self.number,
|
||||
HighlightKind::Error => self.error,
|
||||
HighlightKind::Punctuation => self.punctuation,
|
||||
HighlightKind::Heading => self.heading,
|
||||
};
|
||||
|
||||
let mut style = Style::default().fg(color).bg(self.background);
|
||||
if matches!(kind, HighlightKind::Heading) {
|
||||
style = style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
style
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user