diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 787e70c6d1c..3f096358efc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -86,6 +86,7 @@ "@superset/host-service": "workspace:*", "@superset/local-db": "workspace:*", "@superset/macos-process-metrics": "workspace:*", + "@superset/macos-window-blur": "workspace:*", "@superset/panes": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", diff --git a/apps/desktop/runtime-dependencies.ts b/apps/desktop/runtime-dependencies.ts index 134e1930386..591c8f14d6a 100644 --- a/apps/desktop/runtime-dependencies.ts +++ b/apps/desktop/runtime-dependencies.ts @@ -49,6 +49,12 @@ const externalizedRuntimeModules: ExternalizedRuntimeModule[] = [ packagedCopies: [copyWholeModule("@superset/macos-process-metrics")], asarUnpackGlobs: ["**/node_modules/@superset/macos-process-metrics/**/*"], }, + { + specifier: "@superset/macos-window-blur", + materialize: ["@superset/macos-window-blur"], + packagedCopies: [copyWholeModule("@superset/macos-window-blur")], + asarUnpackGlobs: ["**/node_modules/@superset/macos-window-blur/**/*"], + }, { specifier: "@ast-grep/napi", materialize: ["@ast-grep/napi"], diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 0911ffbb6e2..94e1f5eddf5 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -33,6 +33,7 @@ import { createSettingsRouter } from "./settings"; import { createTabTearoffRouter } from "./tab-tearoff"; import { createTerminalRouter } from "./terminal"; import { createUiStateRouter } from "./ui-state"; +import { createVibrancyRouter } from "./vibrancy"; import { createVscodeExtensionsRouter } from "./vscode-extensions"; import { createWindowRouter } from "./window"; import { createWorkspacesRouter } from "./workspaces"; @@ -76,6 +77,7 @@ export const createAppRouter = ( hostServiceCoordinator: createHostServiceCoordinatorRouter(), tabTearoff: createTabTearoffRouter(wm), extensions: createExtensionsRouter(getWindow), + vibrancy: createVibrancyRouter(wm), vscodeExtensions: createVscodeExtensionsRouter(), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/vibrancy.ts b/apps/desktop/src/lib/trpc/routers/vibrancy.ts new file mode 100644 index 00000000000..38a0e5626c3 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/vibrancy.ts @@ -0,0 +1,92 @@ +import { observable } from "@trpc/server/observable"; +import { nativeTheme } from "electron"; +import { appState } from "main/lib/app-state"; +import { + applyVibrancy, + DEFAULT_VIBRANCY_STATE, + isNativeContinuousBlurSupported, + isVibrancySupported, + normalizeVibrancyState, + type VibrancyBlurLevel, + type VibrancyState, +} from "main/lib/vibrancy"; +import { VIBRANCY_EVENTS, vibrancyEmitter } from "main/lib/vibrancy/emitter"; +import type { WindowManager } from "main/lib/window-manager"; +import { z } from "zod"; +import { publicProcedure, router } from ".."; + +const blurLevelSchema: z.ZodType = z.enum([ + "subtle", + "standard", + "strong", + "ultra", +]); + +const vibrancyInputSchema = z.object({ + enabled: z.boolean().optional(), + opacity: z.number().int().min(0).max(100).optional(), + blurLevel: blurLevelSchema.optional(), + blurRadius: z.number().min(0).max(100).optional(), +}); + +function getCurrentState(): VibrancyState { + const stored = appState.data?.vibrancyState; + // Merge over defaults so older on-disk states (written before we added + // blurRadius) still produce a complete VibrancyState. Otherwise the + // missing field would round-trip as `undefined` and the slider would + // appear to reset on every restart. + return { ...DEFAULT_VIBRANCY_STATE, ...stored }; +} + +async function writeState(next: VibrancyState): Promise { + if (!appState.data) return; + appState.data.vibrancyState = next; + await appState.write(); +} + +function broadcastVibrancy(wm: WindowManager, state: VibrancyState): void { + const isDark = nativeTheme.shouldUseDarkColors; + for (const window of wm.getAll().values()) { + applyVibrancy(window, state, isDark); + } +} + +export const createVibrancyRouter = (wm: WindowManager) => { + return router({ + getSupported: publicProcedure.query(() => { + return { + supported: isVibrancySupported(), + nativeBlurSupported: isNativeContinuousBlurSupported(), + }; + }), + + get: publicProcedure.query(() => { + return getCurrentState(); + }), + + set: publicProcedure + .input(vibrancyInputSchema) + .mutation(async ({ input }) => { + const current = getCurrentState(); + const next = normalizeVibrancyState(input, current); + await writeState(next); + broadcastVibrancy(wm, next); + vibrancyEmitter.emit(VIBRANCY_EVENTS.CHANGED, next); + return next; + }), + + onChanged: publicProcedure.subscription(() => { + return observable((emit) => { + const handler = (state: VibrancyState) => { + emit.next(state); + }; + vibrancyEmitter.on(VIBRANCY_EVENTS.CHANGED, handler); + return () => { + vibrancyEmitter.off(VIBRANCY_EVENTS.CHANGED, handler); + }; + }); + }), + }); +}; + +export type VibrancyRouter = ReturnType; diff --git a/apps/desktop/src/main/lib/app-state/index.ts b/apps/desktop/src/main/lib/app-state/index.ts index 4a42b171c50..68b2fddce1a 100644 --- a/apps/desktop/src/main/lib/app-state/index.ts +++ b/apps/desktop/src/main/lib/app-state/index.ts @@ -60,6 +60,10 @@ function ensureValidShape(data: Partial): AppState { ...(data.hotkeysState?.byPlatform ?? {}), }, }, + vibrancyState: { + ...defaultAppState.vibrancyState, + ...(data.vibrancyState ?? {}), + }, }; } diff --git a/apps/desktop/src/main/lib/app-state/schemas.ts b/apps/desktop/src/main/lib/app-state/schemas.ts index d381e08b2f9..48af24876a6 100644 --- a/apps/desktop/src/main/lib/app-state/schemas.ts +++ b/apps/desktop/src/main/lib/app-state/schemas.ts @@ -3,6 +3,10 @@ */ import type { BaseTabsState } from "shared/tabs-types"; import type { Theme } from "shared/themes"; +import { + DEFAULT_VIBRANCY_STATE, + type VibrancyState, +} from "shared/vibrancy-types"; // Re-export for convenience export type { BaseTabsState as TabsState, Pane } from "shared/tabs-types"; @@ -24,6 +28,7 @@ export interface AppState { tabsState: BaseTabsState; themeState: ThemeState; hotkeysState: LegacyHotkeysState; + vibrancyState: VibrancyState; } export const defaultAppState: AppState = { @@ -44,4 +49,5 @@ export const defaultAppState: AppState = { version: 1, byPlatform: { darwin: {}, win32: {}, linux: {} }, }, + vibrancyState: DEFAULT_VIBRANCY_STATE, }; diff --git a/apps/desktop/src/main/lib/vibrancy/emitter.ts b/apps/desktop/src/main/lib/vibrancy/emitter.ts new file mode 100644 index 00000000000..631000cab55 --- /dev/null +++ b/apps/desktop/src/main/lib/vibrancy/emitter.ts @@ -0,0 +1,25 @@ +import { EventEmitter } from "node:events"; +import type { VibrancyState } from "./index"; + +export const VIBRANCY_EVENTS = { + CHANGED: "vibrancy:changed", +} as const; + +type VibrancyEvents = { + [VIBRANCY_EVENTS.CHANGED]: [VibrancyState]; +}; + +export const vibrancyEmitter = new EventEmitter() as EventEmitter & { + on( + event: K, + listener: (...args: VibrancyEvents[K]) => void, + ): EventEmitter; + off( + event: K, + listener: (...args: VibrancyEvents[K]) => void, + ): EventEmitter; + emit( + event: K, + ...args: VibrancyEvents[K] + ): boolean; +}; diff --git a/apps/desktop/src/main/lib/vibrancy/index.ts b/apps/desktop/src/main/lib/vibrancy/index.ts new file mode 100644 index 00000000000..a78bd924ef9 --- /dev/null +++ b/apps/desktop/src/main/lib/vibrancy/index.ts @@ -0,0 +1,247 @@ +import { + isNativeBlurAvailable, + setWindowBlurRadius, +} from "@superset/macos-window-blur"; +import type { BrowserWindow } from "electron"; +import { PLATFORM } from "shared/constants"; +import { + DEFAULT_VIBRANCY_STATE, + VIBRANCY_BLUR_RADIUS_MAX, + VIBRANCY_BLUR_RADIUS_MIN, + VIBRANCY_OPACITY_MAX, + VIBRANCY_OPACITY_MIN, + type VibrancyBlurLevel, + type VibrancyState, +} from "shared/vibrancy-types"; + +export { + DEFAULT_VIBRANCY_STATE, + type VibrancyBlurLevel, + type VibrancyState, +} from "shared/vibrancy-types"; + +const BLUR_TO_ELECTRON_VIBRANCY: Record< + VibrancyBlurLevel, + "sidebar" | "header" | "content" | "fullscreen-ui" +> = { + subtle: "sidebar", + standard: "header", + strong: "content", + ultra: "fullscreen-ui", +}; + +// Ember dark / Superset light background colors used when vibrancy is off. +const OPAQUE_DARK = "#151110"; +const OPAQUE_LIGHT = "#ffffff"; + +const DARK_RGB = { r: 21, g: 17, b: 16 }; +const LIGHT_RGB = { r: 255, g: 255, b: 255 }; + +export function isVibrancySupported(): boolean { + return PLATFORM.IS_MAC; +} + +/** + * Clamp opacity to the supported range defined in shared/vibrancy-types. + */ +export function normalizeVibrancyState( + partial: Partial, + base: VibrancyState = DEFAULT_VIBRANCY_STATE, +): VibrancyState { + const opacity = + partial.opacity === undefined + ? base.opacity + : Math.max( + VIBRANCY_OPACITY_MIN, + Math.min(VIBRANCY_OPACITY_MAX, Math.round(partial.opacity)), + ); + const blurLevel: VibrancyBlurLevel = + partial.blurLevel && partial.blurLevel in BLUR_TO_ELECTRON_VIBRANCY + ? partial.blurLevel + : base.blurLevel; + const blurRadius = + partial.blurRadius === undefined + ? base.blurRadius + : Math.max( + VIBRANCY_BLUR_RADIUS_MIN, + Math.min(VIBRANCY_BLUR_RADIUS_MAX, Math.round(partial.blurRadius)), + ); + return { + enabled: partial.enabled ?? base.enabled, + opacity, + blurLevel, + blurRadius, + }; +} + +/** + * Whether the native CIGaussianBlur addon loaded successfully on this + * machine. When false, the vibrancy slider UI should fall back to the + * four-step blurLevel selection. + */ +export function isNativeContinuousBlurSupported(): boolean { + return isVibrancySupported() && isNativeBlurAvailable(); +} + +function toHexAlpha(opacityPercent: number): string { + const alpha = Math.round((opacityPercent / 100) * 255); + return alpha.toString(16).padStart(2, "0"); +} + +/** + * Build an #RRGGBBAA color string using the current theme brightness and the + * vibrancy opacity slider. `opacity` here means "how transparent the chrome + * becomes when vibrancy is active" — 0 = fully see-through, 100 = opaque. + * + * When vibrancy is disabled we return a fully opaque color so the window + * renders identically to the pre-vibrancy build. + */ +export function computeBackgroundColor( + state: VibrancyState, + isDark: boolean, +): string { + if (!state.enabled) { + return isDark ? OPAQUE_DARK : OPAQUE_LIGHT; + } + const rgb = isDark ? DARK_RGB : LIGHT_RGB; + // Slider 100 = opaque; lower values = more transparent so desktop shows through. + const alphaHex = toHexAlpha(state.opacity); + const toHex = (n: number) => n.toString(16).padStart(2, "0"); + return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}${alphaHex}`; +} + +export function resolveVibrancyType( + state: VibrancyState, +): "sidebar" | "header" | "content" | "fullscreen-ui" | null { + if (!state.enabled) return null; + return BLUR_TO_ELECTRON_VIBRANCY[state.blurLevel]; +} + +/** + * Apply the current vibrancy state to a BrowserWindow. Only has effect on + * macOS — on other platforms this is a no-op so callers can invoke it + * unconditionally. + */ +export function applyVibrancy( + window: BrowserWindow, + state: VibrancyState, + isDark: boolean, +): void { + if (window.isDestroyed()) return; + if (!isVibrancySupported()) return; + + const vibrancyType = resolveVibrancyType(state); + const backgroundColor = computeBackgroundColor(state, isDark); + + window.setBackgroundColor(backgroundColor); + // Electron's setVibrancy accepts `null` to clear the effect — the type + // definition in Electron 30+ includes `string | null`, so the value + // returned by resolveVibrancyType can be passed through directly. + window.setVibrancy(vibrancyType); + + scheduleNativeBlur(window, state); +} + +// --- Native blur scheduling ---------------------------------------------- +// Each window tracks the "latest requested radius" plus a list of pending +// retry timers. When a new applyVibrancy call arrives we: +// 1. Update the latest radius for that window +// 2. Cancel any still-pending retries from older calls +// 3. Schedule a fresh burst of retries that all read from `latestRadius` +// This kills a subtle race where a user dragging the blur slider quickly +// would have an old value's 180ms retry land after a newer value was +// already applied, clobbering it. + +interface BlurSchedule { + latestRadius: number; + timers: ReturnType[]; +} + +const blurSchedules = new WeakMap(); + +function scheduleNativeBlur(window: BrowserWindow, state: VibrancyState): void { + if (!isNativeBlurAvailable()) return; + + const radius = state.enabled ? state.blurRadius : 0; + let schedule = blurSchedules.get(window); + if (!schedule) { + schedule = { latestRadius: radius, timers: [] }; + blurSchedules.set(window, schedule); + } else { + schedule.latestRadius = radius; + for (const timer of schedule.timers) clearTimeout(timer); + schedule.timers.length = 0; + } + + const handle = window.getNativeWindowHandle(); + const apply = (): void => { + if (window.isDestroyed()) return; + const current = blurSchedules.get(window); + if (!current) return; + try { + setWindowBlurRadius(handle, current.latestRadius); + } catch (error) { + console.warn("[vibrancy] setWindowBlurRadius failed:", error); + } + }; + + // Immediate apply + retries that stretch long enough to beat the + // NSVisualEffectView's own lazy refresh cycle. + apply(); + const delays = [16, 64, 180, 480, 960]; + for (const delay of delays) { + const timer = setTimeout(() => { + if (!schedule) return; + const index = schedule.timers.indexOf(timer); + if (index >= 0) schedule.timers.splice(index, 1); + apply(); + }, delay); + schedule.timers.push(timer); + } +} + +/** + * Options that callers should spread into the BrowserWindow constructor on + * macOS so that vibrancy can later be toggled dynamically via + * `setVibrancy` / `setBackgroundColor` without recreating the window. + * + * `transparent: true` is required at construction time — it cannot be + * toggled later — so we always opt in on macOS even when the user has + * vibrancy disabled. The opaque background color we set keeps the window + * visually identical to the pre-vibrancy build until the user enables it. + */ +export function getInitialWindowOptions( + state: VibrancyState, + isDark: boolean, +): { + transparent?: boolean; + vibrancy?: "sidebar" | "header" | "content" | "fullscreen-ui"; + visualEffectState?: "followWindow" | "active" | "inactive"; + backgroundColor: string; +} { + if (!isVibrancySupported()) { + return { + backgroundColor: isDark ? OPAQUE_DARK : OPAQUE_LIGHT, + }; + } + + const vibrancyType = resolveVibrancyType(state); + const backgroundColor = computeBackgroundColor(state, isDark); + + if (vibrancyType === null) { + // Vibrancy disabled: still opt into transparent so we can toggle later + // without recreating the window. Background color is fully opaque. + return { + transparent: true, + visualEffectState: "active", + backgroundColor, + }; + } + + return { + transparent: true, + vibrancy: vibrancyType, + visualEffectState: "active", + backgroundColor, + }; +} diff --git a/apps/desktop/src/main/lib/window-manager/index.ts b/apps/desktop/src/main/lib/window-manager/index.ts index d3212bf7ae0..e007a24c835 100644 --- a/apps/desktop/src/main/lib/window-manager/index.ts +++ b/apps/desktop/src/main/lib/window-manager/index.ts @@ -1,6 +1,12 @@ import { join } from "node:path"; import { type BrowserWindow, ipcMain, nativeTheme } from "electron"; import { createWindow } from "lib/electron-app/factories/windows/create"; +import { appState } from "../app-state"; +import { + applyVibrancy, + DEFAULT_VIBRANCY_STATE, + getInitialWindowOptions as getInitialVibrancyOptions, +} from "../vibrancy"; interface TearoffWindowOptions { windowId: string; @@ -112,6 +118,13 @@ export class WindowManager { } { const { windowId } = options; + const initialVibrancyState = + appState.data?.vibrancyState ?? DEFAULT_VIBRANCY_STATE; + const vibrancyWindowOptions = getInitialVibrancyOptions( + initialVibrancyState, + nativeTheme.shouldUseDarkColors, + ); + const window = createWindow({ id: "tearoff", title: "Superset", @@ -122,7 +135,7 @@ export class WindowManager { minWidth: 400, minHeight: 400, show: false, - backgroundColor: nativeTheme.shouldUseDarkColors ? "#252525" : "#ffffff", + ...vibrancyWindowOptions, frame: false, titleBarStyle: "hidden", trafficLightPosition: { x: 16, y: 16 }, @@ -146,6 +159,16 @@ export class WindowManager { }); window.webContents.once("did-finish-load", () => { + // Re-apply vibrancy now that the tearoff is on-screen so the + // native blur addon can find the NSVisualEffectView and write + // the user's persisted blurRadius. Without this the tearoff + // would stick to the default material blur until the user + // touched the vibrancy settings again. + applyVibrancy( + window, + appState.data?.vibrancyState ?? DEFAULT_VIBRANCY_STATE, + nativeTheme.shouldUseDarkColors, + ); window.show(); }); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 695d624e881..2afc3880804 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -29,6 +29,11 @@ import { getNotificationTitle, getWorkspaceName, } from "../lib/notifications/utils"; +import { + applyVibrancy, + DEFAULT_VIBRANCY_STATE, + getInitialWindowOptions as getInitialVibrancyOptions, +} from "../lib/vibrancy"; import { windowManager } from "../lib/window-manager"; import { getInitialWindowBounds, @@ -122,6 +127,19 @@ app.on("child-process-gone", (_event, details) => { } }); +// Re-apply vibrancy when the OS dark/light appearance changes. The +// computed setBackgroundColor depends on isDark so the window would +// otherwise keep the previous tint until the user interacted with the +// vibrancy settings again. Only relevant on macOS, but nativeTheme is +// harmless to subscribe to on other platforms. +nativeTheme.on("updated", () => { + const isDark = nativeTheme.shouldUseDarkColors; + const vibrancyState = appState.data?.vibrancyState ?? DEFAULT_VIBRANCY_STATE; + for (const win of windowManager.getAll().values()) { + applyVibrancy(win, vibrancyState, isDark); + } +}); + export async function MainWindow() { const shouldPersistWindowPosition = isWindowPositionPersistenceEnabled(); const savedWindowState = loadWindowState(); @@ -136,6 +154,13 @@ export async function MainWindow() { ? `${productName} — ${workspaceName}` : productName; + const initialVibrancyState = + appState.data?.vibrancyState ?? DEFAULT_VIBRANCY_STATE; + const vibrancyWindowOptions = getInitialVibrancyOptions( + initialVibrancyState, + nativeTheme.shouldUseDarkColors, + ); + const window = createWindow({ id: "main", title: windowTitle, @@ -146,7 +171,7 @@ export async function MainWindow() { minWidth: 400, minHeight: 400, show: false, - backgroundColor: nativeTheme.shouldUseDarkColors ? "#252525" : "#ffffff", + ...vibrancyWindowOptions, center: initialBounds.center, movable: true, resizable: true, @@ -313,6 +338,14 @@ export async function MainWindow() { window.webContents.setZoomLevel(persistedZoomLevel); } + // Re-apply vibrancy now that the window is actually on-screen so the + // native CIGaussianBlur addon has a real NSVisualEffectView to mutate. + applyVibrancy( + window, + appState.data?.vibrancyState ?? DEFAULT_VIBRANCY_STATE, + nativeTheme.shouldUseDarkColors, + ); + if (!hasCompletedFirstLoad) { if (initialBounds.isMaximized) { window.maximize(); diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index 568bedee3d6..c744c1af5e6 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -96,6 +96,66 @@ --highlight-active: rgba(255, 150, 50, 0.55); } +/** + * Vibrancy mode (macOS only, opt-in). + * + * When the vibrancy store sets `data-vibrancy="on"` on , chrome surfaces + * switch to rgba variants so the macOS NSVisualEffectView blur underneath the + * window shows through. `--vibrancy-alpha` is tweaked live from JavaScript + * based on the user's opacity slider (0.0 = fully transparent, 1.0 = opaque). + * + * IMPORTANT: BrowserPane (webview) stays opaque via --background-solid because + * Chromium's compositor always renders frames on an opaque layer. + */ +:root { + --vibrancy-alpha: 0.6; + --background-solid: #151110; +} + +:root.light { + --background-solid: oklch(1 0 0); +} + +:root[data-vibrancy="on"] { + --background: rgba(21, 17, 16, var(--vibrancy-alpha)); + --card: rgba(32, 30, 28, min(1, calc(var(--vibrancy-alpha) + 0.1))); + --popover: rgba(32, 30, 28, 0.95); + --muted: rgba(42, 40, 39, min(1, calc(var(--vibrancy-alpha) + 0.1))); + --accent: rgba(42, 40, 39, min(1, calc(var(--vibrancy-alpha) + 0.1))); + --sidebar: rgba(26, 23, 22, max(0, calc(var(--vibrancy-alpha) - 0.05))); + --sidebar-accent: rgba( + 37, + 34, + 32, + min(1, calc(var(--vibrancy-alpha) + 0.05)) + ); + --tertiary: rgba(26, 23, 22, max(0, calc(var(--vibrancy-alpha) - 0.05))); + --tertiary-active: rgba( + 37, + 34, + 32, + min(1, calc(var(--vibrancy-alpha) + 0.05)) + ); +} + +:root[data-vibrancy="on"].light { + --background: rgb(255 255 255 / var(--vibrancy-alpha)); + --card: rgb(255 255 255 / min(1, calc(var(--vibrancy-alpha) + 0.1))); + --popover: rgb(255 255 255 / 0.95); + --muted: rgb(247 247 247 / min(1, calc(var(--vibrancy-alpha) + 0.1))); + --accent: rgb(247 247 247 / min(1, calc(var(--vibrancy-alpha) + 0.1))); + --sidebar: rgb(251 251 251 / max(0, calc(var(--vibrancy-alpha) - 0.05))); + --sidebar-accent: rgb( + 247 247 247 / + min(1, calc(var(--vibrancy-alpha) + 0.05)) + ); + --tertiary: rgb(243 243 243 / max(0, calc(var(--vibrancy-alpha) - 0.05))); + --tertiary-active: rgb( + 233 233 233 / + min(1, calc(var(--vibrancy-alpha) + 0.05)) + ); +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -182,6 +242,18 @@ height: 100%; width: 100%; } + + /* Vibrancy: force every nested xterm surface to be transparent so + * the canvas / WebGL layer can render with the rgba theme background + * that the terminal theme store is injecting. Without this override + * the .xterm-viewport / .xterm-screen elements keep the stylesheet + * defaults and hide the alpha channel underneath the terminal pane. */ + :root[data-vibrancy="on"] .xterm, + :root[data-vibrancy="on"] .xterm .xterm-viewport, + :root[data-vibrancy="on"] .xterm .xterm-screen, + :root[data-vibrancy="on"] .xterm canvas { + background-color: transparent; + } .xterm .xterm-rows a { color: inherit; cursor: pointer; diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index 7627d36a6f4..f222a139799 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -17,6 +17,7 @@ import { posthog } from "./lib/posthog"; import { electronQueryClient } from "./providers/ElectronTRPCProvider"; import { routeTree } from "./routeTree.gen"; import { useDeepLinkNavigationStore } from "./stores/deep-link-navigation"; +import { useVibrancyStore } from "./stores/vibrancy"; import "./globals.css"; import "./styles/bundled-fonts.css"; @@ -24,6 +25,10 @@ import "./styles/bundled-fonts.css"; const rootElement = document.querySelector("app"); initBootErrorHandling(rootElement); +// Hydrate vibrancy store early so the window chrome doesn't flash opaque when +// the user has vibrancy enabled. Fire-and-forget; failures degrade gracefully. +void useVibrancyStore.getState().hydrate(); + const router = createRouter({ routeTree, history: persistentHistory, diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index 18092df50a3..a5da70f68e0 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -55,6 +55,9 @@ function createTerminal( fontFamily: appearance.fontFamily, fontSize: appearance.fontSize, theme: appearance.theme, + // Needed so the WebGL renderer honours rgba background colors when + // window vibrancy is enabled. + allowTransparency: true, allowProposedApi: true, scrollback: DEFAULT_TERMINAL_SCROLLBACK, macOptionIsMeta: false, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts index d4a1f4d9e98..55ff6314d7b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts @@ -7,12 +7,12 @@ import { type TerminalAppearance, } from "renderer/lib/terminal/appearance"; import { electronTrpcClient } from "renderer/lib/trpc-client"; -import { useTerminalTheme } from "renderer/stores/theme"; +import { useEffectiveTerminalTheme } from "renderer/stores/vibrancy"; const fallbackTheme = getDefaultTerminalAppearance().theme; export function useTerminalAppearance(): TerminalAppearance { - const terminalTheme = useTerminalTheme(); + const terminalTheme = useEffectiveTerminalTheme(); const { data: fontSettings } = useQuery({ queryKey: ["electron", "settings", "getFontSettings"], queryFn: () => electronTrpcClient.settings.getFontSettings.query(), diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/AppearanceSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/AppearanceSettings.tsx index b854f6abef8..6e1d6e235eb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/AppearanceSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/AppearanceSettings.tsx @@ -7,6 +7,7 @@ import { import { FontSettingSection } from "./components/FontSettingSection"; import { MarkdownStyleSection } from "./components/MarkdownStyleSection"; import { ThemeSection } from "./components/ThemeSection"; +import { VibrancySection } from "./components/VibrancySection"; /** * Renders a list of visible sections with automatic border separators. @@ -55,6 +56,10 @@ export function AppearanceSettings({ visibleItems }: AppearanceSettingsProps) { visibleItems, ); const showThemeSection = showTheme || showCustomThemes; + const showVibrancy = isItemVisible( + SETTING_ITEM_ID.APPEARANCE_VIBRANCY, + visibleItems, + ); return (
@@ -67,6 +72,7 @@ export function AppearanceSettings({ visibleItems }: AppearanceSettingsProps) { {showThemeSection && } + {showVibrancy && } {showMarkdown && } {showEditorFont && ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/VibrancySection/VibrancySection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/VibrancySection/VibrancySection.tsx new file mode 100644 index 00000000000..63657ad393b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/VibrancySection/VibrancySection.tsx @@ -0,0 +1,228 @@ +import { Label } from "@superset/ui/label"; +import { Slider } from "@superset/ui/slider"; +import { Switch } from "@superset/ui/switch"; +import { useEffect, useState } from "react"; +import { useVibrancyStore } from "renderer/stores/vibrancy"; +import { + VIBRANCY_BLUR_RADIUS_MAX, + VIBRANCY_BLUR_RADIUS_MIN, + VIBRANCY_OPACITY_MAX, + VIBRANCY_OPACITY_MIN, + type VibrancyBlurLevel, +} from "shared/vibrancy-types"; + +const BLUR_LEVEL_ORDER: VibrancyBlurLevel[] = [ + "subtle", + "standard", + "strong", + "ultra", +]; +const BLUR_LEVEL_LABEL: Record = { + subtle: "弱", + standard: "標準", + strong: "強", + ultra: "最強", +}; + +function sliderValueToBlurLevel(value: number): VibrancyBlurLevel { + const clamped = Math.max(0, Math.min(100, value)); + // 4 equal buckets across the 0-100 range. + const index = Math.min( + BLUR_LEVEL_ORDER.length - 1, + Math.floor((clamped / 100) * BLUR_LEVEL_ORDER.length), + ); + return BLUR_LEVEL_ORDER[index] ?? "standard"; +} + +function blurLevelToSliderValue(level: VibrancyBlurLevel): number { + const index = BLUR_LEVEL_ORDER.indexOf(level); + if (index < 0) return 50; + return Math.round(((index + 0.5) / BLUR_LEVEL_ORDER.length) * 100); +} + +export function VibrancySection() { + const hydrated = useVibrancyStore((s) => s.hydrated); + const supported = useVibrancyStore((s) => s.supported); + const nativeBlurSupported = useVibrancyStore((s) => s.nativeBlurSupported); + const enabled = useVibrancyStore((s) => s.enabled); + const opacity = useVibrancyStore((s) => s.opacity); + const blurLevel = useVibrancyStore((s) => s.blurLevel); + const blurRadius = useVibrancyStore((s) => s.blurRadius); + const setState = useVibrancyStore((s) => s.setState); + const previewOpacity = useVibrancyStore((s) => s.previewOpacity); + + // Drag-local opacity: drives the slider thumb and a CSS preview via the + // `--vibrancy-alpha` variable, so the window updates in real time without + // hitting the filesystem on every tick. Persistence happens on commit. + const [draftOpacity, setDraftOpacity] = useState(null); + const displayOpacity = draftOpacity ?? opacity; + const [draftBlurRadius, setDraftBlurRadius] = useState(null); + const displayBlurRadius = draftBlurRadius ?? blurRadius; + const [draftBlurLevelValue, setDraftBlurLevelValue] = useState( + null, + ); + const displayBlurLevelValue = + draftBlurLevelValue ?? blurLevelToSliderValue(blurLevel); + + useEffect(() => { + // index.tsx already kicks off hydrate at startup; this is a safety net + // for cold settings-only flows. The store itself dedupes concurrent + // calls via an in-flight promise so repeated invocations are cheap. + if (!hydrated) { + void useVibrancyStore.getState().hydrate(); + } + }, [hydrated]); + + if (!supported) { + return ( +
+

ウィンドウ透過

+

+ この機能は現在 macOS でのみ利用できます。 +

+
+ ); + } + + return ( +
+
+

ウィンドウ透過 (macOS)

+

+ Warp + のようにウィンドウ全体を半透明にし、背景をぼかしてデスクトップが透けて見えるようにします。 + ブラウザペイン (webview) は macOS + の制約により透過できず、不透明のまま残ります。 +

+
+ +
+
+ +

+ メインウィンドウと tearoff ウィンドウに適用されます。 +

+
+ { + void setState({ enabled: checked }); + }} + /> +
+ +
+
+ +
+ { + const value = values[0]; + if (typeof value !== "number") return; + setDraftOpacity(value); + // Live-preview via the store so all CSS variable + // overlays are recomputed — no disk write, no IPC. + previewOpacity(value); + }} + onValueCommit={(values) => { + const value = values[0]; + if (typeof value !== "number") return; + setDraftOpacity(null); + void setState({ opacity: value }); + }} + /> +

+ 0% + に近づくほど背景がよく透けて見えます。低すぎると文字が読みづらくなるのでご注意ください。 +

+
+ + {nativeBlurSupported ? ( +
+
+ +
+ { + const value = values[0]; + if (typeof value !== "number") return; + setDraftBlurRadius(value); + }} + onValueCommit={(values) => { + const value = values[0]; + if (typeof value !== "number") return; + setDraftBlurRadius(null); + void setState({ blurRadius: value }); + }} + /> +

+ CIGaussianBlur を 1 単位で調整します (ネイティブアドオン使用)。 +

+
+ ) : ( +
+
+ +
+ { + const value = values[0]; + if (typeof value !== "number") return; + setDraftBlurLevelValue(value); + }} + onValueCommit={(values) => { + const value = values[0]; + if (typeof value !== "number") return; + setDraftBlurLevelValue(null); + const nextLevel = sliderValueToBlurLevel(value); + if (nextLevel !== blurLevel) { + void setState({ blurLevel: nextLevel }); + } + }} + /> +

+ ネイティブブラーアドオンが未ロードのため、NSVisualEffectView の + material を 4 段階で切り替えます (弱 → 標準 → 強 → 最強)。 +

+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/VibrancySection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/VibrancySection/index.ts new file mode 100644 index 00000000000..e0302c9a206 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/VibrancySection/index.ts @@ -0,0 +1 @@ +export { VibrancySection } from "./VibrancySection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index 897311591af..1f8405e1cd2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -18,6 +18,7 @@ export const SETTING_ITEM_ID = { APPEARANCE_CUSTOM_THEMES: "appearance-custom-themes", APPEARANCE_EDITOR_FONT: "appearance-editor-font", APPEARANCE_TERMINAL_FONT: "appearance-terminal-font", + APPEARANCE_VIBRANCY: "appearance-vibrancy", RINGTONES_NOTIFICATION: "ringtones-notification", @@ -328,6 +329,26 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "nerd", ], }, + { + id: SETTING_ITEM_ID.APPEARANCE_VIBRANCY, + section: "appearance", + title: "Window Vibrancy", + description: "Make the window semi-transparent with a macOS vibrancy blur", + keywords: [ + "appearance", + "vibrancy", + "transparent", + "transparency", + "blur", + "opacity", + "glass", + "warp", + "macos", + "ウィンドウ透過", + "不透明度", + "ブラー", + ], + }, { id: SETTING_ITEM_ID.RINGTONES_NOTIFICATION, section: "ringtones", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx index 13a146696f4..dde4dd6ad57 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx @@ -48,6 +48,7 @@ import type { } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor/symbolInteractions.types"; import { getCodeSyntaxHighlighting } from "renderer/screens/main/components/WorkspaceView/utils/code-theme"; import { useResolvedTheme } from "renderer/stores/theme"; +import { useVibrancyStore } from "renderer/stores/vibrancy"; import type { DiffViewMode } from "shared/changes-types"; import { getEditorTheme } from "shared/themes"; @@ -329,6 +330,11 @@ export function CodeMirrorDiffViewer({ const resolveSymbolHoverRef = useRef(resolveSymbolHover); const onGoToDefinitionRef = useRef(onGoToDefinition); const activeTheme = useResolvedTheme(); + const vibrancyEnabled = useVibrancyStore((s) => s.enabled); + const vibrancyOpacityRaw = useVibrancyStore((s) => s.opacity); + const vibrancyOpacity = vibrancyEnabled + ? vibrancyOpacityRaw / 100 + : undefined; const { data: fontSettings } = electronTrpc.settings.getFontSettings.useQuery( undefined, { staleTime: 30_000 }, @@ -603,6 +609,7 @@ export function CodeMirrorDiffViewer({ activeTheme, { fontFamily: editorFontFamily, fontSize: editorFontSize }, true, + { vibrancyOpacity }, ), ]; @@ -671,6 +678,7 @@ export function CodeMirrorDiffViewer({ activeTheme, { fontFamily: editorFontFamily, fontSize: editorFontSize }, true, + { vibrancyOpacity }, ), ]; @@ -682,6 +690,7 @@ export function CodeMirrorDiffViewer({ editorFontSize, themeCompartmentA, themeCompartmentB, + vibrancyOpacity, ]); useEffect(() => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx index cb069cdaf83..56a4d0619ad 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx @@ -130,7 +130,7 @@ function HtmlPreviewWebview({ } }, [zoomLevel]); - return
; + return
; } interface RawFileData { @@ -838,7 +838,7 @@ export function FileViewerContent({ } return ( -
+
{filePath.split("/").pop()
@@ -568,7 +568,13 @@ export function SpreadsheetDiffViewer({ scrollRef={leftScrollRef} peerScrollRef={rightScrollRef} /> -
+
-
+
{/* Outer wrapper: clips to container width */}
{Array.from({ length: activeSheet.columnCount }, (_, i) => { @@ -401,13 +404,13 @@ export function SpreadsheetViewer({ {label} @@ -425,13 +428,13 @@ export function SpreadsheetViewer({ > @@ -494,12 +497,12 @@ export function SpreadsheetViewer({ {activeSheet.truncated && (
Showing first 2,000 rows. Full file contains more rows. diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 42d28032064..fec521ab7ac 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -7,7 +7,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { buildTerminalCommand } from "renderer/lib/terminal/launch-command"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTerminalSuggestionsStore } from "renderer/stores/terminal-suggestions"; -import { useTerminalTheme } from "renderer/stores/theme"; +import { useEffectiveTerminalTheme } from "renderer/stores/vibrancy"; import { sanitizeForTitle } from "./commandBuffer"; import { SessionKilledOverlay } from "./components"; import { @@ -113,7 +113,7 @@ export const Terminal = memo(function Terminal({ const setFocusedPane = useTabsStore((s) => s.setFocusedPane); const setPaneName = useTabsStore((s) => s.setPaneName); const focusedPaneId = useTabsStore((s) => s.focusedPaneIds[tabId]); - const terminalTheme = useTerminalTheme(); + const terminalTheme = useEffectiveTerminalTheme(); // Terminal connection state and mutations const { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts index 06ac54430d8..477ac70ae03 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts @@ -27,6 +27,10 @@ export const TERMINAL_OPTIONS: ITerminalOptions = { fontSize: DEFAULT_TERMINAL_FONT_SIZE, fontFamily: DEFAULT_TERMINAL_FONT_FAMILY, theme: TERMINAL_THEME, + // Required so the WebGL renderer honours rgba background colors when + // the window vibrancy feature is enabled. No visual effect when the + // theme's background is fully opaque. + allowTransparency: true, allowProposedApi: true, scrollback: DEFAULT_TERMINAL_SCROLLBACK, // Allow Option+key to type special characters on international keyboards (e.g., Option+2 = @) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx index a36f410c0df..6be52ef62fc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx @@ -41,6 +41,7 @@ import { electronTrpcClient } from "renderer/lib/trpc-client"; import type { CodeEditorAdapter } from "renderer/screens/main/components/WorkspaceView/ContentView/components"; import { getCodeSyntaxHighlighting } from "renderer/screens/main/components/WorkspaceView/utils/code-theme"; import { useResolvedTheme } from "renderer/stores/theme"; +import { useVibrancyStore } from "renderer/stores/vibrancy"; import { getEditorTheme } from "shared/themes"; import { CodeEditorSearchOverlay } from "./components/CodeEditorSearchOverlay"; import { type BlameEntry, createBlamePlugin } from "./createBlamePlugin"; @@ -588,6 +589,11 @@ export function CodeEditor({ const editorFontSize = fontSettings?.editorFontSize ?? undefined; const activeTheme = useResolvedTheme(); const editorTheme = getEditorTheme(activeTheme); + const vibrancyEnabled = useVibrancyStore((s) => s.enabled); + const vibrancyOpacityRaw = useVibrancyStore((s) => s.opacity); + const vibrancyOpacity = vibrancyEnabled + ? vibrancyOpacityRaw / 100 + : undefined; const inlineCompletionCompartment = useRef(new Compartment()).current; onChangeRef.current = onChange; @@ -877,6 +883,7 @@ export function CodeEditor({ fontSize: editorFontSize, }, fillHeight, + { vibrancyOpacity }, ), ]), languageCompartment.of([]), @@ -966,6 +973,7 @@ export function CodeEditor({ fontSize: editorFontSize, }, fillHeight, + { vibrancyOpacity }, ), ]), }); @@ -975,6 +983,7 @@ export function CodeEditor({ editorFontSize, fillHeight, themeCompartment, + vibrancyOpacity, ]); useEffect(() => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createCodeMirrorTheme.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createCodeMirrorTheme.ts index a2ef5f778ad..8ee16ded0c9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createCodeMirrorTheme.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createCodeMirrorTheme.ts @@ -10,20 +10,49 @@ interface CodeEditorFontSettings { fontSize?: number; } +interface CodeEditorThemeOptions { + /** + * If set (0-1), the editor background is mixed with `transparent` at this + * alpha so the window's vibrancy layer can show through. Leave undefined + * for the default opaque rendering. + */ + vibrancyOpacity?: number; +} + +function toTranslucentBackground(base: string, alpha: number): string { + const clamped = Math.max(0, Math.min(1, alpha)); + return `color-mix(in srgb, ${base} ${(clamped * 100).toFixed(2)}%, transparent)`; +} + export function createCodeMirrorTheme( theme: Theme, fontSettings: CodeEditorFontSettings, fillHeight: boolean, + options: CodeEditorThemeOptions = {}, ) { const fontSize = fontSettings.fontSize ?? DEFAULT_CODE_EDITOR_FONT_SIZE; const lineHeight = Math.round(fontSize * 1.5); const editorTheme = getEditorTheme(theme); + const backgroundColor = + options.vibrancyOpacity !== undefined + ? toTranslucentBackground( + editorTheme.colors.background, + options.vibrancyOpacity, + ) + : editorTheme.colors.background; + const gutterBackground = + options.vibrancyOpacity !== undefined + ? toTranslucentBackground( + editorTheme.colors.gutterBackground, + options.vibrancyOpacity, + ) + : editorTheme.colors.gutterBackground; return EditorView.theme( { "&": { height: fillHeight ? "100%" : "auto", - backgroundColor: editorTheme.colors.background, + backgroundColor, color: editorTheme.colors.foreground, fontFamily: fontSettings.fontFamily ?? DEFAULT_CODE_EDITOR_FONT_FAMILY, fontSize: `${fontSize}px`, @@ -41,7 +70,7 @@ export function createCodeMirrorTheme( padding: "0 12px", }, ".cm-gutters": { - backgroundColor: editorTheme.colors.gutterBackground, + backgroundColor: gutterBackground, color: editorTheme.colors.gutterForeground, borderRight: `1px solid ${editorTheme.colors.border}`, }, diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts index 58edc717ed3..ef5874be28c 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -6,5 +6,6 @@ export * from "./settings-state"; export * from "./sidebar-state"; export * from "./tabs"; export * from "./theme"; +export * from "./vibrancy"; export * from "./workspace-init"; export * from "./workspace-sidebar-state"; diff --git a/apps/desktop/src/renderer/stores/vibrancy/index.ts b/apps/desktop/src/renderer/stores/vibrancy/index.ts new file mode 100644 index 00000000000..84136362bc4 --- /dev/null +++ b/apps/desktop/src/renderer/stores/vibrancy/index.ts @@ -0,0 +1,2 @@ +export type { VibrancyBlurLevel, VibrancyState } from "shared/vibrancy-types"; +export { useEffectiveTerminalTheme, useVibrancyStore } from "./store"; diff --git a/apps/desktop/src/renderer/stores/vibrancy/store.ts b/apps/desktop/src/renderer/stores/vibrancy/store.ts new file mode 100644 index 00000000000..1cb55d6d9d0 --- /dev/null +++ b/apps/desktop/src/renderer/stores/vibrancy/store.ts @@ -0,0 +1,236 @@ +import type { ITheme } from "@xterm/xterm"; +import { useMemo } from "react"; +import type { Theme } from "shared/themes"; +import { + DEFAULT_VIBRANCY_STATE, + type VibrancyState, +} from "shared/vibrancy-types"; +import { create } from "zustand"; +import { electronTrpcClient } from "../../lib/trpc-client"; +import { useThemeStore } from "../theme"; +import { applyUIColors } from "../theme/utils"; + +interface VibrancyStore extends VibrancyState { + supported: boolean; + nativeBlurSupported: boolean; + hydrated: boolean; + setState: (partial: Partial) => Promise; + previewOpacity: (opacity: number) => void; + hydrate: () => Promise; +} + +function clampAlpha(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function toAlphaColor(base: string, alpha: number): string { + const pct = Math.max(0, Math.min(100, alpha * 100)); + // color-mix works with hex, rgb, oklch, etc. — Chromium 111+. + return `color-mix(in srgb, ${base} ${pct.toFixed(2)}%, transparent)`; +} + +function hexToRgba(hex: string, alpha: number): string | null { + const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim()); + if (!match) return null; + const digits = match[1]; + if (!digits) return null; + let r: number; + let g: number; + let b: number; + if (digits.length === 3) { + r = Number.parseInt(`${digits[0]}${digits[0]}`, 16); + g = Number.parseInt(`${digits[1]}${digits[1]}`, 16); + b = Number.parseInt(`${digits[2]}${digits[2]}`, 16); + } else { + r = Number.parseInt(digits.slice(0, 2), 16); + g = Number.parseInt(digits.slice(2, 4), 16); + b = Number.parseInt(digits.slice(4, 6), 16); + } + return `rgba(${r}, ${g}, ${b}, ${clampAlpha(alpha).toFixed(3)})`; +} + +/** + * Hook consumed by the terminal pane. Returns the theme as-is when + * vibrancy is off, and a translucent-background variant when vibrancy + * is on so the xterm canvas blends into the window's vibrancy layer. + */ +export function useEffectiveTerminalTheme(): ITheme | null { + const base = useThemeStore((s) => s.terminalTheme); + const enabled = useVibrancyStore((s) => s.enabled); + const opacity = useVibrancyStore((s) => s.opacity); + // Memoize so the returned object is stable across unrelated renders. + // Terminal.tsx applies the theme inside an effect keyed on the theme + // identity, so returning a fresh `{ ...base, background: rgba }` on + // every render would force xterm to reconfigure itself on each tick. + return useMemo(() => { + if (!base || !enabled) return base; + const bg = base.background; + if (typeof bg !== "string") return base; + const rgba = hexToRgba(bg, opacity / 100); + if (!rgba) return base; + return { ...base, background: rgba }; + }, [base, enabled, opacity]); +} + +/** + * Overlay the translucent color variables inline so they win the cascade + * against the theme store's own inline writes (applyUIColors). Without this + * overlay, toggling vibrancy does nothing visible because the theme store + * already pinned the solid colors to `documentElement.style`. + */ +function applyVibrancyOverlay(theme: Theme, alpha: number): void { + const root = document.documentElement; + const set = (cssVar: string, base: string | undefined, a: number): void => { + if (!base) return; + root.style.setProperty(cssVar, toAlphaColor(base, clampAlpha(a))); + }; + + // The main --background is intentionally set to `transparent` (not + // rgba with low alpha). The window itself is already tinted via + // BrowserWindow.setBackgroundColor(rgba) on top of the NSVisualEffectView, + // so if we made --background semi-transparent as well, every nested + // `bg-background` container would multiply the tint and create visibly + // darker rectangles where the UI stacks panes inside each other. + // Letting the web content be fully transparent means the window + // chrome is the single source of truth for the base color. + root.style.setProperty("--background", "transparent"); + + // Chrome surfaces keep a tint so they stand out from the transparent + // body. We bias them slightly more opaque than the raw alpha so + // sidebars/cards are still legible at low opacity settings. + const chromeAlpha = clampAlpha(alpha + 0.15); + set("--card", theme.ui.card, chromeAlpha); + set("--muted", theme.ui.muted, chromeAlpha); + set("--accent", theme.ui.accent, chromeAlpha); + set("--sidebar", theme.ui.sidebar, chromeAlpha); + set("--sidebar-accent", theme.ui.sidebarAccent, chromeAlpha); + set("--tertiary", theme.ui.tertiary, chromeAlpha); + set("--tertiary-active", theme.ui.tertiaryActive, chromeAlpha); + if (theme.ui.popover) { + // Popovers / menus stay near-opaque so text remains readable. + root.style.setProperty("--popover", toAlphaColor(theme.ui.popover, 0.95)); + } +} + +function applyToDom(state: VibrancyState): void { + if (typeof document === "undefined") return; + const root = document.documentElement; + root.dataset.vibrancy = state.enabled ? "on" : "off"; + root.style.setProperty("--vibrancy-alpha", (state.opacity / 100).toFixed(3)); + + const activeTheme = useThemeStore.getState().activeTheme; + if (!activeTheme) return; + + if (state.enabled) { + applyVibrancyOverlay(activeTheme, state.opacity / 100); + } else { + // Restore solid theme colors by reapplying the theme's own palette. + applyUIColors(activeTheme.ui); + } +} + +let hydratePromise: Promise | null = null; +let subscriptionEstablished = false; +let themeSubscriptionEstablished = false; + +function ensureThemeSubscription(): void { + if (themeSubscriptionEstablished) return; + themeSubscriptionEstablished = true; + // When the user changes theme while vibrancy is on, the theme store + // reapplies solid colors and wipes our overlay — reapply it here. + useThemeStore.subscribe((themeState, prevThemeState) => { + if (themeState.activeTheme === prevThemeState.activeTheme) return; + const vibrancy = useVibrancyStore.getState(); + if (vibrancy.supported && vibrancy.enabled) { + applyToDom(vibrancy); + } + }); +} + +export const useVibrancyStore = create()((set, get) => ({ + ...DEFAULT_VIBRANCY_STATE, + supported: false, + nativeBlurSupported: false, + hydrated: false, + + hydrate: async () => { + // Guard against StrictMode double-invocation and concurrent callers by + // caching the in-flight promise rather than relying on post-await state. + if (get().hydrated) return; + if (hydratePromise) return hydratePromise; + + hydratePromise = (async () => { + try { + const [current, supportInfo] = await Promise.all([ + electronTrpcClient.vibrancy.get.query(), + electronTrpcClient.vibrancy.getSupported.query(), + ]); + // Coerce to disabled on unsupported platforms so a state imported + // from macOS (enabled: true) never leaks into the DOM on Win/Linux. + const effective: VibrancyState = supportInfo.supported + ? current + : { ...current, enabled: false }; + applyToDom(effective); + set({ + ...effective, + supported: supportInfo.supported, + nativeBlurSupported: supportInfo.nativeBlurSupported, + hydrated: true, + }); + ensureThemeSubscription(); + + if (!subscriptionEstablished) { + subscriptionEstablished = true; + electronTrpcClient.vibrancy.onChanged.subscribe(undefined, { + onData: (incoming) => { + const isSupported = get().supported; + const effectiveIncoming: VibrancyState = isSupported + ? incoming + : { ...incoming, enabled: false }; + applyToDom(effectiveIncoming); + set(effectiveIncoming); + }, + onError: (err) => { + console.error("[vibrancy] subscription error:", err); + subscriptionEstablished = false; + }, + }); + } + } catch (error) { + console.error("[vibrancy] Failed to hydrate store:", error); + applyToDom(DEFAULT_VIBRANCY_STATE); + // Allow retry on transient failures. + hydratePromise = null; + } + })(); + + return hydratePromise; + }, + + previewOpacity: (opacity) => { + const current = get(); + if (!current.supported || !current.enabled) return; + applyToDom({ ...current, opacity }); + }, + + setState: async (partial) => { + const current = get(); + const optimistic: VibrancyState = { + enabled: partial.enabled ?? current.enabled, + opacity: partial.opacity ?? current.opacity, + blurLevel: partial.blurLevel ?? current.blurLevel, + blurRadius: partial.blurRadius ?? current.blurRadius, + }; + applyToDom(optimistic); + set(optimistic); + try { + const confirmed = await electronTrpcClient.vibrancy.set.mutate(partial); + applyToDom(confirmed); + set(confirmed); + } catch (error) { + console.error("[vibrancy] Failed to persist state:", error); + applyToDom(current); + set(current); + } + }, +})); diff --git a/apps/desktop/src/shared/vibrancy-types.ts b/apps/desktop/src/shared/vibrancy-types.ts new file mode 100644 index 00000000000..ffa0d6a7f3c --- /dev/null +++ b/apps/desktop/src/shared/vibrancy-types.ts @@ -0,0 +1,34 @@ +/** + * Pure types and constants for the vibrancy feature. Must stay Electron-free + * so it can be imported from both main and renderer (via `shared/`) without + * pulling the Electron runtime into renderer bundles or test harnesses. + */ + +export type VibrancyBlurLevel = "subtle" | "standard" | "strong" | "ultra"; + +export interface VibrancyState { + enabled: boolean; + opacity: number; + blurLevel: VibrancyBlurLevel; + /** + * Continuous Gaussian blur radius (0-100) used by the native + * macos-window-blur addon. When the addon is unavailable this + * value is still persisted but the main process falls back to + * mapping it onto the four discrete NSVisualEffectView materials + * in `blurLevel`. + */ + blurRadius: number; +} + +export const DEFAULT_VIBRANCY_STATE: VibrancyState = { + enabled: false, + opacity: 35, + blurLevel: "standard", + blurRadius: 40, +}; + +/** Lower bound matched by the settings slider. Keep in sync with UI. */ +export const VIBRANCY_OPACITY_MIN = 10; +export const VIBRANCY_OPACITY_MAX = 100; +export const VIBRANCY_BLUR_RADIUS_MIN = 0; +export const VIBRANCY_BLUR_RADIUS_MAX = 100; diff --git a/bun.lock b/bun.lock index 9c8e506cc00..584e8abedce 100644 --- a/bun.lock +++ b/bun.lock @@ -163,6 +163,7 @@ "@superset/host-service": "workspace:*", "@superset/local-db": "workspace:*", "@superset/macos-process-metrics": "workspace:*", + "@superset/macos-window-blur": "workspace:*", "@superset/panes": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", @@ -839,6 +840,13 @@ "node-addon-api": "^7.1.0", }, }, + "packages/macos-window-blur": { + "name": "@superset/macos-window-blur", + "version": "0.0.1", + "dependencies": { + "node-addon-api": "^7.1.0", + }, + }, "packages/mcp": { "name": "@superset/mcp", "version": "0.1.0", @@ -2521,6 +2529,8 @@ "@superset/macos-process-metrics": ["@superset/macos-process-metrics@workspace:packages/macos-process-metrics"], + "@superset/macos-window-blur": ["@superset/macos-window-blur@workspace:packages/macos-window-blur"], + "@superset/marketing": ["@superset/marketing@workspace:apps/marketing"], "@superset/mcp": ["@superset/mcp@workspace:packages/mcp"], diff --git a/packages/macos-window-blur/.gitignore b/packages/macos-window-blur/.gitignore new file mode 100644 index 00000000000..567609b1234 --- /dev/null +++ b/packages/macos-window-blur/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/packages/macos-window-blur/binding.gyp b/packages/macos-window-blur/binding.gyp new file mode 100644 index 00000000000..d82ca2bd1b4 --- /dev/null +++ b/packages/macos-window-blur/binding.gyp @@ -0,0 +1,28 @@ +{ + "targets": [ + { + "target_name": "macos_window_blur", + "sources": ["src/addon.mm"], + "include_dirs": [ + " + +#ifdef __APPLE__ +#import +#import +#import + +static const void* kOriginalBlurRadiusKey = &kOriginalBlurRadiusKey; + +/** + * Walk the view hierarchy rooted at `root` looking for an NSVisualEffectView. + * Electron inserts one of these to implement BrowserWindow's `vibrancy` + * option and it owns the backdrop layer we need to mutate. + */ +static NSVisualEffectView* FindVisualEffectView(NSView* root) { + if (!root) return nil; + if ([root isKindOfClass:[NSVisualEffectView class]]) { + return (NSVisualEffectView*)root; + } + for (NSView* child in root.subviews) { + NSVisualEffectView* found = FindVisualEffectView(child); + if (found) return found; + } + return nil; +} + +/** + * Recursively look for a CALayer whose class name matches `className`. + * The real CABackdropLayer that does the blur may be nested several + * sublayers deep inside the NSVisualEffectView's own layer hierarchy, + * so we can't just check `vev.layer` directly. + */ +static CALayer* FindLayerByClassName(CALayer* root, NSString* className) { + if (!root) return nil; + if ([NSStringFromClass([root class]) isEqualToString:className]) { + return root; + } + Class target = NSClassFromString(className); + if (target && [root isKindOfClass:target]) { + return root; + } + for (CALayer* sublayer in root.sublayers) { + CALayer* found = FindLayerByClassName(sublayer, className); + if (found) return found; + } + return nil; +} + +static id FindBackdropFilter(NSArray* filters, NSString* wantedType) { + for (id filter in filters) { + NSString* name = nil; + NSString* type = nil; + @try { + name = [filter valueForKey:@"name"]; + } @catch (NSException*) {} + @try { + type = [filter valueForKey:@"type"]; + } @catch (NSException*) {} + if ([name isEqualToString:wantedType] || + [type isEqualToString:wantedType]) { + return filter; + } + } + return nil; +} + +static void ApplyBlurRadiusToBackdrop(CALayer* backdrop, double radius) { + // Remember the system-provided default so the caller can restore it + // later by passing radius <= 0. + NSNumber* stored = + objc_getAssociatedObject(backdrop, kOriginalBlurRadiusKey); + if (!stored) { + id existing = FindBackdropFilter(backdrop.filters, @"gaussianBlur"); + double initial = 0.0; + if (existing) { + @try { + initial = [[existing valueForKey:@"inputRadius"] doubleValue]; + } @catch (NSException*) {} + } + objc_setAssociatedObject( + backdrop, + kOriginalBlurRadiusKey, + @(initial > 0.0 ? initial : 30.0), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + stored = objc_getAssociatedObject(backdrop, kOriginalBlurRadiusKey); + } + + double effective = radius <= 0.0 ? stored.doubleValue : radius; + + // Mutating an existing CAFilter's inputRadius in place is accepted by + // the setter but Core Animation does not observe property changes on + // the filter object, so the layer never re-renders. Replace the + // existing gaussianBlur with a brand-new CAFilter instance and + // reassign `backdrop.filters`, which does fire the layer's property + // observer and schedules a display pass. + Class cls = NSClassFromString(@"CAFilter"); + if (!cls || ![cls respondsToSelector:@selector(filterWithType:)]) return; + id replacement = [cls performSelector:@selector(filterWithType:) + withObject:@"gaussianBlur"]; + if (!replacement) return; + @try { + [replacement setValue:@"gaussianBlur" forKey:@"name"]; + } @catch (NSException*) {} + @try { + [replacement setValue:@YES forKey:@"inputNormalizeEdges"]; + } @catch (NSException*) {} + [replacement setValue:@(effective) forKey:@"inputRadius"]; + + NSMutableArray* next = + [NSMutableArray arrayWithArray:backdrop.filters ?: @[]]; + BOOL replaced = NO; + for (NSUInteger i = 0; i < next.count; i++) { + id entry = next[i]; + NSString* name = nil; + NSString* type = nil; + @try { + name = [entry valueForKey:@"name"]; + } @catch (NSException*) {} + @try { + type = [entry valueForKey:@"type"]; + } @catch (NSException*) {} + if ([name isEqualToString:@"gaussianBlur"] || + [type isEqualToString:@"gaussianBlur"]) { + next[i] = replacement; + replaced = YES; + break; + } + } + if (!replaced) { + [next addObject:replacement]; + } + backdrop.filters = next; + [backdrop setNeedsDisplay]; +} + +static NSWindow* WindowFromNativeHandle(const Napi::Buffer& handle) { + if (handle.Length() != sizeof(void*)) return nil; + void* raw = *reinterpret_cast(handle.Data()); + if (!raw) return nil; + id obj = (__bridge id)raw; + if ([obj isKindOfClass:[NSWindow class]]) { + return (NSWindow*)obj; + } + if ([obj isKindOfClass:[NSView class]]) { + return ((NSView*)obj).window; + } + return nil; +} +#endif + +/** + * setWindowBlurRadius(handle: Buffer, radius: number): boolean + * + * Walks into the NSVisualEffectView that Electron created for the window, + * finds its private CABackdropLayer, and rewrites the `gaussianBlur` + * CAFilter in place with the requested radius. Passing `radius <= 0` + * restores the original system-provided radius so the material looks + * normal again. + */ +Napi::Value SetWindowBlurRadius(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + if (info.Length() < 2 || !info[0].IsBuffer() || !info[1].IsNumber()) { + Napi::TypeError::New(env, + "Expected (handle: Buffer, radius: number)") + .ThrowAsJavaScriptException(); + return env.Null(); + } + +#ifdef __APPLE__ + auto handle = info[0].As>(); + double radius = info[1].As().DoubleValue(); + if (radius < 0) radius = 0; + if (radius > 200) radius = 200; + + __block bool success = false; + dispatch_block_t work = ^{ + NSWindow* window = WindowFromNativeHandle(handle); + if (!window) return; + NSView* contentView = window.contentView; + if (!contentView) return; + NSVisualEffectView* vev = FindVisualEffectView(contentView); + if (!vev) return; + [contentView layoutSubtreeIfNeeded]; + [vev layoutSubtreeIfNeeded]; + vev.wantsLayer = YES; + CALayer* backdrop = FindLayerByClassName(vev.layer, @"CABackdropLayer"); + if (!backdrop) return; + + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + ApplyBlurRadiusToBackdrop(backdrop, radius); + [backdrop setNeedsDisplay]; + [backdrop setNeedsLayout]; + [CATransaction commit]; + [CATransaction flush]; + // Force a synchronous display pass so the new filter value is + // picked up immediately rather than waiting for the next vsync. + @try { + [backdrop displayIfNeeded]; + } @catch (NSException*) {} + success = true; + }; + if ([NSThread isMainThread]) { + work(); + } else { + dispatch_sync(dispatch_get_main_queue(), work); + } + return Napi::Boolean::New(env, success); +#else + return Napi::Boolean::New(env, false); +#endif +} + +/** + * isSupported(): boolean — returns true on macOS builds where the native + * code was compiled, false otherwise. + */ +Napi::Value IsSupported(const Napi::CallbackInfo& info) { +#ifdef __APPLE__ + return Napi::Boolean::New(info.Env(), true); +#else + return Napi::Boolean::New(info.Env(), false); +#endif +} + +Napi::Object Init(Napi::Env env, Napi::Object exports) { + exports.Set("setWindowBlurRadius", + Napi::Function::New(env, SetWindowBlurRadius)); + exports.Set("isSupported", Napi::Function::New(env, IsSupported)); + return exports; +} + +NODE_API_MODULE(macos_window_blur, Init)