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>