Add configurable native glass window backgrounds
This commit is contained in:
1 parent
19d949fd13
commit
981991bac0
14 files changed
+687
-5
No files matched your search
@@ -0,0 +1,91 @@
|
||||
/** Only a confirmed native compositor effect may expose the desktop behind the shell.
|
||||
* Reading surfaces retain their opaque backgrounds independently of this material. */
|
||||
export type BackgroundMaterial = "solid" | "glass" | "frosted";
|
||||
export interface AppearanceStatus {
|
||||
requested_material: BackgroundMaterial;
|
||||
effective_material: BackgroundMaterial;
|
||||
opacity: number;
|
||||
native_effect: boolean;
|
||||
fallback_reason: string | null;
|
||||
}
|
||||
export function appearanceView(
|
||||
status: AppearanceStatus | undefined,
|
||||
highContrast: boolean,
|
||||
) {
|
||||
const valid =
|
||||
status &&
|
||||
["glass", "frosted"].includes(status.effective_material) &&
|
||||
status.native_effect;
|
||||
const material: BackgroundMaterial =
|
||||
!highContrast && valid ? status.effective_material : "solid";
|
||||
const opacity = Number.isFinite(status?.opacity)
|
||||
? Math.max(70, Math.min(100, status!.opacity))
|
||||
: 90;
|
||||
const message = highContrast
|
||||
? "High contrast uses a solid background. Your appearance preference is retained."
|
||||
: status?.fallback_reason
|
||||
? `Solid background: ${status.fallback_reason}`
|
||||
: material === "solid"
|
||||
? "Solid background. Search results and passages stay clear."
|
||||
: "The desktop shows subtly through the window. Search results and passages stay clear.";
|
||||
return { material, opacity, message };
|
||||
}
|
||||
|
||||
/** Serializes compositor/settings mutations; a waiting update supersedes earlier
|
||||
* pending work, retaining an unsaved preference when contrast also changes. */
|
||||
export class AppearanceUpdates {
|
||||
revision = 0;
|
||||
private running = false;
|
||||
private pending: {
|
||||
revision: number;
|
||||
highContrast: boolean;
|
||||
preference?: { material: BackgroundMaterial; opacity: number };
|
||||
} | null = null;
|
||||
private write: (update: {
|
||||
highContrast: boolean;
|
||||
preference?: { material: BackgroundMaterial; opacity: number };
|
||||
}) => Promise<AppearanceStatus>;
|
||||
private publish: (status: AppearanceStatus) => void;
|
||||
private fail: (error: unknown) => void;
|
||||
private settled: () => void;
|
||||
constructor(
|
||||
write: AppearanceUpdates["write"],
|
||||
publish: AppearanceUpdates["publish"],
|
||||
fail: AppearanceUpdates["fail"],
|
||||
settled: AppearanceUpdates["settled"],
|
||||
) {
|
||||
this.write = write;
|
||||
this.publish = publish;
|
||||
this.fail = fail;
|
||||
this.settled = settled;
|
||||
}
|
||||
get busy() {
|
||||
return this.running || this.pending !== null;
|
||||
}
|
||||
submit(
|
||||
highContrast: boolean,
|
||||
preference?: { material: BackgroundMaterial; opacity: number },
|
||||
) {
|
||||
this.pending = {
|
||||
revision: ++this.revision,
|
||||
highContrast,
|
||||
preference: preference ?? this.pending?.preference,
|
||||
};
|
||||
if (!this.running) void this.drain();
|
||||
}
|
||||
private async drain() {
|
||||
this.running = true;
|
||||
while (this.pending) {
|
||||
const update = this.pending;
|
||||
this.pending = null;
|
||||
try {
|
||||
const status = await this.write(update);
|
||||
if (update.revision === this.revision) this.publish(status);
|
||||
} catch (error) {
|
||||
if (update.revision === this.revision) this.fail(error);
|
||||
}
|
||||
}
|
||||
this.running = false;
|
||||
this.settled();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
isCaptureSurface,
|
||||
prepareMediaURL,
|
||||
} from "./desktop-api";
|
||||
import { appearanceView, AppearanceUpdates } from "./appearance-state";
|
||||
import type { AppearanceStatus, BackgroundMaterial } from "./appearance-state";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
AutomaticIndexGate,
|
||||
@@ -84,11 +86,14 @@ interface Settings {
|
||||
version: number;
|
||||
roots: string[];
|
||||
cpu_threads: number;
|
||||
background_material?: BackgroundMaterial;
|
||||
background_opacity?: number;
|
||||
}
|
||||
interface DesktopStatus {
|
||||
settings: Settings;
|
||||
indexing: boolean;
|
||||
shortcut_status: string;
|
||||
appearance?: AppearanceStatus;
|
||||
watching?: {
|
||||
root: string;
|
||||
attached: boolean;
|
||||
@@ -140,7 +145,7 @@ interface SearchRequest {
|
||||
}
|
||||
|
||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Folders & resources</h2><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
|
||||
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Settings</h2><section class="appearance-settings" aria-labelledby="appearance-heading"><h3 id="appearance-heading">Appearance</h3><label for="background-material">Window background</label><select id="background-material"><option value="solid">Solid</option><option value="glass">Glass</option><option value="frosted">Frosted glass</option></select><div class="appearance-opacity"><label for="background-opacity">Background tint</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="70" max="100" step="1" value="90" aria-describedby="appearance-status"><p id="appearance-status" class="hint" role="status">A subtle window background. Search results and passages stay clear.</p></section><h3>Folders & resources</h3><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
|
||||
<main>
|
||||
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
|
||||
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
|
||||
@@ -465,8 +470,88 @@ async function refreshSemanticStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDesktopStatus() {
|
||||
const highContrast = window.matchMedia("(forced-colors: active)");
|
||||
const appearanceUpdates = new AppearanceUpdates(
|
||||
(update) =>
|
||||
invoke<AppearanceStatus>(
|
||||
update.preference ? "set_appearance" : "apply_appearance",
|
||||
{ ...update.preference, highContrast: update.highContrast },
|
||||
),
|
||||
(status) => renderAppearance(status),
|
||||
showError,
|
||||
() => {
|
||||
renderAppearance();
|
||||
void refreshDesktopStatus(true).catch(showError);
|
||||
},
|
||||
);
|
||||
let lastAppearance: AppearanceStatus | undefined;
|
||||
function renderAppearance(
|
||||
status?: AppearanceStatus,
|
||||
settings?: Settings,
|
||||
restoreControls = false,
|
||||
) {
|
||||
if (status) lastAppearance = status;
|
||||
const material = element<HTMLSelectElement>("background-material");
|
||||
const opacity = element<HTMLInputElement>("background-opacity");
|
||||
if (
|
||||
!appearanceUpdates.busy &&
|
||||
settings &&
|
||||
(restoreControls || document.activeElement !== opacity)
|
||||
) {
|
||||
material.value = settings.background_material ?? "frosted";
|
||||
opacity.value = String(settings.background_opacity ?? 90);
|
||||
}
|
||||
const view = appearanceView(lastAppearance, highContrast.matches);
|
||||
document.documentElement.dataset.backgroundMaterial = view.material;
|
||||
document.documentElement.style.setProperty(
|
||||
"--window-opacity",
|
||||
String(view.opacity / 100),
|
||||
);
|
||||
element("background-opacity-value").textContent = `${opacity.value}%`;
|
||||
material.disabled = !isNativeDesktop() || appearanceUpdates.busy;
|
||||
opacity.disabled =
|
||||
!isNativeDesktop() ||
|
||||
appearanceUpdates.busy ||
|
||||
material.value === "solid" ||
|
||||
highContrast.matches;
|
||||
element("appearance-status").textContent = view.message;
|
||||
}
|
||||
function saveAppearance() {
|
||||
if (!isNativeDesktop()) return;
|
||||
appearanceUpdates.submit(highContrast.matches, {
|
||||
material: element<HTMLSelectElement>("background-material")
|
||||
.value as BackgroundMaterial,
|
||||
opacity: Number(element<HTMLInputElement>("background-opacity").value),
|
||||
});
|
||||
renderAppearance();
|
||||
}
|
||||
element("background-material").addEventListener("change", () => {
|
||||
void saveAppearance();
|
||||
});
|
||||
element("background-opacity").addEventListener("input", () => {
|
||||
element("background-opacity-value").textContent =
|
||||
`${element<HTMLInputElement>("background-opacity").value}%`;
|
||||
});
|
||||
element("background-opacity").addEventListener("change", () => {
|
||||
void saveAppearance();
|
||||
});
|
||||
highContrast.addEventListener("change", () => {
|
||||
renderAppearance(); // Fail closed immediately, before the native effect changes.
|
||||
if (isNativeDesktop()) appearanceUpdates.submit(highContrast.matches);
|
||||
});
|
||||
|
||||
async function refreshDesktopStatus(restoreAppearanceControls = false) {
|
||||
const appearanceRevision = appearanceUpdates.revision;
|
||||
const status = await invoke<DesktopStatus>("desktop_status");
|
||||
if (
|
||||
!appearanceUpdates.busy &&
|
||||
appearanceRevision === appearanceUpdates.revision
|
||||
)
|
||||
renderAppearance(
|
||||
status.appearance,
|
||||
status.settings,
|
||||
restoreAppearanceControls,
|
||||
);
|
||||
indexingActive = status.indexing;
|
||||
element("shortcut-status").textContent = status.shortcut_status;
|
||||
element("focus-shortcut").textContent = status.shortcut_status.includes(
|
||||
@@ -1433,6 +1518,10 @@ document.addEventListener("keydown", (event) => {
|
||||
});
|
||||
async function initializeSurface() {
|
||||
await initializeDesktopSurface();
|
||||
renderAppearance();
|
||||
if (isNativeDesktop()) {
|
||||
appearanceUpdates.submit(highContrast.matches);
|
||||
}
|
||||
if (isDesktopSurface()) {
|
||||
if (isCaptureSurface()) {
|
||||
for (const id of [
|
||||
|
||||
@@ -949,3 +949,86 @@ aside pre.code-passage {
|
||||
aside pre.code-passage {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Native effects blur the desktop; CSS only supplies its readable shell tint.
|
||||
Never use backdrop-filter as a substitute for an operating-system material. */
|
||||
:root[data-background-material="glass"],
|
||||
:root[data-background-material="frosted"] {
|
||||
background: transparent;
|
||||
}
|
||||
:root[data-background-material="glass"] #app {
|
||||
background: rgba(248, 250, 252, var(--window-opacity, 0.9));
|
||||
}
|
||||
:root[data-background-material="frosted"] #app {
|
||||
background: rgba(
|
||||
248,
|
||||
250,
|
||||
252,
|
||||
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
|
||||
);
|
||||
}
|
||||
.appearance-settings {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.popover-panel h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.appearance-settings > label,
|
||||
.appearance-opacity label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
#background-material {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 7px 0 15px;
|
||||
}
|
||||
.appearance-opacity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
#background-opacity-value {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
#background-opacity {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 9px 0;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-background-material="glass"] #app {
|
||||
background: rgba(11, 15, 20, var(--window-opacity, 0.9));
|
||||
}
|
||||
:root[data-background-material="frosted"] #app {
|
||||
background: rgba(
|
||||
11,
|
||||
15,
|
||||
20,
|
||||
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
|
||||
);
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
:root,
|
||||
:root[data-background-material] #app {
|
||||
background: Canvas;
|
||||
}
|
||||
}
|
||||
|
||||
/* Strong shell copy remains legible even above an unusually bright/dark desktop.
|
||||
Opaque reading panes retain the quieter secondary-text hierarchy. */
|
||||
:root:not([data-background-material="solid"])[data-background-material] header,
|
||||
:root:not([data-background-material="solid"])[data-background-material] footer,
|
||||
:root:not([data-background-material="solid"])[data-background-material]
|
||||
#semantic-panel
|
||||
> summary {
|
||||
--muted: var(--ink);
|
||||
}
|
||||
Reference in new issue
Block a user