diff --git a/Cargo.lock b/Cargo.lock index eb031fb..3b37b28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,7 +63,9 @@ dependencies = [ "arg", "crossterm", "libc", + "once_cell", "ropey", + "toml", "yacexits", ] @@ -130,6 +132,22 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -310,6 +328,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "serde" +version = "1.0.160" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2f3770c8bce3bcda7e149193a069a0f4365bda1fa5cd88e03bca26afc1216c" + +[[package]] +name = "serde_spanned" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4" +dependencies = [ + "serde", +] + [[package]] name = "shlex" version = "1.1.0" @@ -378,6 +411,40 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "toml" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b403acf6f2bb0859c93c7f0d967cb4a75a7ac552100f9322faf64dc047669b21" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "unicode-ident" version = "1.0.8" @@ -495,6 +562,15 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "winnow" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8970b36c66498d8ff1d66685dc86b91b29db0c7739899012f63a63814b4b28" +dependencies = [ + "memchr", +] + [[package]] name = "yacexits" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index 9ec3ecf..aca0966 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,5 +8,7 @@ license = "AGPL-3.0-or-later" arg = "0.4.1" crossterm = "0.26" libc = "0.2.141" +once_cell = "1.17" ropey = "1.6" +toml = "0.7" yacexits = "0.1.5" diff --git a/default_theme.toml b/default_theme.toml new file mode 100644 index 0000000..be3ce26 --- /dev/null +++ b/default_theme.toml @@ -0,0 +1,3 @@ +"ui.linenr" = "gray" + +"error" = "red" diff --git a/src/buffer.rs b/src/buffer.rs new file mode 100644 index 0000000..37d6d30 --- /dev/null +++ b/src/buffer.rs @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2023 Marceline Cramer + * Copyright (c) 2023 Emma Tebibyte + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +use std::io::Write; + +use crossterm::{cursor, ExecutableCommand}; +use ropey::Rope; + +use crate::theme::StyleStore; +use crate::{Cursor, Direction}; + +#[derive(Clone, Debug)] +pub struct Buffer { + text: Rope, +} + +impl Buffer { + pub fn from_str(text: &str) -> Self { + Self { + text: Rope::from_str(text), + } + } + + pub fn draw( + &self, + styles: &mut StyleStore, + cols: u16, + rows: u16, + scroll: Cursor, + out: &mut (impl ExecutableCommand + Write), + ) -> crossterm::Result { + let linenr_style = styles.get_scope("ui.linenr"); + let linenr_width = self.text.len_lines().ilog10() + 1; + let gutter_width = linenr_width + 1; + let text_width = cols as usize - gutter_width as usize; + + out.execute(cursor::MoveTo(0, 0))?; + + for (row, line) in (0..rows).zip(self.text.lines_at(scroll.line)) { + // only the last line is empty and should be skipped + if line.len_chars() == 0 { + break; + } + + let row = row as usize + scroll.line; + let linenr = format!("{:width$} ", row, width = linenr_width as usize); + linenr_style.print_styled(out, &linenr)?; + + let lhs = scroll.column; + let width = line.len_chars().saturating_sub(1); // lop off whitespace + if lhs < width { + let window = text_width.min(width - lhs); + let rhs = lhs + window; + write!(out, "{}", line.slice(lhs..rhs))?; + } + + out.execute(cursor::MoveToNextLine(1))?; + } + + Ok(gutter_width) + } + + pub fn remove(&mut self, cursor: Cursor) { + let index = self.cursor_to_char(cursor); + self.text.remove(index..=index); + } + + pub fn insert_char(&mut self, cursor: Cursor, c: char) { + let index = self.cursor_to_char(cursor); + self.text.insert_char(index, c); + } + + pub fn clamped_cursor(&self, cursor: Cursor) -> Cursor { + Cursor { + line: cursor.line, + column: cursor + .column + .min(self.text.line(cursor.line).len_chars() - 1), + } + } + + pub fn cursor_to_char(&self, cursor: Cursor) -> usize { + let cursor = self.clamped_cursor(cursor); + self.text.line_to_char(cursor.line) + cursor.column + } + + pub fn move_cursor(&self, cursor: &mut Cursor, direction: Direction, enable_linewrap: bool) { + *cursor = self.clamped_cursor(*cursor); + match direction { + Direction::Left => { + if cursor.column > 0 { + cursor.column -= 1; + } else if enable_linewrap && cursor.line > 0 { + cursor.line -= 1; + let line = self.text.line(cursor.line); + cursor.column = line.len_chars() - 1; + } + } + Direction::Down => { + if cursor.line + 2 < self.text.len_lines() { + cursor.line += 1; + } + } + Direction::Up => { + if cursor.line > 0 { + cursor.line -= 1; + } + } + Direction::Right => { + let line = self.text.line(cursor.line); + if cursor.column + 2 > line.len_chars() { + if enable_linewrap && cursor.line + 2 < self.text.len_lines() { + cursor.line += 1; + cursor.column = 0; + } + } else { + cursor.column += 1; + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index e31ca77..6a41a01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,109 +29,16 @@ use crossterm::{ event::{read, Event, KeyCode, KeyEvent}, terminal, ExecutableCommand, Result, }; -use ropey::Rope; use yacexits::{exit, EX_DATAERR, EX_UNAVAILABLE}; -struct Buffer { - pub text: Rope, -} +mod buffer; +mod theme; -impl Buffer { - pub fn from_str(text: &str) -> Self { - Self { - text: Rope::from_str(text), - } - } - - pub fn draw( - &self, - cols: u16, - rows: u16, - scroll: Cursor, - out: &mut (impl ExecutableCommand + Write), - ) -> Result { - let lr_width = self.text.len_lines().ilog10() + 1; - let gutter_width = lr_width + 1; - let text_width = cols as usize - gutter_width as usize; - - out.execute(cursor::MoveTo(0, 0))?; - - for (row, line) in (0..rows).zip(self.text.lines_at(scroll.line)) { - // only the last line is empty and should be skipped - if line.len_chars() == 0 { - break; - } - - let row = row as usize + scroll.line; - write!(out, "{:width$} ", row, width = lr_width as usize)?; - - let lhs = scroll.column; - let width = line.len_chars().saturating_sub(1); // lop off whitespace - if lhs < width { - let window = text_width.min(width - lhs); - let rhs = lhs + window; - write!(out, "{}", line.slice(lhs..rhs))?; - } - - out.execute(cursor::MoveToNextLine(1))?; - } - - Ok(gutter_width) - } - - pub fn clamped_cursor(&self, cursor: Cursor) -> Cursor { - Cursor { - line: cursor.line, - column: cursor - .column - .min(self.text.line(cursor.line).len_chars() - 1), - } - } - - pub fn cursor_to_char(&self, cursor: Cursor) -> usize { - let cursor = self.clamped_cursor(cursor); - self.text.line_to_char(cursor.line) + cursor.column - } - - pub fn move_cursor(&self, cursor: &mut Cursor, direction: Direction, enable_linewrap: bool) { - *cursor = self.clamped_cursor(*cursor); - match direction { - Direction::Left => { - if cursor.column > 0 { - cursor.column -= 1; - } else if enable_linewrap && cursor.line > 0 { - cursor.line -= 1; - let line = self.text.line(cursor.line); - cursor.column = line.len_chars() - 1; - } - } - Direction::Down => { - if cursor.line + 2 < self.text.len_lines() { - cursor.line += 1; - } - } - Direction::Up => { - if cursor.line > 0 { - cursor.line -= 1; - } - } - Direction::Right => { - let line = self.text.line(cursor.line); - if cursor.column + 2 > line.len_chars() { - if enable_linewrap && cursor.line + 2 < self.text.len_lines() { - cursor.line += 1; - cursor.column = 0; - } - } else { - cursor.column += 1; - } - } - } - } -} +use buffer::Buffer; +use theme::StyleStore; #[derive(Copy, Clone, Debug, Default)] -struct Cursor { +pub struct Cursor { pub column: usize, pub line: usize, } @@ -179,7 +86,7 @@ impl Mode { } #[derive(Copy, Clone, Debug)] -enum Direction { +pub enum Direction { Left, Down, Up, @@ -187,6 +94,7 @@ enum Direction { } struct State { + pub style_store: StyleStore, pub buffer: Buffer, pub cursor: Cursor, pub scroll: Cursor, @@ -200,6 +108,7 @@ impl State { let (cols, rows) = terminal::size()?; Ok(Self { + style_store: StyleStore::default(), buffer: Buffer::from_str(text), cursor: Cursor::default(), scroll: Cursor::default(), @@ -209,7 +118,7 @@ impl State { }) } - pub fn draw(&self, out: &mut impl Write) -> Result<()> { + pub fn draw(&mut self, out: &mut impl Write) -> Result<()> { // begin update let (cols, rows) = terminal::size()?; out.execute(terminal::BeginSynchronizedUpdate)?; @@ -229,8 +138,9 @@ impl State { show_status_bar = true; } Mode::Normal(NormalState { error: Some(error) }) => { + let error_style = self.style_store.get_scope("error"); out.execute(cursor::MoveTo(0, rows - 1))?; - write!(out, "{}", error)?; + error_style.print_styled(out, error)?; show_status_bar = true; } _ => {} @@ -238,7 +148,9 @@ impl State { // draw buffer let buffer_rows = if show_status_bar { rows - 1 } else { rows }; - let lr_width = self.buffer.draw(cols, buffer_rows, self.scroll, out)?; + let lr_width = + self.buffer + .draw(&mut self.style_store, cols, buffer_rows, self.scroll, out)?; // draw cursor let cursor_pos = set_cursor_pos.unwrap_or_else(|| { @@ -360,22 +272,18 @@ impl State { match event { Event::Key(KeyEvent { code, .. }) => match code { KeyCode::Char(c) => { - let index = self.buffer.cursor_to_char(self.cursor); - self.buffer.text.insert_char(index, c); + self.buffer.insert_char(self.cursor, c); self.move_cursor(Direction::Right) } KeyCode::Backspace => { self.move_cursor(Direction::Left); - let index = self.buffer.cursor_to_char(self.cursor); - self.buffer.text.remove(index..=index); + self.buffer.remove(self.cursor); } KeyCode::Delete => { - let index = self.buffer.cursor_to_char(self.cursor); - self.buffer.text.remove(index..=index); + self.buffer.remove(self.cursor); } KeyCode::Enter => { - let index = self.buffer.cursor_to_char(self.cursor); - self.buffer.text.insert_char(index, '\n'); + self.buffer.insert_char(self.cursor, '\n'); self.cursor.line += 1; self.cursor.column = 0; } diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..5235321 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2023 Marceline Cramer + * Copyright (c) 2023 Emma Tebibyte + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +use std::collections::HashMap; +use std::io::Write; + +use crossterm::{style::Color, ExecutableCommand}; +use once_cell::sync::Lazy; +use toml::{map::Map, Value}; + +pub static DEFAULT_THEME: Lazy = Lazy::new(|| { + let text = include_str!("../default_theme.toml"); + let value = toml::from_str(text).expect("Failed to parse default theme"); + Theme::from_value("default", value).expect("Failed to load default theme") +}); + +#[derive(Debug)] +pub struct StyleStore { + /// The current theme. + theme: Theme, + + /// The current generation of the store. Increments every update. + generation: usize, + + /// Maps named scopes to indices. + scopes: HashMap, + + /// The style data store. + styles: Vec