Add basic theming

This commit is contained in:
mars 2023-04-11 23:34:24 -04:00
parent d7f8ccad2a
commit 1f9ba13620
5 changed files with 421 additions and 25 deletions

76
Cargo.lock generated
View File

@ -62,7 +62,9 @@ version = "0.1.0"
dependencies = [
"arg",
"crossterm",
"once_cell",
"ropey",
"toml",
"yacexits",
]
@ -129,6 +131,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"
@ -309,6 +327,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"
@ -377,6 +410,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"
@ -494,6 +561,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

@ -7,5 +7,7 @@ license = "AGPL-3.0-or-later"
[dependencies]
arg = "0.4.1"
crossterm = "0.26"
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"

View File

@ -20,12 +20,7 @@
use std::{
env::args,
fs::File,
io::{
Read,
stdout,
Stdout,
Write,
},
io::{stdout, Read, Stdout, Write},
os::fd::FromRawFd,
};
@ -35,7 +30,11 @@ use crossterm::{
terminal, ExecutableCommand, Result,
};
use ropey::Rope;
use yacexits::{ exit, EX_DATAERR, EX_UNAVAILABLE };
use yacexits::{exit, EX_DATAERR, EX_UNAVAILABLE};
mod theme;
use theme::StyleStore;
struct Buffer {
pub text: Rope,
@ -50,13 +49,15 @@ impl Buffer {
pub fn draw(
&self,
styles: &mut StyleStore,
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 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))?;
@ -68,7 +69,8 @@ impl Buffer {
}
let row = row as usize + scroll.line;
write!(out, "{:width$} ", row, width = lr_width as usize)?;
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
@ -192,6 +194,7 @@ enum Direction {
}
struct State {
pub style_store: StyleStore,
pub buffer: Buffer,
pub cursor: Cursor,
pub scroll: Cursor,
@ -205,6 +208,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(),
@ -214,7 +218,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)?;
@ -234,8 +238,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;
}
_ => {}
@ -243,7 +248,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(|| {
@ -452,21 +459,18 @@ fn main() -> Result<()> {
match argv.get(1).map(|s| s.as_str()) {
Some("-") | None => unsafe { File::from_raw_fd(0) }, // stdin as a file
Some(path) => {
std::fs::File::open(path).unwrap_or_else(|_| {
eprintln!(
"{}: {}: No such file or directory.",
argv[0],
argv[1]
);
exit(EX_UNAVAILABLE);
})
},
}.read_to_end(&mut buf).unwrap();
Some(path) => std::fs::File::open(path).unwrap_or_else(|_| {
eprintln!("{}: {}: No such file or directory.", argv[0], argv[1]);
exit(EX_UNAVAILABLE);
}),
}
.read_to_end(&mut buf)
.unwrap();
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)
}
}
}