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:
parent
b34fd6c807
commit
bfb78fd6f5
10 changed files with 508 additions and 15 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
189
frontend/src/lib/views/ShellTheme.svelte
Normal file
189
frontend/src/lib/views/ShellTheme.svelte
Normal 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 & 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue