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"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.7.2"
|
version = "0.7.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Breadway <plasticbread849@gmail.com>"]
|
authors = ["Breadway <plasticbread849@gmail.com>"]
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ pub fn show_prompt(app: &Application, prompt: Prompt) {
|
||||||
panel.append(&buttons);
|
panel.append(&buttons);
|
||||||
|
|
||||||
window.set_child(Some(&panel));
|
window.set_child(Some(&panel));
|
||||||
|
bread_theme::gtk::bind_window_auto_with_app_css(&window, |_| app_css());
|
||||||
|
|
||||||
let reply = prompt.reply.clone();
|
let reply = prompt.reply.clone();
|
||||||
let cookie = prompt.cookie.clone();
|
let cookie = prompt.cookie.clone();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,48 @@
|
||||||
# bread-theme changelog
|
# 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
|
## Coordinated bump policy
|
||||||
|
|
||||||
`bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned
|
`bread-theme` is consumed by `breadbar`, `breadbox`, `breadpad`, and the other
|
||||||
git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature
|
GTK bread apps as a pinned git dependency. A breaking change to `Palette`,
|
||||||
API requires all three dependents to bump their `Cargo.toml` git tag and cut a
|
`css_vars`, or the `gtk` feature API requires dependents to bump their
|
||||||
release together. Note the impact in this file before tagging.
|
`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
|
//! # signal every running bread GUI to recolour
|
||||||
//! bread-theme path # print the stylesheet path
|
//! bread-theme path # print the stylesheet path
|
||||||
//! bread-theme print # render to stdout (no write)
|
//! 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;
|
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 {
|
fn main() -> ExitCode {
|
||||||
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
|
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
|
||||||
match cmd.as_str() {
|
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
|
// 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.
|
// palette and recolour live — shared widgets *and* each app's own rules.
|
||||||
"reload" => write_and_report("reloaded"),
|
"reload" => write_and_report("reloaded"),
|
||||||
|
"generate-output" => generate_output_cmd(),
|
||||||
"-h" | "--help" | "help" => {
|
"-h" | "--help" | "help" => {
|
||||||
eprintln!(
|
print_help();
|
||||||
"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()
|
|
||||||
);
|
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
other => {
|
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
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
|
use gtk4::gdk::prelude::*;
|
||||||
use gtk4::gio;
|
use gtk4::gio;
|
||||||
|
use gtk4::glib::object::ObjectType;
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::CssProvider;
|
use gtk4::CssProvider;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::Path;
|
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! {
|
thread_local! {
|
||||||
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
||||||
|
|
@ -14,8 +24,7 @@ thread_local! {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reload_shared() {
|
fn reload_shared() {
|
||||||
let css = std::fs::read_to_string(crate::shared_css_path())
|
let css = std::fs::read_to_string(crate::shared_css_path()).unwrap_or_else(|_| crate::render());
|
||||||
.unwrap_or_else(|_| crate::render());
|
|
||||||
SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
|
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
|
/// breadpad, and breadman (both cream), none of which agreed with each
|
||||||
/// other or with the shared token.
|
/// other or with the shared token.
|
||||||
pub fn chip(label: &str) -> gtk4::Button {
|
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`
|
/// 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
|
/// 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.
|
/// file is absent so stale overrides don't persist across SIGHUP reloads.
|
||||||
pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) {
|
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")]
|
#[cfg(feature = "adw")]
|
||||||
pub mod 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};
|
pub use palette::{load_palette, Palette};
|
||||||
|
|
||||||
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
|
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
|
||||||
|
|
@ -53,7 +59,11 @@ pub fn luminance(hex: &str) -> f32 {
|
||||||
let h = hex.trim_start_matches('#');
|
let h = hex.trim_start_matches('#');
|
||||||
let lin = |i: usize| -> f32 {
|
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;
|
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)
|
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,
|
/// text readable no matter how light or dark pywal makes a given palette slot,
|
||||||
/// without altering the palette colours themselves.
|
/// without altering the palette colours themselves.
|
||||||
pub fn ink_on(hex: &str) -> &'static str {
|
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.
|
/// 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-tertiary: {r3}px;\n\
|
||||||
\x20\x20--radius-pill: {pill}px;\n\
|
\x20\x20--radius-pill: {pill}px;\n\
|
||||||
}}\n",
|
}}\n",
|
||||||
font = FONT_FAMILY, base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY,
|
font = FONT_FAMILY,
|
||||||
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, xl = SPACE_XL,
|
base = FONT_SIZE_BASE,
|
||||||
r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, r3 = RADIUS_TERTIARY, pill = RADIUS_PILL,
|
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`,
|
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
|
||||||
/// falling back to the cache dir.
|
/// falling back to the cache dir.
|
||||||
pub fn shared_css_path() -> std::path::PathBuf {
|
pub fn shared_css_path() -> std::path::PathBuf {
|
||||||
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
output::runtime_bread_dir().join("theme.css")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
|
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
|
||||||
/// the path written. Used by the `bread-theme` CLI.
|
/// the path written. Used by the `bread-theme` CLI.
|
||||||
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
|
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
|
||||||
let path = shared_css_path();
|
write_shared_css_from(&load_palette())
|
||||||
if let Some(parent) = path.parent() {
|
}
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
|
/// `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");
|
out
|
||||||
std::fs::write(&tmp, render())?;
|
|
||||||
std::fs::rename(&tmp, &path)?;
|
|
||||||
Ok(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
|
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
|
||||||
|
|
@ -301,8 +329,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn css_vars_contains_all_define_color_names() {
|
fn css_vars_contains_all_define_color_names() {
|
||||||
let css = css_vars(&Palette::default());
|
let css = css_vars(&Palette::default());
|
||||||
for name in &["bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
|
for name in &[
|
||||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
"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
|
// color name — the illegible-text bug. css_vars() must now emit
|
||||||
// exactly the same color set as the full stylesheet.
|
// exactly the same color set as the full stylesheet.
|
||||||
let css = css_vars(&Palette::default());
|
let css = css_vars(&Palette::default());
|
||||||
for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
for name in &[
|
||||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
"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 p = Palette::default();
|
||||||
let vars = css_vars(&p);
|
let vars = css_vars(&p);
|
||||||
let sheet = stylesheet(&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} ");
|
let needle = format!("@define-color {name} ");
|
||||||
assert!(vars.contains(&needle) && sheet.contains(&needle));
|
assert!(vars.contains(&needle) && sheet.contains(&needle));
|
||||||
}
|
}
|
||||||
|
|
@ -344,10 +396,21 @@ mod tests {
|
||||||
fn stylesheet_defines_canonical_colors_and_components() {
|
fn stylesheet_defines_canonical_colors_and_components() {
|
||||||
let css = stylesheet(&Palette::default());
|
let css = stylesheet(&Palette::default());
|
||||||
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
|
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
|
// 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(sel), "stylesheet missing selector: {sel}");
|
||||||
}
|
}
|
||||||
assert!(css.contains("Varela Round"));
|
assert!(css.contains("Varela Round"));
|
||||||
|
|
@ -362,7 +425,10 @@ mod tests {
|
||||||
let gtk = define_colors(&p);
|
let gtk = define_colors(&p);
|
||||||
let web = css_custom_properties(&p);
|
let web = css_custom_properties(&p);
|
||||||
for (name, _) in color_pairs(&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}");
|
assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -409,7 +475,10 @@ mod tests {
|
||||||
fn stylesheet_defines_on_colors() {
|
fn stylesheet_defines_on_colors() {
|
||||||
let css = stylesheet(&Palette::default());
|
let css = stylesheet(&Palette::default());
|
||||||
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
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
|
// A bare `label { color: ... }` would override container colours on child
|
||||||
// labels — the bug that made coloured-background text illegible.
|
// labels — the bug that made coloured-background text illegible.
|
||||||
let css = stylesheet(&Palette::default());
|
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]
|
#[test]
|
||||||
fn shared_css_path_uses_runtime_dir() {
|
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");
|
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]
|
#[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
|
/// 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
|
/// stops looking like a dark BOS tool and starts looking like whatever colour
|
||||||
/// the wallpaper happened to be.
|
/// the wallpaper happened to be.
|
||||||
const FIXED_BACKGROUND: &str = "#0c0c0c";
|
pub(crate) const FIXED_BACKGROUND: &str = "#0c0c0c";
|
||||||
const FIXED_FOREGROUND: &str = "#e8e8e8";
|
pub(crate) const FIXED_FOREGROUND: &str = "#e8e8e8";
|
||||||
const FIXED_SURFACE: &str = "#1a1a1a";
|
pub(crate) const FIXED_SURFACE: &str = "#1a1a1a";
|
||||||
const FIXED_OVERLAY: &str = "#d8d8d8";
|
pub(crate) const FIXED_OVERLAY: &str = "#d8d8d8";
|
||||||
|
|
||||||
/// Accent fallback when no pywal palette exists yet (fresh install, before
|
/// Accent fallback when no pywal palette exists yet (fresh install, before
|
||||||
/// any wallpaper has been set for real) — BOS's own bread-toned accents,
|
/// 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> {
|
pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
|
||||||
let wal: WalColors = serde_json::from_str(json).ok()?;
|
let wal: WalColors = serde_json::from_str(json).ok()?;
|
||||||
let c = |k: &str, fallback: &str| -> String {
|
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 {
|
Some(Palette {
|
||||||
background: FIXED_BACKGROUND.into(),
|
background: FIXED_BACKGROUND.into(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue