From dce203174334ef8e0b368f5b0da3c377d74786e0 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 30 Aug 2026 18:37:21 +0800 Subject: [PATCH 1/3] Settings redesign: hub-based navigation, plus hardening and bug fixes Rework the settings app around hub pages (home, network, displays, input, apps, privacy, system) with a redesigned sidebar, shared nav state, and new hub view components. Alongside the redesign: validate webview inputs into root commands (users, firewall, snapshots, wifi), fix set_charge_threshold writing the percentage via tee stdin, prevent streaming installs from hanging on inherited stdin, add frontend type fixes, sync versions to 0.8.2, and clean up clippy/svelte-check warnings. --- frontend/package-lock.json | 18 + frontend/package.json | 3 +- frontend/src/lib/components/Embed.svelte | 10 + frontend/src/lib/components/EmptyState.svelte | 4 +- frontend/src/lib/components/Group.svelte | 22 +- frontend/src/lib/components/Hint.svelte | 10 +- frontend/src/lib/components/Hub.svelte | 51 ++ .../src/lib/components/HyprColorField.svelte | 2 +- .../src/lib/components/NumberField.svelte | 4 +- frontend/src/lib/components/Row.svelte | 46 +- frontend/src/lib/components/SaveButton.svelte | 23 +- .../src/lib/components/SelectField.svelte | 22 +- .../src/lib/components/ServiceControl.svelte | 12 +- frontend/src/lib/components/Sidebar.svelte | 144 ++++-- .../src/lib/components/SliderField.svelte | 44 ++ frontend/src/lib/components/Subnav.svelte | 40 ++ frontend/src/lib/components/Switch.svelte | 27 +- .../src/lib/components/SwitchField.svelte | 8 +- frontend/src/lib/components/TextField.svelte | 4 +- frontend/src/lib/components/Titlebar.svelte | 75 +++ .../src/lib/components/ViewScaffold.svelte | 70 ++- frontend/src/lib/debounce.ts | 7 + frontend/src/lib/embed.ts | 2 + frontend/src/lib/nav.svelte.ts | 17 + frontend/src/lib/nav.ts | 3 - frontend/src/lib/search.ts | 120 +++++ frontend/src/lib/sidebar.ts | 183 ++++---- frontend/src/lib/streaming.ts | 2 +- frontend/src/lib/styles/app.css | 121 +++++ frontend/src/lib/theme/index.ts | 2 +- frontend/src/lib/views/About.svelte | 2 +- frontend/src/lib/views/AboutHub.svelte | 14 + frontend/src/lib/views/Accessibility.svelte | 2 +- frontend/src/lib/views/Appearance.svelte | 152 +++--- frontend/src/lib/views/AppearanceHub.svelte | 14 + frontend/src/lib/views/AppsHub.svelte | 16 + frontend/src/lib/views/Aur.svelte | 2 +- frontend/src/lib/views/Autostart.svelte | 2 +- frontend/src/lib/views/Backup.svelte | 6 +- frontend/src/lib/views/Bluetooth.svelte | 19 +- frontend/src/lib/views/Bread.svelte | 2 +- frontend/src/lib/views/Breadbar.svelte | 80 ++-- frontend/src/lib/views/Breadbox.svelte | 2 +- frontend/src/lib/views/Breadclip.svelte | 2 +- frontend/src/lib/views/Breadcrumbs.svelte | 6 +- frontend/src/lib/views/Breadhelp.svelte | 6 +- frontend/src/lib/views/Breadlock.svelte | 15 +- frontend/src/lib/views/Breadmon.svelte | 9 +- frontend/src/lib/views/Breadpad.svelte | 2 +- frontend/src/lib/views/Breadpaper.svelte | 170 +++---- frontend/src/lib/views/Breadsearch.svelte | 4 +- frontend/src/lib/views/Breadshot.svelte | 4 +- frontend/src/lib/views/Channel.svelte | 2 +- frontend/src/lib/views/Defaults.svelte | 53 +-- frontend/src/lib/views/DesktopHub.svelte | 22 + frontend/src/lib/views/DesktopMore.svelte | 29 ++ frontend/src/lib/views/Display.svelte | 434 +++++++++++++----- frontend/src/lib/views/DisplaysHub.svelte | 16 + frontend/src/lib/views/Firewall.svelte | 26 +- frontend/src/lib/views/Firmware.svelte | 2 +- frontend/src/lib/views/Home.svelte | 315 +++++++++++++ frontend/src/lib/views/InputHub.svelte | 16 + frontend/src/lib/views/InputMethod.svelte | 4 +- frontend/src/lib/views/Keybinds.svelte | 255 +++++++--- frontend/src/lib/views/Network.svelte | 234 +++++----- frontend/src/lib/views/NetworkHub.svelte | 16 + frontend/src/lib/views/NightLight.svelte | 110 +---- frontend/src/lib/views/Optional.svelte | 2 +- frontend/src/lib/views/Packages.svelte | 2 +- frontend/src/lib/views/Power.svelte | 28 +- frontend/src/lib/views/PrivacyHub.svelte | 14 + frontend/src/lib/views/Snapshots.svelte | 6 +- frontend/src/lib/views/Sound.svelte | 143 ++++-- frontend/src/lib/views/SystemHub.svelte | 24 + frontend/src/lib/views/Updates.svelte | 13 +- frontend/src/lib/views/Users.svelte | 2 +- frontend/src/lib/views/Vpn.svelte | 2 +- frontend/src/lib/views/registry.ts | 101 +--- frontend/src/routes/+page.svelte | 77 ++-- frontend/vite.config.js | 1 - src/capabilities/default.json | 4 + src/src/commands/appearance.rs | 4 +- src/src/commands/backup.rs | 2 +- src/src/commands/firewall.rs | 2 + src/src/commands/hyprland.rs | 130 +++++- src/src/commands/keybinds.rs | 4 +- src/src/commands/network.rs | 5 + src/src/commands/nvidia.rs | 2 +- src/src/commands/power.rs | 3 +- src/src/commands/snapshots.rs | 5 + src/src/commands/streaming.rs | 6 + src/src/commands/theme.rs | 4 +- src/src/commands/users.rs | 19 +- src/src/commands/util.rs | 81 ++++ src/src/lib.rs | 1 + src/src/screenshot.rs | 7 + src/tauri.conf.json | 10 +- 97 files changed, 2723 insertions(+), 1142 deletions(-) create mode 100644 frontend/src/lib/components/Embed.svelte create mode 100644 frontend/src/lib/components/Hub.svelte create mode 100644 frontend/src/lib/components/SliderField.svelte create mode 100644 frontend/src/lib/components/Subnav.svelte create mode 100644 frontend/src/lib/components/Titlebar.svelte create mode 100644 frontend/src/lib/debounce.ts create mode 100644 frontend/src/lib/embed.ts create mode 100644 frontend/src/lib/nav.svelte.ts delete mode 100644 frontend/src/lib/nav.ts create mode 100644 frontend/src/lib/search.ts create mode 100644 frontend/src/lib/styles/app.css create mode 100644 frontend/src/lib/views/AboutHub.svelte create mode 100644 frontend/src/lib/views/AppearanceHub.svelte create mode 100644 frontend/src/lib/views/AppsHub.svelte create mode 100644 frontend/src/lib/views/DesktopHub.svelte create mode 100644 frontend/src/lib/views/DesktopMore.svelte create mode 100644 frontend/src/lib/views/DisplaysHub.svelte create mode 100644 frontend/src/lib/views/Home.svelte create mode 100644 frontend/src/lib/views/InputHub.svelte create mode 100644 frontend/src/lib/views/NetworkHub.svelte create mode 100644 frontend/src/lib/views/PrivacyHub.svelte create mode 100644 frontend/src/lib/views/SystemHub.svelte 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} + + {#key tab} + {#if Active} + + {/if} + {/key} + + 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 @@ + + + + + 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} - +
+ {/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} /> + + {#key tab} + {#if Active} + + {/if} + {/key} + 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} + + {: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 @@ - + + {/each} +
+ {:else} +
+ + + +
+ +
+ {#each tiles as tile (tile.id + (tile.tab ?? ""))} + + {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/views/InputHub.svelte b/frontend/src/lib/views/InputHub.svelte new file mode 100644 index 0000000..e9a7664 --- /dev/null +++ b/frontend/src/lib/views/InputHub.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/views/InputMethod.svelte b/frontend/src/lib/views/InputMethod.svelte index ef99f54..c485eb5 100644 --- a/frontend/src/lib/views/InputMethod.svelte +++ b/frontend/src/lib/views/InputMethod.svelte @@ -59,7 +59,7 @@ {#if !st} @@ -87,7 +87,7 @@ {#if st}
    {#each st.packages as p (p.name)} -
  • {p.name}{p.installed ? "" : " — not installed"}
  • +
  • {p.name}{p.installed ? "" : " (missing)"}
  • {/each}
{/if} diff --git a/frontend/src/lib/views/Keybinds.svelte b/frontend/src/lib/views/Keybinds.svelte index dc710cf..07b7b70 100644 --- a/frontend/src/lib/views/Keybinds.svelte +++ b/frontend/src/lib/views/Keybinds.svelte @@ -9,7 +9,7 @@ import SaveButton from "$lib/components/SaveButton.svelte"; import Switch from "$lib/components/Switch.svelte"; - // Mirrors src-tauri/src/commands/keybinds.rs's `Bind` — `action`/`key`/ + // Mirrors src-tauri/src/commands/keybinds.rs's `Bind` - `action`/`key`/ // `mods` are the only fields every bind has; everything else (`command`, // `direction`, `workspace`, `options`, breadhelp's `label`/`category`/ // `demo_cmd`, ...) round-trips through serde's `#[serde(flatten)]` as @@ -35,7 +35,7 @@ } // Every action actually seen in real binds.json files (BOS's shipped - // flat schema and this app's own dev MultiLayout config) — an `action` + // flat schema and this app's own dev MultiLayout config) - an `action` // dropdown beats free-typing a dispatcher name from memory. "Custom…" // keeps anything not in this list reachable without blocking on it. const KNOWN_ACTIONS = [ @@ -56,7 +56,7 @@ ] as const; // The editable, per-row UI state a bind gets flattened into on load and - // reconstructed back into a `Bind` on save — same trade-off the GTK + // reconstructed back into a `Bind` on save - same trade-off the GTK // version made with individual `Entry` widgets per field. interface EditRow { action: string; @@ -64,7 +64,7 @@ mods: string; // Whether `mods` has been explicitly set (present in the loaded JSON, // or touched by the user since). `None`/absent means "fall back to - // default_mods"; `Some([])` — an explicitly *empty* mods list — means + // default_mods"; `Some([])` - an explicitly *empty* mods list - means // "use no modifiers at all, even though default_mods exists" (real // BOS binds rely on this for e.g. bare media keys). Collapsing both // cases to "omit when empty" would silently turn an explicit @@ -77,12 +77,123 @@ // key they don't know about (label/category/demo_cmd, or an action // shape this editor has no dedicated fields for) survives untouched. extraValue: Record; - // Raw-JSON view of `extraValue`, kept in sync both directions — + // Raw-JSON view of `extraValue`, kept in sync both directions - // only actually shown when `advancedOpen` is true, or for an action // not in `KNOWN_ACTIONS` (nothing dedicated to show instead). extraText: string; extraError: boolean; advancedOpen: boolean; + capturing: boolean; + } + + const ACTION_LABELS: Record = { + exec: "Run command", + close: "Close window", + fullscreen: "Fullscreen", + float: "Float window", + pseudo: "Fake fullscreen", + resize: "Resize", + focus: "Go to workspace", + focus_last: "Last workspace", + move: "Move window to workspace", + move_dir: "Move window", + resize_dir: "Resize window", + layout: "Layout", + drag: "Mouse drag", + exit: "Exit Hyprland", + }; + + const KEY_LABELS: Record = { + RETURN: "Enter", + SPACE: "Space", + ESCAPE: "Esc", + BACKSPACE: "Backspace", + TAB: "Tab", + SUPER: "Super", + CTRL: "Ctrl", + ALT: "Alt", + SHIFT: "Shift", + XF86AudioRaiseVolume: "Vol +", + XF86AudioLowerVolume: "Vol -", + XF86AudioMute: "Mute", + XF86AudioMicMute: "Mic mute", + XF86MonBrightnessUp: "Bright +", + XF86MonBrightnessDown: "Bright -", + XF86AudioNext: "Next", + XF86AudioPrev: "Prev", + XF86AudioPlay: "Play", + Print: "Print", + }; + + const CODE_TO_HYPR: Record = { + Space: "SPACE", + Enter: "RETURN", + Escape: "ESCAPE", + Backspace: "BACKSPACE", + Tab: "TAB", + AudioVolumeUp: "XF86AudioRaiseVolume", + AudioVolumeDown: "XF86AudioLowerVolume", + AudioVolumeMute: "XF86AudioMute", + AudioMicMute: "XF86AudioMicMute", + BrightnessUp: "XF86MonBrightnessUp", + BrightnessDown: "XF86MonBrightnessDown", + MediaTrackNext: "XF86AudioNext", + MediaTrackPrevious: "XF86AudioPrev", + MediaPlayPause: "XF86AudioPlay", + PrintScreen: "Print", + }; + + function keyLabel(k: string): string { + return KEY_LABELS[k] ?? k; + } + + function bindTitle(row: EditRow): string { + if (row.action === "exec") { + const cmd = String(row.extraValue.command ?? ""); + if (cmd.includes("set-volume") && cmd.includes("+")) return "Volume up"; + if (cmd.includes("set-volume") && cmd.includes("-")) return "Volume down"; + if (cmd.includes("set-mute") && cmd.includes("SINK")) return "Mute"; + if (cmd.includes("set-mute") && cmd.includes("SOURCE")) return "Mute mic"; + if (cmd.includes("brightnessctl") && cmd.includes("+")) return "Brightness up"; + if (cmd.includes("brightnessctl") && cmd.includes("-")) return "Brightness down"; + if (cmd.includes("playerctl next")) return "Next track"; + if (cmd.includes("playerctl previous")) return "Previous track"; + if (cmd.includes("playerctl play-pause")) return "Play/pause"; + if (cmd) return cmd.split(/\s+/)[0].split("/").pop() ?? "Command"; + } + return ACTION_LABELS[row.action] ?? row.action; + } + + function keyChips(row: EditRow): string[] { + const mods = textToMods(row.mods); + const key = row.key.trim(); + return [...mods, key].filter(Boolean).map(keyLabel); + } + + function hyprFromEvent(e: KeyboardEvent): { mods: string[]; key: string } | null { + if (["Shift", "Control", "Alt", "Meta"].includes(e.key)) return null; + const mods: string[] = []; + if (e.metaKey) mods.push("SUPER"); + if (e.ctrlKey) mods.push("CTRL"); + if (e.altKey) mods.push("ALT"); + if (e.shiftKey) mods.push("SHIFT"); + let key = CODE_TO_HYPR[e.code] ?? CODE_TO_HYPR[e.key]; + if (!key) { + if (e.code.startsWith("Key") && e.code.length === 4) key = e.code.slice(3); + else if (e.code.startsWith("Digit")) key = e.code.slice(5); + else if (e.code.startsWith("F") && /^F\d+$/.test(e.code)) key = e.code; + else key = e.key.length === 1 ? e.key.toUpperCase() : e.key; + } + return { mods, key }; + } + + function allRows(): EditRow[] { + return [...globalsRows, ...commonRows, ...bindingsRows, ...Object.values(layoutRows).flat()]; + } + + function beginCapture(row: EditRow) { + for (const r of allRows()) r.capturing = false; + row.capturing = true; } function syncExtraText(row: EditRow) { @@ -114,7 +225,7 @@ } // Hyprland workspace refs mix bare integers ("1") and relative tokens - // ("e+1", "e-1") in the same field — keep whichever shape the user typed + // ("e+1", "e-1") in the same field - keep whichever shape the user typed // instead of forcing everything through one type. function parseWorkspaceValue(s: string): string | number | undefined { const trimmed = s.trim(); @@ -143,13 +254,14 @@ extraText: "", extraError: false, advancedOpen: false, + capturing: false, }; syncExtraText(row); return row; } function rowToBind(r: EditRow): Bind { - // `drag` binds are only meaningful as a mouse bind — there's no + // `drag` binds are only meaningful as a mouse bind - there's no // dedicated field for `options.mouse` (nothing to configure, it's // always true), so pin it here rather than exposing a checkbox // whose only correct state is "on". @@ -193,6 +305,7 @@ extraText: "", extraError: false, advancedOpen: false, + capturing: false, }; } @@ -208,13 +321,32 @@ let newLayoutName = $state(""); let loadError = $state(""); - onMount(async () => { + onMount(() => { + const onKey = (e: KeyboardEvent) => { + const row = allRows().find((r) => r.capturing); + if (!row) return; + const mapped = hyprFromEvent(e); + if (!mapped) return; + e.preventDefault(); + row.mods = mapped.mods.join(", "); + row.modsTouched = true; + row.key = mapped.key; + row.capturing = false; + }; + window.addEventListener("keydown", onKey); + void loadKeybinds(); + // Register cleanup synchronously so Svelte types the onMount + // callback as returning a function (not a Promise-of-function). + return () => window.removeEventListener("keydown", onKey); + }); + + async function loadKeybinds() { try { const p = await invoke("get_keybinds"); kind = p.kind; // Rust's `#[serde(skip_serializing_if = ...)]` on every one of // these fields means an empty one is OMITTED from the JSON - // entirely, not sent as `[]`/`{}`/`""` — every field here needs + // entirely, not sent as `[]`/`{}`/`""` - every field here needs // a `??` fallback, not just the ones that are "usually" empty. activeLayout = p.file.active_layout ?? ""; defaultMods = modsToText(p.file.default_mods ?? []); @@ -229,7 +361,7 @@ } catch (e) { loadError = String(e); } - }); + } function addLayout() { const name = newLayoutName.trim(); @@ -248,7 +380,7 @@ // A row whose "extra" column currently holds text that doesn't parse as // JSON keeps its *last successfully parsed* value in `extraValue` (see // `onExtraInput`) rather than losing it on every keystroke while the - // user is mid-edit — but that means saving while such a row is still + // user is mid-edit - but that means saving while such a row is still // showing invalid/incomplete text would silently write that stale (or, // for a brand new row, empty) value instead of what's actually on // screen. Block save entirely until every row's extra JSON is valid, so @@ -349,7 +481,7 @@ oninput={(e) => setExtra(row, "layout", e.currentTarget.value)} /> {:else if row.action === "drag"} - Mouse-drag bind — nothing else to set. + Mouse-drag bind. {:else if row.action === "close" || row.action === "fullscreen" || row.action === "float" || row.action === "pseudo" || row.action === "resize" || row.action === "focus_last" || row.action === "exit"} No extra options for this action. {:else} @@ -375,30 +507,19 @@ {#each rows as row, i (i)}
- (row.modsTouched = true)} - /> - - - {#if !KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number])} - - {/if} + {bindTitle(row)} +
@@ -436,7 +557,7 @@ {:else if kind === "flat"} @@ -453,7 +574,7 @@ {:else} {#if layoutOrder.length > 0} @@ -512,9 +633,13 @@ display: flex; flex-direction: column; gap: var(--space-xs, 4px); - 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); + } + + .bind-card:first-child { + border-top: none; + padding-top: 0; } .bind-top, @@ -525,6 +650,34 @@ flex-wrap: wrap; } + .what { + flex: 1; + min-width: 12ch; + } + + .capture { + display: flex; + align-items: center; + gap: 4px; + background: color-mix(in srgb, var(--fg) 6%, transparent); + border: 1px solid var(--line); + border-radius: 10px; + padding: 6px 10px; + color: inherit; + cursor: pointer; + min-height: 32px; + } + + .capture.listening { + border-color: var(--accent); + color: var(--accent); + } + + .plus { + opacity: 0.4; + padding: 0 1px; + } + .bind-card input, .bind-card select { background-color: var(--bg); @@ -544,26 +697,6 @@ border-color: var(--red); } - .mods { - width: 14ch; - flex-shrink: 0; - } - - .key { - width: 9ch; - flex-shrink: 0; - } - - .action-select { - width: 11ch; - flex-shrink: 0; - } - - .action-custom { - width: 11ch; - flex-shrink: 0; - } - .extra-wide { flex: 1; min-width: 16ch; diff --git a/frontend/src/lib/views/Network.svelte b/frontend/src/lib/views/Network.svelte index 0a8e788..b8e2f1b 100644 --- a/frontend/src/lib/views/Network.svelte +++ b/frontend/src/lib/views/Network.svelte @@ -3,9 +3,8 @@ 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 SwitchField from "$lib/components/SwitchField.svelte"; import InfoRow from "$lib/components/InfoRow.svelte"; - import Hint from "$lib/components/Hint.svelte"; import Wifi from "@lucide/svelte/icons/wifi"; import WifiOff from "@lucide/svelte/icons/wifi-off"; import Lock from "@lucide/svelte/icons/lock"; @@ -26,12 +25,6 @@ let pendingSsid = $state(null); let password = $state(""); - // The backend reports the first `nmcli`-visible device of TYPE=ethernet, - // which on a machine running containers/VMs can be a virtual interface - // (docker/podman veth pairs, libvirt bridges, VPN tuns) rather than a - // real NIC — nmcli doesn't distinguish these from physical ethernet. - // Names like "vethYCF3ZA: unmanaged" are meaningless to a non-technical - // user, so hide the card rather than show raw interface jargon. const VIRTUAL_IFACE_PREFIXES = ["veth", "docker", "br-", "virbr", "vnet", "tun", "tap", "vmnet", "podman"]; const ETHERNET_STATE_LABELS: Record = { connected: "Connected", @@ -47,11 +40,14 @@ if (!iface || VIRTUAL_IFACE_PREFIXES.some((prefix) => iface.startsWith(prefix))) return null; return ETHERNET_STATE_LABELS[rawState ?? ""] ?? "Not connected"; }); + let activeNet = $derived(networks?.find((n) => n.active) ?? null); + let nearby = $derived(networks?.filter((n) => !n.active) ?? []); onMount(async () => { const info = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info"); radioEnabled = info.radio_enabled; ethernet = info.ethernet; + if (info.radio_enabled) await scan(); }); async function toggleRadio(enabled: boolean) { @@ -61,10 +57,12 @@ async function scan() { scanning = true; - status = "Scanning…"; - networks = await invoke("scan_wifi"); - status = `Found ${networks.length} network(s)`; - scanning = false; + try { + networks = await invoke("scan_wifi"); + } finally { + scanning = false; + status = ""; + } } async function connect(ssid: string, secured: boolean, known: boolean) { @@ -76,8 +74,9 @@ try { await invoke("connect_wifi", { ssid, password: null }); status = `Connected to ${ssid}`; + await scan(); } catch (e) { - status = `Failed to connect to ${ssid}: ${e}`; + status = `Failed: ${e}`; } } @@ -90,54 +89,76 @@ try { await invoke("connect_wifi", { ssid, password: pw }); status = `Connected to ${ssid}`; + await scan(); } catch { - status = `Failed to connect to ${ssid}: wrong password?`; + status = "Wrong password?"; } } - function signalLabel(signal: number): string { - return `${Math.min(100, Math.max(0, signal))}%`; + function bars(signal: number): number { + if (signal >= 75) return 4; + if (signal >= 50) return 3; + if (signal >= 25) return 2; + return 1; } - - - - - + + + radioEnabled, (v) => toggleRadio(v)} /> + {#if ethernetLabel} + + {/if} - {#if ethernetLabel} - - + {#if activeNet} + +
+
+ {#each [1, 2, 3, 4] as n (n)} + = n}> + {/each} +
+
+ {activeNet.ssid} + {activeNet.secured ? "Secured" : "Open"} · {activeNet.signal}% +
+ Connected +
{/if} - +
- {#if networks === null} + {#if scanning && networks === null}
- - Not scanned yet - Press Scan to see nearby networks. + + Scanning…
- {:else if networks.length === 0} + {:else if networks === null}
- + + No scan yet +
+ {:else if nearby.length === 0 && !activeNet} +
+ No networks found - Try Scan again, or check Wi-Fi radio is on.
{:else} - {#each networks as net (net.ssid)} -
- {net.ssid}{net.active ? " (connected)" : ""} + {#each nearby as net (net.ssid)} +
+
+ {#each [1, 2, 3, 4] as n (n)} + = n}> + {/each} +
+
+ {net.ssid} + {net.known ? "Saved" : net.secured ? "Secured" : "Open"} +
{#if net.secured}{/if} - {signalLabel(net.signal)} - {#if !net.active} - - {/if} +
{/each} {/if} @@ -145,52 +166,28 @@ {#if pendingSsid}
- - + +
{/if} {#if status} {status} {/if} - - + - - + + diff --git a/frontend/src/lib/views/NetworkHub.svelte b/frontend/src/lib/views/NetworkHub.svelte new file mode 100644 index 0000000..da2b0fd --- /dev/null +++ b/frontend/src/lib/views/NetworkHub.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/views/NightLight.svelte b/frontend/src/lib/views/NightLight.svelte index 13ec93f..8f07737 100644 --- a/frontend/src/lib/views/NightLight.svelte +++ b/frontend/src/lib/views/NightLight.svelte @@ -5,8 +5,10 @@ import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import Group from "$lib/components/Group.svelte"; import Hint from "$lib/components/Hint.svelte"; - import NumberField from "$lib/components/NumberField.svelte"; + import SwitchField from "$lib/components/SwitchField.svelte"; + import SliderField from "$lib/components/SliderField.svelte"; import LogView from "$lib/components/LogView.svelte"; + import { debounce } from "$lib/debounce"; interface NightlightStatus { installed: boolean; @@ -20,19 +22,18 @@ let log = $state([]); let busy = $state(false); let message = $state(""); - async function refresh() { st = await invoke("get_nightlight"); } onMount(refresh); - async function apply(enabled: boolean) { + async function applyNow() { if (!st) return; message = ""; try { st = await invoke("set_nightlight", { - enabled, + enabled: st.enabled, temperature: st.temperature, }); if (st.error) message = st.error; @@ -41,6 +42,8 @@ } } + const persistTemp = debounce(applyNow, 200); + async function install() { log = []; busy = true; @@ -53,93 +56,30 @@ - + {#if !st} {:else if !st.installed} - - + + {:else} -
- Night light - -
- - - + st!.enabled, (v) => { st!.enabled = v; applyNow(); }} + /> + {#if st.enabled} + + {/if} {/if} {#if message}{/if}
- - diff --git a/frontend/src/lib/views/Optional.svelte b/frontend/src/lib/views/Optional.svelte index 3fbfcf0..882d537 100644 --- a/frontend/src/lib/views/Optional.svelte +++ b/frontend/src/lib/views/Optional.svelte @@ -38,7 +38,7 @@ log = [...log, line]; }); busy = false; - if (!ok) message = "Install failed — see the log."; + if (!ok) message = "Install failed. See the log."; await refresh(); return ok; } diff --git a/frontend/src/lib/views/Packages.svelte b/frontend/src/lib/views/Packages.svelte index d28509f..ec20f80 100644 --- a/frontend/src/lib/views/Packages.svelte +++ b/frontend/src/lib/views/Packages.svelte @@ -85,7 +85,7 @@ diff --git a/frontend/src/lib/views/Power.svelte b/frontend/src/lib/views/Power.svelte index d81ea9c..dcb50dd 100644 --- a/frontend/src/lib/views/Power.svelte +++ b/frontend/src/lib/views/Power.svelte @@ -40,7 +40,10 @@ } - + {#if info} {#each info.battery as [label, value] (label)} @@ -63,20 +66,22 @@ {#if info.charge_start !== null && info.charge_end !== null} - - setChargeThreshold("start", chargeStart)} /> + + setChargeThreshold("start", chargeStart)} /> + {chargeStart}% - - setChargeThreshold("end", chargeEnd)} /> + + setChargeThreshold("end", chargeEnd)} /> + {chargeEnd}% {/if} @@ -88,15 +93,6 @@ width: 180px; } - input[type="number"] { - width: 8ch; - 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); - } - .pct { margin-left: var(--space-sm, 8px); font-size: var(--font-size-secondary, 12px); diff --git a/frontend/src/lib/views/PrivacyHub.svelte b/frontend/src/lib/views/PrivacyHub.svelte new file mode 100644 index 0000000..e0f7ef1 --- /dev/null +++ b/frontend/src/lib/views/PrivacyHub.svelte @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/lib/views/Snapshots.svelte b/frontend/src/lib/views/Snapshots.svelte index 6834326..592f82a 100644 --- a/frontend/src/lib/views/Snapshots.svelte +++ b/frontend/src/lib/views/Snapshots.svelte @@ -25,7 +25,7 @@ } catch (e) { const msg = `${e}`.toLowerCase(); if (msg.includes("no permission")) { - errorHint = "This user isn't allowed to run snapper. Check ALLOW_USERS in /etc/snapper/configs/root — it should list your username."; + errorHint = "This user is not in ALLOW_USERS for snapper."; } else if (msg.includes("unknown config") || msg.includes("no such file")) { errorHint = "No snapper config exists for root yet, so nothing is being snapshotted. This should be set up automatically at install."; } else { @@ -62,10 +62,10 @@ - +
diff --git a/frontend/src/lib/views/Sound.svelte b/frontend/src/lib/views/Sound.svelte index b863d45..9c887a0 100644 --- a/frontend/src/lib/views/Sound.svelte +++ b/frontend/src/lib/views/Sound.svelte @@ -24,107 +24,154 @@ let output = $state(null); let input = $state(null); + function pick(devices: SoundDevice[], defaultName: string | null): number { + const i = devices.findIndex((d) => d.name === defaultName); + return i >= 0 ? i : 0; + } + async function loadSection(kind: "sinks" | "sources", title: string): Promise { - const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", { kind }); - const selected = Math.max(0, section.devices.findIndex((d) => d.name === section.default_name)); - return { kind, title, devices: section.devices, selected }; + const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", { + kind, + }); + return { kind, title, devices: section.devices, selected: pick(section.devices, section.default_name) }; } onMount(async () => { - output = await loadSection("sinks", "Output"); - input = await loadSection("sources", "Input"); + try { + output = await loadSection("sinks", "Output"); + input = await loadSection("sources", "Input"); + } catch (e) { + console.error(e); + output = { kind: "sinks", title: "Output", devices: [], selected: 0 }; + input = { kind: "sources", title: "Input", devices: [], selected: 0 }; + } }); + function current(section: DeviceSection): SoundDevice | null { + return section.devices[section.selected] ?? section.devices[0] ?? null; + } + async function selectDevice(section: DeviceSection, index: number) { + const device = section.devices[index]; + if (!device) return; section.selected = index; - await invoke("set_default_sound_device", { kind: section.kind, name: section.devices[index].name }); + await invoke("set_default_sound_device", { kind: section.kind, name: device.name }); } async function setVolume(section: DeviceSection, percent: number) { - const device = section.devices[section.selected]; + const device = current(section); + if (!device) return; device.percent = percent; - await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent }); + await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent: Math.round(percent) }); } async function setMute(section: DeviceSection, mute: boolean) { - const device = section.devices[section.selected]; + const device = current(section); + if (!device) return; device.mute = mute; await invoke("set_sound_mute", { kind: section.kind, name: device.name, mute }); } -{#snippet deviceSection(section: DeviceSection | null)} - {#if section} - - {#if section.devices.length === 0} - - {:else} - - - +{#snippet sectionCard(section: DeviceSection)} + + {#if section.devices.length === 0} + + {:else} + {#each section.devices as d, i (d.name + i)} + + {/each} + {#if current(section)} setVolume(section, Number(e.currentTarget.value))} /> - {section.devices[section.selected].percent}% + {Math.round(current(section)!.percent)}% section.devices[section.selected].mute, - (v) => setMute(section, v) - } + bind:value={() => current(section)!.mute, (v) => setMute(section, v)} /> {/if} - - {/if} + {/if} + {/snippet} - - {@render deviceSection(output)} - {@render deviceSection(input)} + + {#if output} + {@render sectionCard(output)} + {/if} + {#if input} + {@render sectionCard(input)} + {/if} - - + +