Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
817 files changed
+143101
No files matched your search
@@ -0,0 +1,134 @@
|
||||
use ely_gpui_component::theme::{Mode, Theme};
|
||||
use ely_platform::settings::{Appearance, Shared};
|
||||
use ely_platform::{accessibility, env, omarchy_current_dir};
|
||||
use gpui::{App, Global, Window, WindowAppearance};
|
||||
|
||||
pub mod omarchy;
|
||||
mod watch;
|
||||
|
||||
use omarchy::OmarchyTheme;
|
||||
|
||||
/// `omarchy_sync` lets System follow Omarchy where present.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ThemeSettings {
|
||||
pub appearance: Appearance,
|
||||
pub omarchy_sync: bool,
|
||||
}
|
||||
|
||||
impl ThemeSettings {
|
||||
/// The settings, then `ELY_APPEARANCE` and `ELY_OMARCHY_SYNC` over them.
|
||||
fn of(shared: &Shared) -> Self {
|
||||
let appearance = match env::value("ELY_APPEARANCE").as_deref() {
|
||||
Some("light") => Appearance::Light,
|
||||
Some("dark") => Appearance::Dark,
|
||||
Some("system") => Appearance::System,
|
||||
None => shared.appearance,
|
||||
Some(other) => {
|
||||
log::warn!("theme: ELY_APPEARANCE={other} is not system, light or dark, ignored");
|
||||
shared.appearance
|
||||
}
|
||||
};
|
||||
Self {
|
||||
appearance,
|
||||
omarchy_sync: env::flag("ELY_OMARCHY_SYNC").unwrap_or(shared.omarchy_sync),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Global for ThemeSettings {}
|
||||
|
||||
/// The theme Omarchy last gave us.
|
||||
#[derive(Default)]
|
||||
struct OmarchyState(Option<OmarchyTheme>);
|
||||
|
||||
impl Global for OmarchyState {}
|
||||
|
||||
/// Applies motion and appearance settings, and starts following Omarchy.
|
||||
pub fn install(cx: &mut App) {
|
||||
motion(cx);
|
||||
cx.set_global(ThemeSettings::of(crate::settings::current(cx)));
|
||||
cx.set_global(OmarchyState::default());
|
||||
match omarchy_current_dir() {
|
||||
Some(dir) => watch::start(dir, cx),
|
||||
None => log::info!("theme: no Omarchy on this machine"),
|
||||
}
|
||||
apply(cx, true);
|
||||
}
|
||||
|
||||
/// Follows changed settings: motion, appearance and Omarchy sync.
|
||||
pub(crate) fn settings_changed(cx: &mut App) {
|
||||
motion(cx);
|
||||
cx.set_global(ThemeSettings::of(crate::settings::current(cx)));
|
||||
apply(cx, false);
|
||||
}
|
||||
|
||||
fn motion(cx: &mut App) {
|
||||
let reduce = accessibility::reduce_motion(crate::settings::current(cx).motion);
|
||||
cx.set_reduce_motion(reduce);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = reduce);
|
||||
log::info!("theme: reduced motion {}", if reduce { "on" } else { "off" });
|
||||
}
|
||||
|
||||
/// Re-applies the theme when the system appearance changes.
|
||||
pub fn follow_system(window: &mut Window) {
|
||||
window
|
||||
.observe_window_appearance(|_, cx| apply(cx, false))
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn apply(cx: &mut App, at_once: bool) {
|
||||
let settings = cx.global::<ThemeSettings>().clone();
|
||||
let omarchy = cx.global::<OmarchyState>().0.clone();
|
||||
let set_mode = |mode: Mode, cx: &mut App| {
|
||||
if at_once {
|
||||
Theme::set_mode_now(mode, cx);
|
||||
} else {
|
||||
Theme::set_mode(mode, cx);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(theme) =
|
||||
omarchy.filter(|_| settings.appearance == Appearance::System && settings.omarchy_sync)
|
||||
{
|
||||
let other = if theme.mode == Mode::Dark {
|
||||
Mode::Light
|
||||
} else {
|
||||
Mode::Dark
|
||||
};
|
||||
Theme::set_palette(theme.mode, Some(theme.palette.clone()), cx);
|
||||
Theme::set_palette(other, None, cx);
|
||||
cx.set_window_appearance(Some(window_appearance(theme.mode)));
|
||||
set_mode(theme.mode, cx);
|
||||
log::info!(
|
||||
"theme: omarchy {} applied, {:?}",
|
||||
theme.name.as_deref().unwrap_or("unnamed"),
|
||||
theme.mode
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Theme::set_palette(Mode::Light, None, cx);
|
||||
Theme::set_palette(Mode::Dark, None, cx);
|
||||
let mode = match settings.appearance {
|
||||
Appearance::System => {
|
||||
cx.set_window_appearance(None);
|
||||
Mode::from(cx.window_appearance())
|
||||
}
|
||||
Appearance::Light => {
|
||||
cx.set_window_appearance(Some(WindowAppearance::Light));
|
||||
Mode::Light
|
||||
}
|
||||
Appearance::Dark => {
|
||||
cx.set_window_appearance(Some(WindowAppearance::Dark));
|
||||
Mode::Dark
|
||||
}
|
||||
};
|
||||
set_mode(mode, cx);
|
||||
}
|
||||
|
||||
fn window_appearance(mode: Mode) -> WindowAppearance {
|
||||
match mode {
|
||||
Mode::Light => WindowAppearance::Light,
|
||||
Mode::Dark => WindowAppearance::Dark,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use ely_gpui_component::theme::{Mix, Mode};
|
||||
use gpui::{Hsla, hsla, rgb};
|
||||
|
||||
const WHITE: Hsla = hsla(0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
pub(super) const HUE_NAMES: [&str; 6] = ["red", "green", "yellow", "blue", "magenta", "cyan"];
|
||||
|
||||
const HUE_KEYS: [&[&str]; 6] = [
|
||||
&["red", "color1"],
|
||||
&["green", "color2"],
|
||||
&["yellow", "color3"],
|
||||
&["blue", "color4"],
|
||||
&["magenta", "color5", "purple"],
|
||||
&["cyan", "color6"],
|
||||
];
|
||||
|
||||
const BRIGHT_KEYS: [&[&str]; 6] = [
|
||||
&["bright_red", "color9"],
|
||||
&["bright_green", "color10"],
|
||||
&["bright_yellow", "color11"],
|
||||
&["bright_blue", "color12"],
|
||||
&["bright_magenta", "color13", "bright_purple"],
|
||||
&["bright_cyan", "color14"],
|
||||
];
|
||||
|
||||
/// A theme's colors, resolved as `omarchy-theme-color` does.
|
||||
pub(super) struct Colors {
|
||||
pub mode: Mode,
|
||||
pub bg: Hsla,
|
||||
pub fg: Hsla,
|
||||
pub bright_fg: Hsla,
|
||||
pub accent: Option<Hsla>,
|
||||
pub selection: Hsla,
|
||||
/// In `HUE_NAMES` order; none if the theme lacks it.
|
||||
pub hues: [Option<Hsla>; 6],
|
||||
pub bright: [Option<Hsla>; 6],
|
||||
pub orange: Option<Hsla>,
|
||||
}
|
||||
|
||||
struct Raw(BTreeMap<String, String>);
|
||||
|
||||
impl Raw {
|
||||
/// Reads lines the way Omarchy does.
|
||||
fn parse(text: &str) -> Self {
|
||||
let mut pairs = BTreeMap::new();
|
||||
for line in text.lines() {
|
||||
let (key, value) = line.split_once('=').unwrap_or((line, ""));
|
||||
let key = key.replace(['"', '\'', ' '], "");
|
||||
if key.is_empty() || key.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if !key
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
log::warn!(
|
||||
"theme: omarchy colors skip '{key}', its name has unsupported characters"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let value = match value.find(['"', '\'']) {
|
||||
Some(open) => {
|
||||
let rest = &value[open + 1..];
|
||||
rest[..rest.find(['"', '\'']).unwrap_or(rest.len())].to_string()
|
||||
}
|
||||
None => value.trim().to_string(),
|
||||
};
|
||||
if !value
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "#(),._+/% -".contains(c))
|
||||
{
|
||||
log::warn!(
|
||||
"theme: omarchy colors skip '{key}', its value has unsupported characters"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
pairs.insert(key, value);
|
||||
}
|
||||
Self(pairs)
|
||||
}
|
||||
|
||||
fn text(&self, key: &str) -> Option<&str> {
|
||||
self.0
|
||||
.get(key)
|
||||
.map(String::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// The first of `keys` the theme sets, as a color.
|
||||
fn pick(&self, keys: &[&str]) -> Result<Option<Hsla>> {
|
||||
let Some(key) = keys.iter().find(|key| self.text(key).is_some()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
hex(self.text(key).unwrap_or_default())
|
||||
.map(Some)
|
||||
.with_context(|| format!("key '{key}'"))
|
||||
}
|
||||
|
||||
fn need(&self, keys: &[&str]) -> Result<Hsla> {
|
||||
self.pick(keys)?
|
||||
.with_context(|| format!("'{}' is missing", keys[0]))
|
||||
}
|
||||
}
|
||||
|
||||
/// `light_file`: the theme directory holds a `light.mode` file.
|
||||
pub(super) fn read(text: &str, light_file: bool) -> Result<Colors> {
|
||||
let raw = Raw::parse(text);
|
||||
let bg = raw.need(&["background", "bg", "color0"])?;
|
||||
let fg = raw.need(&["foreground", "fg", "color7"])?;
|
||||
let mut hues = [None; 6];
|
||||
let mut bright = [None; 6];
|
||||
for i in 0..6 {
|
||||
hues[i] = raw.pick(HUE_KEYS[i])?;
|
||||
bright[i] = match raw.pick(BRIGHT_KEYS[i])? {
|
||||
Some(color) => Some(color),
|
||||
None => hues[i].map(|hue| hue.mix(&WHITE, 0.2)),
|
||||
};
|
||||
}
|
||||
Ok(Colors {
|
||||
mode: mode(&raw, bg, light_file)?,
|
||||
bg,
|
||||
fg,
|
||||
bright_fg: raw
|
||||
.pick(&["bright_foreground", "bright_fg", "color15"])?
|
||||
.unwrap_or(fg),
|
||||
accent: raw.pick(&["accent"])?,
|
||||
selection: raw
|
||||
.pick(&["selection", "selection_background", "color8"])?
|
||||
.unwrap_or(bg),
|
||||
orange: raw.pick(&["orange"])?.or(hues[2]),
|
||||
hues,
|
||||
bright,
|
||||
})
|
||||
}
|
||||
|
||||
/// `mode`, then `theme_type`, then `light.mode`, then the background's brightness.
|
||||
fn mode(raw: &Raw, bg: Hsla, light_file: bool) -> Result<Mode> {
|
||||
match raw.text("mode").or(raw.text("theme_type")) {
|
||||
Some("dark") => Ok(Mode::Dark),
|
||||
Some("light") => Ok(Mode::Light),
|
||||
Some(other) => bail!("mode is '{other}', not dark or light"),
|
||||
None if light_file => Ok(Mode::Light),
|
||||
None => {
|
||||
let c = bg.to_rgb();
|
||||
let sum: u32 = [c.r, c.g, c.b]
|
||||
.iter()
|
||||
.map(|v| (v * 255.0).round() as u32)
|
||||
.sum();
|
||||
Ok(if sum > 382 { Mode::Light } else { Mode::Dark })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn hex(value: &str) -> Result<Hsla> {
|
||||
match value.strip_prefix('#') {
|
||||
Some(digits) if digits.len() == 6 && digits.bytes().all(|b| b.is_ascii_hexdigit()) => {
|
||||
Ok(rgb(u32::from_str_radix(digits, 16)?).into())
|
||||
}
|
||||
_ => bail!("'{value}' is not #rrggbb"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::{fs, io, path::Path};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ely_gpui_component::theme::{Mode, Palette};
|
||||
use futures::channel::mpsc::{self, UnboundedReceiver};
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
|
||||
mod colors;
|
||||
mod palette;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// An Omarchy theme as Ely's palette.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OmarchyTheme {
|
||||
pub name: Option<String>,
|
||||
pub mode: Mode,
|
||||
pub palette: Palette,
|
||||
/// Hues the theme lacks, which Ely's own colors fill.
|
||||
pub standing_in: Vec<&'static str>,
|
||||
}
|
||||
|
||||
/// Reads `<current>/theme/colors.toml`. None when Omarchy has not written one.
|
||||
pub fn load(current: &Path) -> Result<Option<OmarchyTheme>> {
|
||||
let theme = current.join("theme");
|
||||
let file = theme.join("colors.toml");
|
||||
let text = match fs::read_to_string(&file) {
|
||||
Ok(text) => text,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error).with_context(|| format!("cannot read {}", file.display())),
|
||||
};
|
||||
let name = fs::read_to_string(current.join("theme.name"))
|
||||
.ok()
|
||||
.map(|name| name.trim().to_string());
|
||||
parse(&text, name, theme.join("light.mode").exists())
|
||||
.with_context(|| format!("{} is not a usable theme", file.display()))
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
pub fn parse(text: &str, name: Option<String>, light_file: bool) -> Result<OmarchyTheme> {
|
||||
let colors = colors::read(text, light_file)?;
|
||||
let (palette, standing_in) = palette::build(&colors)?;
|
||||
Ok(OmarchyTheme {
|
||||
name,
|
||||
mode: colors.mode,
|
||||
palette,
|
||||
standing_in,
|
||||
})
|
||||
}
|
||||
|
||||
/// One `()` per event under `current`; keep the watcher alive.
|
||||
pub fn changes(current: &Path) -> Result<(RecommendedWatcher, UnboundedReceiver<()>)> {
|
||||
let (sender, receiver) = mpsc::unbounded();
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |event: notify::Result<notify::Event>| match event {
|
||||
Ok(_) => {
|
||||
let _ = sender.unbounded_send(());
|
||||
}
|
||||
Err(error) => log::warn!("theme: watching omarchy failed: {error}"),
|
||||
})
|
||||
.context("cannot start the file watcher")?;
|
||||
watcher
|
||||
.watch(current, RecursiveMode::NonRecursive)
|
||||
.with_context(|| format!("cannot watch {}", current.display()))?;
|
||||
Ok((watcher, receiver))
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
use anyhow::{Result, bail};
|
||||
use ely_gpui_component::{
|
||||
devtools::{contrast, over},
|
||||
theme::{Mix, Mode, Palette, Syntax},
|
||||
};
|
||||
use gpui::{Hsla, hsla};
|
||||
|
||||
use super::colors::{Colors, HUE_NAMES};
|
||||
|
||||
const TEXT: f32 = 4.5;
|
||||
const MARK: f32 = 3.0;
|
||||
const BLACK: Hsla = hsla(0.0, 0.0, 0.0, 1.0);
|
||||
const WHITE: Hsla = hsla(0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
const SURFACE: f32 = 0.04;
|
||||
const OVERLAY: f32 = 0.06;
|
||||
const SUNKEN: f32 = 0.08;
|
||||
const HOVER: f32 = 0.08;
|
||||
const ACTIVE: f32 = 0.14;
|
||||
const SUBTLE: f32 = 0.16;
|
||||
|
||||
const SURFACES: &[&str] = &["bg", "surface", "sunken", "overlay", "hover", "active"];
|
||||
|
||||
/// Mirrors the library's A11yChecker: text colors over their surfaces.
|
||||
const TEXT_PAIRS: &[(&str, &[&str])] = &[
|
||||
("fg", SURFACES),
|
||||
("fg_muted", SURFACES),
|
||||
("fg_subtle", SURFACES),
|
||||
("link", &["bg", "surface", "sunken"]),
|
||||
("on_accent", &["accent", "accent_hover"]),
|
||||
("tooltip_fg", &["tooltip_bg"]),
|
||||
("success", &["bg", "success_subtle"]),
|
||||
("warning", &["bg", "warning_subtle"]),
|
||||
("danger", &["bg", "danger_subtle"]),
|
||||
("info", &["bg", "info_subtle"]),
|
||||
];
|
||||
|
||||
const CODE_SURFACES: [&str; 3] = ["surface", "sunken", "hover"];
|
||||
|
||||
/// `color` via `via` to `extreme`, until it reads `need`:1.
|
||||
fn lift(color: Hsla, grounds: &[Hsla], via: Hsla, extreme: Hsla, need: f32) -> Hsla {
|
||||
let toward = |from: Hsla, to: Hsla, from_step: u32| {
|
||||
(from_step..=20).map(move |step| from.mix(&to, step as f32 / 20.0))
|
||||
};
|
||||
toward(color, via, 0)
|
||||
.chain(toward(via, extreme, 1))
|
||||
.find(|candidate| grounds.iter().all(|g| contrast(*candidate, *g) >= need))
|
||||
.unwrap_or(extreme)
|
||||
}
|
||||
|
||||
/// The palette, and the hues Ely's colors replace.
|
||||
pub(super) fn build(c: &Colors) -> Result<(Palette, Vec<&'static str>)> {
|
||||
let mut p = match c.mode {
|
||||
Mode::Dark => Palette::dark(false),
|
||||
Mode::Light => Palette::light(false),
|
||||
};
|
||||
let bg = c.bg;
|
||||
let tint = |t: f32| bg.mix(&c.fg, t);
|
||||
let grounds = [
|
||||
bg,
|
||||
tint(SURFACE),
|
||||
tint(OVERLAY),
|
||||
tint(SUNKEN),
|
||||
tint(HOVER),
|
||||
tint(ACTIVE),
|
||||
];
|
||||
let worst = |color: Hsla| {
|
||||
grounds
|
||||
.iter()
|
||||
.map(|g| contrast(color, *g))
|
||||
.fold(f32::MAX, f32::min)
|
||||
};
|
||||
let extreme = if worst(WHITE) > worst(BLACK) {
|
||||
WHITE
|
||||
} else {
|
||||
BLACK
|
||||
};
|
||||
let fg = lift(c.fg, &grounds, extreme, extreme, TEXT);
|
||||
let lifted = |color: Hsla, grounds: &[Hsla], need: f32| lift(color, grounds, fg, extreme, need);
|
||||
let text = |color: Hsla| lifted(color, &grounds, TEXT);
|
||||
|
||||
let mut standing_in = Vec::new();
|
||||
let mut hue = |i: usize, ely: Hsla| {
|
||||
c.hues[i].unwrap_or_else(|| {
|
||||
standing_in.push(HUE_NAMES[i]);
|
||||
ely
|
||||
})
|
||||
};
|
||||
let (red, green, yellow, blue) = (
|
||||
hue(0, p.danger),
|
||||
hue(1, p.success),
|
||||
hue(2, p.warning),
|
||||
hue(3, p.info),
|
||||
);
|
||||
let (magenta, cyan) = (hue(4, p.chart[4]), hue(5, p.chart[1]));
|
||||
let orange = c.orange.unwrap_or(yellow);
|
||||
let accent = c.accent.or(c.hues[3]).unwrap_or_else(|| {
|
||||
standing_in.push("accent");
|
||||
p.accent
|
||||
});
|
||||
let bright = |i: usize, hue: Hsla| c.bright[i].unwrap_or(hue);
|
||||
|
||||
p.bg = bg;
|
||||
(p.surface, p.overlay, p.sunken, p.hover, p.active) =
|
||||
(grounds[1], grounds[2], grounds[3], grounds[4], grounds[5]);
|
||||
p.border = tint(0.16);
|
||||
p.border_strong = tint(0.32);
|
||||
p.fg = fg;
|
||||
p.fg_muted = text(fg.mix(&bg, 0.25));
|
||||
p.fg_subtle = text(fg.mix(&bg, 0.45));
|
||||
p.fg_disabled = fg.mix(&bg, 0.65);
|
||||
p.accent = accent;
|
||||
p.on_accent = if contrast(WHITE, accent) > contrast(BLACK, accent) {
|
||||
WHITE
|
||||
} else {
|
||||
BLACK
|
||||
};
|
||||
let away = if p.on_accent == WHITE { BLACK } else { WHITE };
|
||||
p.accent_hover = [accent.mix(&fg, 0.12), accent.mix(&away, 0.12)]
|
||||
.into_iter()
|
||||
.find(|hover| contrast(p.on_accent, *hover) >= TEXT)
|
||||
.unwrap_or(accent);
|
||||
p.focus = lifted(accent, &grounds[..2], MARK);
|
||||
p.link = text(accent);
|
||||
p.selection = c.selection;
|
||||
p.success_subtle = bg.mix(&green, SUBTLE);
|
||||
p.warning_subtle = bg.mix(&yellow, SUBTLE);
|
||||
p.danger_subtle = bg.mix(&red, SUBTLE);
|
||||
p.info_subtle = bg.mix(&blue, SUBTLE);
|
||||
p.success = lifted(green, &[bg, p.success_subtle], TEXT);
|
||||
p.warning = lifted(yellow, &[bg, p.warning_subtle], TEXT);
|
||||
p.danger = lifted(red, &[bg, p.danger_subtle], TEXT);
|
||||
p.info = lifted(blue, &[bg, p.info_subtle], TEXT);
|
||||
p.tooltip_bg = fg;
|
||||
p.tooltip_fg = bg;
|
||||
p.chart = [
|
||||
blue,
|
||||
cyan,
|
||||
yellow,
|
||||
red,
|
||||
magenta,
|
||||
green,
|
||||
orange,
|
||||
bright(5, cyan),
|
||||
]
|
||||
.map(|hue| lifted(hue, &[bg], MARK));
|
||||
p.ansi = [
|
||||
bg,
|
||||
red,
|
||||
green,
|
||||
yellow,
|
||||
blue,
|
||||
magenta,
|
||||
cyan,
|
||||
fg,
|
||||
p.fg_subtle,
|
||||
bright(0, red),
|
||||
bright(1, green),
|
||||
bright(2, yellow),
|
||||
bright(3, blue),
|
||||
bright(4, magenta),
|
||||
bright(5, cyan),
|
||||
c.bright_fg,
|
||||
];
|
||||
let s = &mut p.syntax;
|
||||
s.keyword = text(magenta);
|
||||
s.string = text(green);
|
||||
s.number = text(orange);
|
||||
s.comment = p.fg_subtle;
|
||||
s.function = text(blue);
|
||||
s.type_name = text(yellow);
|
||||
s.constant = text(orange);
|
||||
s.property = text(cyan);
|
||||
s.tag = text(red);
|
||||
s.attribute = text(yellow);
|
||||
s.operator = text(cyan);
|
||||
s.punctuation = p.fg_muted;
|
||||
s.variable = fg;
|
||||
verify(&p)?;
|
||||
Ok((p, standing_in))
|
||||
}
|
||||
|
||||
/// Fails on the first pair the library's audit fails.
|
||||
fn verify(p: &Palette) -> Result<()> {
|
||||
let check = |front: &str, color: Hsla, back: &str, need: f32| -> Result<()> {
|
||||
let ground = p.token(back);
|
||||
let ratio = contrast(over(color, ground), ground);
|
||||
if ratio < need {
|
||||
bail!("{front} on {back} reads {ratio:.2}:1, needs {need}:1");
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for (front, backs) in TEXT_PAIRS {
|
||||
for back in *backs {
|
||||
check(front, p.token(front), back, TEXT)?;
|
||||
}
|
||||
}
|
||||
for name in Syntax::NAMES {
|
||||
for back in CODE_SURFACES {
|
||||
check(&format!("syntax.{name}"), p.syntax.token(name), back, TEXT)?;
|
||||
}
|
||||
}
|
||||
for back in ["bg", "surface"] {
|
||||
check("focus", p.focus, back, MARK)?;
|
||||
}
|
||||
for (ix, hue) in p.chart.iter().enumerate() {
|
||||
check(&format!("chart hue {ix}"), *hue, "bg", MARK)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use std::{fs, thread, time::Duration};
|
||||
|
||||
use ely_gpui_component::{
|
||||
devtools::contrast,
|
||||
theme::{Mix, Mode, Palette},
|
||||
};
|
||||
use futures::channel::mpsc::UnboundedReceiver;
|
||||
|
||||
use super::{OmarchyTheme, changes, colors::hex, load, parse};
|
||||
use gpui::Hsla;
|
||||
|
||||
const BUNDLED: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/omarchy");
|
||||
const GENERATED: &str = include_str!("../../../tests/fixtures/omarchy-generated/tokyo-night.toml");
|
||||
const TOKYO_NIGHT: &str = include_str!("../../../tests/fixtures/omarchy/tokyo-night.toml");
|
||||
const LATTE: &str = include_str!("../../../tests/fixtures/omarchy/catppuccin-latte.toml");
|
||||
|
||||
fn bundled() -> Vec<(String, String)> {
|
||||
let mut themes: Vec<_> = fs::read_dir(BUNDLED)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.map(|path| {
|
||||
let name = path.file_stem().unwrap().to_string_lossy().into_owned();
|
||||
(name, fs::read_to_string(path).unwrap())
|
||||
})
|
||||
.collect();
|
||||
themes.sort();
|
||||
themes
|
||||
}
|
||||
|
||||
fn value_of(text: &str, key: &str) -> String {
|
||||
let line = text
|
||||
.lines()
|
||||
.find(|line| line.starts_with(&format!("{key} =")))
|
||||
.unwrap();
|
||||
line.split('"').nth(1).unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The library's contrast rules, written out apart from `verify`.
|
||||
fn assert_contract(name: &str, theme: &OmarchyTheme) {
|
||||
let p = &theme.palette;
|
||||
let s = &p.syntax;
|
||||
let surfaces = [p.bg, p.surface, p.overlay, p.sunken, p.hover, p.active];
|
||||
let on = |token: &str, color: Hsla, grounds: &[Hsla], need: f32| {
|
||||
for (ix, ground) in grounds.iter().enumerate() {
|
||||
let ratio = contrast(color, *ground);
|
||||
assert!(
|
||||
ratio >= need,
|
||||
"{name}: {token} on ground {ix} reads {ratio:.2}"
|
||||
);
|
||||
}
|
||||
};
|
||||
for (token, color) in [
|
||||
("fg", p.fg),
|
||||
("fg_muted", p.fg_muted),
|
||||
("fg_subtle", p.fg_subtle),
|
||||
("link", p.link),
|
||||
("keyword", s.keyword),
|
||||
("string", s.string),
|
||||
("number", s.number),
|
||||
("comment", s.comment),
|
||||
("function", s.function),
|
||||
("type_name", s.type_name),
|
||||
("constant", s.constant),
|
||||
("property", s.property),
|
||||
("tag", s.tag),
|
||||
("attribute", s.attribute),
|
||||
("operator", s.operator),
|
||||
("punctuation", s.punctuation),
|
||||
("variable", s.variable),
|
||||
] {
|
||||
on(token, color, &surfaces, 4.5);
|
||||
}
|
||||
for (token, color, subtle) in [
|
||||
("success", p.success, p.success_subtle),
|
||||
("warning", p.warning, p.warning_subtle),
|
||||
("danger", p.danger, p.danger_subtle),
|
||||
("info", p.info, p.info_subtle),
|
||||
] {
|
||||
on(token, color, &[p.bg, subtle], 4.5);
|
||||
}
|
||||
on("on_accent", p.on_accent, &[p.accent, p.accent_hover], 4.5);
|
||||
on("tooltip_fg", p.tooltip_fg, &[p.tooltip_bg], 4.5);
|
||||
on("focus", p.focus, &[p.bg, p.surface], 3.0);
|
||||
for (ix, hue) in p.chart.iter().enumerate() {
|
||||
on(&format!("chart {ix}"), *hue, &[p.bg], 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bundled_theme_maps_with_its_colors_and_text_that_reads_on_every_surface() {
|
||||
let themes = bundled();
|
||||
assert!(themes.len() >= 22, "only {} fixtures", themes.len());
|
||||
for (name, text) in themes {
|
||||
let theme =
|
||||
parse(&text, Some(name.clone()), false).unwrap_or_else(|e| panic!("{name}: {e:#}"));
|
||||
|
||||
let mode = if value_of(&text, "mode") == "dark" {
|
||||
Mode::Dark
|
||||
} else {
|
||||
Mode::Light
|
||||
};
|
||||
assert_eq!(theme.mode, mode, "{name}");
|
||||
assert!(
|
||||
theme.standing_in.is_empty(),
|
||||
"{name}: {:?}",
|
||||
theme.standing_in
|
||||
);
|
||||
assert_eq!(
|
||||
theme.palette.bg,
|
||||
hex(&value_of(&text, "background")).unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
theme.palette.accent,
|
||||
hex(&value_of(&text, "accent")).unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
theme.palette.selection,
|
||||
hex(&value_of(&text, "selection")).unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
let red = hex(&value_of(&text, "red")).unwrap();
|
||||
let reads = |ground| contrast(red, ground) >= 4.5;
|
||||
if reads(theme.palette.bg) && reads(theme.palette.danger_subtle) {
|
||||
assert_eq!(
|
||||
theme.palette.danger, red,
|
||||
"{name}: a red that reads is kept"
|
||||
);
|
||||
}
|
||||
assert_contract(&name, &theme);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_whose_text_already_reads_keeps_its_foreground() {
|
||||
let theme = parse(TOKYO_NIGHT, None, false).unwrap();
|
||||
|
||||
assert_eq!(theme.palette.fg, hex("#a9b1d6").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_generated_theme_with_only_colorn_keys_gets_its_hues_through_omarchys_aliases() {
|
||||
let theme = parse(GENERATED, None, false).unwrap();
|
||||
let p = &theme.palette;
|
||||
|
||||
assert_eq!(theme.mode, Mode::Dark);
|
||||
assert!(theme.standing_in.is_empty(), "{:?}", theme.standing_in);
|
||||
assert_eq!(p.danger, hex("#f7768e").unwrap());
|
||||
assert_eq!(p.success, hex("#9ece6a").unwrap());
|
||||
assert_eq!(p.warning, hex("#e0af68").unwrap());
|
||||
assert_eq!(p.info, hex("#7aa2f7").unwrap());
|
||||
assert_eq!(p.chart[4], hex("#ad8ee6").unwrap());
|
||||
assert_eq!(p.chart[1], hex("#449dab").unwrap());
|
||||
assert_eq!(p.chart[7], hex("#0db9d7").unwrap());
|
||||
assert_eq!(p.ansi[9], hex("#ff7a93").unwrap());
|
||||
assert_eq!(p.ansi[15], hex("#c0caf5").unwrap());
|
||||
assert_contract("generated", &theme);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bright_hues_and_orange_follow_omarchys_derivations_when_absent() {
|
||||
let bare = "background = \"#101010\"\nforeground = \"#e0e0e0\"\nred = \"#c04040\"\nyellow = \"#c0a040\"\n";
|
||||
let theme = parse(bare, None, false).unwrap();
|
||||
let p = &theme.palette;
|
||||
|
||||
assert_eq!(
|
||||
p.ansi[9],
|
||||
hex("#c04040").unwrap().mix(&hex("#ffffff").unwrap(), 0.2)
|
||||
);
|
||||
assert_eq!(p.chart[6], hex("#c0a040").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_that_lacks_a_hue_is_told_apart_from_one_that_has_it() {
|
||||
let no_red = TOKYO_NIGHT.replace("red = \"#f7768e\"", "");
|
||||
let theme = parse(&no_red, None, false).unwrap();
|
||||
|
||||
assert_eq!(theme.standing_in, ["red"]);
|
||||
assert_eq!(theme.palette.danger, Palette::dark(false).danger);
|
||||
|
||||
let bare = parse(
|
||||
"background = \"#101010\"\nforeground = \"#e0e0e0\"",
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bare.standing_in,
|
||||
[
|
||||
"red", "green", "yellow", "blue", "magenta", "cyan", "accent"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reader_takes_omarchys_loose_syntax_and_skips_what_omarchy_skips() {
|
||||
let loose = "# a comment\n\"background\" = '#1a1b26'\nforeground=#a9b1d6\n accent = \"#7aa2f7\" # note\nmode = \"dark\"\nodd key! = \"#ffffff\"\nname = \"a;b\"\n[table]\n";
|
||||
let theme = parse(loose, None, false).unwrap();
|
||||
|
||||
assert_eq!(theme.palette.bg, hex("#1a1b26").unwrap());
|
||||
assert_eq!(theme.palette.accent, hex("#7aa2f7").unwrap());
|
||||
assert_eq!(theme.palette.fg, hex("#a9b1d6").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_comes_from_mode_then_theme_type_then_the_light_file_then_brightness() {
|
||||
let colors =
|
||||
|extra: &str, bg: &str| format!("{extra}\nbackground = \"{bg}\"\nforeground = \"#808080\"");
|
||||
let mode = |text: String, light_file: bool| parse(&text, None, light_file).unwrap().mode;
|
||||
|
||||
assert_eq!(
|
||||
mode(colors("mode = \"light\"", "#000000"), false),
|
||||
Mode::Light
|
||||
);
|
||||
assert_eq!(
|
||||
mode(colors("theme_type = \"light\"", "#000000"), false),
|
||||
Mode::Light
|
||||
);
|
||||
assert_eq!(
|
||||
mode(
|
||||
colors("mode = \"dark\"\ntheme_type = \"light\"", "#ffffff"),
|
||||
false
|
||||
),
|
||||
Mode::Dark
|
||||
);
|
||||
assert_eq!(mode(colors("", "#000000"), true), Mode::Light);
|
||||
assert_eq!(mode(colors("mode = \"dark\"", "#ffffff"), true), Mode::Dark);
|
||||
assert_eq!(mode(colors("", "#7f8080"), false), Mode::Light);
|
||||
assert_eq!(mode(colors("", "#7f7f80"), false), Mode::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cause_is_named_for_a_missing_color_a_bad_color_a_bad_mode_and_unreadable_text() {
|
||||
let say = |text: &str| format!("{:#}", parse(text, None, false).unwrap_err());
|
||||
let without = |key: &str| {
|
||||
TOKYO_NIGHT
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with(key))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
assert!(say(&without("foreground")).contains("'foreground' is missing"));
|
||||
assert!(say(&without("background")).contains("'background' is missing"));
|
||||
assert!(
|
||||
say(&TOKYO_NIGHT.replace("#7aa2f7\"\nselection", "blue\"\nselection")).contains("'accent'")
|
||||
);
|
||||
assert!(say(&TOKYO_NIGHT.replace("#f7768e", "#+12345")).contains("'#+12345' is not #rrggbb"));
|
||||
assert!(
|
||||
say(&TOKYO_NIGHT.replace("mode = \"dark\"", "mode = \"dim\""))
|
||||
.contains("not dark or light")
|
||||
);
|
||||
assert!(say("not toml at all").contains("'background' is missing"));
|
||||
assert!(say("background = \"#777777\"\nforeground = \"#000000\"").contains("4.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_reads_the_theme_its_name_and_its_light_marker_and_a_missing_file_is_none() {
|
||||
let current = tempfile::tempdir().unwrap();
|
||||
assert!(load(current.path()).unwrap().is_none());
|
||||
|
||||
let theme_dir = current.path().join("theme");
|
||||
fs::create_dir(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("colors.toml"), LATTE).unwrap();
|
||||
fs::write(current.path().join("theme.name"), "catppuccin-latte\n").unwrap();
|
||||
let theme = load(current.path()).unwrap().expect("a theme");
|
||||
assert_eq!(theme.name.as_deref(), Some("catppuccin-latte"));
|
||||
assert_eq!(theme.mode, Mode::Light);
|
||||
|
||||
fs::write(
|
||||
theme_dir.join("colors.toml"),
|
||||
"background = \"#000000\"\nforeground = \"#ffffff\"",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(load(current.path()).unwrap().unwrap().mode, Mode::Dark);
|
||||
fs::write(theme_dir.join("light.mode"), "").unwrap();
|
||||
assert_eq!(load(current.path()).unwrap().unwrap().mode, Mode::Light);
|
||||
|
||||
fs::write(theme_dir.join("colors.toml"), "mode = \"dark\"").unwrap();
|
||||
let error = format!("{:#}", load(current.path()).unwrap_err());
|
||||
assert!(
|
||||
error.contains("colors.toml") && error.contains("'background' is missing"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
fn event_within(changes: &mut UnboundedReceiver<()>, tries: u32) -> bool {
|
||||
(0..tries).any(|_| {
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
changes.try_recv().is_ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changes_sees_a_theme_swap_in_the_order_omarchy_does_it() {
|
||||
let current = tempfile::tempdir().unwrap();
|
||||
let (_watcher, mut events) = changes(current.path()).unwrap();
|
||||
|
||||
fs::create_dir(current.path().join("theme")).unwrap();
|
||||
assert!(event_within(&mut events, 60), "no event for a new theme");
|
||||
while events.try_recv().is_ok() {}
|
||||
|
||||
fs::create_dir(current.path().join("next-theme")).unwrap();
|
||||
fs::remove_dir_all(current.path().join("theme")).unwrap();
|
||||
fs::rename(
|
||||
current.path().join("next-theme"),
|
||||
current.path().join("theme"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(current.path().join("theme.name"), "nord\n").unwrap();
|
||||
assert!(
|
||||
event_within(&mut events, 60),
|
||||
"no event for a swapped theme"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use futures::StreamExt;
|
||||
use gpui::{App, AsyncApp};
|
||||
|
||||
use super::{OmarchyState, apply, omarchy};
|
||||
|
||||
const SETTLE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Loads the Omarchy theme now and after each change.
|
||||
pub(super) fn start(dir: PathBuf, cx: &mut App) {
|
||||
reload(&dir, cx);
|
||||
let (watcher, mut changes) = match omarchy::changes(&dir) {
|
||||
Ok(watch) => watch,
|
||||
Err(error) => {
|
||||
log::error!("theme: omarchy sync does not follow changes: {error:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
cx.spawn(async move |cx: &mut AsyncApp| {
|
||||
let _watcher = watcher;
|
||||
while changes.next().await.is_some() {
|
||||
cx.background_executor().timer(SETTLE).await;
|
||||
while changes.try_recv().is_ok() {}
|
||||
cx.update(|cx| reload(&dir, cx));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn reload(dir: &Path, cx: &mut App) {
|
||||
match omarchy::load(dir) {
|
||||
Ok(Some(theme)) => {
|
||||
let name = theme.name.as_deref().unwrap_or("unnamed");
|
||||
if !theme.standing_in.is_empty() {
|
||||
log::warn!(
|
||||
"theme: omarchy {name} lacks {}, Ely's colors stand in",
|
||||
theme.standing_in.join(", ")
|
||||
);
|
||||
}
|
||||
cx.global_mut::<OmarchyState>().0 = Some(theme);
|
||||
apply(cx, false);
|
||||
}
|
||||
Ok(None) => {
|
||||
if cx.global_mut::<OmarchyState>().0.take().is_some() {
|
||||
log::info!("theme: omarchy theme has no colors.toml, Ely's own colors again");
|
||||
apply(cx, false);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("theme: omarchy theme ignored, the current colors stay: {error:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user