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:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+134
View File
@@ -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,
}
}
+164
View File
@@ -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"),
}
}
+66
View File
@@ -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))
}
+210
View File
@@ -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(())
}
+316
View File
@@ -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"
);
}
+57
View File
@@ -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:#}");
}
}
}