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
+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
}
}