diff --git a/src/App.tsx b/src/App.tsx index 2b918be46..9be9f2b29 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -115,8 +115,9 @@ function MainApp() { setAppSettings, doctor, appSettingsLoading, - reduceTransparency, - setReduceTransparency, + transparencyMode, + setTransparencyMode, + transparencyModes, uiScale, scaleShortcutTitle, scaleShortcutText, @@ -144,7 +145,7 @@ function MainApp() { handleCopyDebug, clearDebugEntries, } = useDebugLog(); - useLiquidGlassEffect({ reduceTransparency, onDebug: addDebugEntry }); + useLiquidGlassEffect({ transparencyMode, onDebug: addDebugEntry }); const [accessMode, setAccessMode] = useState("current"); const [activeTab, setActiveTab] = useState< "projects" | "codex" | "git" | "log" @@ -1275,7 +1276,7 @@ function MainApp() { const appClassName = `app ${isCompact ? "layout-compact" : "layout-desktop"}${ isPhone ? " layout-phone" : "" }${isTablet ? " layout-tablet" : ""}${ - reduceTransparency ? " reduced-transparency" : "" + transparencyMode === "reduced" ? " reduced-transparency" : "" }${!isCompact && sidebarCollapsed ? " sidebar-collapsed" : ""}${ !isCompact && rightPanelCollapsed ? " right-panel-collapsed" : "" }${isDefaultScale ? " ui-scale-default" : ""}`; @@ -1748,8 +1749,9 @@ function MainApp() { onMoveWorkspaceGroup: moveWorkspaceGroup, onDeleteWorkspaceGroup: deleteWorkspaceGroup, onAssignWorkspaceGroup: assignWorkspaceGroup, - reduceTransparency, - onToggleTransparency: setReduceTransparency, + transparencyMode, + transparencyModes, + onTransparencyModeChange: setTransparencyMode, appSettings, onUpdateAppSettings: async (next) => { await queueSaveSettings(next); diff --git a/src/features/app/hooks/useAppSettingsController.ts b/src/features/app/hooks/useAppSettingsController.ts index a40f9ff74..9955cc5d7 100644 --- a/src/features/app/hooks/useAppSettingsController.ts +++ b/src/features/app/hooks/useAppSettingsController.ts @@ -13,8 +13,11 @@ export function useAppSettingsController() { } = useAppSettings(); useThemePreference(appSettings.theme); - const { reduceTransparency, setReduceTransparency } = - useTransparencyPreference(); + const { + transparencyMode, + setTransparencyMode, + availableModes: transparencyModes, + } = useTransparencyPreference(); const { uiScale, @@ -34,8 +37,9 @@ export function useAppSettingsController() { queueSaveSettings, doctor, appSettingsLoading, - reduceTransparency, - setReduceTransparency, + transparencyMode, + setTransparencyMode, + transparencyModes, uiScale, scaleShortcutTitle, scaleShortcutText, diff --git a/src/features/app/hooks/useLiquidGlassEffect.ts b/src/features/app/hooks/useLiquidGlassEffect.ts index c942b3f90..917572161 100644 --- a/src/features/app/hooks/useLiquidGlassEffect.ts +++ b/src/features/app/hooks/useLiquidGlassEffect.ts @@ -5,14 +5,14 @@ import { GlassMaterialVariant, } from "tauri-plugin-liquid-glass-api"; import { Effect, EffectState, getCurrentWindow } from "@tauri-apps/api/window"; -import type { DebugEntry } from "../../../types"; +import type { DebugEntry, TransparencyMode } from "../../../types"; type Params = { - reduceTransparency: boolean; + transparencyMode: TransparencyMode; onDebug?: (entry: DebugEntry) => void; }; -export function useLiquidGlassEffect({ reduceTransparency, onDebug }: Params) { +export function useLiquidGlassEffect({ transparencyMode, onDebug }: Params) { const supportedRef = useRef(null); useEffect(() => { @@ -21,24 +21,25 @@ export function useLiquidGlassEffect({ reduceTransparency, onDebug }: Params) { const apply = async () => { try { const window = getCurrentWindow(); - if (reduceTransparency) { - if (supportedRef.current === null) { - supportedRef.current = await isGlassSupported(); - } - if (supportedRef.current) { - await setLiquidGlassEffect({ enabled: false }); - } - await window.setEffects({ effects: [] }); - return; - } - if (supportedRef.current === null) { supportedRef.current = await isGlassSupported(); } if (cancelled) { return; } - if (supportedRef.current) { + if (supportedRef.current && transparencyMode !== "glass") { + await setLiquidGlassEffect({ enabled: false }); + } + + const userAgent = navigator.userAgent ?? ""; + const isMac = userAgent.includes("Macintosh"); + const isLinux = userAgent.includes("Linux"); + if (transparencyMode === "reduced") { + await window.setEffects({ effects: [] }); + return; + } + + if (supportedRef.current && transparencyMode === "glass") { await window.setEffects({ effects: [] }); await setLiquidGlassEffect({ enabled: true, @@ -48,9 +49,6 @@ export function useLiquidGlassEffect({ reduceTransparency, onDebug }: Params) { return; } - const userAgent = navigator.userAgent ?? ""; - const isMac = userAgent.includes("Macintosh"); - const isLinux = userAgent.includes("Linux"); if (!isMac && !isLinux) { return; } @@ -78,5 +76,5 @@ export function useLiquidGlassEffect({ reduceTransparency, onDebug }: Params) { return () => { cancelled = true; }; - }, [onDebug, reduceTransparency]); + }, [onDebug, transparencyMode]); } diff --git a/src/features/layout/hooks/useTransparencyPreference.ts b/src/features/layout/hooks/useTransparencyPreference.ts index 024d543fc..8dc017a0f 100644 --- a/src/features/layout/hooks/useTransparencyPreference.ts +++ b/src/features/layout/hooks/useTransparencyPreference.ts @@ -1,17 +1,80 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { isGlassSupported } from "tauri-plugin-liquid-glass-api"; +import type { TransparencyMode } from "../../../types"; -export function useTransparencyPreference(storageKey = "reduceTransparency") { - const [reduceTransparency, setReduceTransparency] = useState(() => { - const stored = localStorage.getItem(storageKey); - return stored === "true"; - }); +const STORAGE_KEY = "transparencyMode"; +const LEGACY_STORAGE_KEY = "reduceTransparency"; + +const isTransparencyMode = (value: string | null): value is TransparencyMode => + value === "glass" || value === "blur" || value === "reduced"; + +const readStoredTransparency = () => { + const stored = localStorage.getItem(STORAGE_KEY); + if (isTransparencyMode(stored)) { + return { mode: stored, hasStoredPreference: true }; + } + const legacyStored = localStorage.getItem(LEGACY_STORAGE_KEY); + if (legacyStored === "true") { + return { mode: "reduced" as TransparencyMode, hasStoredPreference: true }; + } + return { mode: "blur" as TransparencyMode, hasStoredPreference: false }; +}; + +export function useTransparencyPreference() { + const [{ mode, hasStoredPreference }, setPreferenceState] = useState(() => + readStoredTransparency(), + ); + const [glassSupported, setGlassSupported] = useState(null); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, mode); + }, [mode]); useEffect(() => { - localStorage.setItem(storageKey, String(reduceTransparency)); - }, [reduceTransparency, storageKey]); + let cancelled = false; + + const checkSupport = async () => { + try { + const supported = await isGlassSupported(); + if (cancelled) { + return; + } + setGlassSupported(supported); + if (!supported && mode === "glass") { + setPreferenceState({ mode: "blur", hasStoredPreference }); + } + if (!hasStoredPreference && supported && mode !== "glass") { + setPreferenceState({ mode: "glass", hasStoredPreference: false }); + } + } catch { + if (!cancelled) { + setGlassSupported(false); + } + } + }; + + void checkSupport(); + + return () => { + cancelled = true; + }; + }, [hasStoredPreference, mode]); + + const setTransparencyMode = useCallback((next: TransparencyMode) => { + setPreferenceState({ mode: next, hasStoredPreference: true }); + }, []); + + const availableModes = useMemo(() => { + if (glassSupported) { + return ["glass", "blur", "reduced"]; + } + return ["blur", "reduced"]; + }, [glassSupported]); return { - reduceTransparency, - setReduceTransparency, + transparencyMode: mode, + setTransparencyMode, + availableModes, + glassSupported, }; } diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 7f0e32769..b87c2a00c 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -81,18 +81,21 @@ const createDoctorResult = () => ({ const renderDisplaySection = ( options: { appSettings?: Partial; - reduceTransparency?: boolean; + transparencyMode?: ComponentProps["transparencyMode"]; + transparencyModes?: ComponentProps["transparencyModes"]; onUpdateAppSettings?: ComponentProps["onUpdateAppSettings"]; - onToggleTransparency?: ComponentProps["onToggleTransparency"]; + onTransparencyModeChange?: ComponentProps["onTransparencyModeChange"]; } = {}, ) => { cleanup(); const onUpdateAppSettings = options.onUpdateAppSettings ?? vi.fn().mockResolvedValue(undefined); - const onToggleTransparency = options.onToggleTransparency ?? vi.fn(); + const onTransparencyModeChange = + options.onTransparencyModeChange ?? vi.fn(); const props: ComponentProps = { - reduceTransparency: options.reduceTransparency ?? false, - onToggleTransparency, + transparencyMode: options.transparencyMode ?? "blur", + transparencyModes: options.transparencyModes ?? ["blur", "reduced"], + onTransparencyModeChange, appSettings: { ...baseSettings, ...options.appSettings }, onUpdateAppSettings, workspaceGroups: [], @@ -120,7 +123,7 @@ const renderDisplaySection = ( render(); fireEvent.click(screen.getByRole("button", { name: "Display & Sound" })); - return { onUpdateAppSettings, onToggleTransparency }; + return { onUpdateAppSettings, onTransparencyModeChange }; }; describe("SettingsView Display", () => { @@ -138,19 +141,23 @@ describe("SettingsView Display", () => { }); }); - it("toggles reduce transparency", () => { - const onToggleTransparency = vi.fn(); - renderDisplaySection({ onToggleTransparency, reduceTransparency: false }); + it("updates transparency mode", () => { + const onTransparencyModeChange = vi.fn(); + renderDisplaySection({ + onTransparencyModeChange, + transparencyMode: "blur", + transparencyModes: ["blur", "reduced"], + }); const row = screen - .getByText("Reduce transparency") + .getByText("Transparency") .closest(".settings-toggle-row") as HTMLElement | null; if (!row) { - throw new Error("Expected reduce transparency row"); + throw new Error("Expected transparency row"); } - fireEvent.click(within(row).getByRole("button")); + fireEvent.click(within(row).getByRole("radio", { name: "Reduced" })); - expect(onToggleTransparency).toHaveBeenCalledWith(true); + expect(onTransparencyModeChange).toHaveBeenCalledWith("reduced"); }); it("commits interface scale on blur and enter with clamping", async () => { @@ -280,8 +287,9 @@ describe("SettingsView Shortcuts", () => { onMoveWorkspaceGroup={vi.fn().mockResolvedValue(null)} onDeleteWorkspaceGroup={vi.fn().mockResolvedValue(null)} onAssignWorkspaceGroup={vi.fn().mockResolvedValue(null)} - reduceTransparency={false} - onToggleTransparency={vi.fn()} + transparencyMode="blur" + transparencyModes={["blur", "reduced"]} + onTransparencyModeChange={vi.fn()} appSettings={baseSettings} onUpdateAppSettings={vi.fn().mockResolvedValue(undefined)} onRunDoctor={vi.fn().mockResolvedValue(createDoctorResult())} @@ -318,8 +326,9 @@ describe("SettingsView Shortcuts", () => { onMoveWorkspaceGroup={vi.fn().mockResolvedValue(null)} onDeleteWorkspaceGroup={vi.fn().mockResolvedValue(null)} onAssignWorkspaceGroup={vi.fn().mockResolvedValue(null)} - reduceTransparency={false} - onToggleTransparency={vi.fn()} + transparencyMode="blur" + transparencyModes={["blur", "reduced"]} + onTransparencyModeChange={vi.fn()} appSettings={baseSettings} onUpdateAppSettings={vi.fn().mockResolvedValue(undefined)} onRunDoctor={vi.fn().mockResolvedValue(createDoctorResult())} diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 93265ee68..50c6ad68b 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -16,6 +16,7 @@ import type { AppSettings, CodexDoctorResult, DictationModelStatus, + TransparencyMode, WorkspaceGroup, WorkspaceInfo, } from "../../../types"; @@ -112,8 +113,9 @@ export type SettingsViewProps = { workspaceId: string, groupId: string | null, ) => Promise; - reduceTransparency: boolean; - onToggleTransparency: (value: boolean) => void; + transparencyMode: TransparencyMode; + transparencyModes: TransparencyMode[]; + onTransparencyModeChange: (value: TransparencyMode) => void; appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; onRunDoctor: (codexBin: string | null) => Promise; @@ -190,8 +192,9 @@ export function SettingsView({ onMoveWorkspaceGroup, onDeleteWorkspaceGroup, onAssignWorkspaceGroup, - reduceTransparency, - onToggleTransparency, + transparencyMode, + transparencyModes, + onTransparencyModeChange, appSettings, onUpdateAppSettings, onRunDoctor, @@ -251,6 +254,14 @@ export function SettingsView({ ) ?? DICTATION_MODELS[1] ); }, [appSettings.dictationModelId]); + const transparencyLabels: Record = { + glass: "Glass", + blur: "Blur", + reduced: "Reduced", + }; + const transparencySubtitle = transparencyModes.includes("glass") + ? "Choose glass, blur, or reduced surfaces." + : "Choose blur or reduced surfaces."; const projects = useMemo( () => groupedWorkspaces.flatMap((group) => group.workspaces), @@ -978,19 +989,31 @@ export function SettingsView({
-
Reduce transparency
+
Transparency
- Use solid surfaces instead of glass. + {transparencySubtitle}
- + {transparencyModes.map((mode) => ( + + ))} +
diff --git a/src/styles/settings.css b/src/styles/settings.css index 96bd8afc2..09f5f6e6d 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -550,6 +550,39 @@ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); } +.settings-segmented { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px; + border-radius: 999px; + background: var(--surface-control); + border: 1px solid var(--border-muted); +} + +.settings-segmented-button { + padding: 6px 12px; + border-radius: 999px; + border: 1px solid transparent; + background: transparent; + color: var(--text-subtle); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.01em; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; +} + +.settings-segmented-button.active { + background: var(--surface-card-strong); + color: var(--text-strong); + border-color: var(--border-strong); +} + +.app.reduced-transparency .settings-segmented { + background: var(--surface-control-hover); + border-color: var(--border-stronger); +} + @media (max-width: 720px) { .settings-body { grid-template-columns: 1fr; diff --git a/src/types.ts b/src/types.ts index 8c0cdbff9..0e61d0792 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,6 +73,7 @@ export type ReviewTarget = export type AccessMode = "read-only" | "current" | "full-access"; export type BackendMode = "local" | "remote"; export type ThemePreference = "system" | "light" | "dark"; +export type TransparencyMode = "glass" | "blur" | "reduced"; export type ComposerEditorPreset = "default" | "helpful" | "smart";