Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,21 @@ lim is that editor with all of it **built in**:
of Lerna: one body, many heads, like one editor working many windows. It greets you, in braille, on
the start screen.

![Editing: file tree, git signs, indent guides, TODO and color highlights](docs/screenshots/editor.png)
![Editing: file tree, indent guides, TODO, URL and color highlights](docs/screenshots/editor.png)

| Start screen | Find files |
| **Start screen** (the hydra and nvdash's buttons) | **Find files** (with preview) |
|---|---|
| ![Start screen with the hydra](docs/screenshots/dashboard.png) | ![File picker with preview](docs/screenshots/picker.png) |
| **Grep** | **which-key over two splits** |
| **Live grep** | **which-key** (`<Leader>`) |
| ![Live grep](docs/screenshots/grep.png) | ![which-key menu](docs/screenshots/whichkey.png) |
| **Cheat sheet** (`<F1>`) | **Theme switcher** (`<Leader>ft`, live preview) |
| **Cheat sheet** (`<F1>`) | **Theme switcher** (`<Leader>ft`, live on a code sample) |
| ![Cheat sheet](docs/screenshots/cheatsheet.png) | ![Theme picker](docs/screenshots/themes.png) |
| **Completion** (words from open buffers) | **Command-line completion** (tabs aligned with the windows) |
| **Completion** (words from open buffers) | **Command-line completion** |
| ![Completion menu](docs/screenshots/completion.png) | ![Command-line completion](docs/screenshots/cmdline.png) |
| **Tools** (`<Leader>xc`, a popup that keeps its history) | **Visual line selection** |
| ![Calculator popup](docs/screenshots/tools.png) | ![Visual line selection](docs/screenshots/visual.png) |
| **Settings** (`<Leader>,`, every option, searchable) | |
| ![Settings](docs/screenshots/settings.png) | |

## Features

Expand All @@ -63,7 +67,7 @@ the start screen.
| Vim editing | Normal, insert, replace, visual, visual-line and visual-block modes · counts · operators `d c y > < gu gU g~ gc` over any motion or text object · `.` · macros · undo/redo (1000 levels) · named registers (`"a`, `"A` append, `"0`, `"1`–`"9` shifting, `"-`, `"_`, `"+`) · marks and jump list that follow edits · Vim regexes in `/`, `?`, `*`, `:s`, `:g` · `:g`/`:v`, `:norm`, `:sort`, `:m`, `:t`, `:r` · line ranges with offsets |
| Motions | `h j k l w b e ge W B E gE 0 ^ $ g_ { } % gg G H M L f F t T ; , n N * #`, `gn` (`cgn` then `.`), page and line scrolling, `<S-arrows>` (word / page), `zz zt zb` · `:` and `/` history |
| Text objects | `iw` `aw` `iW` `aW` `ip` `ap` `it` `at` (HTML/XML tags) `i(` `a(` `i[` `a[` `i{` `a{` `i<` `a<` `i"` `a"` `i'` `a'` `` i` `` `` a` `` |
| AstroNvim look | 30 colorschemes (NvChad's onedark by default) · NvChad-style statusline (rounded, slanted, arrow or flat ends) · NvChad tabufline with jump labels and clickable tabs, close marks, light/dark and close-all buttons, aligned with the windows when the file tree is open · git branch and +/~/- counts in the statusline, and a hydra spinner while background commands (`:upgrade`, config commands) run · nvim-tree look for the file tree · optional winbar path and relative numbers · sign column · cursorline · borderless NvChad-style floats with pill titles · notifications · start screen with the hydra and nvdash buttons |
| NvChad look | 30 colorschemes (NvChad's onedark by default) · NvChad-style statusline (rounded, slanted, arrow or flat ends) · NvChad tabufline with jump labels and clickable tabs, close marks, light/dark and close-all buttons, aligned with the windows when the file tree is open · git branch and +/~/- counts in the statusline, and a hydra spinner while background commands (`:upgrade`, config commands) run · nvim-tree look for the file tree · optional winbar path and relative numbers · sign column · cursor line number (or the whole cursorline) · borderless NvChad-style floats with pill titles · notifications · start screen with the hydra and nvdash buttons |
| Code | keyword highlighting for 40+ languages and formats (Rust, Python, JS/TS, C/C++, C#, Java, Kotlin, Go, PHP, Ruby, Swift, Dart, Scala, Lua, SQL, HTML/XML, CSS/SCSS, Markdown, JSON/YAML/TOML, shell, PowerShell, batch, Makefile, Dockerfile, CMake, Zig, Haskell, Elixir, R, Perl, asm, Nix, Terraform...) with multi-line comments and strings, HTML tags and config keys · blink.cmp-style completion from the words of open buffers · autopairs · auto-indent · comment toggling |
| Helpers | indent guides · matching bracket · references of the word under the cursor (`]r`) · URLs (`gx`) · `#RRGGBB` shown in its color · `TODO:`/`FIX:`/`HACK:`… highlighted (`]T`) · Markdown preview beside the file, live as you type (`<Leader>um`) |
| Git | signs against the index · hunk jumps · graph of every branch, log, file log, status and stash pickers with diffs · `git browse` · status colors in the file tree |
Expand Down
9 changes: 7 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ src/
├── editor/ modes, operators, registers, keymaps, the built-in binding table, windows, picker,
│ file tree, which-key, cheat sheet, completion, sessions, prompts, clipboard, macros,
│ text tools, reverse-engineering commands, config commands
│ └── tools/ the <Leader>x tools, one module each (calc, units, bases, time, color, json, case,
│ generated text), and their popup
├── syntax/ keyword highlighter with multi-line state, language tables, todo-comments
├── config/ JSON reader, key notation, options/palette/highlights/keymaps/commands
└── render/ cell grid, themes and palettes, icon sets, windows, bars, floats, picker, tree, start screen
```

- **Rendering** draws each frame into a grid of cells (floats are later writes), encodes it to ANSI and sends
only the rows that changed; frames are skipped while keys are queued.
- **Rendering** draws each frame into a grid of cells, back to front (`render/mod.rs`, `compose`): the tabline,
the side panels, the windows, the bars, then the floats over them. It encodes the grid to ANSI and sends only
the rows that changed; frames are skipped while keys are queued. A line of text is drawn in layers
(`render/view.rs`, `LinePaint`): its syntax color, then URL, word, TODO, hex color, bracket and selection or
search highlights over it.
- **Built-in keys** live in one table (`editor/bindings.rs`): each prefixed key's action and description.
The dispatcher, which-key, the cheat sheet and the keymaps picker all read it, so they cannot disagree.
- **Undo** stores the lines an edit replaced, not copies of the file, and keeps 1000 steps. The same
Expand Down
14 changes: 12 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Rust 1.88+, no dependencies. The code layout and design are in [How lim is built](architecture.md).

```sh
cargo test # 125+ tests: behaviour, fuzzers on every input, perf budgets
cargo test # 160+ tests: behaviour, fuzzers on every input, perf budgets
cargo clippy --all-targets # pedantic, zero warnings; unwrap/expect/panic are denied
cargo fmt --check # rustfmt.toml, 120 columns
cargo test --release perf -- --nocapture # the benchmark table: run it before and after a change
Expand Down Expand Up @@ -80,4 +80,14 @@ of 5, Neovim 0.12 with `-u NONE` (no config, no plugins: its fastest). lim must

(Linux in WSL, release build.)

Screenshots are real lim frames captured in a pseudo-terminal and rasterised.
## Screenshots

The README's screenshots and the gallery of [Themes and icons](themes.md) are real lim frames, drawn by the
renderer itself. `cargo test gallery -- --ignored` rewrites the gallery's SVGs in `docs/gallery`.
`cargo test screenshots -- --ignored` plays each README scene with real keys in a small demo project and writes its
frame as SVG to `target/screenshots`; run it on Linux (in WSL), so paths read `src/main.rs`. Each SVG is then
rasterised to `docs/screenshots/<scene>.png` at its own size by a headless browser, with a Nerd Font installed:

```powershell
msedge --headless --disable-gpu --hide-scrollbars --screenshot=docs\screenshots\editor.png --window-size=1080,612 target\screenshots\editor.svg
```
Binary file modified docs/screenshots/cheatsheet.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/cmdline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/completion.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/grep.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/picker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/themes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/tools.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/visual.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/whichkey.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 9 additions & 2 deletions src/buffer/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,16 @@ pub fn find_word(line: &str, word: &str) -> Vec<Range<usize>> {

/// URLs (`http://`, `https://`, `file://`) as char ranges, for `highlighturl`.
pub fn find_urls(line: &str) -> Vec<Range<usize>> {
// Runs on every shown line of every frame, and almost none holds a URL: one substring search rules it out.
if !line.contains("://") {
return Vec::new();
}
let chars: Vec<char> = line.chars().collect();
let mut urls: Vec<Range<usize>> = Vec::new();
let mut i: usize = 0;
while i < chars.len() {
let rest: String = chars[i..chars.len().min(i + 8)].iter().collect();
if ["https://", "http://", "file://"].iter().any(|p| rest.starts_with(p)) {
let at = |prefix: &str| prefix.chars().enumerate().all(|(k, c)| chars.get(i + k) == Some(&c));
if ["https://", "http://", "file://"].into_iter().any(at) {
let start: usize = i;
while i < chars.len() && !chars[i].is_whitespace() && !"\"'<>`".contains(chars[i]) {
i += 1;
Expand All @@ -180,6 +184,9 @@ pub fn find_urls(line: &str) -> Vec<Range<usize>> {

/// `#RRGGBB` color literals (highlight-colors) as char ranges with their value.
pub fn find_hex_colors(line: &str) -> Vec<(Range<usize>, u32)> {
if !line.contains('#') {
return Vec::new();
}
let chars: Vec<char> = line.chars().collect();
let mut found: Vec<(Range<usize>, u32)> = Vec::new();
for i in 0..chars.len() {
Expand Down
38 changes: 31 additions & 7 deletions src/editor/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,40 @@ fn is_word(c: char) -> bool {
/// Candidates for `prefix` from word lists (current buffer first), smartcase, best first.
pub fn candidates<'a>(prefix: &str, sources: impl Iterator<Item = &'a [String]>) -> Vec<String> {
let sensitive: bool = prefix.chars().any(char::is_uppercase);
// Plain ASCII on both sides compares bytes; anything else lowercases (the Kelvin sign is a `k`).
let ascii: bool = prefix.is_ascii();
// Compared char by char: lowercasing every word of every buffer on each key was the slow part.
let matches = |w: &str| {
if sensitive {
return w.starts_with(prefix);
}
if ascii && w.is_ascii() {
return w.len() >= prefix.len() && w.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes());
}
let mut word = w.chars().flat_map(char::to_lowercase);
prefix.chars().flat_map(char::to_lowercase).all(|p| word.next() == Some(p))
};
let mut seen: HashSet<String> = HashSet::new();
let mut found: Vec<(usize, String)> = Vec::new();
let mut seen: HashSet<&str> = HashSet::new();
// Sort key: same-case prefix first, then the current buffer, then shorter words, then the word.
let mut found: Vec<(bool, usize, usize, &str)> = Vec::new();
for (rank, words) in sources.enumerate() {
// A buffer's words are unique already (`words_of`): only the later buffers need checking.
if rank == 1 {
seen.extend(found.iter().map(|f| f.3));
}
for w in words.iter().filter(|w| w.as_str() != prefix && w.len() > prefix.len() && matches(w)) {
if seen.insert(w.clone()) {
found.push((rank, w.clone()));
if rank == 0 || seen.insert(w) {
found.push((!w.starts_with(prefix), rank, w.chars().count(), w));
}
}
}
// Same-case prefix first, then the current buffer, then shorter words.
found.sort_by_key(|(rank, w)| (!w.starts_with(prefix), *rank, w.chars().count(), w.clone()));
found.into_iter().map(|(_, w)| w).take(MAX_ITEMS).collect()
// The menu shows MAX_ITEMS: pick those, then sort only them (a big file matches thousands on each key).
if found.len() > MAX_ITEMS {
found.select_nth_unstable(MAX_ITEMS);
found.truncate(MAX_ITEMS);
}
found.sort_unstable();
found.into_iter().map(|(.., w)| w.to_owned()).collect()
}

impl Editor {
Expand Down Expand Up @@ -177,5 +191,15 @@ mod tests {
let found: Vec<String> = candidates("res", [here.as_slice(), other.as_slice()].into_iter());
assert_eq!(found, vec!["restock", "result", "resolve", "Restore"]);
assert_eq!(candidates("Res", [here.as_slice()].into_iter()), vec!["Restore"]);
// More matches than the menu holds: the same first ones, in order, as a full sort gives.
let many: Vec<String> = (0..500).rev().map(|i| format!("item{i}")).collect();
let shown: Vec<String> = candidates("it", [many.as_slice()].into_iter());
let mut all: Vec<String> = many.clone();
all.sort_by_key(|w| (w.len(), w.clone()));
assert_eq!(shown, all[..MAX_ITEMS]);
// Non-ASCII words still compare by lowercasing: the Kelvin sign is a `k`.
let kelvin: Vec<String> = vec!["\u{212a}elvin".into(), "kettle".into()];
assert_eq!(candidates("ke", [kelvin.as_slice()].into_iter()), vec!["kettle", "\u{212a}elvin"]);
assert_eq!(candidates("re", [here.as_slice(), here.as_slice()].into_iter()).len(), 3, "no duplicates");
}
}
2 changes: 1 addition & 1 deletion src/editor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ use crate::term::Key;
pub use cheatsheet::{Card, Cheatsheet};
use cmdhistory::CmdHistory;
pub use complete::{Completion, MENU_HEIGHT, words_of};
pub use explorer::{Explorer, GitStatus, WIDTH as EXPLORER_WIDTH};
pub use explorer::{Entry, Explorer, GitStatus, WIDTH as EXPLORER_WIDTH};
use motion::Motion;
pub use mouse::{Pane, TabClick, TabHit};
use operator::Operator;
Expand Down
115 changes: 64 additions & 51 deletions src/editor/picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,58 +336,10 @@ impl Corpus {
}

impl Editor {
/// The `<Leader>f…` pickers: lists what `kind` picks from, filtered as you type.
pub(super) fn open_picker(&mut self, kind: PickerKind) {
let items: Vec<Item> = match kind {
PickerKind::Files { all } => file_items(list_files(all)),
PickerKind::GitFiles => match git_files() {
Some(files) => file_items(files),
None => return self.info("Not a git repository"),
},
PickerKind::Grep { .. } => Vec::new(),
PickerKind::Buffers => self
.buffers
.iter()
.enumerate()
.map(|(i, b)| Item {
label: b.path.as_ref().map_or_else(|| b.name(), |p| p.display().to_string()),
target: Target::Buffer(i),
})
.collect(),
PickerKind::Recent => file_items(self.recent.iter().filter(|p| p.is_file()).cloned().collect()),
PickerKind::Keymaps => self.keymap_items(),
PickerKind::Commands => super::command::COMMANDS
.iter()
.copied()
.chain(self.config.commands.iter().map(|c| (c.name.as_str(), c.desc.as_str())))
.map(|(cmd, desc)| Item { label: format!("{cmd:<12}{desc}"), target: Target::Command(cmd.to_owned()) })
.collect(),
PickerKind::Lines => self
.buf()
.lines()
.iter()
.enumerate()
.filter(|(_, l)| !l.trim().is_empty())
.map(|(y, l)| Item { label: format!("{:>5} {}", y + 1, l.trim()), target: Target::Line(y) })
.collect(),
PickerKind::Smart => {
let mut seen: HashSet<PathBuf> = HashSet::new();
let open = self.buffers.iter().filter_map(|b| b.path.clone());
let recent = self.recent.iter().filter(|p| p.is_file()).cloned();
let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
let all: Vec<PathBuf> = open
.chain(recent)
.map(|p| p.strip_prefix(&cwd).map(Path::to_path_buf).unwrap_or(p))
.chain(list_files(false))
.filter(|p| seen.insert(p.clone()))
.collect();
file_items(all)
}
PickerKind::Todos => todo_items(&list_files(false)),
other => match self.extra_items(other) {
Some(items) => items,
None => return,
},
};
let Some(items): Option<Vec<Item>> = self.picker_items(kind) else { return };
// Live grep searches as you type: it starts with the files, not with items.
let files: Vec<PathBuf> = match kind {
PickerKind::Grep { all } => list_files(all),
_ => Vec::new(),
Expand All @@ -410,6 +362,67 @@ impl Editor {
self.selection_changed();
}

/// What picker `kind` lists; None when it cannot open (outside a git repository, for the git files).
fn picker_items(&mut self, kind: PickerKind) -> Option<Vec<Item>> {
Some(match kind {
PickerKind::Files { all } => file_items(list_files(all)),
PickerKind::GitFiles => {
let Some(files): Option<Vec<PathBuf>> = git_files() else {
self.info("Not a git repository");
return None;
};
file_items(files)
}
PickerKind::Grep { .. } => Vec::new(),
PickerKind::Buffers => self.buffer_items(),
PickerKind::Recent => file_items(self.recent.iter().filter(|p| p.is_file()).cloned().collect()),
PickerKind::Keymaps => self.keymap_items(),
PickerKind::Commands => self.command_items(),
PickerKind::Lines => self.line_items(),
PickerKind::Smart => self.smart_items(),
PickerKind::Todos => todo_items(&list_files(false)),
other => self.extra_items(other)?,
})
}

/// The open buffers, by path (or name for an unnamed one).
fn buffer_items(&self) -> Vec<Item> {
let label = |b: &Buffer| b.path.as_ref().map_or_else(|| b.name(), |p| p.display().to_string());
self.buffers.iter().enumerate().map(|(i, b)| Item { label: label(b), target: Target::Buffer(i) }).collect()
}

/// The Ex commands lim knows, then the config's own, each with what it does.
fn command_items(&self) -> Vec<Item> {
let own = self.config.commands.iter().map(|c| (c.name.as_str(), c.desc.as_str()));
super::command::COMMANDS
.iter()
.copied()
.chain(own)
.map(|(cmd, desc)| Item { label: format!("{cmd:<12}{desc}"), target: Target::Command(cmd.to_owned()) })
.collect()
}

/// The current buffer's lines that are not blank, numbered.
fn line_items(&self) -> Vec<Item> {
let lines = self.buf().lines().iter().enumerate().filter(|(_, l)| !l.trim().is_empty());
lines.map(|(y, l)| Item { label: format!("{:>5} {}", y + 1, l.trim()), target: Target::Line(y) }).collect()
}

/// snacks' smart picker: open buffers, then recent files, then the project's, each file once.
fn smart_items(&self) -> Vec<Item> {
let mut seen: HashSet<PathBuf> = HashSet::new();
let open = self.buffers.iter().filter_map(|b| b.path.clone());
let recent = self.recent.iter().filter(|p| p.is_file()).cloned();
let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
let all: Vec<PathBuf> = open
.chain(recent)
.map(|p| p.strip_prefix(&cwd).map(Path::to_path_buf).unwrap_or(p))
.chain(list_files(false))
.filter(|p| seen.insert(p.clone()))
.collect();
file_items(all)
}

/// `<Leader>fc`: grep for the word under the cursor.
pub(super) fn grep_word(&mut self) {
let buf: &Buffer = self.buf();
Expand Down
Loading
Loading