emma
/
breed
Archived
forked from mars/breed
1
0
Fork 0

Compare commits

...

11 Commits

Author SHA1 Message Date
Emma Tebibyte c06b39239c Merge remote-tracking branch 'upstream/main' 2023-04-12 11:55:36 -04:00
mars b048eba60c Merge pull request 'o-insert' (#5) from roux/breed:o-insert into main
Reviewed-on: mars/breed#5
2023-04-12 04:21:45 +00:00
Roux 8cb37a7820 Capital O insert mode 2023-04-12 00:19:01 -04:00
Roux badd6a0b71 fixed outdated code 2023-04-12 00:10:32 -04:00
Roux 36bbb3b77b resolve merge conflict 2023-04-12 00:04:50 -04:00
mars 5022408e65 Make Cursor and Direction pub to avoid warnings 2023-04-12 00:01:53 -04:00
mars cc1debc051 Merge branch 'main' of https://git.tebibyte.media/mars/breed 2023-04-11 23:57:29 -04:00
mars 31a1cf961e Move Buffer to new module 2023-04-11 23:55:56 -04:00
Roux f5ea473328 cargo fmt 2023-04-11 23:55:33 -04:00
Roux 8a110c5b01 Insert: add o-insert with newline 2023-04-11 23:54:42 -04:00
mars 1f9ba13620 Add basic theming 2023-04-11 23:34:24 -04:00
6 changed files with 564 additions and 112 deletions

76
Cargo.lock generated
View File

@ -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"

View File

@ -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"

3
default_theme.toml Normal file
View File

@ -0,0 +1,3 @@
"ui.linenr" = "gray"
"error" = "red"

138
src/buffer.rs Normal file
View File

@ -0,0 +1,138 @@
/*
* Copyright (c) 2023 Marceline Cramer
* Copyright (c) 2023 Emma Tebibyte <emma@tebibyte.media>
* 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 {
pub 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<u32> {
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;
}
}
}
}
}

View File

@ -36,109 +36,16 @@ use crossterm::{
event::{read, Event, KeyCode, KeyEvent},
terminal, ExecutableCommand, Result,
};
use ropey::Rope;
use yacexits::{ exit, EX_DATAERR, EX_UNAVAILABLE };
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<u32> {
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,
}
@ -186,7 +93,7 @@ impl Mode {
}
#[derive(Copy, Clone, Debug)]
enum Direction {
pub enum Direction {
Left,
Down,
Up,
@ -194,6 +101,7 @@ enum Direction {
}
struct State {
pub style_store: StyleStore,
pub buffer: Buffer,
pub cursor: Cursor,
pub file: Option<OsString>,
@ -208,6 +116,7 @@ impl State {
let (cols, rows) = terminal::size()?;
Ok(Self {
style_store: StyleStore::default(),
buffer: Buffer::from_str(text),
cursor: Cursor::default(),
file: file_name,
@ -218,7 +127,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)?;
@ -238,8 +147,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;
}
_ => {}
@ -247,7 +157,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(|| {
@ -291,6 +203,19 @@ impl State {
self.move_cursor(Direction::Right);
self.mode = Mode::Insert(state);
}
KeyCode::Char('o') => {
let state = InsertState { append: false };
self.cursor.line += 1;
self.cursor.column = 0;
self.buffer.insert_char(self.cursor, '\n');
self.mode = Mode::Insert(state);
}
KeyCode::Char('O') => {
let state = InsertState { append: false };
self.cursor.column = 0;
self.buffer.insert_char(self.cursor, '\n');
self.mode = Mode::Insert(state);
}
KeyCode::Char(':') => {
self.mode = Mode::Command(Default::default());
}
@ -361,22 +286,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;
}
@ -530,7 +451,8 @@ fn main() -> Result<()> {
let text = String::from_utf8(buf).unwrap_or_else(|_| {
eprintln!(
"{}: {}: File contents are not valid UTF-8.", argv[0], argv[1]
"{}: {}: File contents are not valid UTF-8.",
argv[0], argv[1]
);
exit(EX_DATAERR);
});

311
src/theme.rs Normal file
View File

@ -0,0 +1,311 @@
/*
* Copyright (c) 2023 Marceline Cramer
* Copyright (c) 2023 Emma Tebibyte <emma@tebibyte.media>
* 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<Theme> = 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<String, usize>,
/// The style data store.
styles: Vec<Style>,
}
impl Default for StyleStore {
fn default() -> Self {
let theme = Lazy::force(&DEFAULT_THEME).to_owned();
Self::new(theme)
}
}
impl StyleStore {
/// Creates a new style store with a given theme.
pub fn new(theme: Theme) -> Self {
Self {
theme,
generation: 0,
scopes: HashMap::new(),
styles: Vec::new(),
}
}
/// Adds a scope to this store and returns its index. Reuses indices for existing scopes.
pub fn add_scope(&mut self, scope: &str) -> usize {
let style = self.theme.get_scope_style(scope);
if let Some(style_idx) = self.scopes.get(scope).copied() {
let old_style = self
.styles
.get_mut(style_idx)
.expect("StyleStore index is out-of-bounds");
let _ = std::mem::replace(old_style, style);
style_idx
} else {
let style_idx = self.styles.len();
self.styles.push(style);
self.scopes.insert(scope.to_string(), style_idx);
style_idx
}
}
/// Gets the style for a scope by name.
pub fn get_scope(&mut self, scope: &str) -> Style {
let idx = self.add_scope(scope);
self.get_style(idx)
}
/// Gets the style for a scope index. Panics if out-of-bounds.
pub fn get_style(&self, index: usize) -> Style {
self.styles
.get(index)
.expect("StyleStore is out-of-bounds")
.to_owned()
}
/// The current generation of the store. Increments every update.
pub fn generation(&self) -> usize {
self.generation
}
/// Applies a theme to this store and updates the generation.
pub fn apply(&mut self, theme: Theme) {
self.generation += 1;
self.theme = theme;
for (scope, style_idx) in self.scopes.iter() {
let new_style = self.theme.get_scope_style(scope);
let old_style = self.styles.get_mut(*style_idx).unwrap();
let _ = std::mem::replace(old_style, new_style);
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Style {
pub fg: Option<Color>,
pub bg: Option<Color>,
}
impl Style {
pub fn print_styled(
&self,
out: &mut (impl ExecutableCommand + Write),
content: &str,
) -> crossterm::Result<()> {
use crossterm::style::{ResetColor, SetBackgroundColor, SetForegroundColor};
if let Some(fg) = self.fg {
out.execute(SetForegroundColor(fg))?;
}
if let Some(bg) = self.bg {
out.execute(SetBackgroundColor(bg))?;
}
write!(out, "{}", content)?;
out.execute(ResetColor)?;
Ok(())
}
}
#[derive(Clone, Debug, Default)]
pub struct Theme {
pub name: String,
pub styles: HashMap<String, Style>,
}
impl Theme {
pub fn from_value(name: &str, value: Value) -> Result<Self, String> {
if let Value::Table(table) = value {
Self::from_map(name, table)
} else {
panic!("Expected theme TOML value to be a table, found {:?}", value);
}
}
pub fn from_map(name: &str, mut values: Map<String, Value>) -> Result<Self, String> {
let palette = values
.remove("palette")
.map(Palette::try_from)
.unwrap_or_else(|| Ok(Palette::default()))?;
let mut styles = HashMap::with_capacity(values.len());
for (name, style) in values {
let style = palette.parse_style(style)?;
styles.insert(name.to_string(), style);
}
Ok(Self {
name: name.to_string(),
styles,
})
}
/// Gets a style for a scope, matching the longest available scope.
///
/// If no match is found, defaults to the default [Style].
pub fn get_scope_style(&self, scope: &str) -> Style {
let mut longest = Style::default();
let mut longest_len = 0;
for (target, style) in self.styles.iter() {
if target.len() > longest_len && scope.starts_with(target) {
longest = *style;
longest_len = target.len();
}
}
longest
}
}
pub struct Palette {
pub palette: HashMap<String, Color>,
}
impl Default for Palette {
fn default() -> Self {
use Color::*;
let pairs = &[
("black", Black),
("red", DarkRed),
("green", DarkGreen),
("yellow", DarkYellow),
("blue", DarkBlue),
("magenta", DarkMagenta),
("cyan", DarkCyan),
("gray", DarkGrey),
("grey", DarkGrey),
("light-red", Red),
("light-green", Green),
("light-yellow", Yellow),
("light-blue", Blue),
("light-magenta", Magenta),
("light-cyan", Cyan),
("light-gray", Grey),
("light-grey", Grey),
("white", White),
];
Self {
palette: HashMap::from_iter(
pairs
.into_iter()
.map(|(name, color)| (name.to_string(), *color)),
),
}
}
}
impl TryFrom<Value> for Palette {
type Error = String;
fn try_from(value: Value) -> Result<Self, String> {
let values = value.as_table().ok_or(format!(
"Theme: expected table for palette but got: {:?}",
value
))?;
let default = Self::default();
let mut palette = HashMap::with_capacity(values.len());
for (name, value) in values {
let color = default.parse_color(value)?;
palette.insert(name.to_string(), color);
}
Ok(Self { palette })
}
}
impl Palette {
pub fn hex_string_to_rgb(s: &str) -> Result<Color, String> {
if s.starts_with('#') && s.len() == 7 {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&s[1..3], 16),
u8::from_str_radix(&s[3..5], 16),
u8::from_str_radix(&s[5..7], 16),
) {
return Ok(Color::Rgb { r, g, b });
}
}
Err(format!("Theme: malformed hexcode: {}", s))
}
pub fn parse_value_as_str(value: &Value) -> Result<&str, String> {
value
.as_str()
.ok_or(format!("Theme: expected string but got: {:?}", value))
}
pub fn parse_color(&self, value: &Value) -> Result<Color, String> {
let str = Self::parse_value_as_str(&value)?;
self.palette
.get(str)
.copied()
.ok_or("")
.or_else(|_| Self::hex_string_to_rgb(&str))
}
pub fn parse_style_map(&self, entries: Map<String, Value>) -> Result<Style, String> {
let mut style = Style::default();
for (name, value) in entries {
match name.as_str() {
"fg" => style.fg = Some(self.parse_color(&value)?),
"bg" => style.bg = Some(self.parse_color(&value)?),
_ => return Err(format!("Theme: invalid style attribute: {}", name)),
}
}
Ok(style)
}
pub fn parse_style(&self, value: Value) -> Result<Style, String> {
if let Value::Table(entries) = value {
self.parse_style_map(entries)
} else {
let mut style = Style::default();
style.fg = Some(self.parse_color(&value)?);
Ok(style)
}
}
}