diff --git a/.gitignore b/.gitignore
index 45cd856..df7d56a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,8 @@ logs/
# Claude Code local agent state
.claude/
+
+# .freebuff local tool state (not for commit)
+.freebuff/
+# graphify knowledge-graph output (local tool cache, not for commit)
+graphify-out/
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6f1479c..03950c8 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -19,6 +19,7 @@
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
+ "@types/node": "^26.4.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
@@ -1308,6 +1309,16 @@
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.4.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
+ "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1837,6 +1848,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 08d2563..62b0eb1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "bos-settings-frontend",
- "version": "0.8.0",
+ "version": "0.8.2",
"description": "Frontend for BOS Settings",
"type": "module",
"scripts": {
@@ -23,6 +23,7 @@
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
+ "@types/node": "^26.4.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
diff --git a/frontend/src/lib/components/Embed.svelte b/frontend/src/lib/components/Embed.svelte
new file mode 100644
index 0000000..40090e5
--- /dev/null
+++ b/frontend/src/lib/components/Embed.svelte
@@ -0,0 +1,10 @@
+
+
+{@render children()}
diff --git a/frontend/src/lib/components/EmptyState.svelte b/frontend/src/lib/components/EmptyState.svelte
index 357d48f..aba4339 100644
--- a/frontend/src/lib/components/EmptyState.svelte
+++ b/frontend/src/lib/components/EmptyState.svelte
@@ -16,8 +16,8 @@
flex-direction: column;
align-items: center;
gap: 6px;
- padding: var(--space-xl, 20px) 0;
- opacity: 0.7;
+ padding: 28px 8px;
+ color: var(--muted);
text-align: center;
}
diff --git a/frontend/src/lib/components/Group.svelte b/frontend/src/lib/components/Group.svelte
index 5a6caae..fd11cb0 100644
--- a/frontend/src/lib/components/Group.svelte
+++ b/frontend/src/lib/components/Group.svelte
@@ -22,33 +22,35 @@
display: flex;
flex-direction: column;
min-width: 0;
+ background: var(--surface);
+ border: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
+ border-radius: var(--radius, 14px);
+ padding: 16px 18px;
}
.group.wide {
- grid-column: 1 / -1;
+ width: 100%;
}
.title {
font-weight: 600;
- font-size: 1.05em;
- margin: 0 0 var(--space-sm, 8px);
+ font-size: 13px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
+ margin: 0 0 12px;
}
.hint {
- opacity: 0.75;
+ color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
font-size: var(--font-size-secondary, 12px);
line-height: 1.4;
- margin: 0 0 var(--space-sm, 8px);
+ margin: -6px 0 12px;
}
.rows {
display: flex;
flex-direction: column;
- /* Flex children default to refusing to shrink below their content's
- natural width (min-width: auto) — without this, a button/input
- with enough text overflows past the grid column's actual pixel
- width instead of wrapping, visually spilling into the next
- column. */
min-width: 0;
}
diff --git a/frontend/src/lib/components/Hint.svelte b/frontend/src/lib/components/Hint.svelte
index 450e2ad..e86d531 100644
--- a/frontend/src/lib/components/Hint.svelte
+++ b/frontend/src/lib/components/Hint.svelte
@@ -6,13 +6,9 @@
diff --git a/frontend/src/lib/components/Hub.svelte b/frontend/src/lib/components/Hub.svelte
new file mode 100644
index 0000000..dd404a0
--- /dev/null
+++ b/frontend/src/lib/components/Hub.svelte
@@ -0,0 +1,51 @@
+
+
+
+ {#if tabs.length > 1}
+ ({ id, label }))} bind:value={tab} />
+ {/if}
+
+
diff --git a/frontend/src/lib/components/HyprColorField.svelte b/frontend/src/lib/components/HyprColorField.svelte
index 115d4d7..cc9f430 100644
--- a/frontend/src/lib/components/HyprColorField.svelte
+++ b/frontend/src/lib/components/HyprColorField.svelte
@@ -1,7 +1,7 @@
-
{label}
+
+ {label}
+ {#if hint}{hint}{/if}
+
{@render children()}
diff --git a/frontend/src/lib/components/SaveButton.svelte b/frontend/src/lib/components/SaveButton.svelte
index 5dcd916..faadab1 100644
--- a/frontend/src/lib/components/SaveButton.svelte
+++ b/frontend/src/lib/components/SaveButton.svelte
@@ -19,7 +19,7 @@
-
+
{status}
@@ -28,28 +28,11 @@
display: flex;
align-items: center;
gap: var(--space-md, 12px);
- margin-top: var(--space-lg, 16px);
- /* Always spans the full grid width, regardless of how many
- Group columns the rest of the page laid out above it. */
- grid-column: 1 / -1;
- }
-
- button {
- background-color: var(--accent);
- color: var(--on-accent);
- border: none;
- border-radius: var(--radius-primary, 8px);
- padding: var(--space-sm, 8px) var(--space-lg, 16px);
- cursor: pointer;
- }
-
- button:disabled {
- opacity: 0.5;
- cursor: default;
+ margin-top: 4px;
}
.status {
- opacity: 0.6;
+ color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
font-size: var(--font-size-secondary, 12px);
}
diff --git a/frontend/src/lib/components/SelectField.svelte b/frontend/src/lib/components/SelectField.svelte
index 2dc50c6..feba79e 100644
--- a/frontend/src/lib/components/SelectField.svelte
+++ b/frontend/src/lib/components/SelectField.svelte
@@ -1,13 +1,25 @@
-
+
@@ -18,8 +30,8 @@
background-color: var(--bg);
color: var(--on-surface);
border: 1px solid transparent;
- border-radius: var(--radius-secondary, 6px);
- padding: var(--space-xs, 4px) var(--space-sm, 8px);
+ border-radius: var(--radius-sm, 10px);
+ padding: 6px 10px;
}
option {
diff --git a/frontend/src/lib/components/ServiceControl.svelte b/frontend/src/lib/components/ServiceControl.svelte
index 1c81d29..9f3e8c0 100644
--- a/frontend/src/lib/components/ServiceControl.svelte
+++ b/frontend/src/lib/components/ServiceControl.svelte
@@ -30,7 +30,7 @@
function toggle() {
if (active && critical) {
- if (!confirm(`Stop ${unit}? This is a core part of the desktop's event handling — stopping it may affect other bread apps until it's restarted.`)) {
+ if (!confirm(`Stop ${unit}? Other bread apps may stall until it is restarted.`)) {
return;
}
}
@@ -57,7 +57,7 @@
{#if hasConfig}
-
+
{/if}
@@ -74,16 +74,16 @@
}
button {
- background-color: var(--surface);
+ background-color: var(--surface-2, var(--bg));
color: var(--on-surface);
- border: none;
- border-radius: var(--radius-primary, 8px);
+ border: 1px solid var(--line, transparent);
+ border-radius: var(--radius-sm, 10px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
}
button:hover {
- background-color: color-mix(in srgb, var(--on-surface) 14%, transparent);
+ background-color: color-mix(in srgb, var(--on-surface) 10%, transparent);
}
button:disabled {
diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte
index 95fb43e..d7c67e8 100644
--- a/frontend/src/lib/components/Sidebar.svelte
+++ b/frontend/src/lib/components/Sidebar.svelte
@@ -1,59 +1,130 @@
diff --git a/frontend/src/lib/components/SliderField.svelte b/frontend/src/lib/components/SliderField.svelte
new file mode 100644
index 0000000..e3ee9a9
--- /dev/null
+++ b/frontend/src/lib/components/SliderField.svelte
@@ -0,0 +1,44 @@
+
+
+
+ onChange?.()} />
+ {shown}{suffix}
+
+
+
diff --git a/frontend/src/lib/components/Subnav.svelte b/frontend/src/lib/components/Subnav.svelte
new file mode 100644
index 0000000..acc782b
--- /dev/null
+++ b/frontend/src/lib/components/Subnav.svelte
@@ -0,0 +1,40 @@
+
+
+
+ {#each items as item (item.id)}
+
+ {/each}
+
+
+
diff --git a/frontend/src/lib/components/Switch.svelte b/frontend/src/lib/components/Switch.svelte
index e2504d3..c62300d 100644
--- a/frontend/src/lib/components/Switch.svelte
+++ b/frontend/src/lib/components/Switch.svelte
@@ -11,33 +11,40 @@
aria-label={ariaLabel}
onclick={() => (value = !value)}
>
-
+
diff --git a/frontend/src/lib/components/SwitchField.svelte b/frontend/src/lib/components/SwitchField.svelte
index a49caaf..a8e2b19 100644
--- a/frontend/src/lib/components/SwitchField.svelte
+++ b/frontend/src/lib/components/SwitchField.svelte
@@ -2,9 +2,13 @@
import Row from "./Row.svelte";
import Switch from "./Switch.svelte";
- let { label, value = $bindable() }: { label: string; value: boolean } = $props();
+ let {
+ label,
+ hint,
+ value = $bindable(),
+ }: { label: string; hint?: string; value: boolean } = $props();
-
+
diff --git a/frontend/src/lib/components/TextField.svelte b/frontend/src/lib/components/TextField.svelte
index 62fa918..1711e56 100644
--- a/frontend/src/lib/components/TextField.svelte
+++ b/frontend/src/lib/components/TextField.svelte
@@ -18,8 +18,8 @@
background-color: var(--bg);
color: var(--on-surface);
border: 1px solid transparent;
- border-radius: var(--radius-secondary, 6px);
- padding: var(--space-xs, 4px) var(--space-sm, 8px);
+ border-radius: var(--radius-sm, 10px);
+ padding: 6px 10px;
}
input:focus {
diff --git a/frontend/src/lib/components/Titlebar.svelte b/frontend/src/lib/components/Titlebar.svelte
new file mode 100644
index 0000000..78fe93a
--- /dev/null
+++ b/frontend/src/lib/components/Titlebar.svelte
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
Settings
+
+
Ctrl K
+
+
+
diff --git a/frontend/src/lib/components/ViewScaffold.svelte b/frontend/src/lib/components/ViewScaffold.svelte
index bb27e82..1eef417 100644
--- a/frontend/src/lib/components/ViewScaffold.svelte
+++ b/frontend/src/lib/components/ViewScaffold.svelte
@@ -1,40 +1,64 @@
-
-
{title}
-
+{#if embedded}
+
{@render children()}
-
+{:else}
+
+
{title}
+ {#if lede}
+
{lede}
+ {/if}
+
+ {@render children()}
+
+
+{/if}
diff --git a/frontend/src/lib/debounce.ts b/frontend/src/lib/debounce.ts
new file mode 100644
index 0000000..463b59b
--- /dev/null
+++ b/frontend/src/lib/debounce.ts
@@ -0,0 +1,7 @@
+export function debounce(fn: () => void, ms = 280): () => void {
+ let t: ReturnType
| undefined;
+ return () => {
+ clearTimeout(t);
+ t = setTimeout(fn, ms);
+ };
+}
diff --git a/frontend/src/lib/embed.ts b/frontend/src/lib/embed.ts
new file mode 100644
index 0000000..86ff3ab
--- /dev/null
+++ b/frontend/src/lib/embed.ts
@@ -0,0 +1,2 @@
+/** When set, ViewScaffold renders inner groups only - used by hub tab panes. */
+export const EMBEDDED_KEY = "bos-settings-embedded";
diff --git a/frontend/src/lib/nav.svelte.ts b/frontend/src/lib/nav.svelte.ts
new file mode 100644
index 0000000..b7382c9
--- /dev/null
+++ b/frontend/src/lib/nav.svelte.ts
@@ -0,0 +1,17 @@
+import { DEFAULT_PAGE, resolvePage } from "$lib/sidebar";
+
+/** Shared navigation - module `$state` so sidebar/search/hubs all see the same page. */
+export const nav = $state({
+ page: DEFAULT_PAGE,
+ tab: null as string | null,
+ searchNonce: 0,
+});
+
+export function go(page: string, tab?: string) {
+ const resolved = resolvePage(page);
+ nav.tab = tab ?? resolved.tab ?? "";
+ nav.page = resolved.page;
+}
+
+export type Navigate = (page: string, tab?: string) => void;
+export const NAVIGATE_KEY = "bos-settings-navigate";
diff --git a/frontend/src/lib/nav.ts b/frontend/src/lib/nav.ts
deleted file mode 100644
index bced4f0..0000000
--- a/frontend/src/lib/nav.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-/** Sidebar page switch. Set from +page.svelte; views call it to jump. */
-export type Navigate = (page: string) => void;
-export const NAVIGATE_KEY = "bos-settings-navigate";
diff --git a/frontend/src/lib/search.ts b/frontend/src/lib/search.ts
new file mode 100644
index 0000000..2b5f4e6
--- /dev/null
+++ b/frontend/src/lib/search.ts
@@ -0,0 +1,120 @@
+export interface SearchHit {
+ page: string;
+ tab?: string;
+ label: string;
+ keywords: string;
+}
+
+export const SEARCH_INDEX: SearchHit[] = [
+ { page: "home", label: "Home", keywords: "search overview status" },
+ {
+ page: "network",
+ label: "Wi-Fi",
+ keywords: "wifi ssid password scan radio ethernet hotspot dns",
+ },
+ {
+ page: "network",
+ tab: "vpn",
+ label: "VPN / WireGuard",
+ keywords: "vpn wireguard tailscale tunnel import conf",
+ },
+ {
+ page: "network",
+ tab: "breadcrumbs",
+ label: "Saved networks",
+ keywords: "wifi profiles breadcrumbs known ssid tailscale",
+ },
+ { page: "bluetooth", label: "Bluetooth", keywords: "bluetooth headphones pair scan mouse keyboard" },
+ {
+ page: "displays",
+ label: "Displays",
+ keywords: "monitor scale resolution arrange rotate vrr hdr hyprland",
+ },
+ {
+ page: "displays",
+ tab: "nightlight",
+ label: "Night light",
+ keywords: "night light hyprsunset temperature warmth sunset",
+ },
+ {
+ page: "displays",
+ tab: "breadmon",
+ label: "Live arrange",
+ keywords: "breadmon mirror profile arrange",
+ },
+ {
+ page: "appearance",
+ label: "Wallpaper",
+ keywords: "wallpaper theme palette pywal breadpaper accent",
+ },
+ {
+ page: "appearance",
+ tab: "appearance",
+ label: "Windows",
+ keywords: "gaps blur rounding border shadow tiling dwindle",
+ },
+ { page: "sound", label: "Sound", keywords: "volume mute microphone output input speaker pavucontrol" },
+ {
+ page: "power",
+ label: "Power & battery",
+ keywords: "battery brightness charge tlp suspend idle lid",
+ },
+ {
+ page: "input",
+ label: "Keyboard & mouse",
+ keywords: "keybind shortcut layout touchpad tap natural scroll follow mouse",
+ },
+ { page: "input", tab: "ime", label: "Input method", keywords: "fcitx5 ime cjk chinese japanese korean" },
+ {
+ page: "input",
+ tab: "accessibility",
+ label: "Accessibility",
+ keywords: "orca screen reader zoom magnifier sticky keys",
+ },
+ {
+ page: "desktop",
+ label: "Bar",
+ keywords: "breadbar modules clock tray workspaces",
+ },
+ { page: "desktop", tab: "breadbox", label: "Launcher", keywords: "breadbox launcher apps" },
+ { page: "desktop", tab: "breadlock", label: "Lock screen", keywords: "lock greet breadlock idle" },
+ { page: "desktop", tab: "breadshot", label: "Screenshots", keywords: "screenshot grim slurp breadshot" },
+ { page: "desktop", tab: "autostart", label: "Startup apps", keywords: "autostart login startup" },
+ { page: "desktop", tab: "breadclip", label: "Clipboard", keywords: "clipboard history breadclip" },
+ { page: "desktop", tab: "breadpad", label: "Notes", keywords: "breadpad notes calendar caldav" },
+ { page: "desktop", tab: "breadsearch", label: "File search", keywords: "breadsearch breadmill index" },
+ { page: "desktop", tab: "bread", label: "Daemon", keywords: "breadd daemon adapters lua" },
+ { page: "apps", label: "Default apps", keywords: "browser terminal pdf mime default" },
+ { page: "apps", tab: "optional", label: "Optional software", keywords: "steam flatpak libreoffice" },
+ { page: "apps", tab: "printing", label: "Printing", keywords: "cups printer" },
+ { page: "privacy", label: "Firewall", keywords: "firewall ufw port allow" },
+ { page: "privacy", tab: "users", label: "Users", keywords: "users password account" },
+ { page: "system", label: "Updates", keywords: "update bakery pacman firmware nvidia" },
+ { page: "system", tab: "packages", label: "Packages", keywords: "packages bakery install" },
+ { page: "system", tab: "aur", label: "AUR", keywords: "aur yay" },
+ { page: "system", tab: "snapshots", label: "Snapshots", keywords: "snapper snapshot rollback grub" },
+ { page: "system", tab: "backup", label: "Backup", keywords: "restic backup restore" },
+ { page: "system", tab: "channel", label: "Bakery channel", keywords: "track stable beta dev bakery" },
+ { page: "about", label: "About", keywords: "hostname kernel cpu gpu about machine" },
+ { page: "about", tab: "datetime", label: "Date & time", keywords: "timezone ntp clock date time" },
+ { page: "desktop", tab: "breadhelp", label: "Help", keywords: "help breadhelp onboarding" },
+];
+
+export function searchSettings(query: string, limit = 8): SearchHit[] {
+ const q = query.trim().toLowerCase();
+ if (q.length < 1) return [];
+ const scored = SEARCH_INDEX.map((hit) => {
+ const hay = `${hit.label} ${hit.keywords} ${hit.page}`.toLowerCase();
+ let score = 0;
+ if (hit.label.toLowerCase().startsWith(q)) score += 8;
+ if (hit.label.toLowerCase().includes(q)) score += 4;
+ if (hay.includes(q)) score += 2;
+ for (const part of q.split(/\s+/)) {
+ if (hay.includes(part)) score += 1;
+ }
+ return { hit, score };
+ })
+ .filter((x) => x.score > 0)
+ .sort((a, b) => b.score - a.score);
+ return scored.slice(0, limit).map((x) => x.hit);
+}
diff --git a/frontend/src/lib/sidebar.ts b/frontend/src/lib/sidebar.ts
index c9f6740..f7d9d50 100644
--- a/frontend/src/lib/sidebar.ts
+++ b/frontend/src/lib/sidebar.ts
@@ -1,117 +1,112 @@
-// Ports src/ui/sidebar.rs's declarative item lists verbatim. Grouped by task,
-// not "app vs system internals" — a user thinks "I want to change my Wi-Fi",
-// not "which of these is a bread-ecosystem app" (why breadcrumbs/Wi-Fi
-// Profiles lives in System, not Personalization).
-
import type { Component } from "svelte";
+import House from "@lucide/svelte/icons/house";
import Wifi from "@lucide/svelte/icons/wifi";
-import Network from "@lucide/svelte/icons/network";
import Bluetooth from "@lucide/svelte/icons/bluetooth";
-import Shield from "@lucide/svelte/icons/shield";
+import Monitor from "@lucide/svelte/icons/monitor";
import Volume2 from "@lucide/svelte/icons/volume-2";
import BatteryFull from "@lucide/svelte/icons/battery-full";
-import Clock from "@lucide/svelte/icons/clock";
-import Monitor from "@lucide/svelte/icons/monitor";
-import Keyboard from "@lucide/svelte/icons/keyboard";
-import Rocket from "@lucide/svelte/icons/rocket";
-import Users from "@lucide/svelte/icons/users";
import Palette from "@lucide/svelte/icons/palette";
-import Image from "@lucide/svelte/icons/image";
-import LayoutGrid from "@lucide/svelte/icons/layout-grid";
-import Grid3x3 from "@lucide/svelte/icons/grid-3x3";
-import Clipboard from "@lucide/svelte/icons/clipboard";
-import NotebookPen from "@lucide/svelte/icons/notebook-pen";
-import Search from "@lucide/svelte/icons/search";
-import Cog from "@lucide/svelte/icons/cog";
-import Package from "@lucide/svelte/icons/package";
-import RefreshCw from "@lucide/svelte/icons/refresh-cw";
-import History from "@lucide/svelte/icons/history";
-import Info from "@lucide/svelte/icons/info";
-import Lock from "@lucide/svelte/icons/lock";
-import Camera from "@lucide/svelte/icons/camera";
import AppWindow from "@lucide/svelte/icons/app-window";
-import CircleHelp from "@lucide/svelte/icons/circle-help";
+import Keyboard from "@lucide/svelte/icons/keyboard";
+import LayoutGrid from "@lucide/svelte/icons/layout-grid";
+import Shield from "@lucide/svelte/icons/shield";
import Download from "@lucide/svelte/icons/download";
-import Printer from "@lucide/svelte/icons/printer";
-import ShieldEllipsis from "@lucide/svelte/icons/shield-ellipsis";
-import Moon from "@lucide/svelte/icons/moon";
-import Languages from "@lucide/svelte/icons/languages";
-import Accessibility from "@lucide/svelte/icons/accessibility";
-import AppWindowMac from "@lucide/svelte/icons/app-window-mac";
-import GitBranch from "@lucide/svelte/icons/git-branch";
-import Archive from "@lucide/svelte/icons/archive";
-import Boxes from "@lucide/svelte/icons/boxes";
+import Info from "@lucide/svelte/icons/info";
export interface SidebarItem {
- /** Must match a key in the view component map (see routing in +page.svelte). */
id: string;
label: string;
- /** Dim second line — the underlying binary/config name, for items whose
- * human label doesn't already make that obvious. */
- sublabel?: string;
icon: Component;
}
-export const SYSTEM_ITEMS: SidebarItem[] = [
- { id: "network", label: "Network", icon: Wifi },
- { id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network },
- { id: "vpn", label: "VPN / WireGuard", sublabel: "NetworkManager", icon: ShieldEllipsis },
- { id: "bluetooth", label: "Bluetooth", icon: Bluetooth },
- { id: "printing", label: "Printing", sublabel: "CUPS", icon: Printer },
- { id: "firewall", label: "Firewall", icon: Shield },
- { id: "sound", label: "Sound", icon: Volume2 },
- { id: "power", label: "Power", icon: BatteryFull },
- { id: "datetime", label: "Date & Time", icon: Clock },
- { id: "hyprland", label: "Display", sublabel: "monitors.json", icon: Monitor },
- { id: "nightlight", label: "Night light", sublabel: "hyprsunset", icon: Moon },
- { id: "breadmon", label: "Monitors", sublabel: "breadmon", icon: AppWindow },
- { id: "breadlock", label: "Lock & greet", sublabel: "breadlock", icon: Lock },
- { id: "keybinds", label: "Keybinds", sublabel: "binds.json", icon: Keyboard },
- { id: "ime", label: "Input method", sublabel: "fcitx5", icon: Languages },
- { id: "accessibility", label: "Accessibility", icon: Accessibility },
- { id: "breadshot", label: "Screenshots", sublabel: "breadshot", icon: Camera },
- { id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket },
- { id: "users", label: "Users", icon: Users },
-];
-
-export const PERSONALIZATION_ITEMS: SidebarItem[] = [
- { id: "appearance", label: "Appearance", sublabel: "settings.json", icon: Palette },
- { id: "breadpaper", label: "Wallpaper", sublabel: "breadpaper", icon: Image },
- { id: "breadbar", label: "Bar", sublabel: "breadbar", icon: LayoutGrid },
- { id: "breadbox", label: "Launcher", sublabel: "breadbox", icon: Grid3x3 },
- { id: "breadclip", label: "Clipboard", sublabel: "breadclipd", icon: Clipboard },
- { id: "breadpad", label: "Notes", sublabel: "breadpad", icon: NotebookPen },
- { id: "breadsearch", label: "File Search", sublabel: "breadsearch", icon: Search },
- { id: "defaults", label: "Default apps", sublabel: "mimeapps.list", icon: AppWindowMac },
- { id: "bread", label: "Daemon", sublabel: "breadd", icon: Cog },
-];
-
-export const MAINTENANCE_ITEMS: SidebarItem[] = [
- { id: "updates", label: "Updates", icon: Download },
- { id: "packages", label: "Packages", icon: Package },
- { id: "aur", label: "AUR", icon: Search },
- { id: "firmware", label: "Firmware", icon: RefreshCw },
- { id: "snapshots", label: "Snapshots", icon: History },
- { id: "channel", label: "Bakery channel", sublabel: "track", icon: GitBranch },
- { id: "backup", label: "Backup", sublabel: "restic", icon: Archive },
- { id: "optional", label: "Optional software", icon: Boxes },
-];
-
-export const ABOUT_ITEMS: SidebarItem[] = [
- { id: "breadhelp", label: "Help", sublabel: "breadhelp", icon: CircleHelp },
- { id: "about", label: "About", icon: Info },
-];
-
export interface SidebarSection {
title: string | null;
items: SidebarItem[];
}
export const SIDEBAR_SECTIONS: SidebarSection[] = [
- { title: "System", items: SYSTEM_ITEMS },
- { title: "Personalization", items: PERSONALIZATION_ITEMS },
- { title: "Maintenance", items: MAINTENANCE_ITEMS },
- { title: null, items: ABOUT_ITEMS },
+ { title: "Overview", items: [{ id: "home", label: "Home", icon: House }] },
+ {
+ title: "Devices",
+ items: [
+ { id: "network", label: "Wi-Fi & internet", icon: Wifi },
+ { id: "bluetooth", label: "Bluetooth", icon: Bluetooth },
+ { id: "displays", label: "Displays", icon: Monitor },
+ { id: "sound", label: "Sound", icon: Volume2 },
+ { id: "power", label: "Power & battery", icon: BatteryFull },
+ ],
+ },
+ {
+ title: "Desktop",
+ items: [
+ { id: "appearance", label: "Appearance", icon: Palette },
+ { id: "desktop", label: "Bar & apps", icon: AppWindow },
+ { id: "input", label: "Keyboard & mouse", icon: Keyboard },
+ { id: "apps", label: "Default apps", icon: LayoutGrid },
+ ],
+ },
+ {
+ title: "System",
+ items: [
+ { id: "privacy", label: "Privacy & users", icon: Shield },
+ { id: "system", label: "Updates & backup", icon: Download },
+ { id: "about", label: "About", icon: Info },
+ ],
+ },
];
-export const DEFAULT_PAGE = "about";
+/** Old sidebar ids (screenshot CLI, in-app jumps) → hub page + optional tab. */
+export const PAGE_ALIASES: Record = {
+ home: { page: "home" },
+ network: { page: "network" },
+ vpn: { page: "network", tab: "vpn" },
+ breadcrumbs: { page: "network", tab: "breadcrumbs" },
+ bluetooth: { page: "bluetooth" },
+ displays: { page: "displays" },
+ hyprland: { page: "displays", tab: "hyprland" },
+ nightlight: { page: "displays", tab: "nightlight" },
+ breadmon: { page: "displays", tab: "breadmon" },
+ sound: { page: "sound" },
+ power: { page: "power" },
+ appearance: { page: "appearance" },
+ breadpaper: { page: "appearance", tab: "breadpaper" },
+ desktop: { page: "desktop" },
+ breadbar: { page: "desktop", tab: "breadbar" },
+ breadbox: { page: "desktop", tab: "breadbox" },
+ breadlock: { page: "desktop", tab: "breadlock" },
+ breadshot: { page: "desktop", tab: "breadshot" },
+ autostart: { page: "desktop", tab: "autostart" },
+ breadclip: { page: "desktop", tab: "more" },
+ breadpad: { page: "desktop", tab: "more" },
+ breadsearch: { page: "desktop", tab: "more" },
+ bread: { page: "desktop", tab: "more" },
+ breadhelp: { page: "desktop", tab: "more" },
+ more: { page: "desktop", tab: "more" },
+ input: { page: "input" },
+ keybinds: { page: "input", tab: "keybinds" },
+ ime: { page: "input", tab: "ime" },
+ accessibility: { page: "input", tab: "accessibility" },
+ apps: { page: "apps" },
+ defaults: { page: "apps" },
+ optional: { page: "apps", tab: "optional" },
+ printing: { page: "apps", tab: "printing" },
+ privacy: { page: "privacy" },
+ firewall: { page: "privacy" },
+ users: { page: "privacy", tab: "users" },
+ system: { page: "system" },
+ updates: { page: "system" },
+ packages: { page: "system", tab: "packages" },
+ aur: { page: "system", tab: "aur" },
+ firmware: { page: "system", tab: "firmware" },
+ snapshots: { page: "system", tab: "snapshots" },
+ backup: { page: "system", tab: "backup" },
+ channel: { page: "system", tab: "channel" },
+ about: { page: "about" },
+ datetime: { page: "about", tab: "datetime" },
+};
+
+export const DEFAULT_PAGE = "home";
+
+export function resolvePage(id: string): { page: string; tab?: string } {
+ return PAGE_ALIASES[id] ?? { page: id };
+}
diff --git a/frontend/src/lib/streaming.ts b/frontend/src/lib/streaming.ts
index daaeee4..bf78f05 100644
--- a/frontend/src/lib/streaming.ts
+++ b/frontend/src/lib/streaming.ts
@@ -1,5 +1,5 @@
// Frontend half of the event-streaming command pattern (see
-// src/src/commands/streaming.rs) — listens for `cmd-output` lines from a
+// src/src/commands/streaming.rs) - listens for `cmd-output` lines from a
// typed Tauri command that runs a hardcoded program, then resolves once
// the process exits.
diff --git a/frontend/src/lib/styles/app.css b/frontend/src/lib/styles/app.css
new file mode 100644
index 0000000..a626e8e
--- /dev/null
+++ b/frontend/src/lib/styles/app.css
@@ -0,0 +1,121 @@
+:root {
+ --bg-2: color-mix(in oklab, var(--bg) 82%, var(--surface));
+ --surface-2: color-mix(in oklab, var(--surface) 72%, var(--overlay));
+ --muted: color-mix(in oklab, var(--fg) 58%, transparent);
+ --faint: color-mix(in oklab, var(--fg) 38%, transparent);
+ --line: color-mix(in srgb, var(--fg) 8%, transparent);
+ --accent-soft: color-mix(in oklab, var(--accent) 18%, transparent);
+ --radius: 14px;
+ --radius-sm: 10px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ border: none;
+ outline: none;
+ height: 100%;
+ color-scheme: dark;
+ background-color: var(--bg, #12161c);
+ color: var(--fg);
+ font-family: var(--font-family, sans-serif);
+ font-size: var(--font-size-base, 14px);
+}
+
+#svelte {
+ height: 100%;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+ color: inherit;
+}
+
+:focus {
+ outline: none;
+}
+
+:focus-visible {
+ outline: 2px solid color-mix(in oklab, var(--accent) 70%, white);
+ outline-offset: 2px;
+}
+
+input[type="range"] {
+ accent-color: var(--accent);
+}
+
+.btn {
+ background: var(--surface-2, var(--surface));
+ border: 1px solid var(--line);
+ padding: 8px 14px;
+ border-radius: var(--radius-sm, 10px);
+ font-size: 13px;
+ cursor: pointer;
+ color: var(--fg);
+}
+
+.btn:hover {
+ background: color-mix(in oklab, var(--surface-2, var(--surface)) 80%, var(--fg));
+}
+
+.btn.primary {
+ background: var(--accent);
+ color: var(--on-accent);
+ border-color: transparent;
+ font-weight: 600;
+}
+
+.btn.primary:hover {
+ filter: brightness(1.06);
+}
+
+.btn.danger {
+ color: var(--red);
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.kbd {
+ font-size: 11px;
+ letter-spacing: 0.04em;
+ background: color-mix(in srgb, var(--fg) 6%, transparent);
+ border: 1px solid var(--line);
+ padding: 2px 7px;
+ border-radius: 6px;
+ color: var(--muted);
+}
+
+.pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 11px;
+ border-radius: 999px;
+ font-size: 12px;
+ background: color-mix(in srgb, var(--fg) 6%, transparent);
+ border: 1px solid var(--line);
+ color: var(--fg);
+ cursor: pointer;
+}
+
+.pill.on {
+ background: var(--accent);
+ color: var(--on-accent);
+ border-color: transparent;
+ font-weight: 600;
+}
diff --git a/frontend/src/lib/theme/index.ts b/frontend/src/lib/theme/index.ts
index a358759..87e4b17 100644
--- a/frontend/src/lib/theme/index.ts
+++ b/frontend/src/lib/theme/index.ts
@@ -1,6 +1,6 @@
// Bridges bread-theme's pywal-derived palette into the webview. Mirrors
// bread_theme::gtk::apply_shared()'s two-phase pattern: fetch once at
-// startup, then keep it live via a backend-pushed event — see
+// startup, then keep it live via a backend-pushed event - see
// src-tauri/src/commands/theme.rs for the file-watch side of this.
import { invoke } from "@tauri-apps/api/core";
diff --git a/frontend/src/lib/views/About.svelte b/frontend/src/lib/views/About.svelte
index 894aca2..43c0e56 100644
--- a/frontend/src/lib/views/About.svelte
+++ b/frontend/src/lib/views/About.svelte
@@ -24,7 +24,7 @@
// The backend reports the GPU as a raw lspci device string, e.g.
// "Advanced Micro Devices, Inc. [AMD/ATI] Krackan [Radeon 840M / 860M Graphics] (rev c2)".
// That's too technical for a general settings page, so extract just the
- // marketing model name. Heuristic (not exhaustive — just needs to read
+ // marketing model name. Heuristic (not exhaustive - just needs to read
// well for common vendors): drop the trailing "(rev ..)", prefer the
// text inside the last [...] bracket group (usually the model name),
// collapse "840M / 860M" style multi-model lists to the last variant,
diff --git a/frontend/src/lib/views/AboutHub.svelte b/frontend/src/lib/views/AboutHub.svelte
new file mode 100644
index 0000000..b4f7a2e
--- /dev/null
+++ b/frontend/src/lib/views/AboutHub.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/frontend/src/lib/views/Accessibility.svelte b/frontend/src/lib/views/Accessibility.svelte
index 02bb427..ed4094b 100644
--- a/frontend/src/lib/views/Accessibility.svelte
+++ b/frontend/src/lib/views/Accessibility.svelte
@@ -94,7 +94,7 @@
{/if}
-
+
{#if st}
diff --git a/frontend/src/lib/views/Appearance.svelte b/frontend/src/lib/views/Appearance.svelte
index b9d7541..2ef5fe2 100644
--- a/frontend/src/lib/views/Appearance.svelte
+++ b/frontend/src/lib/views/Appearance.svelte
@@ -5,13 +5,12 @@
import Group from "$lib/components/Group.svelte";
import SwitchField from "$lib/components/SwitchField.svelte";
import SelectField from "$lib/components/SelectField.svelte";
- import NumberField from "$lib/components/NumberField.svelte";
+ import SliderField from "$lib/components/SliderField.svelte";
import HyprColorField from "$lib/components/HyprColorField.svelte";
- import Row from "$lib/components/Row.svelte";
import Hint from "$lib/components/Hint.svelte";
- import SaveButton from "$lib/components/SaveButton.svelte";
+ import { debounce } from "$lib/debounce";
- // Common XKB layout codes. Not exhaustive — if the config holds
+ // Common XKB layout codes. Not exhaustive - if the config holds
// something else, it's merged in below so it's never dropped from the
// dropdown.
const COMMON_KB_LAYOUTS = [
@@ -25,13 +24,51 @@
// window under the cursor; 2 = focus follows the cursor, but clicking a
// window keeps keyboard focus there until the mouse moves again; 3 =
// cursor and keyboard focus are fully independent.
- const FOLLOW_MOUSE_MODES: { value: number; label: string }[] = [
- { value: 0, label: "Off — click to focus" },
- { value: 1, label: "Follow mouse" },
- { value: 2, label: "Follow mouse, detach on click" },
- { value: 3, label: "Fully detached from keyboard focus" },
+ const FOLLOW_MOUSE_MODES: { value: string; label: string }[] = [
+ { value: "0", label: "Click to focus" },
+ { value: "1", label: "Follow mouse" },
+ { value: "2", label: "Follow, keep focus on click" },
+ { value: "3", label: "Mouse and keys independent" },
];
+ const KB_LABELS: Record = {
+ us: "English (US)",
+ gb: "English (UK)",
+ de: "German",
+ fr: "French",
+ es: "Spanish",
+ it: "Italian",
+ pt: "Portuguese",
+ nl: "Dutch",
+ se: "Swedish",
+ no: "Norwegian",
+ dk: "Danish",
+ fi: "Finnish",
+ pl: "Polish",
+ cz: "Czech",
+ sk: "Slovak",
+ hu: "Hungarian",
+ ro: "Romanian",
+ gr: "Greek",
+ tr: "Turkish",
+ ru: "Russian",
+ ua: "Ukrainian",
+ jp: "Japanese",
+ kr: "Korean",
+ cn: "Chinese",
+ br: "Portuguese (Brazil)",
+ ca: "English (Canada)",
+ ch: "German (Switzerland)",
+ be: "Belgian",
+ at: "German (Austria)",
+ ie: "English (Ireland)",
+ };
+
+ const LAYOUT_LABELS: Record = {
+ dwindle: "Tiling",
+ master: "Master stack",
+ };
+
interface Appearance {
gaps_in: number;
gaps_out: number;
@@ -53,76 +90,73 @@
}
let cfg = $state(null);
+ let loaded = $state(false);
+ let followMouse = $state("1");
let kbLayoutOptions = $derived(
cfg && !COMMON_KB_LAYOUTS.includes(cfg.kb_layout) ? [...COMMON_KB_LAYOUTS, cfg.kb_layout] : COMMON_KB_LAYOUTS,
);
+ const persist = debounce(() => {
+ if (!cfg) return;
+ invoke("save_appearance", { appearance: { ...cfg, follow_mouse: Number(followMouse) } });
+ }, 450);
+
onMount(async () => {
cfg = await invoke("get_appearance");
+ followMouse = String(cfg.follow_mouse);
+ loaded = true;
});
- async function save() {
- await invoke("save_appearance", { appearance: cfg });
- }
+ let primed = false;
+ $effect(() => {
+ if (!loaded || !cfg) return;
+ JSON.stringify(cfg);
+ void followMouse;
+ if (!primed) {
+ primed = true;
+ return;
+ }
+ persist();
+ });
{#if cfg}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
+ {#if cfg.blur_enabled}
+
+
+ {/if}
+
+ {#if cfg.shadow_enabled}
+
+ {/if}
-
-
-
-
-
-
+
+
+ m.value)}
+ labels={Object.fromEntries(FOLLOW_MOUSE_MODES.map((m) => [m.value, m.label]))}
+ />
+
-
-
+
{/if}
-
-
diff --git a/frontend/src/lib/views/AppearanceHub.svelte b/frontend/src/lib/views/AppearanceHub.svelte
new file mode 100644
index 0000000..692f3ea
--- /dev/null
+++ b/frontend/src/lib/views/AppearanceHub.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/frontend/src/lib/views/AppsHub.svelte b/frontend/src/lib/views/AppsHub.svelte
new file mode 100644
index 0000000..5b5c900
--- /dev/null
+++ b/frontend/src/lib/views/AppsHub.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/frontend/src/lib/views/Aur.svelte b/frontend/src/lib/views/Aur.svelte
index 11c035f..ecf2ae0 100644
--- a/frontend/src/lib/views/Aur.svelte
+++ b/frontend/src/lib/views/Aur.svelte
@@ -34,7 +34,7 @@
diff --git a/frontend/src/lib/views/Autostart.svelte b/frontend/src/lib/views/Autostart.svelte
index 5bc5248..914bf12 100644
--- a/frontend/src/lib/views/Autostart.svelte
+++ b/frontend/src/lib/views/Autostart.svelte
@@ -41,7 +41,7 @@
{#if entries}
{#each entries as entry, i (i)}
diff --git a/frontend/src/lib/views/Backup.svelte b/frontend/src/lib/views/Backup.svelte
index b650c30..32cc11b 100644
--- a/frontend/src/lib/views/Backup.svelte
+++ b/frontend/src/lib/views/Backup.svelte
@@ -84,7 +84,7 @@
log = [...log, line];
});
busy = false;
- if (!ok) message = "Command failed — see the log.";
+ if (!ok) message = "Failed. See the log.";
}
async function listSnaps() {
@@ -122,7 +122,7 @@
{#if !st}
@@ -140,7 +140,7 @@
diff --git a/frontend/src/lib/views/Bluetooth.svelte b/frontend/src/lib/views/Bluetooth.svelte
index 49d5962..73bad27 100644
--- a/frontend/src/lib/views/Bluetooth.svelte
+++ b/frontend/src/lib/views/Bluetooth.svelte
@@ -75,7 +75,10 @@
}
-
+
{#if powered === null}
{dev.name}{dev.connected ? " (connected)" : ""}
-
+
{/each}
@@ -107,7 +110,7 @@
{#if scanResults === null}
@@ -144,9 +147,13 @@
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
- background-color: var(--surface);
- border-radius: var(--radius-secondary, 6px);
- padding: var(--space-sm, 8px) var(--space-md, 12px);
+ padding: 10px 2px;
+ border-top: 1px solid var(--line);
+ }
+
+ .row:first-child {
+ border-top: none;
+ padding-top: 0;
}
.name {
diff --git a/frontend/src/lib/views/Bread.svelte b/frontend/src/lib/views/Bread.svelte
index 15fa6a9..a0de5cf 100644
--- a/frontend/src/lib/views/Bread.svelte
+++ b/frontend/src/lib/views/Bread.svelte
@@ -34,7 +34,7 @@
let cfg = $state
(null);
// The daemon's 4 compiled-in modules plus every *.lua file actually
- // sitting in the configured module directory — real installed modules,
+ // sitting in the configured module directory - real installed modules,
// not a guess, so picking one to disable is a click.
let knownModules = $state([]);
diff --git a/frontend/src/lib/views/Breadbar.svelte b/frontend/src/lib/views/Breadbar.svelte
index ee844ef..7748928 100644
--- a/frontend/src/lib/views/Breadbar.svelte
+++ b/frontend/src/lib/views/Breadbar.svelte
@@ -3,10 +3,10 @@
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
- import Row from "$lib/components/Row.svelte";
import TextField from "$lib/components/TextField.svelte";
- import NumberField from "$lib/components/NumberField.svelte";
- import SaveButton from "$lib/components/SaveButton.svelte";
+ import SliderField from "$lib/components/SliderField.svelte";
+ import Hint from "$lib/components/Hint.svelte";
+ import { debounce } from "$lib/debounce";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
@@ -23,20 +23,33 @@
}
let style = $state(null);
+ let loaded = $state(false);
let advancedOpen = $state(false);
let css = $state("");
let cssStatus = $state("");
let cssSaving = $state(false);
+ const persist = debounce(() => {
+ if (!style) return;
+ invoke("save_breadbar_style", { style }).then(() => invoke("get_breadbar_css").then((c) => (css = c)));
+ }, 320);
+
onMount(async () => {
style = await invoke("get_breadbar_style");
css = await invoke("get_breadbar_css");
+ loaded = true;
});
- async function saveStyle() {
- await invoke("save_breadbar_style", { style });
- css = await invoke("get_breadbar_css");
- }
+ let primed = false;
+ $effect(() => {
+ if (!loaded || !style) return;
+ JSON.stringify(style);
+ if (!primed) {
+ primed = true;
+ return;
+ }
+ persist();
+ });
async function saveCss() {
cssSaving = true;
@@ -55,36 +68,37 @@
{#if style}
-
+
-
+
-
-
+
+
-
-
-
-
-
- {Math.round(style.workspace_inactive_opacity * 100)}%
-
-
+
+
+
-
-
-
-
+
+
+
+
-
+
{/if}
-
+
diff --git a/frontend/src/lib/views/Breadsearch.svelte b/frontend/src/lib/views/Breadsearch.svelte
index c46fec9..1ac6880 100644
--- a/frontend/src/lib/views/Breadsearch.svelte
+++ b/frontend/src/lib/views/Breadsearch.svelte
@@ -48,7 +48,7 @@
{#if cfg}
@@ -56,7 +56,7 @@
{/each}
-
+
{:else}
{/if}
@@ -60,45 +58,18 @@
diff --git a/frontend/src/lib/views/DesktopHub.svelte b/frontend/src/lib/views/DesktopHub.svelte
new file mode 100644
index 0000000..a2f7aa0
--- /dev/null
+++ b/frontend/src/lib/views/DesktopHub.svelte
@@ -0,0 +1,22 @@
+
+
+
diff --git a/frontend/src/lib/views/DesktopMore.svelte b/frontend/src/lib/views/DesktopMore.svelte
new file mode 100644
index 0000000..3c34441
--- /dev/null
+++ b/frontend/src/lib/views/DesktopMore.svelte
@@ -0,0 +1,29 @@
+
+
+ ({ id, label }))} bind:value={tab} />
+
diff --git a/frontend/src/lib/views/Display.svelte b/frontend/src/lib/views/Display.svelte
index 296bc3d..e121a6f 100644
--- a/frontend/src/lib/views/Display.svelte
+++ b/frontend/src/lib/views/Display.svelte
@@ -2,151 +2,369 @@
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
- import InfoRow from "$lib/components/InfoRow.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
- import SaveButton from "$lib/components/SaveButton.svelte";
+ import Row from "$lib/components/Row.svelte";
interface LiveMonitor {
name: string;
mode: string;
- }
- interface MonitorRule {
- output: string;
- mode: string;
- position: string;
- scale: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ refresh: number;
+ scale: number;
+ transform: number;
+ available_modes: string[];
}
- let liveMonitors = $state(null);
- let rules = $state(null);
+ const SCALES = [
+ { label: "100%", value: 1 },
+ { label: "125%", value: 1.25 },
+ { label: "150%", value: 1.5 },
+ { label: "200%", value: 2 },
+ ];
- onMount(async () => {
- liveMonitors = await invoke("get_live_monitors");
- rules = await invoke("get_monitor_rules");
+ const TURNS = [
+ { label: "Landscape", value: 0 },
+ { label: "Right", value: 1 },
+ { label: "Upside down", value: 2 },
+ { label: "Left", value: 3 },
+ ];
+
+ const PAD = 28;
+ const STAGE_H = 300;
+
+ let monitors = $state([]);
+ let selected = $state(null);
+ let stageEl: HTMLDivElement | undefined = $state();
+ let stageW = $state(640);
+ let message = $state("");
+ let applying = $state(false);
+
+ let live = $derived(monitors.find((m) => m.name === selected) ?? null);
+
+ let view = $derived.by(() => {
+ if (monitors.length === 0) return { minX: 0, minY: 0, scale: 0.1 };
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const m of monitors) {
+ const w = boxW(m);
+ const h = boxH(m);
+ minX = Math.min(minX, m.x);
+ minY = Math.min(minY, m.y);
+ maxX = Math.max(maxX, m.x + w);
+ maxY = Math.max(maxY, m.y + h);
+ }
+ const bw = Math.max(maxX - minX, 1);
+ const bh = Math.max(maxY - minY, 1);
+ const scale = Math.min((stageW - PAD * 2) / bw, (STAGE_H - PAD * 2) / bh);
+ return { minX, minY, scale };
});
- function addRule() {
- rules = [...(rules ?? []), { output: "", mode: "preferred", position: "auto", scale: "auto" }];
+ type Drag = { i: number; grabX: number; grabY: number; pointer: number };
+ let drag = $state(null);
+
+ function rotated(m: LiveMonitor): boolean {
+ return m.transform === 1 || m.transform === 3 || m.transform === 5 || m.transform === 7;
+ }
+ function boxW(m: LiveMonitor): number {
+ return rotated(m) ? m.height : m.width;
+ }
+ function boxH(m: LiveMonitor): number {
+ return rotated(m) ? m.width : m.height;
}
- function removeRule(i: number) {
- rules = rules!.filter((_, idx) => idx !== i);
- if (rules.length === 0) {
- rules = [{ output: "", mode: "preferred", position: "auto", scale: "auto" }];
+ function screenX(m: LiveMonitor): number {
+ return PAD + (m.x - view.minX) * view.scale;
+ }
+ function screenY(m: LiveMonitor): number {
+ return PAD + (m.y - view.minY) * view.scale;
+ }
+ function screenW(m: LiveMonitor): number {
+ return Math.max(48, boxW(m) * view.scale);
+ }
+ function screenH(m: LiveMonitor): number {
+ return Math.max(32, boxH(m) * view.scale);
+ }
+
+ function nearestScale(v: number): number {
+ return SCALES.reduce((best, s) => (Math.abs(s.value - v) < Math.abs(best - v) ? s.value : best), 1);
+ }
+
+ async function refresh() {
+ monitors = await invoke("get_live_monitors");
+ if (!selected || !monitors.some((m) => m.name === selected)) {
+ selected = monitors[0]?.name ?? null;
}
}
- async function save() {
- await invoke("save_monitor_rules", { rules });
+ onMount(refresh);
+
+ $effect(() => {
+ const el = stageEl;
+ if (!el) return;
+ stageW = el.clientWidth;
+ const ro = new ResizeObserver(() => {
+ stageW = el.clientWidth;
+ });
+ ro.observe(el);
+ return () => ro.disconnect();
+ });
+
+ function snap(idx: number, x: number, y: number): { x: number; y: number } {
+ const moving = monitors[idx];
+ if (!moving) return { x, y };
+ const mw = boxW(moving);
+ const mh = boxH(moving);
+ const thresh = Math.max(16, 18 / Math.max(view.scale, 0.01));
+ let bestX = x;
+ let bestY = y;
+ let bestXd = thresh + 1;
+ let bestYd = thresh + 1;
+ for (let i = 0; i < monitors.length; i++) {
+ if (i === idx) continue;
+ const o = monitors[i];
+ const ow = boxW(o);
+ const oh = boxH(o);
+ const xs = [x - o.x, x - (o.x + ow), x + mw - o.x, x + mw - (o.x + ow)];
+ for (const d of xs) {
+ const ad = Math.abs(d);
+ if (ad < bestXd) {
+ bestXd = ad;
+ bestX = x - d;
+ }
+ }
+ const ys = [y - o.y, y - (o.y + oh), y + mh - o.y, y + mh - (o.y + oh)];
+ for (const d of ys) {
+ const ad = Math.abs(d);
+ if (ad < bestYd) {
+ bestYd = ad;
+ bestY = y - d;
+ }
+ }
+ }
+ return { x: Math.round(bestX), y: Math.round(bestY) };
+ }
+
+ function onDown(e: PointerEvent, i: number) {
+ const m = monitors[i];
+ if (!m || !stageEl) return;
+ selected = m.name;
+ const rect = stageEl.getBoundingClientRect();
+ const px = e.clientX - rect.left;
+ const py = e.clientY - rect.top;
+ drag = { i, grabX: px - screenX(m), grabY: py - screenY(m), pointer: e.pointerId };
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
+ e.preventDefault();
+ }
+
+ function onMove(e: PointerEvent) {
+ if (!drag || !stageEl) return;
+ const rect = stageEl.getBoundingClientRect();
+ const px = e.clientX - rect.left - drag.grabX;
+ const py = e.clientY - rect.top - drag.grabY;
+ const wx = view.minX + (px - PAD) / view.scale;
+ const wy = view.minY + (py - PAD) / view.scale;
+ const snapped = snap(drag.i, wx, wy);
+ monitors[drag.i].x = snapped.x;
+ monitors[drag.i].y = snapped.y;
+ monitors = [...monitors];
+ }
+
+ async function onUp(e: PointerEvent) {
+ if (!drag) return;
+ if (e.pointerId !== drag.pointer && e.type !== "pointerleave") return;
+ drag = null;
+ await apply();
+ }
+
+ async function apply() {
+ if (monitors.length === 0) return;
+ applying = true;
+ message = "";
+ try {
+ await invoke("apply_monitor_layout", { monitors });
+ await refresh();
+ } catch (err) {
+ message = `${err}`;
+ } finally {
+ applying = false;
+ }
+ }
+
+ async function setScale(value: number) {
+ if (!live) return;
+ live.scale = value;
+ monitors = [...monitors];
+ await apply();
+ }
+
+ async function setTransform(value: number) {
+ if (!live) return;
+ live.transform = value;
+ monitors = [...monitors];
+ await apply();
+ }
+
+ async function setMode(modeStr: string) {
+ if (!live) return;
+ const cleaned = modeStr.replace(/Hz$/i, "");
+ const [res, hz] = cleaned.split("@");
+ const [w, h] = (res ?? "").split("x");
+ const width = Number(w);
+ const height = Number(h);
+ const refresh = Number(hz);
+ if (!width || !height) return;
+ live.width = width;
+ live.height = height;
+ if (refresh) live.refresh = refresh;
+ live.mode = `${width}x${height} @ ${Math.round(live.refresh)}Hz`;
+ monitors = [...monitors];
+ await apply();
}
-
-
- {#if liveMonitors && liveMonitors.length > 0}
- {#each liveMonitors as m (m.name)}
-
- {/each}
+
+
+ {#if monitors.length === 0}
+
{:else}
-
+
+ {#each monitors as m, i (m.name)}
+
+ {/each}
+
+
{/if}
-
-
-
-
-
- {#if rules}
-
- {#each rules as rule, i (i)}
-
-
-
-
-
-
+ {#if live}
+
+
+ {#if live.available_modes.length > 0}
+ setMode(e.currentTarget.value)}>
+ {#each live.available_modes as mode (mode)}
+
+ {/each}
+
+ {:else}
+ {live.mode}
+ {/if}
+
+
+
+ {#each SCALES as s (s.value)}
+
+ {/each}
- {/each}
-
-
-
+
+
+
+ {#each TURNS as t (t.value)}
+
+ {/each}
+
+
+
+ {live.x}, {live.y}
+
+ {#if message}
+
+ {/if}
{/if}
diff --git a/frontend/src/lib/views/DisplaysHub.svelte b/frontend/src/lib/views/DisplaysHub.svelte
new file mode 100644
index 0000000..3e86d77
--- /dev/null
+++ b/frontend/src/lib/views/DisplaysHub.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/frontend/src/lib/views/Firewall.svelte b/frontend/src/lib/views/Firewall.svelte
index 9bbdca0..aba18bf 100644
--- a/frontend/src/lib/views/Firewall.svelte
+++ b/frontend/src/lib/views/Firewall.svelte
@@ -1,4 +1,5 @@
-
+