Add per-output palettes and window-scoped theme binding
Some checks failed
dev bakery / build (push) Failing after 1s
dev bread-theme / build (push) Failing after 1s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
release bakery / build (push) Failing after 1s
release bread-theme / build (push) Failing after 1s
Build and publish package / package (push) Failing after 39s
Some checks failed
dev bakery / build (push) Failing after 1s
dev bread-theme / build (push) Failing after 1s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
release bakery / build (push) Failing after 1s
release bread-theme / build (push) Failing after 1s
Build and publish package / package (push) Failing after 39s
Each Hyprland/GDK connector can have its own palette and stylesheet
under $XDG_RUNTIME_DIR/bread/{palettes,themes}/. GTK apps bind a
widget-level provider so two windows in one process can follow
different wallpapers. Bump workspace version to 0.7.4 for the tag.
This commit is contained in:
parent
11c0e844e5
commit
fcba376038
8 changed files with 1016 additions and 58 deletions
|
|
@ -3,7 +3,7 @@ members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screensh
|
|||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Breadway <plasticbread849@gmail.com>"]
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ pub fn show_prompt(app: &Application, prompt: Prompt) {
|
|||
panel.append(&buttons);
|
||||
|
||||
window.set_child(Some(&panel));
|
||||
bread_theme::gtk::bind_window_auto_with_app_css(&window, |_| app_css());
|
||||
|
||||
let reply = prompt.reply.clone();
|
||||
let cookie = prompt.cookie.clone();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,48 @@
|
|||
# bread-theme changelog
|
||||
|
||||
## 0.7.4
|
||||
|
||||
Per-output (per-monitor) theming. Session-global `theme.css` remains the
|
||||
fallback / focused-monitor sheet; each Hyprland/GDK connector can now have
|
||||
its own palette and stylesheet. BOS still keeps bg/surface/overlay/fg
|
||||
fixed — only color1–6 come from the wallpaper.
|
||||
|
||||
On disk under `$XDG_RUNTIME_DIR/bread/` (same fallback as `shared_css_path`):
|
||||
|
||||
- `palettes/<sanitized-output>.json` — accents only (round-trips through
|
||||
`from_wal_json` / a color1–6 object; never persists pywal's light bg)
|
||||
- `themes/<sanitized-output>.css` — `stylesheet()` for that palette
|
||||
|
||||
New lib API:
|
||||
|
||||
- `themes_dir`, `palettes_dir`, `output_css_path`, `output_palette_path`,
|
||||
`sanitize_output`
|
||||
- `load_palette_for`, `write_output_palette`, `write_output_css`,
|
||||
`write_shared_css_from`
|
||||
- `palette_from_image` (isolated `wal -i`, does not touch `~/.cache/wal`),
|
||||
`generate_output`, `palette_from_json`
|
||||
- `stylesheet_resolved` — inlines `@accent` / `@on-bg` / … to hex so GTK's
|
||||
display-global `@define-color` cannot leak the wrong monitor's accent
|
||||
|
||||
GTK (`gtk` feature): `bind_window`, `bind_window_with_app_css`,
|
||||
`output_for_widget`, `bind_window_auto`, `bind_window_auto_with_app_css`.
|
||||
Widget-scoped providers at `USER - 10` so they beat `apply_shared` but
|
||||
lose to user CSS. Existing `apply_shared` / `apply_app_css` /
|
||||
`apply_css` / `apply_user_css` are unchanged.
|
||||
|
||||
CLI: `bread-theme generate-output <OUTPUT> --image <PATH> | --from-json
|
||||
<FILE> [--shared]`. Does not write `theme.css` unless `--shared`.
|
||||
|
||||
## Coordinated bump policy
|
||||
|
||||
`bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned
|
||||
git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature
|
||||
API requires all three dependents to bump their `Cargo.toml` git tag and cut a
|
||||
release together. Note the impact in this file before tagging.
|
||||
`bread-theme` is consumed by `breadbar`, `breadbox`, `breadpad`, and the other
|
||||
GTK bread apps as a pinned git dependency. A breaking change to `Palette`,
|
||||
`css_vars`, or the `gtk` feature API requires dependents to bump their
|
||||
`Cargo.toml` git tag and cut a release together. Note the impact in this file
|
||||
before tagging.
|
||||
|
||||
**0.7.4** adds per-output bind APIs (`bind_window*`, `load_palette_for`,
|
||||
`generate_output`). Apps that call those must pin `tag = "v0.7.4"`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
//! # signal every running bread GUI to recolour
|
||||
//! bread-theme path # print the stylesheet path
|
||||
//! bread-theme print # render to stdout (no write)
|
||||
//! bread-theme generate-output <OUTPUT> --image <PATH> [--shared]
|
||||
//! bread-theme generate-output <OUTPUT> --from-json <PATH> [--shared]
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
|
|
@ -25,6 +27,149 @@ fn write_and_report(verb: &str) -> ExitCode {
|
|||
}
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
eprintln!(
|
||||
"bread-theme — shared stylesheet generator\n\n\
|
||||
USAGE:\n\
|
||||
\x20 bread-theme [generate|reload|path|print]\n\
|
||||
\x20 bread-theme generate-output <OUTPUT> --image <PATH> [--shared]\n\
|
||||
\x20 bread-theme generate-output <OUTPUT> --from-json <WAL-OR-PALETTE.json> [--shared]\n\n\
|
||||
generate render the pywal palette to the shared stylesheet (default)\n\
|
||||
reload re-render and signal running bread GUIs to recolour live\n\
|
||||
path print the stylesheet path ({})\n\
|
||||
print render to stdout without writing\n\
|
||||
generate-output write palettes/<OUTPUT>.json and themes/<OUTPUT>.css\n\
|
||||
\x20 --image isolated `wal -i` (does not touch ~/.cache/wal)\n\
|
||||
\x20 --from-json wal colors.json or a color1-6 object\n\
|
||||
\x20 --shared also write the session-global theme.css",
|
||||
bread_theme::shared_css_path().display()
|
||||
);
|
||||
}
|
||||
|
||||
fn generate_output_cmd() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(2).collect();
|
||||
if args.is_empty()
|
||||
|| args
|
||||
.iter()
|
||||
.any(|a| matches!(a.as_str(), "-h" | "--help" | "help"))
|
||||
{
|
||||
print_help();
|
||||
return if args.is_empty() {
|
||||
ExitCode::FAILURE
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
};
|
||||
}
|
||||
|
||||
let output = args[0].as_str();
|
||||
if output.starts_with('-') {
|
||||
eprintln!("bread-theme: generate-output requires an OUTPUT name (got '{output}')");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let mut image: Option<&str> = None;
|
||||
let mut from_json: Option<&str> = None;
|
||||
let mut shared = false;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--shared" => shared = true,
|
||||
"--image" => {
|
||||
i += 1;
|
||||
match args.get(i) {
|
||||
Some(p) => image = Some(p.as_str()),
|
||||
None => {
|
||||
eprintln!("bread-theme: --image requires a path");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
"--from-json" => {
|
||||
i += 1;
|
||||
match args.get(i) {
|
||||
Some(p) => from_json = Some(p.as_str()),
|
||||
None => {
|
||||
eprintln!("bread-theme: --from-json requires a path");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
eprintln!("bread-theme: unknown generate-output flag '{other}'");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
match (image, from_json) {
|
||||
(Some(_), Some(_)) => {
|
||||
eprintln!("bread-theme: pass only one of --image or --from-json");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
(None, None) => {
|
||||
eprintln!("bread-theme: generate-output needs --image <PATH> or --from-json <PATH>");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
(Some(path), None) => {
|
||||
match bread_theme::generate_output(output, std::path::Path::new(path)) {
|
||||
Ok(css) => finish_generate_output(output, css, shared),
|
||||
Err(e) => {
|
||||
eprintln!("bread-theme: generate-output failed: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, Some(path)) => match write_output_from_json(output, path, shared) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("bread-theme: generate-output failed: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn write_output_from_json(output: &str, json_path: &str, shared: bool) -> std::io::Result<()> {
|
||||
let json = std::fs::read_to_string(json_path)?;
|
||||
let palette = bread_theme::palette_from_json(&json).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("could not parse palette JSON: {json_path}"),
|
||||
)
|
||||
})?;
|
||||
let pal_path = bread_theme::write_output_palette(output, &palette)?;
|
||||
let css_path = bread_theme::write_output_css(output, &palette)?;
|
||||
eprintln!(
|
||||
"bread-theme: wrote {} and {}",
|
||||
pal_path.display(),
|
||||
css_path.display()
|
||||
);
|
||||
if shared {
|
||||
let shared_path = bread_theme::write_shared_css_from(&palette)?;
|
||||
eprintln!("bread-theme: wrote shared {}", shared_path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_generate_output(output: &str, css: std::path::PathBuf, shared: bool) -> ExitCode {
|
||||
eprintln!("bread-theme: wrote {}", css.display());
|
||||
if shared {
|
||||
match bread_theme::write_shared_css_from(&bread_theme::load_palette_for(output)) {
|
||||
Ok(path) => {
|
||||
eprintln!("bread-theme: wrote shared {}", path.display());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("bread-theme: failed to write shared stylesheet: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
|
||||
match cmd.as_str() {
|
||||
|
|
@ -42,20 +187,15 @@ fn main() -> ExitCode {
|
|||
// the file monitor in every running bread GUI, so they all re-read the
|
||||
// palette and recolour live — shared widgets *and* each app's own rules.
|
||||
"reload" => write_and_report("reloaded"),
|
||||
"generate-output" => generate_output_cmd(),
|
||||
"-h" | "--help" | "help" => {
|
||||
eprintln!(
|
||||
"bread-theme — shared stylesheet generator\n\n\
|
||||
USAGE:\n bread-theme [generate|reload|path|print]\n\n\
|
||||
generate render the pywal palette to the shared stylesheet (default)\n\
|
||||
reload re-render and signal running bread GUIs to recolour live\n\
|
||||
path print the stylesheet path ({})\n\
|
||||
print render to stdout without writing",
|
||||
bread_theme::shared_css_path().display()
|
||||
);
|
||||
print_help();
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
other => {
|
||||
eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)");
|
||||
eprintln!(
|
||||
"bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output)"
|
||||
);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
use gtk4::gdk::prelude::*;
|
||||
use gtk4::gio;
|
||||
use gtk4::glib::object::ObjectType;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::CssProvider;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::Palette;
|
||||
|
||||
/// Above APPLICATION (600) so we beat [`apply_shared`], below USER (800)
|
||||
/// so `apply_user_css` still wins.
|
||||
const BIND_PRIORITY: u32 = gtk4::STYLE_PROVIDER_PRIORITY_USER - 10;
|
||||
|
||||
thread_local! {
|
||||
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
||||
|
|
@ -14,8 +24,7 @@ thread_local! {
|
|||
}
|
||||
|
||||
fn reload_shared() {
|
||||
let css = std::fs::read_to_string(crate::shared_css_path())
|
||||
.unwrap_or_else(|_| crate::render());
|
||||
let css = std::fs::read_to_string(crate::shared_css_path()).unwrap_or_else(|_| crate::render());
|
||||
SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +130,10 @@ pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {
|
|||
/// breadpad, and breadman (both cream), none of which agreed with each
|
||||
/// other or with the shared token.
|
||||
pub fn chip(label: &str) -> gtk4::Button {
|
||||
gtk4::Button::builder().label(label).css_classes(["chip"]).build()
|
||||
gtk4::Button::builder()
|
||||
.label(label)
|
||||
.css_classes(["chip"])
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Toggles a chip's (or any widget's) `active` CSS class — the `.chip.active`
|
||||
|
|
@ -137,6 +149,319 @@ pub fn set_chip_active(chip: &impl IsA<gtk4::Widget>, active: bool) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Gdk connector for the monitor currently showing this widget, if any.
|
||||
pub fn output_for_widget(widget: &impl IsA<gtk4::Widget>) -> Option<String> {
|
||||
let widget = widget.as_ref();
|
||||
let native = widget.native()?;
|
||||
let surface = NativeExt::surface(&native)?;
|
||||
let monitor = widget.display().monitor_at_surface(&surface)?;
|
||||
monitor.connector().map(|c| c.to_string())
|
||||
}
|
||||
|
||||
struct WidgetBind {
|
||||
output: String,
|
||||
theme: CssProvider,
|
||||
app: Option<CssProvider>,
|
||||
app_build: Option<Rc<dyn Fn(&Palette) -> String>>,
|
||||
/// Keep the directory monitor + child model alive for this widget.
|
||||
_watch: Option<gio::ListModel>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static BINDS: RefCell<HashMap<usize, WidgetBind>> = RefCell::new(HashMap::new());
|
||||
static THEMES_MONITOR: RefCell<Option<gio::FileMonitor>> = const { RefCell::new(None) };
|
||||
static DESTROY_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
static AUTO_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
static ENTER_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
}
|
||||
|
||||
fn widget_key(widget: >k4::Widget) -> usize {
|
||||
widget.as_ptr() as usize
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
fn add_widget_provider(widget: >k4::Widget, provider: &CssProvider, prio: u32) {
|
||||
widget.style_context().add_provider(provider, prio);
|
||||
}
|
||||
|
||||
/// Same `CssProvider` on the widget and its current descendants so component
|
||||
/// rules actually reach buttons/labels (a style-context provider is not
|
||||
/// inherited by children).
|
||||
fn attach_tree(widget: >k4::Widget, theme: &CssProvider, app: Option<&CssProvider>) {
|
||||
add_widget_provider(widget, theme, BIND_PRIORITY);
|
||||
if let Some(app) = app {
|
||||
add_widget_provider(widget, app, BIND_PRIORITY + 1);
|
||||
}
|
||||
let mut child = widget.first_child();
|
||||
while let Some(c) = child {
|
||||
attach_tree(&c, theme, app);
|
||||
child = c.next_sibling();
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_destroy_cleanup(widget: >k4::Widget) {
|
||||
let key = widget_key(widget);
|
||||
let inserted = DESTROY_HOOKED.with(|s| s.borrow_mut().insert(key));
|
||||
if !inserted {
|
||||
return;
|
||||
}
|
||||
widget.connect_destroy(move |w| {
|
||||
let key = widget_key(w);
|
||||
BINDS.with(|b| {
|
||||
b.borrow_mut().remove(&key);
|
||||
});
|
||||
DESTROY_HOOKED.with(|s| {
|
||||
s.borrow_mut().remove(&key);
|
||||
});
|
||||
AUTO_HOOKED.with(|s| {
|
||||
s.borrow_mut().remove(&key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn ensure_themes_watch() {
|
||||
THEMES_MONITOR.with(|cell| {
|
||||
if cell.borrow().is_some() {
|
||||
return;
|
||||
}
|
||||
let dir = crate::themes_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let monitor = gio::File::for_path(&dir)
|
||||
.monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE)
|
||||
.ok();
|
||||
if let Some(ref m) = monitor {
|
||||
m.connect_changed(move |_, file, other, _event| {
|
||||
let path = file.path().or_else(|| other.and_then(|f| f.path()));
|
||||
let Some(path) = path else {
|
||||
return;
|
||||
};
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("css") {
|
||||
return;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
return;
|
||||
};
|
||||
reload_binds_for_sanitized(stem);
|
||||
});
|
||||
}
|
||||
*cell.borrow_mut() = monitor;
|
||||
});
|
||||
}
|
||||
|
||||
fn reload_binds_for_sanitized(sanitized: &str) {
|
||||
BINDS.with(|binds| {
|
||||
for bind in binds.borrow_mut().values_mut() {
|
||||
if crate::sanitize_output(&bind.output) != sanitized {
|
||||
continue;
|
||||
}
|
||||
let palette = crate::load_palette_for(&bind.output);
|
||||
bind.theme
|
||||
.load_from_string(&crate::stylesheet_resolved(&palette));
|
||||
if let (Some(build), Some(provider)) = (&bind.app_build, &bind.app) {
|
||||
provider.load_from_string(&crate::resolve_color_names(&build(&palette), &palette));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn watch_root_children(widget: >k4::Widget) -> gio::ListModel {
|
||||
let model = widget.observe_children();
|
||||
let root = widget.downgrade();
|
||||
model.connect_items_changed(move |_, _, _, _| {
|
||||
let Some(root) = root.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let key = widget_key(&root);
|
||||
BINDS.with(|binds| {
|
||||
if let Some(bind) = binds.borrow().get(&key) {
|
||||
attach_tree(&root, &bind.theme, bind.app.as_ref());
|
||||
}
|
||||
});
|
||||
});
|
||||
model
|
||||
}
|
||||
|
||||
fn bind_window_inner(
|
||||
widget: >k4::Widget,
|
||||
output: &str,
|
||||
app_build: Option<Rc<dyn Fn(&Palette) -> String>>,
|
||||
) {
|
||||
let key = widget_key(widget);
|
||||
let palette = crate::load_palette_for(output);
|
||||
let theme_css = crate::stylesheet_resolved(&palette);
|
||||
let app_css = app_build
|
||||
.as_ref()
|
||||
.map(|build| crate::resolve_color_names(&build(&palette), &palette));
|
||||
|
||||
BINDS.with(|binds| {
|
||||
let mut map = binds.borrow_mut();
|
||||
if let Some(existing) = map.get_mut(&key) {
|
||||
existing.output = output.to_string();
|
||||
existing.theme.load_from_string(&theme_css);
|
||||
existing.app_build = app_build.clone();
|
||||
match (&app_css, existing.app.as_ref()) {
|
||||
(Some(css), Some(p)) => p.load_from_string(css),
|
||||
(Some(css), None) => {
|
||||
let p = CssProvider::new();
|
||||
p.load_from_string(css);
|
||||
add_widget_provider(widget, &p, BIND_PRIORITY + 1);
|
||||
existing.app = Some(p);
|
||||
}
|
||||
(None, Some(p)) => p.load_from_string(""),
|
||||
(None, None) => {}
|
||||
}
|
||||
attach_tree(widget, &existing.theme, existing.app.as_ref());
|
||||
return;
|
||||
}
|
||||
|
||||
let theme = CssProvider::new();
|
||||
theme.load_from_string(&theme_css);
|
||||
add_widget_provider(widget, &theme, BIND_PRIORITY);
|
||||
|
||||
let app = app_css.map(|css| {
|
||||
let p = CssProvider::new();
|
||||
p.load_from_string(&css);
|
||||
add_widget_provider(widget, &p, BIND_PRIORITY + 1);
|
||||
p
|
||||
});
|
||||
|
||||
attach_tree(widget, &theme, app.as_ref());
|
||||
|
||||
let child_model = watch_root_children(widget);
|
||||
map.insert(
|
||||
key,
|
||||
WidgetBind {
|
||||
output: output.to_string(),
|
||||
theme,
|
||||
app,
|
||||
app_build,
|
||||
_watch: Some(child_model),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
ensure_destroy_cleanup(widget);
|
||||
ensure_themes_watch();
|
||||
ensure_map_reattach(widget);
|
||||
}
|
||||
|
||||
fn ensure_map_reattach(widget: >k4::Widget) {
|
||||
// `connect_map` once per widget — re-bind already lives in BINDS.
|
||||
thread_local! {
|
||||
static MAP_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
}
|
||||
let key = widget_key(widget);
|
||||
let inserted = MAP_HOOKED.with(|s| s.borrow_mut().insert(key));
|
||||
if !inserted {
|
||||
return;
|
||||
}
|
||||
widget.connect_map(|w| {
|
||||
BINDS.with(|binds| {
|
||||
if let Some(bind) = binds.borrow().get(&widget_key(w)) {
|
||||
attach_tree(w, &bind.theme, bind.app.as_ref());
|
||||
}
|
||||
});
|
||||
});
|
||||
widget.connect_destroy(move |_| {
|
||||
MAP_HOOKED.with(|s| {
|
||||
s.borrow_mut().remove(&key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Attach a widget-level `CssProvider` with
|
||||
/// `stylesheet_resolved(load_palette_for(output))` above APPLICATION so it
|
||||
/// beats [`apply_shared`] for this widget tree. User CSS still wins.
|
||||
/// Calling again on the same widget replaces the provider; it does not stack.
|
||||
pub fn bind_window(widget: &impl IsA<gtk4::Widget>, output: &str) {
|
||||
bind_window_inner(widget.as_ref(), output, None);
|
||||
}
|
||||
|
||||
/// [`bind_window`], then also apply `build(&palette)` on the same widget.
|
||||
/// App CSS may still use `@accent` etc.; those names are inlined against
|
||||
/// the same palette before loading.
|
||||
pub fn bind_window_with_app_css<F>(widget: &impl IsA<gtk4::Widget>, output: &str, build: F)
|
||||
where
|
||||
F: Fn(&Palette) -> String + 'static,
|
||||
{
|
||||
bind_window_inner(widget.as_ref(), output, Some(Rc::new(build)));
|
||||
}
|
||||
|
||||
fn attach_enter_monitor(widget: >k4::Widget, build: Option<Rc<dyn Fn(&Palette) -> String>>) {
|
||||
let Some(native) = widget.native() else {
|
||||
return;
|
||||
};
|
||||
let Some(surface) = NativeExt::surface(&native) else {
|
||||
return;
|
||||
};
|
||||
let surf_key = surface.as_ptr() as usize;
|
||||
let already = ENTER_HOOKED.with(|s| !s.borrow_mut().insert(surf_key));
|
||||
if already {
|
||||
return;
|
||||
}
|
||||
let widget = widget.clone();
|
||||
surface.connect_enter_monitor(move |_, monitor| {
|
||||
let Some(conn) = monitor.connector() else {
|
||||
return;
|
||||
};
|
||||
bind_window_inner(&widget, conn.as_str(), build.clone());
|
||||
});
|
||||
}
|
||||
|
||||
fn bind_auto(native: >k4::Native, build: Option<Rc<dyn Fn(&Palette) -> String>>) {
|
||||
let widget = native.upcast_ref::<gtk4::Widget>().clone();
|
||||
|
||||
let apply = {
|
||||
let widget = widget.clone();
|
||||
let build = build.clone();
|
||||
Rc::new(move || {
|
||||
if let Some(output) = output_for_widget(&widget) {
|
||||
bind_window_inner(&widget, &output, build.clone());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
let key = widget_key(&widget);
|
||||
let inserted = AUTO_HOOKED.with(|s| s.borrow_mut().insert(key));
|
||||
if inserted {
|
||||
widget.connect_realize({
|
||||
let apply = apply.clone();
|
||||
let widget = widget.clone();
|
||||
let build = build.clone();
|
||||
move |_| {
|
||||
apply();
|
||||
attach_enter_monitor(&widget, build.clone());
|
||||
}
|
||||
});
|
||||
widget.connect_map({
|
||||
let apply = apply.clone();
|
||||
move |_| apply()
|
||||
});
|
||||
ensure_destroy_cleanup(&widget);
|
||||
}
|
||||
|
||||
if widget.is_realized() {
|
||||
attach_enter_monitor(&widget, build);
|
||||
}
|
||||
}
|
||||
|
||||
/// Realize + `GdkSurface::enter-monitor`: rebind when the window moves
|
||||
/// outputs. If the connector is unknown, leave unbound (display fallback)
|
||||
/// rather than guessing the wrong monitor.
|
||||
pub fn bind_window_auto(window: &impl IsA<gtk4::Native>) {
|
||||
bind_auto(window.as_ref(), None);
|
||||
}
|
||||
|
||||
/// [`bind_window_auto`] plus per-output app CSS, resolved to hex.
|
||||
pub fn bind_window_auto_with_app_css<F>(window: &impl IsA<gtk4::Native>, build: F)
|
||||
where
|
||||
F: Fn(&Palette) -> String + 'static,
|
||||
{
|
||||
bind_auto(window.as_ref(), Some(Rc::new(build)));
|
||||
}
|
||||
|
||||
/// Apply a user CSS override file at USER priority. Clears the provider if the
|
||||
/// file is absent so stale overrides don't persist across SIGHUP reloads.
|
||||
pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
pub mod palette;
|
||||
#[cfg(feature = "gtk")]
|
||||
pub mod gtk;
|
||||
#[cfg(feature = "adw")]
|
||||
pub mod adw;
|
||||
#[cfg(feature = "gtk")]
|
||||
pub mod gtk;
|
||||
mod output;
|
||||
pub mod palette;
|
||||
|
||||
pub use output::{
|
||||
generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image,
|
||||
palette_from_json, palettes_dir, sanitize_output, themes_dir, write_output_css,
|
||||
write_output_palette, write_shared_css_from,
|
||||
};
|
||||
pub use palette::{load_palette, Palette};
|
||||
|
||||
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
|
||||
|
|
@ -53,7 +59,11 @@ pub fn luminance(hex: &str) -> f32 {
|
|||
let h = hex.trim_start_matches('#');
|
||||
let lin = |i: usize| -> f32 {
|
||||
let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0;
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
if c <= 0.04045 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
|
||||
}
|
||||
|
|
@ -64,7 +74,11 @@ pub fn luminance(hex: &str) -> f32 {
|
|||
/// text readable no matter how light or dark pywal makes a given palette slot,
|
||||
/// without altering the palette colours themselves.
|
||||
pub fn ink_on(hex: &str) -> &'static str {
|
||||
if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" }
|
||||
if luminance(hex) > 0.179 {
|
||||
"#11111b"
|
||||
} else {
|
||||
"#f5f5f5"
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical (name, value) list: the single naming all bread apps share.
|
||||
|
|
@ -143,9 +157,18 @@ pub fn css_tokens() -> String {
|
|||
\x20\x20--radius-tertiary: {r3}px;\n\
|
||||
\x20\x20--radius-pill: {pill}px;\n\
|
||||
}}\n",
|
||||
font = FONT_FAMILY, base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY,
|
||||
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, xl = SPACE_XL,
|
||||
r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, r3 = RADIUS_TERTIARY, pill = RADIUS_PILL,
|
||||
font = FONT_FAMILY,
|
||||
base = FONT_SIZE_BASE,
|
||||
sec = FONT_SIZE_SECONDARY,
|
||||
xs = SPACE_XS,
|
||||
sm = SPACE_SM,
|
||||
md = SPACE_MD,
|
||||
lg = SPACE_LG,
|
||||
xl = SPACE_XL,
|
||||
r1 = RADIUS_PRIMARY,
|
||||
r2 = RADIUS_SECONDARY,
|
||||
r3 = RADIUS_TERTIARY,
|
||||
pill = RADIUS_PILL,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -261,28 +284,33 @@ pub fn render() -> String {
|
|||
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
|
||||
/// falling back to the cache dir.
|
||||
pub fn shared_css_path() -> std::path::PathBuf {
|
||||
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
if !rt.is_empty() {
|
||||
return std::path::PathBuf::from(rt).join("bread").join("theme.css");
|
||||
}
|
||||
}
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
|
||||
.join("bread")
|
||||
.join("theme.css")
|
||||
output::runtime_bread_dir().join("theme.css")
|
||||
}
|
||||
|
||||
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
|
||||
/// the path written. Used by the `bread-theme` CLI.
|
||||
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
|
||||
let path = shared_css_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
write_shared_css_from(&load_palette())
|
||||
}
|
||||
|
||||
/// `stylesheet()` with `@name` references in rule bodies replaced by hex.
|
||||
/// Longer names first (`on-surface` before `surface`, `on-bg` before `bg`)
|
||||
/// so a prefix match cannot half-replace `@on-bg`.
|
||||
pub fn stylesheet_resolved(p: &Palette) -> String {
|
||||
resolve_color_names(&stylesheet(p), p)
|
||||
}
|
||||
|
||||
/// Replace `@define-color` names (`@accent`, `@on-bg`, …) with hex values.
|
||||
/// Used by [`stylesheet_resolved`] and by GTK `bind_window` so display-global
|
||||
/// named colors cannot leak the wrong monitor's accent.
|
||||
pub(crate) fn resolve_color_names(css: &str, p: &Palette) -> String {
|
||||
let mut pairs: Vec<(&str, String)> = color_pairs(p).into_iter().collect();
|
||||
pairs.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
|
||||
let mut out = css.to_string();
|
||||
for (name, value) in pairs {
|
||||
out = out.replace(&format!("@{name}"), &value);
|
||||
}
|
||||
let tmp = path.with_extension("css.tmp");
|
||||
std::fs::write(&tmp, render())?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
Ok(path)
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
|
||||
|
|
@ -301,8 +329,13 @@ mod tests {
|
|||
#[test]
|
||||
fn css_vars_contains_all_define_color_names() {
|
||||
let css = css_vars(&Palette::default());
|
||||
for name in &["bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
for name in &[
|
||||
"bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay",
|
||||
] {
|
||||
assert!(
|
||||
css.contains(&format!("@define-color {name} ")),
|
||||
"missing @define-color {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -322,8 +355,18 @@ mod tests {
|
|||
// color name — the illegible-text bug. css_vars() must now emit
|
||||
// exactly the same color set as the full stylesheet.
|
||||
let css = css_vars(&Palette::default());
|
||||
for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
for name in &[
|
||||
"accent",
|
||||
"on-bg",
|
||||
"on-surface",
|
||||
"on-accent",
|
||||
"on-red",
|
||||
"on-overlay",
|
||||
] {
|
||||
assert!(
|
||||
css.contains(&format!("@define-color {name} ")),
|
||||
"missing @define-color {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -334,7 +377,16 @@ mod tests {
|
|||
let p = Palette::default();
|
||||
let vars = css_vars(&p);
|
||||
let sheet = stylesheet(&p);
|
||||
for name in &["bg", "fg", "surface", "overlay", "accent", "on-bg", "on-surface", "on-accent"] {
|
||||
for name in &[
|
||||
"bg",
|
||||
"fg",
|
||||
"surface",
|
||||
"overlay",
|
||||
"accent",
|
||||
"on-bg",
|
||||
"on-surface",
|
||||
"on-accent",
|
||||
] {
|
||||
let needle = format!("@define-color {name} ");
|
||||
assert!(vars.contains(&needle) && sheet.contains(&needle));
|
||||
}
|
||||
|
|
@ -344,10 +396,21 @@ mod tests {
|
|||
fn stylesheet_defines_canonical_colors_and_components() {
|
||||
let css = stylesheet(&Palette::default());
|
||||
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
assert!(
|
||||
css.contains(&format!("@define-color {name} ")),
|
||||
"missing @define-color {name}"
|
||||
);
|
||||
}
|
||||
// a representative spread of the shared component selectors
|
||||
for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".page-title"] {
|
||||
for sel in &[
|
||||
"button",
|
||||
"entry",
|
||||
"switch:checked",
|
||||
".card",
|
||||
".sidebar",
|
||||
"scrollbar slider",
|
||||
".page-title",
|
||||
] {
|
||||
assert!(css.contains(sel), "stylesheet missing selector: {sel}");
|
||||
}
|
||||
assert!(css.contains("Varela Round"));
|
||||
|
|
@ -362,7 +425,10 @@ mod tests {
|
|||
let gtk = define_colors(&p);
|
||||
let web = css_custom_properties(&p);
|
||||
for (name, _) in color_pairs(&p) {
|
||||
assert!(gtk.contains(&format!("@define-color {name} ")), "gtk missing {name}");
|
||||
assert!(
|
||||
gtk.contains(&format!("@define-color {name} ")),
|
||||
"gtk missing {name}"
|
||||
);
|
||||
assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
|
||||
}
|
||||
}
|
||||
|
|
@ -409,7 +475,10 @@ mod tests {
|
|||
fn stylesheet_defines_on_colors() {
|
||||
let css = stylesheet(&Palette::default());
|
||||
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
assert!(
|
||||
css.contains(&format!("@define-color {name} ")),
|
||||
"missing @define-color {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -418,13 +487,58 @@ mod tests {
|
|||
// A bare `label { color: ... }` would override container colours on child
|
||||
// labels — the bug that made coloured-background text illegible.
|
||||
let css = stylesheet(&Palette::default());
|
||||
assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced");
|
||||
assert!(
|
||||
!css.contains("label { color:"),
|
||||
"blanket label colour rule reintroduced"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_css_path_uses_runtime_dir() {
|
||||
let _lock = crate::output::XDG_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
||||
assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css"));
|
||||
assert_eq!(
|
||||
shared_css_path(),
|
||||
std::path::PathBuf::from("/run/user/1234/bread/theme.css")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_resolved_inlines_color4_and_drops_named_refs_in_rules() {
|
||||
let mut p = Palette::default();
|
||||
p.color4 = "#7aa2f7".into();
|
||||
let css = stylesheet_resolved(&p);
|
||||
assert!(css.contains("#7aa2f7"), "color4 must appear as hex: {css}");
|
||||
// Rule bodies must not keep named colors — GTK display-global
|
||||
// @define-color would otherwise leak the wrong monitor's accent.
|
||||
let rules = css
|
||||
.lines()
|
||||
.filter(|l| !l.trim_start().starts_with("@define-color"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
!rules.contains("@accent"),
|
||||
"leftover @accent in rules:\n{rules}"
|
||||
);
|
||||
assert!(
|
||||
!rules.contains("@on-bg"),
|
||||
"leftover @on-bg in rules:\n{rules}"
|
||||
);
|
||||
assert!(
|
||||
!rules.contains("@on-surface"),
|
||||
"leftover @on-surface in rules:\n{rules}"
|
||||
);
|
||||
assert!(
|
||||
!rules.contains("@on-accent"),
|
||||
"leftover @on-accent in rules:\n{rules}"
|
||||
);
|
||||
// Longer names first: @on-bg must not become @on-#...
|
||||
assert!(
|
||||
!rules.contains("@on-#"),
|
||||
"half-replaced on-* name:\n{rules}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
338
bread-theme/src/output.rs
Normal file
338
bread-theme/src/output.rs
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
//! Per-output (per-monitor) palette and stylesheet paths under
|
||||
//! `$XDG_RUNTIME_DIR/bread/{palettes,themes}/`.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::palette::{from_wal_json, Palette};
|
||||
use crate::{load_palette, stylesheet};
|
||||
|
||||
/// Session-scoped `$XDG_RUNTIME_DIR/bread`, same fallback as [`crate::shared_css_path`].
|
||||
pub(crate) fn runtime_bread_dir() -> PathBuf {
|
||||
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
if !rt.is_empty() {
|
||||
return PathBuf::from(rt).join("bread");
|
||||
}
|
||||
}
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("bread")
|
||||
}
|
||||
|
||||
/// Keep `[A-Za-z0-9._-]`; replace everything else with `_`.
|
||||
pub fn sanitize_output(output: &str) -> String {
|
||||
let s: String = output
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if s.is_empty() {
|
||||
"_".into()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
pub fn themes_dir() -> PathBuf {
|
||||
runtime_bread_dir().join("themes")
|
||||
}
|
||||
|
||||
pub fn palettes_dir() -> PathBuf {
|
||||
runtime_bread_dir().join("palettes")
|
||||
}
|
||||
|
||||
pub fn output_css_path(output: &str) -> PathBuf {
|
||||
themes_dir().join(format!("{}.css", sanitize_output(output)))
|
||||
}
|
||||
|
||||
pub fn output_palette_path(output: &str) -> PathBuf {
|
||||
palettes_dir().join(format!("{}.json", sanitize_output(output)))
|
||||
}
|
||||
|
||||
fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(name) => path.with_file_name(format!("{name}.tmp")),
|
||||
None => path.with_extension("tmp"),
|
||||
};
|
||||
std::fs::write(&tmp, contents)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accents only — never persist pywal's light background/surface/overlay/fg.
|
||||
#[derive(Serialize)]
|
||||
struct StoredColors {
|
||||
color1: String,
|
||||
color2: String,
|
||||
color3: String,
|
||||
color4: String,
|
||||
color5: String,
|
||||
color6: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StoredPalette {
|
||||
colors: StoredColors,
|
||||
}
|
||||
|
||||
/// Parse on-disk JSON: wal `colors.json` shape, or a flat `{color1..color6}` object.
|
||||
/// Always forces FIXED background/foreground/color0/color7 via [`from_wal_json`].
|
||||
pub fn palette_from_json(json: &str) -> Option<Palette> {
|
||||
let value: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
if value
|
||||
.get("colors")
|
||||
.and_then(|c| c.as_object())
|
||||
.is_some_and(|o| !o.is_empty())
|
||||
{
|
||||
return from_wal_json(json);
|
||||
}
|
||||
if value.get("color1").is_some()
|
||||
|| value.get("color2").is_some()
|
||||
|| value.get("color3").is_some()
|
||||
|| value.get("color4").is_some()
|
||||
|| value.get("color5").is_some()
|
||||
|| value.get("color6").is_some()
|
||||
{
|
||||
let wrapped = serde_json::json!({ "colors": value });
|
||||
return from_wal_json(&wrapped.to_string());
|
||||
}
|
||||
from_wal_json(json)
|
||||
}
|
||||
|
||||
/// Load `palettes/<output>.json`; fall back to [`load_palette`].
|
||||
pub fn load_palette_for(output: &str) -> Palette {
|
||||
std::fs::read_to_string(output_palette_path(output))
|
||||
.ok()
|
||||
.and_then(|s| palette_from_json(&s))
|
||||
.unwrap_or_else(load_palette)
|
||||
}
|
||||
|
||||
pub fn write_output_palette(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = output_palette_path(output);
|
||||
let stored = StoredPalette {
|
||||
colors: StoredColors {
|
||||
color1: palette.color1.clone(),
|
||||
color2: palette.color2.clone(),
|
||||
color3: palette.color3.clone(),
|
||||
color4: palette.color4.clone(),
|
||||
color5: palette.color5.clone(),
|
||||
color6: palette.color6.clone(),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&stored)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
atomic_write(&path, &json)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn write_output_css(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = output_css_path(output);
|
||||
atomic_write(&path, &stylesheet(palette))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Like [`crate::write_shared_css`] but from an explicit palette.
|
||||
pub fn write_shared_css_from(palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = crate::shared_css_path();
|
||||
atomic_write(&path, &stylesheet(palette))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Isolated `wal -i <image> -n -q` with `XDG_CACHE_HOME` set to a unique temp
|
||||
/// dir so the user's `~/.cache/wal` is not clobbered.
|
||||
pub fn palette_from_image(path: &Path) -> std::io::Result<Palette> {
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let tmp = std::env::temp_dir().join(format!("bread-theme-wal-{pid}-{nanos}"));
|
||||
std::fs::create_dir_all(&tmp)?;
|
||||
struct Rm(PathBuf);
|
||||
impl Drop for Rm {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let _guard = Rm(tmp.clone());
|
||||
|
||||
// Classic pywal ignores XDG_CACHE_HOME and writes $HOME/.cache/wal.
|
||||
// Point HOME at the temp dir so a per-output extract cannot clobber
|
||||
// the session cache (or the other monitor's last `wal -i`).
|
||||
let status = match std::process::Command::new("wal")
|
||||
.arg("-i")
|
||||
.arg(path)
|
||||
.args(["-n", "-q"])
|
||||
.env("HOME", &tmp)
|
||||
.env("XDG_CACHE_HOME", tmp.join(".cache"))
|
||||
.status()
|
||||
{
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"wal is not installed",
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
Ok(s) => s,
|
||||
};
|
||||
if !status.success() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("wal failed with {status}"),
|
||||
));
|
||||
}
|
||||
|
||||
let json_path = [
|
||||
tmp.join(".cache").join("wal").join("colors.json"),
|
||||
tmp.join("wal").join("colors.json"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| p.is_file())
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"wal did not write colors.json under the isolated cache",
|
||||
)
|
||||
})?;
|
||||
let json = std::fs::read_to_string(&json_path)?;
|
||||
from_wal_json(&json).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"wal produced unparseable colors.json",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`palette_from_image`] + [`write_output_palette`] + [`write_output_css`].
|
||||
pub fn generate_output(output: &str, image: &Path) -> std::io::Result<PathBuf> {
|
||||
let palette = palette_from_image(image)?;
|
||||
write_output_palette(output, &palette)?;
|
||||
write_output_css(output, &palette)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::palette::{FIXED_BACKGROUND, FIXED_FOREGROUND, FIXED_OVERLAY, FIXED_SURFACE};
|
||||
|
||||
fn lock_xdg() -> std::sync::MutexGuard<'static, ()> {
|
||||
XDG_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn with_runtime_dir<T>(f: impl FnOnce(&Path) -> T) -> T {
|
||||
let _lock = lock_xdg();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bread-theme-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let old = std::env::var("XDG_RUNTIME_DIR").ok();
|
||||
std::env::set_var("XDG_RUNTIME_DIR", &dir);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir)));
|
||||
match old {
|
||||
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
|
||||
None => std::env::remove_var("XDG_RUNTIME_DIR"),
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => std::panic::resume_unwind(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_output_keeps_hyprland_connectors() {
|
||||
assert_eq!(sanitize_output("HDMI-A-1"), "HDMI-A-1");
|
||||
assert_eq!(sanitize_output("eDP-1"), "eDP-1");
|
||||
assert_eq!(sanitize_output("DP-2"), "DP-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_output_replaces_unsafe_chars() {
|
||||
assert_eq!(sanitize_output("HDMI A:1"), "HDMI_A_1");
|
||||
assert_eq!(sanitize_output("foo/bar"), "foo_bar");
|
||||
assert_eq!(sanitize_output(""), "_");
|
||||
assert_eq!(sanitize_output("..ok_name-1"), "..ok_name-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_paths_use_sanitize_and_sit_under_dirs() {
|
||||
let _lock = lock_xdg();
|
||||
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
||||
let css = output_css_path("HDMI A:1");
|
||||
let pal = output_palette_path("HDMI A:1");
|
||||
assert_eq!(css, themes_dir().join("HDMI_A_1.css"));
|
||||
assert_eq!(pal, palettes_dir().join("HDMI_A_1.json"));
|
||||
assert!(css.starts_with(themes_dir()));
|
||||
assert!(pal.starts_with(palettes_dir()));
|
||||
assert_eq!(
|
||||
output_css_path("eDP-1"),
|
||||
PathBuf::from("/run/user/1234/bread/themes/eDP-1.css")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_palette_for_missing_file_has_fixed_bg() {
|
||||
with_runtime_dir(|_| {
|
||||
let p = load_palette_for("no-such-output");
|
||||
assert_eq!(p.background, FIXED_BACKGROUND);
|
||||
assert!(p.color4.starts_with('#'));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_output_palette_roundtrips_color4() {
|
||||
with_runtime_dir(|_| {
|
||||
let mut p = Palette::default();
|
||||
p.color4 = "#7aa2f7".into();
|
||||
p.background = "#ffffff".into();
|
||||
write_output_palette("HDMI-A-1", &p).unwrap();
|
||||
let loaded = load_palette_for("HDMI-A-1");
|
||||
assert_eq!(loaded.color4, "#7aa2f7");
|
||||
assert_eq!(loaded.background, FIXED_BACKGROUND);
|
||||
assert_eq!(loaded.foreground, FIXED_FOREGROUND);
|
||||
assert_eq!(loaded.color0, FIXED_SURFACE);
|
||||
assert_eq!(loaded.color7, FIXED_OVERLAY);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_shared_css_from_writes_shared_css_path() {
|
||||
with_runtime_dir(|rt| {
|
||||
let path = write_shared_css_from(&Palette::default()).unwrap();
|
||||
assert_eq!(path, crate::shared_css_path());
|
||||
assert_eq!(path, rt.join("bread").join("theme.css"));
|
||||
let css = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(css.contains("@define-color accent "));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_palette_for_accepts_flat_color_object() {
|
||||
with_runtime_dir(|_| {
|
||||
let path = output_palette_path("DP-1");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, r##"{"color4":"#112233","color1":"#abcdef"}"##).unwrap();
|
||||
let p = load_palette_for("DP-1");
|
||||
assert_eq!(p.color4, "#112233");
|
||||
assert_eq!(p.color1, "#abcdef");
|
||||
assert_eq!(p.background, FIXED_BACKGROUND);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -9,10 +9,10 @@ use std::path::PathBuf;
|
|||
/// off-hue background, and every bread GUI's panels inherit it — the app
|
||||
/// stops looking like a dark BOS tool and starts looking like whatever colour
|
||||
/// the wallpaper happened to be.
|
||||
const FIXED_BACKGROUND: &str = "#0c0c0c";
|
||||
const FIXED_FOREGROUND: &str = "#e8e8e8";
|
||||
const FIXED_SURFACE: &str = "#1a1a1a";
|
||||
const FIXED_OVERLAY: &str = "#d8d8d8";
|
||||
pub(crate) const FIXED_BACKGROUND: &str = "#0c0c0c";
|
||||
pub(crate) const FIXED_FOREGROUND: &str = "#e8e8e8";
|
||||
pub(crate) const FIXED_SURFACE: &str = "#1a1a1a";
|
||||
pub(crate) const FIXED_OVERLAY: &str = "#d8d8d8";
|
||||
|
||||
/// Accent fallback when no pywal palette exists yet (fresh install, before
|
||||
/// any wallpaper has been set for real) — BOS's own bread-toned accents,
|
||||
|
|
@ -84,7 +84,10 @@ pub fn load_palette() -> Palette {
|
|||
pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
|
||||
let wal: WalColors = serde_json::from_str(json).ok()?;
|
||||
let c = |k: &str, fallback: &str| -> String {
|
||||
wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into())
|
||||
wal.colors
|
||||
.get(k)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback.into())
|
||||
};
|
||||
Some(Palette {
|
||||
background: FIXED_BACKGROUND.into(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue