дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
Adds Solarized Light, Solarized Dark, One Light and One Dark as built-in themes across the workbench, editor, terminal and Mermaid, keeping the original palettes (MIT notices in THIRD_PARTY_LICENSES.md, mapping in docs/themes.md). - Native window colours are read from tokens.css at build time (`theme_frame.rs`) instead of a second colour table. - Startup restores the built-in palette before first paint. - Editor schemes, the terminal and Mermaid refresh on switches between themes of the same kind. - The plugin packages of the same themes are removed. - A test requires every built-in palette theme to define each token (only listed ones may inherit from the base theme), so new tokens can't silently miss them. Co-authored-by: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
202 рядки
7.8 KiB
TypeScript
202 рядки
7.8 KiB
TypeScript
/** Applies `data-theme` and the accent colour from appearance settings. */
|
||
import { useEffect, useSyncExternalStore } from "react";
|
||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||
import { create } from "zustand";
|
||
import { call, commands, isTauri } from "@/platform/ipc";
|
||
import { useProject } from "@/platform/project";
|
||
import { BUILTIN_THEMES, isBuiltinTheme, themeKind, type BuiltinThemeId } from "@/platform/themes";
|
||
import { resolveTokenColor, toHex } from "@/platform/cssColor";
|
||
|
||
export type ThemeSetting = BuiltinThemeId | "system";
|
||
/** PyCharm's brand blue: the accent both Islands themes ship with. */
|
||
export const DEFAULT_ACCENT = "#3871e1";
|
||
/** Accent presets (Settings › Appearance, Welcome › Customize); the first is the Islands default. */
|
||
export const ACCENT_PRESETS = [
|
||
{ value: DEFAULT_ACCENT, label: "Blue" },
|
||
{ value: "#24a394", label: "Teal" },
|
||
{ value: "#955ae0", label: "Purple" },
|
||
{ value: "#e08855", label: "Orange" },
|
||
] as const;
|
||
/** Accents that mean "use the theme's own" (the current default and the pre-Islands one). */
|
||
const THEME_ACCENTS = new Set([DEFAULT_ACCENT, "#8fa5d6"]);
|
||
|
||
/** Session override used when settings can't be persisted (no backend). */
|
||
export const useThemeOverride = create<{ theme: ThemeSetting | null }>(() => ({ theme: null }));
|
||
|
||
const media = () =>
|
||
typeof window !== "undefined" && window.matchMedia ? window.matchMedia("(prefers-color-scheme: light)") : null;
|
||
|
||
function subscribeScheme(cb: () => void) {
|
||
const m = media();
|
||
m?.addEventListener?.("change", cb);
|
||
return () => m?.removeEventListener?.("change", cb);
|
||
}
|
||
|
||
const prefersLight = () => !!media()?.matches;
|
||
|
||
interface AppearanceLike {
|
||
theme?: string;
|
||
syncWithOs?: boolean;
|
||
preferredLightTheme?: string;
|
||
preferredDarkTheme?: string;
|
||
}
|
||
|
||
/**
|
||
* Concrete theme id: the chosen theme, or with "Sync with OS" (legacy
|
||
* `theme = "system"`) the preferred light / dark theme for the OS mode.
|
||
*/
|
||
export function resolveThemeId(a: AppearanceLike | undefined, systemLight: boolean): BuiltinThemeId {
|
||
const concrete = (v: string | undefined, d: "dark" | "light") => (isBuiltinTheme(v) ? v : d);
|
||
if (a?.syncWithOs || a?.theme === "system")
|
||
return systemLight ? concrete(a?.preferredLightTheme, "light") : concrete(a?.preferredDarkTheme, "dark");
|
||
return concrete(a?.theme, "light");
|
||
}
|
||
|
||
/** `dark-hc` resolves to `dark` plus `data-contrast="high"`. */
|
||
export function resolveTheme(setting: string | undefined, systemLight: boolean): "dark" | "light" {
|
||
return themeKind(resolveThemeId({ theme: setting }, systemLight));
|
||
}
|
||
|
||
const ACCENT_RE = /^#[0-9a-f]{3,8}$/i;
|
||
|
||
export function useApplyTheme() {
|
||
const appearance = useProject((s) => s.settings?.appearance);
|
||
const override = useThemeOverride((s) => s.theme);
|
||
const systemLight = useSyncExternalStore(subscribeScheme, prefersLight, () => false);
|
||
const id = override
|
||
? resolveThemeId({ theme: override }, systemLight)
|
||
: resolveThemeId(appearance as AppearanceLike | undefined, systemLight);
|
||
const accent = appearance?.accent;
|
||
|
||
// Until settings arrive, keep the theme public/boot.js restored rather than the light default.
|
||
const pending = !override && !appearance;
|
||
useEffect(() => {
|
||
const root = document.documentElement;
|
||
if (pending && root.dataset.theme) return;
|
||
applyThemeAttributes(id, root);
|
||
// Each theme ships its own accent; only a deliberate choice overrides it.
|
||
const custom = !!accent && ACCENT_RE.test(accent) && !THEME_ACCENTS.has(accent.toLowerCase());
|
||
if (custom) root.style.setProperty("--accent", accent);
|
||
else root.style.removeProperty("--accent");
|
||
syncWindowBackground();
|
||
}, [pending, id, accent]);
|
||
}
|
||
|
||
/**
|
||
* Sets `data-theme` / `data-contrast` / `data-color-theme` (the palette themes
|
||
* over a light / dark base, tokens.css) for a built-in theme id. While a plugin
|
||
* theme is active (`data-plugin-theme-kind`), its light / dark base wins.
|
||
*/
|
||
export function applyThemeAttributes(id: BuiltinThemeId, root: HTMLElement = document.documentElement) {
|
||
const pluginKind = root.dataset.pluginThemeKind;
|
||
if (pluginKind === "light" || pluginKind === "dark") {
|
||
delete root.dataset.colorTheme;
|
||
root.dataset.theme = pluginKind;
|
||
delete root.dataset.contrast;
|
||
return;
|
||
}
|
||
if (id === "light" || id === "dark" || id === "dark-hc") delete root.dataset.colorTheme;
|
||
else root.dataset.colorTheme = id;
|
||
root.dataset.theme = themeKind(id);
|
||
if (id === "dark-hc") root.dataset.contrast = "high";
|
||
else delete root.dataset.contrast;
|
||
}
|
||
|
||
/**
|
||
* The frame colour as Tauri's `[r, g, b, a]` (0–255). Tauri parses neither
|
||
* `hsl()` nor `color-mix()`, which themes may use, so it is resolved here.
|
||
*/
|
||
export function windowBackgroundColor(
|
||
root: HTMLElement = document.documentElement,
|
||
): [number, number, number, number] | null {
|
||
const c = resolveTokenColor("--bg-frame", root);
|
||
if (!c) return null;
|
||
const [r, g, b, a] = c;
|
||
return [Math.round(r), Math.round(g), Math.round(b), Math.round(a * 255)];
|
||
}
|
||
|
||
/** The frame colour as `#rrggbb[aa]`, for native window commands that take a string. */
|
||
export function windowBackgroundHex(root: HTMLElement = document.documentElement): string | null {
|
||
const c = resolveTokenColor("--bg-frame", root);
|
||
return c ? toHex(c) : null;
|
||
}
|
||
|
||
/** localStorage key read by public/boot.js. */
|
||
export const STARTUP_THEME_KEY = "openidea.theme";
|
||
|
||
/**
|
||
* Remembers the theme for public/boot.js, which applies it to the next window
|
||
* before any stylesheet loads: the light / dark base, and the resolved frame,
|
||
* text and accent colours its splash is painted in.
|
||
*/
|
||
export function rememberStartupTheme(root: HTMLElement = document.documentElement) {
|
||
const hex = (name: string) => {
|
||
const c = resolveTokenColor(name, root);
|
||
return c ? toHex(c) : undefined;
|
||
};
|
||
try {
|
||
localStorage.setItem(
|
||
STARTUP_THEME_KEY,
|
||
JSON.stringify({
|
||
theme: root.dataset.theme,
|
||
contrast: root.dataset.contrast,
|
||
colorTheme: root.dataset.colorTheme,
|
||
frame: hex("--bg-frame"),
|
||
fg: hex("--fg-muted"),
|
||
accent: hex("--accent"),
|
||
}),
|
||
);
|
||
} catch {
|
||
/* storage unavailable */
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Where a live resize outruns the page, the native window and the webview (WebView2's default
|
||
* background) show their own colours; match both to the frame. The webview one is only set at
|
||
* window creation otherwise, so a theme switched later left it light. Also remembers the theme
|
||
* for the next window's start-up.
|
||
*/
|
||
export function syncWindowBackground() {
|
||
rememberStartupTheme();
|
||
if (!isTauri()) return;
|
||
const color = windowBackgroundColor();
|
||
if (!color) return;
|
||
getCurrentWebviewWindow()
|
||
.setBackgroundColor(color)
|
||
.catch(() => {});
|
||
}
|
||
|
||
export function currentTheme(): "dark" | "light" {
|
||
return themeKind(currentThemeId());
|
||
}
|
||
|
||
/** Themes offered by quick switch menus, in IDEA's order. */
|
||
export const THEME_CHOICES = BUILTIN_THEMES;
|
||
|
||
/** The concrete theme in effect (`dark-hc` kept distinct). */
|
||
export function currentThemeId(): BuiltinThemeId {
|
||
const override = useThemeOverride.getState().theme;
|
||
return override
|
||
? resolveThemeId({ theme: override }, prefersLight())
|
||
: resolveThemeId(useProject.getState().settings?.appearance as AppearanceLike | undefined, prefersLight());
|
||
}
|
||
|
||
export async function setTheme(next: ThemeSetting) {
|
||
useThemeOverride.setState({ theme: next });
|
||
if (!isTauri()) return;
|
||
try {
|
||
let settings = await call(commands.settingsSet("global", "appearance.theme", JSON.stringify(next)));
|
||
// An explicit choice ends "Sync with OS", as in IDEA.
|
||
if (settings.appearance?.syncWithOs) settings = await call(commands.settingsSet("global", "appearance.syncWithOs", "false"));
|
||
useProject.setState({ settings });
|
||
useThemeOverride.setState({ theme: null });
|
||
} catch {
|
||
/* keep the session override */
|
||
}
|
||
}
|
||
|
||
export async function toggleTheme() {
|
||
await setTheme(currentTheme() === "dark" ? "light" : "dark");
|
||
}
|