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