makepad/apps/wm/src/theme.rs
Admin 232595aed3 apps, examples: the window manager on Android and Windows, the phone layouts, the task manager, director, mail and the rest; Flow, the asset UI and VJ move to Stage
App and example changes, with the apps that moved to the Stage repository (apps/vj, apps/asset-ui) removed here.

Covers work ebf442a94..2b1a41df4 for apps examples:

- wm: the Android super-app compiles tiles on the phone against the host engine
- cargo-makepad: the super-app APK is packed by `android dyn-pack`, in Rust
- widgets, storybook: the widget catalogue app, some eighty new widgets, a theme store with mixable style sheets, and the rule that a press belongs to
- wm, widgets: the style tween starts with all eight weights, and the new style sheet takes the last number instead of macOS's
- platform: the Android desk swipes at 120 Hz — offscreen render passes and framebuffers cached, SVG meshes kept, font families that complete
- flow: the flow-ui app and the flowgraph canvas return to the workspace, with staged progress in libs/flow
- app_main!: only ship the fonts an app declares (#1247)
- platform, widgets, wm: every Vulkan pass renders through a negative-height viewport again, so a Wayland or X11 window stands upright, and no capture
- calendar: its own colour contrast is named explicitly, now that the theme store exports one under the same name through the widgets glob
- platform, wm, director: a hosted app's software frame arrives as a top-left texture and the window manager and the director carry no flip term at al
- wm: the desktop style menu lists each style once
- cef, video, platform: what Stage's browser needs from the shared layers
- platform, wm: work builds again on iOS, tvOS and wasm, and the window manager is warning-free on Windows
- ci: tools/ci, a Splash runtime that watches a branch and runs every ci.splash it finds
- ci: the terminal gets its pty helper, director is a desktop tool, and a refused input is asked again
- apps: what the CI box found
- Remove Flow, asset and VJ applications moved into Stage
- ci: a quiet header, a build that visibly moves, and scripts that leave the machine alone
- tests: the workspace suite, run as a whole for the first time, passes outside the example UI tests
- examples: the UI tests pass, for the reasons they failed
- tests: the last three failures of the CI box's night
- tools: makepad-screen is makepad-agents, and its binary is `agents`
- tests: the three binaries over ten seconds in release come under it
- wm: the shell menu says what it did, and the CI waits for that
- mail: the phone layout lines up
- clock: the phone layout fits both orientations
- calendar, reminders, calculator, sheets: the phone layouts fit their space
- platform, widgets, wm: a touch that is taken away is cancelled, never released
- weather, notes, finance, photos, route: the phone layouts tidy up
- files: the phone layout tidies up
- wm: the phone shell's surfaces match the app and the grid
- wm on Android: every app is its own process
- clock: a cancelled release neither opens an alarm nor switches tab
- platform, wm: hosted apps ask the WM for what they cannot do themselves
- wm on Android: apps build on the phone
- wm on Android: app transitions and gestures feel like the phone's
- android: rotated Vulkan windows stop rebuilding their swapchain every frame, and hosted children get touch
- android: hosted children hand frames over with GPU fences and draw only when they have work
- wm on Android: gestures and surfaces settle like the phone's
- widgets: touch lists scroll with Android's physics
- wm on Android: the shell follows the Pixel launcher's motion
- wm on Android: Recents shows the apps over a receding home, and the home screen switches looks
- wm on Android: the look switcher stays put, the iOS dock shows its icons, background apps follow a look change
- task, cargo-makepad, widgets, audio: the task manager chooses its columns, graphs every process' network and disk traffic and installs itself as a D
- files: the tile view's three projections are toolbar buttons
- mail: the local view takes a theme-derived palette and line icons, search pages share records
- director: agent lanes form a tree and Grok joins the usage bar
- widgets, draw: skeuomorphic surfaces light each other through an optional relief buffer
- widgets, platform: a texture can light the relief buffer, menus take colour chips, and a style reload recompiles changed shader functions
- platform, widgets, audio_route, ai: window crossfades and whole-frame presents, caption controls that click, themed menus, a stereo-pair audio tap,
- builder: the Builder TUI matches the new terminal design, Windows executables carry app icons, and every app has one
- files: the Files app deletes nothing and never overwrites
- director: coding agents ask before acting unless you choose otherwise
- sheets, score, git, home: saves cannot cut a file short or overwrite another one, and checkouts cannot write outside the repository
- widgets, builder: the big widget families are features, all on by default, and the Builder takes none of them
- ai: Claude Desktop drives any app with the F10 panel
- wm: starts on Windows
- wm: children open no console windows on Windows
- wm: the whole deck out of a Builder's source, compiled only when the person opens an app
- ai: Connect hands the .mcpb to Claude Desktop itself
- wm: the clock and calendar know the date on Windows
- wm: opening an app rebuilds it when its binary is out of date; warm instances never build
- civil-time: one local wall clock for every app, and it knows the zone on Windows
- platform + wm: a hosted child redraws at its tile's new size
- calendar: the Calendars sheet is an opaque panel, and the wide layout does not float one over its sidebar
- platform: an app's package dir, icons and bundle name rebuild only that app
- build: the AI stack, csg, Scope and Source Library join the one workspace
- ai chat: the chat's backends are the CLI, MCP and cloud providers; the local model is the `localai` feature
- apps: every app that hosts the chat has a `localai` feature for the local model, off unless the app's own job runs one
- widgets: every widget family is a crate of its own under widgets/families, and makepad-widgets links, re-exports and registers the ones its features
- aichat, widgets: the chat reads the design feedback through the tweaker's hook, so linking the chat no longer needs the tweaker
- apps, examples, libs: each crate builds only the widget families it uses, and the design overlay is each app's own default feature
- ai chat: a build without local AI starts on the first provider that answers here, not on "No model"
- repo: no third-party comic archive, generated models or sample asset in the tree
- remove big example media files
- wm: in a Makepad Builder installation apps build through the Builder, however wm was started

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:10:58 +02:00

1320 lines
47 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! wm theming lives in SPLASH. A theme is a `theme.splash` file defining
//! `mod.wm_theme = { ... }`, evaluated into the script VM at startup and
//! on switch; the chrome styles itself from `mod.wm_theme.*` and the
//! terminal palette handed to child terminals comes from the same object.
//! Users edit the .splash file directly — it is the theme.
//!
//! Omarchy themes are an IMPORT SOURCE only: `--import-theme <name>`
//! downloads an omarchy theme (basecamp/omarchy), runs its color-derivation
//! cascade (ported from `bin/omarchy-theme-color`) once, and writes a
//! self-contained theme.splash + the background images. Nothing of
//! omarchy's format survives past the import.
//!
//! Installed layout: `~/.makepad/wm/themes/<name>/theme.splash`
//! `~/.makepad/wm/themes/<name>/backgrounds/*`
use std::collections::HashMap;
use std::fmt::Write as _;
use std::path::PathBuf;
pub const DEFAULT_THEME: &str = "tokyo-night";
/// The omarchy theme catalog (basecamp/omarchy `themes/`), the import
/// menu's list. Importing any of them writes a self-contained theme.splash
/// here; nothing of omarchy's format survives the conversion.
pub const OMARCHY_THEMES: &[&str] = &[
"tokyo-night",
"catppuccin",
"catppuccin-latte",
"ethereal",
"everforest",
"flexoki-light",
"gruvbox",
"hackerman",
"kanagawa",
"last-horizon",
"lumon",
"lupine",
"matte-black",
"miasma",
"nord",
"osaka-jade",
"retro-82",
"ristretto",
"rose-pine",
"solitude",
"vantablack",
"white",
];
/// Stamped into every generated theme.splash. Bump it whenever the emitted
/// key set changes: `ensure_default_theme` reseeds the bundled theme when
/// the marker on disk is older, so new keys reach existing installs.
pub const THEME_FORMAT: &str = "(format 3)";
/// The alpha omarchy's templates give every border stop: `rgba(...ee)`.
const BORDER_ALPHA: f64 = 0.933;
/// The focused border a theme gets when it names none. Omarchy's
/// `default/themed/hyprland.lua.tpl` reads
/// `active_border = {{ hypr_gradient hyprland_active_border accent }}`.
/// A theme that names its own gradient (hackerman, last-horizon,
/// solitude) still wins; this is what every other theme gets, and it is
/// hyprland's OWN default, which omarchy keeps in
/// `default/hypr/looknfeel.lua:3-4`:
/// `active_border_color = { colors = { "rgba(33ccffee)", "rgba(00ff99ee)" }, angle = 45 }`
/// — the cyan→green diagonal ring the reference desktop shows.
pub const DEFAULT_ACTIVE_BORDER: Gradient = Gradient {
start: Stop {
rgb: Rgb {
r: 0x33,
g: 0xcc,
b: 0xff,
},
alpha: BORDER_ALPHA,
},
end: Stop {
rgb: Rgb {
r: 0x00,
g: 0xff,
b: 0x99,
},
alpha: BORDER_ALPHA,
},
angle: 45.0,
};
/// Hyprland's `rgba(595959aa)`, lifted from 0.667 to 0.85 alpha: omarchy
/// composites it over a lit wallpaper, our desk is darker and 0.667 sinks
/// the edge into the background. Themes tune it with `inactive_border` and
/// `inactive_border_alpha`.
pub const DEFAULT_INACTIVE_BORDER: Stop = Stop {
rgb: Rgb {
r: 0x59,
g: 0x59,
b: 0x59,
},
alpha: 0.85,
};
const OMARCHY_RAW: &str = "https://raw.githubusercontent.com/omacom/omarchy/quattro/themes";
const OMARCHY_API: &str = "https://api.github.com/repos/omacom/omarchy/contents/themes";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub fn hex(self) -> String {
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
}
/// Hex form for RUNTIME-evaluated splash sources. Plain `#hex`: the
/// digit-then-`e` exponent hazard only exists in the proc-macro path
/// (Rust's tokenizer); the runtime script tokenizer takes pure hex and
/// does not know the `#` escape.
pub fn splash_hex(self) -> String {
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
}
}
pub fn parse_hex(s: &str) -> Option<Rgb> {
let s = s.trim().trim_start_matches("#").trim_start_matches('#');
if s.len() != 6 {
return None;
}
Some(Rgb {
r: u8::from_str_radix(&s[0..2], 16).ok()?,
g: u8::from_str_radix(&s[2..4], 16).ok()?,
b: u8::from_str_radix(&s[4..6], 16).ok()?,
})
}
/// One color stop of a border: an rgb triple plus its own alpha.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Stop {
pub rgb: Rgb,
pub alpha: f64,
}
/// A hyprland border value: two stops and an angle in degrees. A solid
/// color is the degenerate gradient (both stops equal).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Gradient {
pub start: Stop,
pub end: Stop,
pub angle: f64,
}
impl Gradient {
pub fn solid(rgb: Rgb, alpha: f64) -> Self {
let stop = Stop { rgb, alpha };
Self {
start: stop,
end: stop,
angle: 0.0,
}
}
pub fn is_solid(&self) -> bool {
self.start == self.end
}
}
/// A hyprland color literal: `rgba(RRGGBBAA)`, `rgb(RRGGBB)`, `0xAARRGGBB`
/// or a plain `#rrggbb` / `#rrggbbaa`.
pub fn parse_hypr_color(s: &str) -> Option<Stop> {
let s = s.trim();
let hex = |body: &str, has_alpha: bool, alpha_first: bool| -> Option<Stop> {
let want = if has_alpha { 8 } else { 6 };
if body.len() != want || !body.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let (a, rgb) = if !has_alpha {
(255u8, body)
} else if alpha_first {
(u8::from_str_radix(&body[0..2], 16).ok()?, &body[2..8])
} else {
(u8::from_str_radix(&body[6..8], 16).ok()?, &body[0..6])
};
Some(Stop {
rgb: parse_hex(rgb)?,
alpha: a as f64 / 255.0,
})
};
if let Some(body) = s.strip_prefix("rgba(").and_then(|s| s.strip_suffix(')')) {
return hex(body.trim(), true, false);
}
if let Some(body) = s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) {
return hex(body.trim(), false, false);
}
if let Some(body) = s.strip_prefix("0x") {
return hex(body.trim(), true, true);
}
let body = s.trim_start_matches('#');
hex(body, body.len() == 8, false)
}
/// A hyprland border value: `rgba(33ccffee) rgba(00ff99ee) 45deg`, or any
/// single color. Extra stops beyond the second are ignored (we draw a two
/// stop gradient); the angle defaults to 0.
pub fn parse_hypr_gradient(s: &str) -> Option<Gradient> {
let mut stops: Vec<Stop> = Vec::new();
let mut angle = 0.0f64;
for token in s.split_whitespace() {
if let Some(deg) = token.strip_suffix("deg") {
if let Ok(v) = deg.parse::<f64>() {
angle = v;
}
continue;
}
if let Some(stop) = parse_hypr_color(token) {
stops.push(stop);
}
}
let start = *stops.first()?;
let end = *stops.get(1).unwrap_or(&start);
Some(Gradient { start, end, angle })
}
/// Linear per-channel blend, rounding half up (`mix_color`).
fn mix(a: Rgb, b: Rgb, amount: f64) -> Rgb {
let amount = amount.clamp(0.0, 1.0);
let ch = |x: u8, y: u8| ((x as f64 + (y as f64 - x as f64) * amount) + 0.5) as u8;
Rgb {
r: ch(a.r, b.r),
g: ch(a.g, b.g),
b: ch(a.b, b.b),
}
}
const BLACK: Rgb = Rgb { r: 0, g: 0, b: 0 };
const WHITE: Rgb = Rgb {
r: 255,
g: 255,
b: 255,
};
/// Import-time intermediate: a resolved omarchy palette on its way into
/// splash. Also the Rust-side fallback when a theme.splash is unreadable.
#[derive(Clone, Debug)]
pub struct ImportedTheme {
pub name: String,
pub light_mode: bool,
pub accent: Rgb,
pub selection: Rgb,
pub muted: Rgb,
pub background: Rgb,
pub dark_background: Rgb,
pub darker_background: Rgb,
pub lighter_background: Rgb,
pub foreground: Rgb,
pub dark_foreground: Rgb,
pub light_foreground: Rgb,
pub bright_foreground: Rgb,
/// red yellow orange green cyan blue magenta brown
pub hues: [Rgb; 8],
/// bright: red yellow green cyan blue magenta
pub bright: [Rgb; 6],
pub cursor: Rgb,
/// Focused window border: two stops + an angle (a solid color is the
/// degenerate case where both stops match).
pub active_border: Gradient,
/// Unfocused window border: one stop, alpha included.
pub inactive_border: Stop,
}
impl ImportedTheme {
/// Terminal base16 per omarchy's alacritty mapping: normal black =
/// background ... white = foreground; bright black = muted ... bright
/// white = bright_foreground.
pub fn terminal_base16(&self) -> [Rgb; 16] {
let [red, yellow, _orange, green, cyan, blue, magenta, _brown] = self.hues;
let [b_red, b_yellow, b_green, b_cyan, b_blue, b_magenta] = self.bright;
[
self.background,
red,
green,
yellow,
blue,
magenta,
cyan,
self.foreground,
self.muted,
b_red,
b_green,
b_yellow,
b_blue,
b_magenta,
b_cyan,
self.bright_foreground,
]
}
}
/// Parse an omarchy `colors.toml` (flat, string values) and run the
/// derivation cascade from `omarchy-theme-color`. Import-time only.
pub fn resolve_omarchy_colors(name: &str, toml: &str) -> Option<ImportedTheme> {
let mut raw: HashMap<String, String> = HashMap::new();
for line in toml.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
raw.insert(
key.trim().to_string(),
value.trim().trim_matches('"').to_string(),
);
}
// Legacy short-name + ANSI aliases; canonical name wins.
let aliases: &[(&str, &[&str])] = &[
("background", &["bg", "color0"]),
("dark_background", &["dark_bg"]),
("darker_background", &["darker_bg"]),
("lighter_background", &["lighter_bg"]),
("foreground", &["fg", "color7"]),
("dark_foreground", &["dark_fg", "color8"]),
("light_foreground", &["light_fg"]),
("bright_foreground", &["bright_fg", "color15"]),
("red", &["color1"]),
("green", &["color2"]),
("yellow", &["color3"]),
("blue", &["color4"]),
("magenta", &["purple", "color5"]),
("cyan", &["color6"]),
("bright_red", &["color9"]),
("bright_green", &["color10"]),
("bright_yellow", &["color11"]),
("bright_blue", &["color12"]),
("bright_magenta", &["bright_purple", "color13"]),
("bright_cyan", &["color14"]),
];
for (canon, alts) in aliases {
if !raw.contains_key(*canon) {
for alt in *alts {
if let Some(v) = raw.get(*alt).cloned() {
raw.insert((*canon).to_string(), v);
break;
}
}
}
}
let get = |raw: &HashMap<String, String>, key: &str| -> Option<Rgb> {
raw.get(key).and_then(|v| parse_hex(v))
};
let background = get(&raw, "background")?;
let foreground = get(&raw, "foreground")?;
let light_foreground = get(&raw, "light_foreground").unwrap_or(foreground);
let bright_foreground = get(&raw, "bright_foreground").unwrap_or(foreground);
let cursor = bright_foreground;
let lighter_background = get(&raw, "lighter_background").unwrap_or(background);
let dark_foreground = get(&raw, "dark_foreground").unwrap_or(foreground);
let muted = get(&raw, "muted").unwrap_or(dark_foreground);
let selection = get(&raw, "selection")
.or_else(|| get(&raw, "selection_background"))
.or_else(|| get(&raw, "color8"))
.unwrap_or(background);
let yellow = get(&raw, "yellow").unwrap_or(foreground);
let orange = get(&raw, "orange").unwrap_or(yellow);
let brown = get(&raw, "brown").unwrap_or_else(|| mix(orange, BLACK, 0.5));
let dark_background =
get(&raw, "dark_background").unwrap_or_else(|| mix(background, BLACK, 0.25));
let darker_background =
get(&raw, "darker_background").unwrap_or_else(|| mix(background, BLACK, 0.5));
let hue = |raw: &HashMap<String, String>, key: &str| -> Rgb {
get(raw, key).unwrap_or(foreground)
};
let red = hue(&raw, "red");
let green = hue(&raw, "green");
let cyan = hue(&raw, "cyan");
let blue = hue(&raw, "blue");
let magenta = hue(&raw, "magenta");
let bright_of = |raw: &HashMap<String, String>, key: &str, base: Rgb| -> Rgb {
get(raw, key).unwrap_or_else(|| mix(base, WHITE, 0.2))
};
let accent = get(&raw, "accent").unwrap_or(blue);
// Borders follow omarchy's default/themed/hyprland.lua.tpl:
// active = hypr_gradient(hyprland_active_border, accent)
// inactive = hypr_gradient(hyprland_inactive_border, rgba(595959aa))
// A theme that names its own gradient (hackerman, last-horizon,
// solitude) wins. Everything else gets hyprland's own default, which
// omarchy keeps in looknfeel.lua and the reference desktop shows: the
// cyan→green 45° ring, NOT the theme accent solid.
let active_border = raw
.get("hyprland_active_border")
.and_then(|v| parse_hypr_gradient(v))
.unwrap_or(DEFAULT_ACTIVE_BORDER);
let inactive_border = raw
.get("hyprland_inactive_border")
.and_then(|v| parse_hypr_gradient(v))
.map(|g| g.start)
.unwrap_or(DEFAULT_INACTIVE_BORDER);
let light_mode = match raw.get("mode").map(|s| s.as_str()) {
Some("light") => true,
Some(_) => false,
None => match raw.get("theme_type").map(|s| s.as_str()) {
Some("light") => true,
Some(_) => false,
None => background.r as u32 + background.g as u32 + background.b as u32 > 382,
},
};
Some(ImportedTheme {
name: name.to_string(),
light_mode,
accent,
selection,
muted,
background,
dark_background,
darker_background,
lighter_background,
foreground,
dark_foreground,
light_foreground,
bright_foreground,
hues: [red, yellow, orange, green, cyan, blue, magenta, brown],
bright: [
bright_of(&raw, "bright_red", red),
bright_of(&raw, "bright_yellow", yellow),
bright_of(&raw, "bright_green", green),
bright_of(&raw, "bright_cyan", cyan),
bright_of(&raw, "bright_blue", blue),
bright_of(&raw, "bright_magenta", magenta),
],
cursor,
active_border,
inactive_border,
})
}
/// Emit the theme.splash for an imported theme. From here on THIS FILE is
/// the theme: wm evaluates it, the chrome reads `mod.wm_theme.*`, and
/// the `term` block is the palette handed to child terminals.
pub fn splash_source(theme: &ImportedTheme) -> String {
let mut s = String::new();
let _ = writeln!(
s,
"// wm theme \"{}\" — imported from the omarchy theme of the same",
theme.name
);
let _ = writeln!(
s,
"// name; this splash file IS the theme now. Edit freely. {}",
THEME_FORMAT
);
let _ = writeln!(s, "mod.wm_theme = {{");
{
let mut kv = |k: &str, v: Rgb| {
let _ = writeln!(s, " {}: {}", k, v.splash_hex());
};
kv("accent", theme.accent);
kv("selection", theme.selection);
kv("muted", theme.muted);
kv("background", theme.background);
kv("dark_background", theme.dark_background);
kv("darker_background", theme.darker_background);
kv("lighter_background", theme.lighter_background);
kv("foreground", theme.foreground);
kv("dark_foreground", theme.dark_foreground);
kv("light_foreground", theme.light_foreground);
kv("bright_foreground", theme.bright_foreground);
kv("cursor", theme.cursor);
kv("active_border", theme.active_border.start.rgb);
kv("active_border_end", theme.active_border.end.rgb);
}
// A float literal, always: the script VM types `45` as an int and
// refuses to apply it to the shader's f32 angle.
let _ = writeln!(s, " active_border_angle: {:.1}", theme.active_border.angle);
let _ = writeln!(
s,
" active_border_alpha: {:.3}",
theme.active_border.start.alpha
);
let _ = writeln!(
s,
" inactive_border: {}",
theme.inactive_border.rgb.splash_hex()
);
let _ = writeln!(
s,
" inactive_border_alpha: {:.3}",
theme.inactive_border.alpha
);
let _ = writeln!(s, " light_mode: {}", theme.light_mode);
let _ = writeln!(s, " term: {{");
for (i, c) in theme.terminal_base16().iter().enumerate() {
let _ = writeln!(s, " color{}: {}", i, c.splash_hex());
}
let _ = writeln!(s, " foreground: {}", theme.foreground.splash_hex());
let _ = writeln!(s, " background: {}", theme.background.splash_hex());
let _ = writeln!(s, " cursor: {}", theme.cursor.splash_hex());
let _ = writeln!(s, " selection: {}", theme.selection.splash_hex());
let _ = writeln!(s, " }}");
let _ = writeln!(s, "}}");
s
}
// ----------------------------------------------------------------------
// Install / download
// ----------------------------------------------------------------------
pub fn makepad_home() -> PathBuf {
// An empty MAKEPAD_HOME counts as unset: PathBuf::from("") would make
// every path below it relative to the current folder.
if let Some(home) = std::env::var_os("MAKEPAD_HOME").filter(|home| !home.is_empty()) {
return PathBuf::from(home);
}
std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
.unwrap_or_else(crate::host::homeless_root)
.join(".makepad")
}
pub fn themes_dir() -> PathBuf {
makepad_home().join("wm/themes")
}
pub fn theme_splash_path(name: &str) -> PathBuf {
themes_dir().join(name).join("theme.splash")
}
pub fn installed_themes() -> Vec<String> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(themes_dir()) {
for entry in entries.flatten() {
if entry.path().join("theme.splash").exists() {
if let Some(name) = entry.file_name().to_str() {
out.push(name.to_string());
}
}
}
}
out.sort();
out
}
/// The theme's splash source (bundled default when nothing is installed).
pub fn load_theme_source(name: &str) -> String {
std::fs::read_to_string(theme_splash_path(name))
.unwrap_or_else(|_| BUNDLED_TOKYO_NIGHT_SPLASH.to_string())
}
/// Sorted background images of an installed theme.
pub fn theme_backgrounds(name: &str) -> Vec<PathBuf> {
let dir = themes_dir().join(name).join("backgrounds");
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
.map(|entries| {
entries
.flatten()
.map(|e| e.path())
.filter(|p| {
matches!(
p.extension().and_then(|e| e.to_str()),
Some("jpg" | "jpeg" | "png" | "webp" | "bmp" | "gif")
)
})
.collect()
})
.unwrap_or_default();
files.sort();
files
}
/// Seed the default theme locally without touching the network (a later
/// `--import-theme` upgrades it with the real backgrounds).
pub fn ensure_default_theme() {
let path = theme_splash_path(DEFAULT_THEME);
if let Ok(existing) = std::fs::read_to_string(&path) {
// Reseed files generated with the retired `#x` color form (the
// runtime tokenizer never understood it) and anything older than
// the current key set.
if !existing.contains("#x") && existing.contains(THEME_FORMAT) {
return;
}
}
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(&path, BUNDLED_TOKYO_NIGHT_SPLASH);
}
fn curl(url: &str) -> Option<Vec<u8>> {
let mut command = std::process::Command::new("curl");
crate::host::no_console_window(&mut command);
let out = command.args(["-sfL", "--max-time", "60", url]).output().ok()?;
if !out.status.success() {
return None;
}
Some(out.stdout)
}
/// Fetch a theme's wallpapers from the omarchy repo into its `backgrounds`
/// folder when that folder is empty — the bundled default theme ships its
/// colours but not its pictures, so a fresh install has nothing behind the
/// desk until this runs. Off the UI thread (the caller spawns it); the
/// pictures are applied when the desk notices them (`theme_backgrounds`).
/// Nothing when the folder already has pictures or the network is away.
pub fn fetch_backgrounds_if_missing(name: &str) -> usize {
let dir = themes_dir().join(name).join("backgrounds");
if !theme_backgrounds(name).is_empty() {
return 0;
}
let _ = std::fs::create_dir_all(&dir);
let listing_url = format!("{}/{}/backgrounds", OMARCHY_API, name);
let Some(listing) = curl(&listing_url) else {
return 0;
};
let listing = String::from_utf8_lossy(&listing);
let mut fetched = 0usize;
for part in listing.split("\"download_url\"") {
let Some(url) = part.split('"').nth(1).filter(|u| u.starts_with("https://")) else {
continue;
};
let Some(file) = url.rsplit('/').next() else {
continue;
};
if let Some(bytes) = curl(url) {
let _ = std::fs::write(dir.join(file), bytes);
fetched += 1;
}
}
fetched
}
/// Download an omarchy theme by name and CONVERT it: theme.splash plus the
/// background images. Accepts "Tokyo Night" or "tokyo-night".
pub fn import_omarchy_theme(name: &str) -> Result<String, String> {
// omarchy-theme-set's normalization: lowercase, spaces to dashes.
let slug = name.trim().to_lowercase().replace(' ', "-");
if slug.is_empty() || slug.starts_with('.') || slug.contains('/') {
return Err(format!("invalid theme name: {}", name));
}
let toml_url = format!("{}/{}/colors.toml", OMARCHY_RAW, slug);
let toml_bytes =
curl(&toml_url).ok_or_else(|| format!("theme '{}' not found at {}", slug, toml_url))?;
let toml = String::from_utf8_lossy(&toml_bytes).to_string();
let theme = resolve_omarchy_colors(&slug, &toml)
.ok_or_else(|| format!("theme '{}' has an unparseable colors.toml", slug))?;
let dir = themes_dir().join(&slug);
std::fs::create_dir_all(dir.join("backgrounds")).map_err(|e| e.to_string())?;
std::fs::write(dir.join("theme.splash"), splash_source(&theme))
.map_err(|e| e.to_string())?;
// Backgrounds via the github contents API listing — the same fetch a
// fresh install runs for the bundled theme on its own.
let fetched = fetch_backgrounds_if_missing(&slug);
Ok(format!(
"imported '{}' to splash ({} backgrounds) at {}",
slug,
fetched,
dir.display()
))
}
/// Fallback terminal palette values, read from a theme.splash source by
/// the WM when it hands a palette to a child terminal. This is a plain
/// line scan over the generated shape — themes edited into fancier splash
/// still work for the CHROME (full evaluation); only the term block needs
/// to keep `colorN: #hex` lines scannable.
pub fn scan_term_palette(splash_src: &str) -> Option<TermPalette> {
let mut base16 = [Rgb { r: 0, g: 0, b: 0 }; 16];
let mut seen = 0u32;
let mut foreground = None;
let mut background = None;
let mut cursor = None;
let mut in_term = false;
for line in splash_src.lines() {
let line = line.trim();
if line.starts_with("term:") {
in_term = true;
continue;
}
if !in_term {
continue;
}
if line.starts_with('}') {
break;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_end_matches(',');
if let Some(idx) = key.strip_prefix("color") {
if let (Ok(i), Some(rgb)) = (idx.parse::<usize>(), parse_hex(value)) {
if i < 16 {
base16[i] = rgb;
seen |= 1 << i;
}
}
} else if key == "foreground" {
foreground = parse_hex(value);
} else if key == "background" {
background = parse_hex(value);
} else if key == "cursor" {
cursor = parse_hex(value);
}
}
if seen != 0xffff {
return None;
}
Some(TermPalette {
base16,
foreground: foreground?,
background: background?,
cursor,
})
}
/// Read the border keys out of a theme.splash source: `active_border`,
/// `active_border_end`, `active_border_angle`, `active_border_alpha`,
/// `inactive_border`, `inactive_border_alpha`. A line scan over the shape
/// `splash_source` emits; anything missing falls back to the omarchy
/// default, so hand-written and older themes still draw.
pub fn scan_borders(splash_src: &str) -> (Gradient, Stop) {
let mut active = DEFAULT_ACTIVE_BORDER;
let mut inactive = DEFAULT_INACTIVE_BORDER;
let mut start: Option<Stop> = None;
let mut end: Option<Stop> = None;
let mut active_alpha: Option<f64> = None;
let mut inactive_alpha: Option<f64> = None;
for line in splash_src.lines() {
let line = line.trim();
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = value.trim().trim_end_matches(',');
match key.trim() {
"active_border" => start = parse_hypr_color(value),
"active_border_end" => end = parse_hypr_color(value),
"active_border_angle" => {
if let Ok(v) = value.parse::<f64>() {
active.angle = v;
}
}
"active_border_alpha" => active_alpha = value.parse::<f64>().ok(),
"inactive_border" => {
if let Some(stop) = parse_hypr_color(value) {
inactive = stop;
}
}
"inactive_border_alpha" => inactive_alpha = value.parse::<f64>().ok(),
_ => {}
}
}
if let Some(start) = start {
active.start = start;
// One stop only: a solid border, not a gradient into the default.
active.end = end.unwrap_or(start);
} else if let Some(end) = end {
active.end = end;
}
if let Some(alpha) = active_alpha {
active.start.alpha = alpha;
active.end.alpha = alpha;
}
if let Some(alpha) = inactive_alpha {
inactive.alpha = alpha;
}
(active, inactive)
}
#[derive(Clone, Debug)]
pub struct TermPalette {
pub base16: [Rgb; 16],
pub foreground: Rgb,
pub background: Rgb,
pub cursor: Option<Rgb>,
}
impl TermPalette {
/// The MAKEPAD_TERMINAL_COLORS env value handed to child terminals.
pub fn env_value(&self) -> String {
let mut s = String::new();
for (i, c) in self.base16.iter().enumerate() {
let _ = write!(s, "color{}={};", i, c.hex());
}
let _ = write!(s, "foreground={};", self.foreground.hex());
let _ = write!(s, "background={};", self.background.hex());
if let Some(c) = self.cursor {
let _ = write!(s, "cursor={};", c.hex());
}
s
}
}
/// Omarchy's default ("Tokyo Night", seeded by install/user/theme.sh),
/// pre-imported to splash so first launch needs no network.
pub const BUNDLED_TOKYO_NIGHT_SPLASH: &str = r##"// wm theme "tokyo-night" — imported from the omarchy theme of the same
// name; this splash file IS the theme now. Edit freely. (format 3)
mod.wm_theme = {
accent: #7aa2f7
selection: #292e42
muted: #414868
background: #1a1b26
dark_background: #13141c
darker_background: #0e0e14
lighter_background: #24283b
foreground: #a9b1d6
dark_foreground: #565f89
light_foreground: #b4bee6
bright_foreground: #c0caf5
cursor: #c0caf5
active_border: #33ccff
active_border_end: #00ff99
active_border_angle: 45.0
active_border_alpha: 0.933
inactive_border: #595959
inactive_border_alpha: 0.850
light_mode: false
term: {
color0: #1a1b26
color1: #f7768e
color2: #9ece6a
color3: #e0af68
color4: #7aa2f7
color5: #ad8ee6
color6: #449dab
color7: #a9b1d6
color8: #414868
color9: #ff7a93
color10: #b9f27c
color11: #ff9e64
color12: #7da6ff
color13: #bb9af7
color14: #0db9d7
color15: #c0caf5
foreground: #a9b1d6
background: #1a1b26
cursor: #c0caf5
selection: #292e42
}
}
"##;
// ----------------------------------------------------------------------
// The shell token object — `default/themed/shell.toml.tpl`
// ----------------------------------------------------------------------
/// Read one `key: #rrggbb` out of a theme.splash source (top level).
fn scan_rgb(source: &str, key: &str) -> Option<Rgb> {
for line in source.lines() {
let line = line.trim();
let Some((k, v)) = line.split_once(':') else {
continue;
};
if k.trim() == key {
return parse_hex(v.trim().trim_end_matches(','));
}
}
None
}
/// True when the theme ships its own `shell: { ... }` block, in which case
/// it replaces the generated one wholesale (omarchy's rule for a theme
/// shipping `themes/<name>/shell.toml` instead of the generated file).
pub fn theme_defines_shell(source: &str) -> bool {
source
.lines()
.any(|l| l.trim_start().starts_with("shell:") || l.trim_start().starts_with("shell :"))
}
/// `mod.wm_theme.shell = { ... }`, resolved from the theme's own palette
/// exactly the way `bin/omarchy-theme-set-templates` fills
/// `default/themed/shell.toml.tpl`:
///
/// * `{{ background }}` / `{{ foreground }}` / `{{ accent }}` / `{{ red }}`
/// come from the palette,
/// * `hyprland.active-border` is `shell_gradient hyprland_active_border
/// accent` — the theme's border gradient, or its accent when it names
/// none,
/// * `hyprland.active-border-foreground` is the same gradient with
/// `foreground` as the fallback (which is what a theme with no gradient
/// gets on the menu, launcher and tooltip),
/// * every size and alpha is the `.tpl` default.
///
/// Sizes are px and alphas 0..1, like the `.tpl`; the shell reads this and
/// nothing else at runtime.
pub fn shell_splash_block(source: &str) -> String {
let foreground = scan_rgb(source, "foreground").unwrap_or(Rgb {
r: 0xca,
g: 0xcc,
b: 0xcc,
});
let background = scan_rgb(source, "background").unwrap_or(Rgb {
r: 0x10,
g: 0x13,
b: 0x15,
});
let accent = scan_rgb(source, "accent").unwrap_or(foreground);
// `{{ red }}`: the palette's red, which the terminal block carries as
// color1 (`urgent` in Color.qml's vocabulary).
let red = scan_term_palette(source)
.map(|p| p.base16[1])
.unwrap_or(Rgb {
r: 0xa5,
g: 0x55,
b: 0x55,
});
let (active, _inactive) = scan_borders(source);
// A theme that names no gradient got `accent, solid` from the import,
// so the `-foreground` variant of the token falls back to foreground.
let named_gradient = !(active.is_solid() && active.start.rgb == accent);
let (fg_border_start, fg_border_end, fg_border_angle) = if named_gradient {
(active.start.rgb, active.end.rgb, active.angle)
} else {
(foreground, foreground, 0.0)
};
let (ac_border_start, ac_border_end, ac_border_angle) =
(active.start.rgb, active.end.rgb, active.angle);
let h = |c: Rgb| c.splash_hex();
let mut s = String::new();
let _ = writeln!(s, "mod.wm_theme.shell = {{");
let _ = writeln!(s, " corner_radius: 0.0");
let _ = writeln!(s, " bar: {{");
let _ = writeln!(s, " background: {}", h(background));
let _ = writeln!(s, " background_alpha: 1.0");
let _ = writeln!(s, " text: {}", h(foreground));
let _ = writeln!(s, " active: {}", h(red));
let _ = writeln!(s, " size_horizontal: 26.0");
let _ = writeln!(s, " size_vertical: 28.0");
let _ = writeln!(s, " icon_slot: 27.0");
let _ = writeln!(s, " icon_canvas: 16.0");
let _ = writeln!(s, " icon_font: 13.0");
let _ = writeln!(s, " status_slot: 21.0");
let _ = writeln!(s, " }}");
/// One surface section: the card colors plus its border, which may be
/// the accent-fallback gradient or the foreground-fallback one.
#[allow(clippy::too_many_arguments)]
fn surface(
s: &mut String,
name: &str,
bg_alpha: &str,
border_width: &str,
background: Rgb,
foreground: Rgb,
border: (Rgb, Rgb, f64),
) {
let _ = writeln!(s, " {}: {{", name);
let _ = writeln!(s, " background: {}", background.splash_hex());
let _ = writeln!(s, " background_alpha: {}", bg_alpha);
let _ = writeln!(s, " text: {}", foreground.splash_hex());
let _ = writeln!(s, " border: {}", border.0.splash_hex());
let _ = writeln!(s, " border_end: {}", border.1.splash_hex());
let _ = writeln!(s, " border_angle: {:.1}", border.2);
let _ = writeln!(s, " border_alpha: 1.0");
let _ = writeln!(s, " border_width: {}", border_width);
}
let accent_border = (ac_border_start, ac_border_end, ac_border_angle);
let fg_border = (fg_border_start, fg_border_end, fg_border_angle);
surface(
&mut s, "popups", "1.0", "2.0", background, foreground, accent_border,
);
let _ = writeln!(s, " }}");
surface(
&mut s, "tooltip", "0.97", "1.0", background, foreground, fg_border,
);
let _ = writeln!(s, " }}");
surface(
&mut s,
"notifications",
"1.0",
"2.0",
background,
foreground,
accent_border,
);
let _ = writeln!(s, " countdown: {}", h(accent));
let _ = writeln!(s, " }}");
for (name, card_alpha) in [("menu", "1.0"), ("launcher", "0.95")] {
surface(
&mut s, name, card_alpha, "2.0", background, foreground, fg_border,
);
let _ = writeln!(s, " scrim: {}", h(background));
let _ = writeln!(s, " scrim_alpha: 0.5");
let _ = writeln!(s, " selected_background: {}", h(foreground));
let _ = writeln!(s, " selected_background_alpha: 0.08");
let _ = writeln!(s, " selected_text: {}", h(accent));
let _ = writeln!(s, " selected_border: {}", h(fg_border_start));
let _ = writeln!(s, " selected_border_alpha: 0.25");
let _ = writeln!(s, " }}");
}
let _ = writeln!(s, " controls: {{");
for state in ["normal", "hover", "focus", "selected"] {
let (fill, border_a, width) = match state {
"normal" => ("0.04", "0.4", "1.0"),
"hover" | "focus" => ("0.08", "0.25", "1.0"),
_ => ("0.18", "1.0", "0.0"),
};
let _ = writeln!(s, " {}_color: {}", state, h(foreground));
let _ = writeln!(s, " {}_fill_alpha: {}", state, fill);
let _ = writeln!(s, " {}_border: {}", state, h(foreground));
let _ = writeln!(s, " {}_border_width: {}", state, width);
let _ = writeln!(s, " {}_border_alpha: {}", state, border_a);
}
let _ = writeln!(s, " pressed_fill_alpha: 0.22");
let _ = writeln!(s, " selection_fill_alpha: 0.35");
let _ = writeln!(s, " }}");
let _ = writeln!(s, " spacing: {{");
for (k, v) in [
("xxs", 2.0),
("xs", 3.0),
("sm", 4.0),
("md", 6.0),
("lg", 8.0),
("xl", 10.0),
("xxl", 12.0),
("xxxl", 14.0),
("huge", 18.0),
("control_gap", 8.0),
("control_padding_x", 10.0),
("control_padding_y", 6.0),
("input_padding_y", 7.0),
("control_height", 28.0),
("popup_row_height", 28.0),
("dropdown_width", 240.0),
("searchable_dropdown_width", 260.0),
("number_field_width", 120.0),
("searchable_popup_min_height", 220.0),
("row_gap", 8.0),
("row_padding_x", 12.0),
("label_gap", 4.0),
("panel_gap", 14.0),
("panel_padding", 18.0),
("popup_padding", 14.0),
("gaps_out", 5.0),
] {
let _ = writeln!(s, " {}: {:.1}", k, v);
}
let _ = writeln!(s, " }}");
let _ = writeln!(s, " font: {{");
for (k, v) in [
("base_size", 12.0),
("caption", 10.0),
("body_small", 11.0),
("body", 12.0),
("subtitle", 13.0),
("title", 14.0),
("heading", 16.0),
("display", 24.0),
("display_large", 28.0),
("icon_small", 11.0),
("icon", 14.0),
("icon_large", 18.0),
] {
let _ = writeln!(s, " {}: {:.1}", k, v);
}
let _ = writeln!(s, " }}");
let _ = writeln!(s, "}}");
s
}
#[cfg(test)]
mod tests {
use super::*;
const TOKYO_TOML: &str = r##"mode = "dark"
accent = "#7aa2f7"
selection = "#292e42"
muted = "#414868"
background = "#1a1b26"
dark_background = "#13141c"
darker_background = "#0e0e14"
lighter_background = "#24283b"
foreground = "#a9b1d6"
dark_foreground = "#565f89"
light_foreground = "#b4bee6"
bright_foreground = "#c0caf5"
red = "#f7768e"
yellow = "#e0af68"
orange = "#eb927b"
green = "#9ece6a"
cyan = "#449dab"
blue = "#7aa2f7"
magenta = "#ad8ee6"
brown = "#75493d"
bright_red = "#ff7a93"
bright_yellow = "#ff9e64"
bright_green = "#b9f27c"
bright_cyan = "#0db9d7"
bright_blue = "#7da6ff"
bright_magenta = "#bb9af7"
"##;
#[test]
fn import_matches_bundled_splash() {
let t = resolve_omarchy_colors("tokyo-night", TOKYO_TOML).unwrap();
assert_eq!(splash_source(&t), BUNDLED_TOKYO_NIGHT_SPLASH);
}
#[test]
fn cascade_derives_missing_values() {
let minimal = "background = \"#102030\"\nforeground = \"#c0c0c0\"\nred = \"#ff0000\"\n";
let t = resolve_omarchy_colors("x", minimal).unwrap();
assert_eq!(t.dark_background.hex(), "#0c1824");
assert_eq!(t.darker_background.hex(), "#081018");
assert_eq!(t.bright[0].hex(), "#ff3333");
assert_eq!(t.cursor.hex(), "#c0c0c0");
assert_eq!(t.hues[2].hex(), "#c0c0c0");
assert!(!t.light_mode);
}
#[test]
fn ansi_alias_only_theme() {
let legacy = "color0 = \"#111111\"\ncolor7 = \"#dddddd\"\ncolor1 = \"#aa0000\"\ncolor8 = \"#555555\"\n";
let t = resolve_omarchy_colors("legacy", legacy).unwrap();
assert_eq!(t.background.hex(), "#111111");
assert_eq!(t.foreground.hex(), "#dddddd");
assert_eq!(t.hues[0].hex(), "#aa0000");
assert_eq!(t.muted.hex(), "#555555");
}
#[test]
fn scan_term_palette_from_splash() {
let p = scan_term_palette(BUNDLED_TOKYO_NIGHT_SPLASH).unwrap();
assert_eq!(p.base16[0].hex(), "#1a1b26");
assert_eq!(p.base16[14].hex(), "#0db9d7");
assert_eq!(p.foreground.hex(), "#a9b1d6");
assert_eq!(p.cursor.unwrap().hex(), "#c0caf5");
let env = p.env_value();
assert!(env.contains("color9=#ff7a93;"));
assert!(env.contains("background=#1a1b26;"));
}
#[test]
fn a_theme_without_a_gradient_gets_hyprlands_own() {
// looknfeel.lua:3-4 — a theme that names no gradient keeps
// hyprland's default cyan→green 45° ring, which is what the
// reference desktop shows; only the accent-solid guess was ours.
let t = resolve_omarchy_colors("tokyo-night", TOKYO_TOML).unwrap();
assert!(!t.active_border.is_solid());
assert_eq!(t.active_border.start.rgb.hex(), "#33ccff");
assert_eq!(t.active_border.end.rgb.hex(), "#00ff99");
assert_eq!(t.active_border.angle, 45.0);
assert_eq!(t.inactive_border.rgb.hex(), "#595959");
}
#[test]
fn theme_can_override_borders() {
// hackerman's real colors.toml line.
let toml = format!(
"{}hyprland_active_border = \"rgba(26a269ee) rgba(2ec27eee) 45deg\"\nhyprland_inactive_border = \"rgba(584e51aa)\"\n",
TOKYO_TOML
);
let t = resolve_omarchy_colors("x", &toml).unwrap();
assert!(!t.active_border.is_solid());
assert_eq!(t.active_border.start.rgb.hex(), "#26a269");
assert_eq!(t.active_border.end.rgb.hex(), "#2ec27e");
assert_eq!(t.active_border.angle, 45.0);
assert_eq!(t.inactive_border.rgb.hex(), "#584e51");
assert!((t.inactive_border.alpha - 0.667).abs() < 0.002);
// last-horizon's: two stops, NO angle — a horizontal gradient.
let toml = format!(
"{}hyprland_active_border = \"rgba(8a8588ee) rgba(e2dddcee)\"\n",
TOKYO_TOML
);
let t = resolve_omarchy_colors("x", &toml).unwrap();
assert_eq!(t.active_border.angle, 0.0);
assert_eq!(t.active_border.end.rgb.hex(), "#e2dddc");
// solitude's inactive: an rgb() with no alpha at all.
let toml = format!("{}hyprland_inactive_border = \"rgb(1e1e1e)\"\n", TOKYO_TOML);
let t = resolve_omarchy_colors("x", &toml).unwrap();
assert_eq!(t.inactive_border.rgb.hex(), "#1e1e1e");
assert_eq!(t.inactive_border.alpha, 1.0);
}
#[test]
fn hypr_color_forms() {
assert_eq!(
parse_hypr_color("rgba(33ccffee)").unwrap().rgb.hex(),
"#33ccff"
);
assert!((parse_hypr_color("rgba(33ccffee)").unwrap().alpha - 0.933).abs() < 0.002);
assert_eq!(parse_hypr_color("rgb(00ff99)").unwrap().alpha, 1.0);
assert_eq!(parse_hypr_color("0xff112233").unwrap().rgb.hex(), "#112233");
assert_eq!(parse_hypr_color("#abcdef").unwrap().rgb.hex(), "#abcdef");
assert!(parse_hypr_color("nope").is_none());
let g = parse_hypr_gradient("rgba(33ccffee) rgba(00ff99ee) 45deg").unwrap();
assert_eq!(g.end.rgb.hex(), "#00ff99");
assert_eq!(g.angle, 45.0);
assert!(parse_hypr_gradient("rgba(595959aa)").unwrap().is_solid());
}
#[test]
fn scan_borders_reads_the_emitted_shape() {
let (active, inactive) = scan_borders(BUNDLED_TOKYO_NIGHT_SPLASH);
assert_eq!(active.start.rgb.hex(), "#33ccff");
assert_eq!(active.end.rgb.hex(), "#00ff99");
assert!(!active.is_solid());
assert_eq!(active.angle, 45.0);
assert!((active.start.alpha - 0.933).abs() < 0.001);
assert_eq!(inactive.rgb.hex(), "#595959");
assert!((inactive.alpha - 0.85).abs() < 0.001);
// A theme that only names one stop draws a SOLID border.
let solid = scan_borders("mod.wm_theme = {\n active_border: #7aa2f7\n}\n");
assert!(solid.0.is_solid());
assert_eq!(solid.0.start.rgb.hex(), "#7aa2f7");
// Nothing at all: the omarchy default.
assert_eq!(scan_borders("").0, DEFAULT_ACTIVE_BORDER);
assert_eq!(scan_borders("").1, DEFAULT_INACTIVE_BORDER);
}
/// A theme.splash the shell-token tests own, so they do not move when
/// the bundled theme's border defaults do.
const SHELL_SRC: &str = r##"mod.wm_theme = {
accent: #7aa2f7
background: #1a1b26
foreground: #a9b1d6
active_border: #7aa2f7
active_border_end: #7aa2f7
active_border_angle: 0.0
term: {
color0: #1a1b26
color1: #f7768e
color2: #9ece6a
color3: #e0af68
color4: #7aa2f7
color5: #ad8ee6
color6: #449dab
color7: #a9b1d6
color8: #414868
color9: #ff7a93
color10: #b9f27c
color11: #ff9e64
color12: #7da6ff
color13: #bb9af7
color14: #0db9d7
color15: #c0caf5
foreground: #a9b1d6
background: #1a1b26
cursor: #c0caf5
selection: #292e42
}
}
"##;
#[test]
fn the_shell_block_resolves_the_tpl_contract() {
let block = shell_splash_block(SHELL_SRC);
assert!(block.starts_with("mod.wm_theme.shell = {"));
// [bar]: background at α 1.0, text = foreground, active = red.
assert!(block.contains(" background: #1a1b26\n"));
assert!(block.contains(" text: #a9b1d6\n"));
assert!(block.contains(" active: #f7768e\n"));
assert!(block.contains(" size_horizontal: 26.0\n"));
// [launcher] is the translucent card, [menu] the opaque one.
assert!(block.contains(" launcher: {\n"));
assert!(block.contains(" background_alpha: 0.95\n"));
assert!(block.contains(" scrim_alpha: 0.5\n"));
assert!(block.contains(" selected_background_alpha: 0.08\n"));
// [tooltip] keeps its legacy 0.97.
assert!(block.contains(" background_alpha: 0.97\n"));
// [controls]: the shared state alphas.
assert!(block.contains(" normal_fill_alpha: 0.04\n"));
assert!(block.contains(" hover_fill_alpha: 0.08\n"));
assert!(block.contains(" selected_fill_alpha: 0.18\n"));
assert!(block.contains(" pressed_fill_alpha: 0.22\n"));
// The spacing and type scales.
assert!(block.contains(" control_height: 28.0\n"));
assert!(block.contains(" panel_padding: 18.0\n"));
assert!(block.contains(" display_large: 28.0\n"));
// A theme that ships its own block keeps it.
assert!(theme_defines_shell("mod.wm_theme = {\n shell: {}\n}\n"));
assert!(!theme_defines_shell(SHELL_SRC));
}
#[test]
fn the_shell_border_falls_back_the_way_shell_gradient_does() {
// A theme that names no gradient: popups take the accent, and the
// `-foreground` surfaces (menu, launcher, tooltip) take foreground.
let block = shell_splash_block(SHELL_SRC);
let section = |name: &str| -> String {
let start = block.find(&format!(" {}: {{\n", name)).unwrap();
let end = block[start..].find("\n }").unwrap();
block[start..start + end].to_string()
};
assert!(section("popups").contains("border: #7aa2f7"));
assert!(section("menu").contains("border: #a9b1d6"));
assert!(section("tooltip").contains("border: #a9b1d6"));
// A theme with a real gradient hands both stops and the angle to
// every surface that references the hyprland token.
let themed = SHELL_SRC.replace(
" active_border_end: #7aa2f7",
" active_border_end: #2ec27e",
);
let block = shell_splash_block(&themed);
assert!(block.contains(" border_end: #2ec27e\n"));
}
#[test]
fn light_mode_resolution() {
let light = "mode = \"light\"\nbackground = \"#101010\"\nforeground = \"#000000\"\n";
assert!(resolve_omarchy_colors("x", light).unwrap().light_mode);
let lum = "background = \"#f0f0f0\"\nforeground = \"#000000\"\n";
assert!(resolve_omarchy_colors("x", lum).unwrap().light_mode);
}
}