Inital commit

This commit is contained in:
2026-06-21 20:08:56 -05:00
commit 27513283ff
38 changed files with 5969 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
.DS_Store
Generated
+1313
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -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"
+21
View File
@@ -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.
+963
View File
@@ -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.
+83
View File
@@ -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
+15
View File
@@ -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" }
+26
View File
@@ -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))
}
+13
View File
@@ -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
+270
View File
@@ -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 }
}
+31
View File
@@ -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,
}
+35
View File
@@ -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;
}
}
+703
View File
@@ -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(), "åß");
}
}
+21
View File
@@ -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>;
+94
View File
@@ -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
}
);
}
}
+19
View File
@@ -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,
}
}
}
+20
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
pub const DEFAULT_PAGE_LINES: usize = 20;
+6
View File
@@ -0,0 +1,6 @@
use crate::edit::TextRange;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchMatch {
pub range: TextRange,
}
+12
View File
@@ -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 }
}
}
+13
View File
@@ -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
+524
View File
@@ -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);
}
}
+90
View File
@@ -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);
}
}
+7
View File
@@ -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;
+10
View File
@@ -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" }
}
}
+17
View File
@@ -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" }
+756
View File
@@ -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)
}
+46
View File
@@ -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,
);
}
+74
View File
@@ -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"))
}
}
+206
View File
@@ -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);
}
}
+181
View File
@@ -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());
}
}
+80
View File
@@ -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')));
}
}
+45
View File
@@ -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]
}
+14
View File
@@ -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;
+49
View File
@@ -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(),
}
}
}
+42
View File
@@ -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,
);
}
+40
View File
@@ -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);
}
}
+92
View File
@@ -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
}
}