appearance: per-monitor wallpaper + a shell-theme picker

Wallpaper (Breadpaper.svelte) becomes monitor-aware: with more than one
output connected it shows a 'use the same wallpaper on every monitor'
switch; turning it off reveals per-output chips and routes 'Choose image'
/ library picks through 'breadpaper --output <NAME> set', which
regenerates only that monitor's pywal palette. Single-monitor behaviour
is unchanged. Backend: get_wallpapers_by_output (reads breadpaper's
current.json) + set_wallpaper_on.

New 'Shell theme' tab: lists bread_theme:🐚:list() (liquid-motion,
glass-workbench, spotlight, daylight) and writes ~/.config/bread/shell.toml
'active =' non-destructively via toml_edit. This is a single global
selector — per-monitor differences come from the wallpaper/palette, not
the shell theme. breadbar/breadbox don't hot-reload the change today, so
the tab is explicit that it applies on restart/login and offers a
'Restart bar & launcher' button (setsid -f, so it leaves no zombies).

bread-theme bumped v0.7.4 -> v0.7.5 for shell::list().
This commit is contained in:
Breadway 2026-09-02 09:31:20 +08:00
parent b34fd6c807
commit bfb78fd6f5
10 changed files with 508 additions and 15 deletions

View file

@ -45,7 +45,13 @@ export const SEARCH_INDEX: SearchHit[] = [
{
page: "appearance",
label: "Wallpaper",
keywords: "wallpaper theme palette pywal breadpaper accent",
keywords: "wallpaper theme palette pywal breadpaper accent per monitor multi monitor background",
},
{
page: "appearance",
tab: "shelltheme",
label: "Shell theme",
keywords: "shell theme breadbar breadbox bar launcher style liquid motion glass workbench spotlight daylight layout animation",
},
{
page: "appearance",

View file

@ -1,6 +1,7 @@
<script lang="ts">
import Hub from "$lib/components/Hub.svelte";
import Breadpaper from "./Breadpaper.svelte";
import ShellTheme from "./ShellTheme.svelte";
import Appearance from "./Appearance.svelte";
</script>
@ -9,6 +10,7 @@
lede="Wallpaper sets the colors. Windows follow."
tabs={[
{ id: "breadpaper", label: "Wallpaper", component: Breadpaper },
{ id: "shelltheme", label: "Shell theme", component: ShellTheme },
{ id: "appearance", label: "Windows", component: Appearance },
]}
/>

View file

@ -4,25 +4,57 @@
import { open } from "@tauri-apps/plugin-dialog";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import SwitchField from "$lib/components/SwitchField.svelte";
interface LibraryEntry {
path: string;
name: string;
}
interface LiveMonitor {
name: string;
mode: string;
}
interface OutputWallpaper {
output: string;
path: string;
}
let monitors = $state<LiveMonitor[]>([]);
let byOutput = $state<Record<string, string>>({});
let globalPath = $state<string | null>(null);
let selected = $state<string | null>(null);
let sameOnAll = $state(true);
let currentPath = $state<string | null>(null);
let status = $state("");
let libraryDir = $state("");
let library = $state<LibraryEntry[] | null>(null);
let scanning = $state(false);
async function refreshCurrent() {
currentPath = await invoke<string | null>("get_current_wallpaper");
// The wallpaper the current selection resolves to: the global one when
// "same on every monitor" is on, otherwise the selected monitor's own
// (falling back to the global if that monitor was never set explicitly).
let activePath = $derived(
sameOnAll ? globalPath : (selected && byOutput[selected]) || globalPath,
);
const perMonitor = $derived(monitors.length > 1);
async function refresh() {
globalPath = await invoke<string | null>("get_current_wallpaper");
const list = await invoke<OutputWallpaper[]>("get_wallpapers_by_output");
byOutput = Object.fromEntries(list.map((o) => [o.output, o.path]));
monitors = await invoke<LiveMonitor[]>("get_live_monitors");
if (!selected || !monitors.some((m) => m.name === selected)) {
selected = monitors[0]?.name ?? null;
}
}
onMount(async () => {
libraryDir = await invoke<string>("wallpaper_library_dir_display");
await refreshCurrent();
await refresh();
// Start in per-monitor mode only if the monitors genuinely disagree.
const distinct = new Set(Object.values(byOutput));
sameOnAll = distinct.size <= 1;
scanning = true;
try {
library = await invoke<LibraryEntry[]>("list_wallpaper_library");
@ -34,8 +66,12 @@
async function apply(path: string) {
status = "Setting…";
try {
await invoke("set_wallpaper", { path });
await refreshCurrent();
if (sameOnAll || !selected) {
await invoke("set_wallpaper", { path });
} else {
await invoke("set_wallpaper_on", { output: selected, path });
}
await refresh();
status = "Set";
} catch (e) {
status = `${e}`;
@ -51,13 +87,39 @@
});
if (typeof path === "string") await apply(path);
}
function shortName(p: string | null): string {
return p ? (p.split("/").pop() ?? p) : "";
}
</script>
<ViewScaffold title="Wallpaper">
<Group title="Wallpaper" hint="This also updates desktop colors.">
{#if currentPath}
<div class="hero" style="background-image: url('{convertFileSrc(currentPath)}')">
<span class="cap">{currentPath.split("/").pop()}</span>
<Group title="Wallpaper" hint="This also sets the desktop colors — each monitor's accent comes from its own wallpaper.">
{#if perMonitor}
<SwitchField label="Use the same wallpaper on every monitor" bind:value={sameOnAll} />
{/if}
{#if perMonitor && !sameOnAll}
<div class="mons">
{#each monitors as m (m.name)}
<button
type="button"
class="mon"
class:on={selected === m.name}
title={m.mode}
onclick={() => (selected = m.name)}
>
{m.name}
</button>
{/each}
</div>
{/if}
{#if activePath}
<div class="hero" style="background-image: url('{convertFileSrc(activePath)}')">
<span class="cap">
{#if perMonitor && !sameOnAll}{selected} · {/if}{shortName(activePath)}
</span>
</div>
{:else}
<div class="hero placeholder">No wallpaper</div>
@ -78,7 +140,7 @@
{#each library as item (item.path)}
<button
class="thumb"
class:on={item.path === currentPath}
class:on={item.path === activePath}
onclick={() => apply(item.path)}
title={item.path}
>
@ -91,6 +153,30 @@
</ViewScaffold>
<style>
.mons {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 12px;
}
.mon {
padding: 6px 11px;
border-radius: 999px;
font-size: 12px;
background: color-mix(in srgb, var(--fg) 6%, transparent);
border: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
color: var(--fg);
cursor: pointer;
}
.mon.on {
background: var(--accent);
color: var(--on-accent);
border-color: transparent;
font-weight: 600;
}
.hero {
height: 180px;
border-radius: 12px;

View file

@ -0,0 +1,189 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
interface ShellThemeInfo {
id: string;
name: string;
source: string; // "builtin" | "system" | "user"
}
let themes = $state<ShellThemeInfo[]>([]);
let active = $state<string>("");
let loaded = $state(false);
let busy = $state(false);
let status = $state("");
// Set once the user switches, so the restart prompt gains emphasis.
// breadbar/breadbox don't hot-reload a shell-theme change today — it
// takes effect when they restart or on the next login.
let switched = $state(false);
onMount(async () => {
[themes, active] = await Promise.all([
invoke<ShellThemeInfo[]>("list_shell_themes"),
invoke<string>("get_active_shell_theme"),
]);
loaded = true;
});
async function select(id: string) {
if (id === active || busy) return;
const prev = active;
active = id;
busy = true;
status = "Applying…";
try {
await invoke("set_active_shell_theme", { id });
switched = true;
status = "Applied";
setTimeout(() => (status = ""), 3000);
} catch (e) {
active = prev;
status = `${e}`;
} finally {
busy = false;
}
}
async function restart() {
busy = true;
status = "Restarting…";
try {
await invoke("restart_shell_apps");
switched = false;
status = "Restarted";
setTimeout(() => (status = ""), 3000);
} catch (e) {
status = `${e}`;
} finally {
busy = false;
}
}
</script>
<ViewScaffold
title="Shell theme"
lede="The layout and motion style for the bar and launcher. Colors come from the wallpaper."
>
<Group title="Theme" wide>
{#if !loaded}
<Hint text="Loading…" />
{:else}
<div class="cards">
{#each themes as t (t.id)}
<button
type="button"
class="card"
class:on={active === t.id}
disabled={busy}
onclick={() => select(t.id)}
>
<span class="dot" aria-hidden="true"></span>
<span class="meta">
<span class="name">{t.name}</span>
<span class="sub">
<span class="id">{t.id}</span>{#if t.source !== "builtin"} · {t.source}{/if}
</span>
</span>
</button>
{/each}
</div>
<div class="foot">
<span class="status">{status}</span>
</div>
{/if}
</Group>
{#if loaded}
<Group title="Apply">
<Hint
text={switched
? "Restart the bar and launcher (or log out and back in) to switch to this theme."
: "A theme change takes effect when the bar and launcher restart, or on the next login."}
/>
<div class="foot">
<button class="btn primary" disabled={busy} onclick={restart}>
Restart bar &amp; launcher
</button>
</div>
</Group>
{/if}
</ViewScaffold>
<style>
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
.card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 10px;
text-align: left;
background: var(--bg);
border: 2px solid transparent;
color: inherit;
cursor: pointer;
}
.card:disabled {
cursor: default;
opacity: 0.7;
}
.card.on {
border-color: var(--accent);
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
flex-shrink: 0;
background: color-mix(in srgb, var(--fg) 25%, transparent);
}
.card.on .dot {
background: var(--accent);
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
font-size: 14px;
}
.sub {
font-size: 11px;
color: var(--muted);
text-transform: capitalize;
}
.sub .id {
text-transform: none;
font-family: var(--font-mono, ui-monospace, monospace);
}
.foot {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.status {
color: var(--muted);
font-size: 12px;
}
</style>

7
src/Cargo.lock generated
View file

@ -307,12 +307,15 @@ dependencies = [
[[package]]
name = "bread-theme"
version = "0.7.4"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e"
version = "0.7.5"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8"
dependencies = [
"anyhow",
"dirs 5.0.1",
"serde",
"serde_json",
"toml 0.8.23",
"tracing",
]
[[package]]

View file

@ -36,7 +36,7 @@ toml_edit = "0.22"
tokio = { version = "1", features = ["process", "io-util", "time", "macros"] }
notify = "7"
regex = "1"
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" }
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5" }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] }
anyhow = "1"

View file

@ -84,6 +84,64 @@ pub async fn set_wallpaper(path: String) -> Result<(), String> {
}
}
/// One compositor output's persisted wallpaper, from breadpaper's own
/// `~/.config/breadpaper/current.json` (`{ "outputs": { "<name>": "<path>" } }`).
#[derive(Serialize)]
pub struct OutputWallpaper {
output: String,
path: String,
}
fn current_json_path() -> PathBuf {
super::config::config_dir().join("breadpaper/current.json")
}
/// The wallpaper breadpaper last set on each output. Read straight from
/// `current.json` rather than shelling `breadpaper --output <n> get` once
/// per monitor — it's the same file breadpaper's `get_on` reads, and a
/// settings window can have several monitors to report.
#[tauri::command]
pub fn get_wallpapers_by_output() -> Vec<OutputWallpaper> {
let Ok(text) = std::fs::read_to_string(current_json_path()) else {
return Vec::new();
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
return Vec::new();
};
let Some(outputs) = value.get("outputs").and_then(|v| v.as_object()) else {
return Vec::new();
};
outputs
.iter()
.filter_map(|(output, path)| {
Some(OutputWallpaper {
output: output.clone(),
path: path.as_str()?.to_string(),
})
})
.collect()
}
/// Set the wallpaper (and regenerate that output's pywal palette) on a
/// single compositor output. `breadpaper --output <NAME> set <path>`
/// leaves every other output's wallpaper untouched (see breadpaper's
/// `current.json` `set_one_output_does_not_drop_others` test).
#[tauri::command]
pub async fn set_wallpaper_on(output: String, path: String) -> Result<(), String> {
let ok = Command::new("breadpaper")
.args(["--output", &output, "set"])
.arg(&path)
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if ok {
Ok(())
} else {
Err("breadpaper failed — see terminal/journal".into())
}
}
#[derive(Serialize)]
pub struct LibraryEntry {
path: String,

View file

@ -34,6 +34,7 @@ pub mod packages;
pub mod power;
pub mod printing;
pub mod service;
pub mod shell_theme;
pub mod snapshots;
pub mod sound;
pub mod streaming;

View file

@ -0,0 +1,142 @@
//! `~/.config/bread/shell.toml` — the `active = "<id>"` key selects the
//! **shell theme**: breadbar/breadbox chrome, layout, and animation style
//! (`liquid-motion`, `glass-workbench`, `spotlight`, `daylight`). This is a
//! single global selector; per-monitor differences come from the wallpaper
//! and its pywal palette, not from the shell theme.
//!
//! `bread_theme::shell::list()` enumerates every discoverable theme (user
//! config → system dir → compiled-in builtins). Setting `active` is a
//! non-destructive `toml_edit` write; a running breadbar picks the change
//! up live via its own `bread_theme::shell::watch()` (colours and CSS
//! tokens re-resolve without a restart). Two things it *cannot* hot-apply:
//! window-spec changes (a bottom-anchored theme like `daylight` moves the
//! bar), and breadbox — which reads `shell::load()` once at startup and has
//! no watch. `restart_shell_apps` covers both.
use serde::Serialize;
use super::config;
fn shell_toml_path() -> std::path::PathBuf {
config::config_dir().join("bread/shell.toml")
}
/// Default when `shell.toml` is missing or has no `active` — matches
/// `bread_theme::shell`'s own hardcoded fallback.
const DEFAULT_THEME_ID: &str = "liquid-motion";
#[derive(Serialize)]
pub struct ShellThemeInfo {
id: String,
name: String,
/// `"builtin"`, `"system"`, or `"user"` — for a source badge in the picker.
source: String,
}
#[tauri::command]
pub fn list_shell_themes() -> Vec<ShellThemeInfo> {
bread_theme::shell::list()
.into_iter()
.map(|t| ShellThemeInfo {
id: t.id,
name: t.name,
source: match t.source {
bread_theme::shell::ThemeSource::User => "user",
bread_theme::shell::ThemeSource::System => "system",
bread_theme::shell::ThemeSource::Builtin => "builtin",
}
.to_string(),
})
.collect()
}
#[tauri::command]
pub fn get_active_shell_theme() -> String {
let doc = config::load_doc(&shell_toml_path());
doc.get("active")
.and_then(|i| i.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
}
/// Writes `active = "<id>"` into `shell.toml`, preserving any other keys and
/// comments. Rejects an id that isn't a discoverable theme — a typo would
/// silently fall breadbar/breadbox back to the builtin with no error.
#[tauri::command]
pub fn set_active_shell_theme(id: String) -> Result<(), String> {
if !bread_theme::shell::list().iter().any(|t| t.id == id) {
return Err(format!("no shell theme with id '{id}'"));
}
let path = shell_toml_path();
let mut doc = config::load_doc(&path);
doc["active"] = toml_edit::value(id);
config::save_doc(&path, &doc).map_err(|e| e.to_string())
}
/// Restart breadbar and breadbox so a shell-theme change takes full effect:
/// breadbar's live watch handles colours/tokens but not window geometry
/// (anchors, exclusive zone), and breadbox has no watch at all. Detached so
/// the new processes outlive this settings process. Best-effort — a missing
/// binary or a compositor without them running is not an error.
#[tauri::command]
pub fn restart_shell_apps() -> Result<(), String> {
for app in ["breadbar", "breadbox"] {
let _ = std::process::Command::new("pkill").args(["-x", app]).status();
}
std::thread::sleep(std::time::Duration::from_millis(250));
spawn_detached("breadbar", &[]);
// Matches `~/.config/hypr/autostart.json` — breadbox runs as a command
// bus (`breadbox listen`); the launcher window is spawned on demand.
spawn_detached("breadbox", &["listen"]);
Ok(())
}
fn spawn_detached(prog: &str, args: &[&str]) {
use std::process::{Command, Stdio};
// `setsid -f` forks the target into its own session and exits at once;
// the target is reparented to init, so it never becomes a zombie of
// this settings process. `.status()` reaps `setsid` itself (it returns
// immediately) so that doesn't linger either — a plain `.spawn()` here
// leaves a `<defunct>` entry behind for every restart.
let _ = Command::new("setsid")
.arg("-f")
.arg(prog)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// `set`-style write must not drop sibling keys or comments in shell.toml.
#[test]
fn writing_active_preserves_other_content() {
let dir = std::env::temp_dir().join(format!("bos-shelltoml-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("shell.toml");
let mut f = std::fs::File::create(&path).unwrap();
write!(
f,
"# my shell config\nactive = \"spotlight\"\n\n[experimental]\nfoo = true\n"
)
.unwrap();
let mut doc = config::load_doc(&path);
doc["active"] = toml_edit::value("daylight");
config::save_doc(&path, &doc).unwrap();
let out = std::fs::read_to_string(&path).unwrap();
assert!(out.contains("active = \"daylight\""));
assert!(out.contains("# my shell config"));
assert!(out.contains("[experimental]"));
assert!(out.contains("foo = true"));
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -41,8 +41,14 @@ pub fn run() {
commands::breadcrumbs::save_breadcrumbs_config,
commands::breadpaper::get_current_wallpaper,
commands::breadpaper::set_wallpaper,
commands::breadpaper::get_wallpapers_by_output,
commands::breadpaper::set_wallpaper_on,
commands::breadpaper::list_wallpaper_library,
commands::breadpaper::wallpaper_library_dir_display,
commands::shell_theme::list_shell_themes,
commands::shell_theme::get_active_shell_theme,
commands::shell_theme::set_active_shell_theme,
commands::shell_theme::restart_shell_apps,
commands::appearance::get_appearance,
commands::appearance::save_appearance,
commands::autostart::get_autostart_entries,