Compare commits

...
Author SHA1 Message Date
l0ng-ai 32bfbc3a78 fix(theme): switch themes without flicker, preview them at once, drop the built-in One themes
Switching a theme with several windows open flickered for seconds: the plugin theme is shared by every window, and each window put back the theme its own settings named whenever it changed, before those settings caught up. Windows now act on the plugin theme only when their own selection changes, and applying the theme already shown keeps its stylesheet instead of replacing it.

Theme previews in the gear menu, Quick Switch and Settings waited 500 ms, and on themes written with hsl() or color-mix() the terminal took up to a second to read its colours: each token was resolved through a new element inserted under the root, restyling the whole page every time. Previews now show at once, and token colours resolve through one element kept for it.

The built-in One Light and One Dark themes are removed in favour of the One Dark Pro plugin; settings naming them read as Islands Light and Islands Dark.
2026-11-12 22:41:36 +08:00
33 changed files with 272 additions and 281 deletions

No files matched your search

+32
View File
@@ -194,6 +194,19 @@ where
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
}
/// A theme id, with the built-in themes that were removed read as the Islands theme of their kind.
fn theme_id<'de, D>(d: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let id = String::deserialize(d)?;
Ok(match id.as_str() {
"one-light" => "light".into(),
"one-dark" => "dark".into(),
_ => id,
})
}
/// [`AgentSettings::defaults`], keeping only the text values of each agent's table.
fn agent_defaults<'de, D>(d: D) -> Result<BTreeMap<String, BTreeMap<String, String>>, D::Error>
where
@@ -557,13 +570,19 @@ pub enum NavigationBarPosition {
pub struct AppearanceSettings {
/// A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
/// "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
#[serde(deserialize_with = "theme_id")]
#[specta(type = String)]
pub theme: String,
pub accent: String,
pub ui_font_size: f32,
pub locale: String,
/// Follow the OS light/dark mode with the preferred themes below.
pub sync_with_os: bool,
#[serde(deserialize_with = "theme_id")]
#[specta(type = String)]
pub preferred_light_theme: String,
#[serde(deserialize_with = "theme_id")]
#[specta(type = String)]
pub preferred_dark_theme: String,
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
pub file_icon_theme: String,
@@ -1424,6 +1443,19 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn removed_one_themes_read_as_islands_themes() {
let settings: Settings = serde_json::from_str(
r#"{"appearance":{"theme":"one-dark","preferredLightTheme":"one-light","preferredDarkTheme":"one-dark"}}"#,
)
.unwrap();
let a = settings.appearance;
assert_eq!((a.theme.as_str(), a.preferred_light_theme.as_str()), ("dark", "light"));
assert_eq!(a.preferred_dark_theme, "dark");
let kept: Settings = serde_json::from_str(r#"{"appearance":{"theme":"solarized-dark"}}"#).unwrap();
assert_eq!(kept.appearance.theme, "solarized-dark");
}
#[test]
fn window_close_settings_default_off_and_round_trip() {
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
-1
View File
@@ -11,7 +11,6 @@ Open **Settings › Appearance & Behavior › Appearance** and pick a **Theme**.
| ----------------------- | -------------- |
| Islands Light (default) | Islands Dark |
| Solarized Light | Solarized Dark |
| One Light | One Dark |
| | High Contrast |
Themes from installed plugins appear in the same list, under the plugin's name.
+2 -3
View File
@@ -3,9 +3,8 @@ title: "Themes"
description: "Write a colour theme plugin: the theme and editor scheme files, how themes are applied, importing IntelliJ themes, porting VS Code themes, and the full token reference."
---
OpenIDEA ships seven built-in themes: Islands Light, Islands Dark, High Contrast,
Solarized Light, Solarized Dark, One Light and One Dark. Other themes come from **theme
plugins**.
OpenIDEA ships five built-in themes: Islands Light, Islands Dark, High Contrast,
Solarized Light and Solarized Dark. Other themes come from **theme plugins**.
A theme plugin is data only. Like an IntelliJ theme (a `.theme.json` plus an optional
`.icls` editor scheme), it has no code: no WebAssembly, nothing to compile, no
+19 -19
View File
@@ -94,25 +94,25 @@ Settings › Appearance & Behavior › System Settings, unless noted.
Settings › Appearance & Behavior › Appearance, unless noted.
| Key | Default | Description |
| -------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `theme` | `"light"` | `light` (Islands Light), `dark` (Islands Dark), `dark-hc` (High Contrast), `solarized-light`, `solarized-dark`, `one-light`, `one-dark`, or a plugin theme as `plugin:<plugin-id>/<theme-id>`. |
| `syncWithOs` | `false` | **Sync with OS**. |
| `preferredLightTheme` / `preferredDarkTheme` | `"light"` / `"dark"` | Themes used with **Sync with OS**. |
| `accent` | `"#3871e1"` | **Accent color**. |
| `fileIconTheme` | `""` | A plugin's file icon theme as `<plugin-id>/<theme-id>`; empty uses the built-in icons. |
| `zoom` | `100` | **Zoom**, in percent (50 to 300). |
| `useCustomFont` | `false` | **Use custom font:** apply `uiFontFamily` and `uiFontSize`. |
| `uiFontFamily` / `uiFontSize` | `"Inter"` / `13` | UI font. |
| `uiFontFallback` | `""` | **UI fallback font**. |
| `contrastScrollbars` | `false` | **Use contrast scrollbars**. |
| `redGreenVision` | `false` | **Adjust colors for red-green vision deficiency**. |
| `compactMode` | `false` | **Compact mode**. |
| `showMainToolbar` / `showStatusBar` | `true` / `true` | **Show main toolbar**, **Show status bar**. |
| `navigationBar` | `"statusBar"` | **Navigation bar**: `top`, `statusBar` or `hidden`. |
| `mainMenuStyle` | `"hamburger"` | **Main menu**: `hamburger` or `merged` (with the main toolbar). |
| `ideAntialiasing` / `editorAntialiasing` | `"greyscale"` | `subpixel`, `greyscale` or `none`. |
| `locale` | `"en"` | **Language** (Language and Region page): `en`, `zh`, `ko`, `fr`, `pl`, `pt`, `es`. |
| Key | Default | Description |
| -------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `theme` | `"light"` | `light` (Islands Light), `dark` (Islands Dark), `dark-hc` (High Contrast), `solarized-light`, `solarized-dark`, or a plugin theme as `plugin:<plugin-id>/<theme-id>`. |
| `syncWithOs` | `false` | **Sync with OS**. |
| `preferredLightTheme` / `preferredDarkTheme` | `"light"` / `"dark"` | Themes used with **Sync with OS**. |
| `accent` | `"#3871e1"` | **Accent color**. |
| `fileIconTheme` | `""` | A plugin's file icon theme as `<plugin-id>/<theme-id>`; empty uses the built-in icons. |
| `zoom` | `100` | **Zoom**, in percent (50 to 300). |
| `useCustomFont` | `false` | **Use custom font:** apply `uiFontFamily` and `uiFontSize`. |
| `uiFontFamily` / `uiFontSize` | `"Inter"` / `13` | UI font. |
| `uiFontFallback` | `""` | **UI fallback font**. |
| `contrastScrollbars` | `false` | **Use contrast scrollbars**. |
| `redGreenVision` | `false` | **Adjust colors for red-green vision deficiency**. |
| `compactMode` | `false` | **Compact mode**. |
| `showMainToolbar` / `showStatusBar` | `true` / `true` | **Show main toolbar**, **Show status bar**. |
| `navigationBar` | `"statusBar"` | **Navigation bar**: `top`, `statusBar` or `hidden`. |
| `mainMenuStyle` | `"hamburger"` | **Main menu**: `hamburger` or `merged` (with the main toolbar). |
| `ideAntialiasing` / `editorAntialiasing` | `"greyscale"` | `subpixel`, `greyscale` or `none`. |
| `locale` | `"en"` | **Language** (Language and Region page): `en`, `zh`, `ko`, `fr`, `pl`, `pt`, `es`. |
### `[editor]`
-1
View File
@@ -11,7 +11,6 @@ description: "选择界面主题和编辑器配色方案,导入 IntelliJ 主
| -------------------- | -------------- |
| Islands 浅色(默认) | Islands 深色 |
| Solarized 浅色 | Solarized 深色 |
| One 浅色 | One 深色 |
| | 高对比度 |
已安装插件提供的主题也会出现在这个列表中,归在插件名下。
+1 -1
View File
@@ -3,7 +3,7 @@ title: "主题"
description: "编写配色主题插件:主题文件与编辑器配色方案文件、主题如何生效、导入 IntelliJ 主题、移植 VS Code 主题,以及完整的 token 参考。"
---
OpenIDEA 内置七个主题:Islands 浅色、Islands 深色、高对比度、Solarized 浅色、Solarized 深色、One 浅色和 One 深色。
OpenIDEA 内置五个主题:Islands 浅色、Islands 深色、高对比度、Solarized 浅色和 Solarized 深色。
其他主题都来自 **主题插件**。
主题插件是纯数据插件。和 IntelliJ 主题(一个 `.theme.json` 加一个可选的 `.icls` 编辑器配色方案)一样,它没有代码:
+19 -19
View File
@@ -94,25 +94,25 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
除特别说明外,对应 设置 › 外观与行为 › 外观。
| 键 | 默认值 | 说明 |
| -------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `theme` | `"light"` | `light`(Islands 浅色)、`dark`(Islands 深色)、`dark-hc`(高对比度)、`solarized-light`、`solarized-dark`、`one-light`、`one-dark`,或以 `plugin:<plugin-id>/<theme-id>` 表示的插件主题。 |
| `syncWithOs` | `false` | **与操作系统同步**。 |
| `preferredLightTheme` / `preferredDarkTheme` | `"light"` / `"dark"` | 与操作系统同步时使用的主题。 |
| `accent` | `"#3871e1"` | **强调色**。 |
| `fileIconTheme` | `""` | 插件提供的文件图标主题,写作 `<plugin-id>/<theme-id>`;为空时使用内置图标。 |
| `zoom` | `100` | **缩放**,百分比(50 到 300)。 |
| `useCustomFont` | `false` | **使用自定义字体:**,启用 `uiFontFamily` 和 `uiFontSize`。 |
| `uiFontFamily` / `uiFontSize` | `"Inter"` / `13` | 界面字体。 |
| `uiFontFallback` | `""` | **UI 备用字体**。 |
| `contrastScrollbars` | `false` | **使用对比度滚动条**。 |
| `redGreenVision` | `false` | **针对红绿色觉缺陷调整颜色**。 |
| `compactMode` | `false` | **紧凑模式**。 |
| `showMainToolbar` / `showStatusBar` | `true` / `true` | **显示主工具栏**、**显示状态栏**。 |
| `navigationBar` | `"statusBar"` | **导航栏**:`top`、`statusBar` 或 `hidden`。 |
| `mainMenuStyle` | `"hamburger"` | **主菜单**:`hamburger`(汉堡按钮)或 `merged`(与主工具栏合并)。 |
| `ideAntialiasing` / `editorAntialiasing` | `"greyscale"` | `subpixel`、`greyscale` 或 `none`。 |
| `locale` | `"en"` | **语言**(语言和地区页):`en`、`zh`、`ko`、`fr`、`pl`、`pt`、`es`。 |
| 键 | 默认值 | 说明 |
| -------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `theme` | `"light"` | `light`(Islands 浅色)、`dark`(Islands 深色)、`dark-hc`(高对比度)、`solarized-light`、`solarized-dark`,或以 `plugin:<plugin-id>/<theme-id>` 表示的插件主题。 |
| `syncWithOs` | `false` | **与操作系统同步**。 |
| `preferredLightTheme` / `preferredDarkTheme` | `"light"` / `"dark"` | 与操作系统同步时使用的主题。 |
| `accent` | `"#3871e1"` | **强调色**。 |
| `fileIconTheme` | `""` | 插件提供的文件图标主题,写作 `<plugin-id>/<theme-id>`;为空时使用内置图标。 |
| `zoom` | `100` | **缩放**,百分比(50 到 300)。 |
| `useCustomFont` | `false` | **使用自定义字体:**,启用 `uiFontFamily` 和 `uiFontSize`。 |
| `uiFontFamily` / `uiFontSize` | `"Inter"` / `13` | 界面字体。 |
| `uiFontFallback` | `""` | **UI 备用字体**。 |
| `contrastScrollbars` | `false` | **使用对比度滚动条**。 |
| `redGreenVision` | `false` | **针对红绿色觉缺陷调整颜色**。 |
| `compactMode` | `false` | **紧凑模式**。 |
| `showMainToolbar` / `showStatusBar` | `true` / `true` | **显示主工具栏**、**显示状态栏**。 |
| `navigationBar` | `"statusBar"` | **导航栏**:`top`、`statusBar` 或 `hidden`。 |
| `mainMenuStyle` | `"hamburger"` | **主菜单**:`hamburger`(汉堡按钮)或 `merged`(与主工具栏合并)。 |
| `ideAntialiasing` / `editorAntialiasing` | `"greyscale"` | `subpixel`、`greyscale` 或 `none`。 |
| `locale` | `"en"` | **语言**(语言和地区页):`en`、`zh`、`ko`、`fr`、`pl`、`pt`、`es`。 |
### `[editor]`
+2 -2
View File
@@ -51,12 +51,12 @@ test("stripe tooltips stay inside the viewport with a gap on either side and at
test("stripe themes inherit base tooltip tokens and keep shortcut text readable", async ({ workbench: page }) => {
const button = page.locator('[data-tooltip-stripe="left"][data-tooltip-shortcut]').first();
for (const theme of ["dark", "light", "dark-hc", "solarized-light", "solarized-dark", "one-light", "one-dark"]) {
for (const theme of ["dark", "light", "dark-hc", "solarized-light", "solarized-dark"]) {
await page.mouse.move(700, 400);
await page.evaluate((theme) => {
const root = document.documentElement;
root.dataset.theme = theme.endsWith("light") ? "light" : theme === "dark-hc" ? "dark-hc" : "dark";
if (theme.includes("-light") || ["solarized-dark", "one-dark"].includes(theme)) root.dataset.colorTheme = theme;
if (theme.includes("-light") || theme === "solarized-dark") root.dataset.colorTheme = theme;
else delete root.dataset.colorTheme;
}, theme);
await button.hover();
+13 -45
View File
@@ -20,22 +20,6 @@ const palettes = [
fg: "rgb(131, 148, 150)",
frame: [7, 54, 66, 255],
},
{
id: "one-light",
label: "One Light",
kind: "light",
bg: "rgb(250, 250, 250)",
fg: "rgb(56, 58, 66)",
frame: [234, 234, 235, 255],
},
{
id: "one-dark",
label: "One Dark",
kind: "dark",
bg: "rgb(40, 44, 52)",
fg: "rgb(171, 178, 191)",
frame: [33, 37, 43, 255],
},
];
async function choose(page: Page, control: string, label: string) {
@@ -100,15 +84,7 @@ for (const palette of palettes) {
return activeSession().term.options.theme.background;
}),
)
.toBe(
palette.id === "solarized-light"
? "#fdf6e3"
: palette.id === "solarized-dark"
? "#002b36"
: palette.id === "one-light"
? "#fafafa"
: "#282c34",
);
.toBe(palette.id === "solarized-light" ? "#fdf6e3" : "#002b36");
await editor.locator(".cm-content").click();
await page.keyboard.press("Meta+a");
await expect(editor.locator(".cm-selectionBackground:visible").first()).toBeVisible();
@@ -135,15 +111,7 @@ for (const palette of palettes) {
test("same-kind switching, legacy themes, system mode and explicit editor schemes", async ({ workbench: page }) => {
await openFromTree(page, "build.gradle.kts");
const dialog = await settings(page);
for (const label of [
"Solarized Light",
"One Light",
"Solarized Dark",
"One Dark",
"Islands Light",
"Islands Dark",
"High Contrast",
]) {
for (const label of ["Solarized Light", "Solarized Dark", "Islands Light", "Islands Dark", "High Contrast"]) {
await choose(page, "Theme", label);
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
if (label === "High Contrast") await expect(page.locator("html")).toHaveAttribute("data-contrast", "high");
@@ -160,7 +128,7 @@ test("same-kind switching, legacy themes, system mode and explicit editor scheme
: "rgb(0, 0, 0)");
await expect(page.locator(".cm-editor").first()).toHaveCSS("background-color", bg);
}
await choose(page, "Theme", "One Light");
await choose(page, "Theme", "Solarized Light");
await choose(page, "Editor color scheme", "Solarized Dark");
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
await expect(page.locator(".cm-editor").first()).toHaveCSS("background-color", "rgb(0, 43, 54)");
@@ -168,16 +136,16 @@ test("same-kind switching, legacy themes, system mode and explicit editor scheme
await dialog.getByLabel("Sync with OS", { exact: true }).check();
await dialog.getByRole("button", { name: "Preferred themes", exact: true }).click();
await choose(page, "Preferred light theme", "Solarized Light");
await choose(page, "Preferred dark theme", "One Dark");
await choose(page, "Preferred dark theme", "Solarized Dark");
await page.keyboard.press("Escape");
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
await page.emulateMedia({ colorScheme: "light" });
await expect(page.locator("html")).toHaveAttribute("data-color-theme", "solarized-light");
await page.emulateMedia({ colorScheme: "dark" });
await expect(page.locator("html")).toHaveAttribute("data-color-theme", "one-dark");
await expect(page.locator("html")).toHaveAttribute("data-color-theme", "solarized-dark");
});
test("quick theme menu offers all four palettes and applies their kind", async ({ workbench: page }) => {
test("quick theme menu offers both palettes and applies their kind", async ({ workbench: page }) => {
for (const palette of palettes) {
await page.getByRole("button", { name: "IDE and Project Settings", exact: true }).click();
await page.getByText("Theme", { exact: true }).click();
@@ -206,14 +174,14 @@ test("gear › Theme previews the theme under the pointer and keeps the one chos
await gear.click();
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
await theme("One Dark").hover();
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
await theme("One Dark").click();
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
await theme("Solarized Dark").hover();
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
await theme("Solarized Dark").click();
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
// Chosen, not previewed: the next start-up opens with it.
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("openidea.theme") ?? "{}").colorTheme))
.toBe("one-dark");
.toBe("solarized-dark");
});
test("Settings › Appearance › Theme previews the highlighted theme until Cancel", async ({ workbench: page }) => {
@@ -226,8 +194,8 @@ test("Settings › Appearance › Theme previews the highlighted theme until Can
await expect(html).toHaveAttribute("data-theme", "light");
await expect(html).not.toHaveAttribute("data-color-theme");
// Picked: it shows until Apply or Cancel.
await choose(page, "Theme", "One Dark");
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
await choose(page, "Theme", "Solarized Dark");
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(html).toHaveAttribute("data-theme", "light");
await expect(html).not.toHaveAttribute("data-color-theme");
-2
View File
@@ -133,8 +133,6 @@ mod tests {
("dark-hc", Color(0x1a, 0x1b, 0x1e, 0xff)),
("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)),
("solarized-dark", Color(0x07, 0x36, 0x42, 0xff)),
("one-light", Color(0xea, 0xea, 0xeb, 0xff)),
("one-dark", Color(0x21, 0x25, 0x2b, 0xff)),
] {
assert_eq!(builtin_frame(id), Some(color), "{id}");
}
+3 -6
View File
@@ -1803,12 +1803,9 @@ mod tests {
#[test]
fn additional_palettes_keep_native_startup_and_os_preferences_in_sync() {
for (id, color) in [
("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)),
("solarized-dark", Color(0x07, 0x36, 0x42, 0xff)),
("one-light", Color(0xea, 0xea, 0xeb, 0xff)),
("one-dark", Color(0x21, 0x25, 0x2b, 0xff)),
] {
for (id, color) in
[("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)), ("solarized-dark", Color(0x07, 0x36, 0x42, 0xff))]
{
let mut a = AppearanceSettings { theme: id.into(), ..Default::default() };
assert_eq!(frame_color(&a, false), color);
assert_eq!(frame_color(&a, true), color);
+67 -3
View File
@@ -2269,7 +2269,9 @@ export type AppMetrics = {
memory: ProcessMemory[],
};
export type AppearanceSettings = {
export type AppearanceSettings = AppearanceSettings_Serialize | AppearanceSettings_Deserialize;
export type AppearanceSettings_Deserialize = {
/**
* A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
* "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
@@ -2331,6 +2333,68 @@ export type AppearanceSettings = {
mainToolbar?: MainToolbarSettings | null,
};
export type AppearanceSettings_Serialize = {
/**
* A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
* "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
*/
theme: string,
accent: string,
uiFontSize: number | null,
locale: string,
/** Follow the OS light/dark mode with the preferred themes below. */
syncWithOs: boolean,
preferredLightTheme: string,
preferredDarkTheme: string,
/** A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons. */
fileIconTheme: string,
fileIconThemeOptions: { [key in string]: FileIconThemeOptions },
/** IDE zoom in percent (50–300). */
zoom: number,
/** Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). */
useCustomFont: boolean,
uiFontFamily: string,
/** Font tried when `ui_font_family` lacks a glyph; empty = none. */
uiFontFallback: string,
contrastScrollbars: boolean,
/** Adjust colours for red-green vision deficiency. */
redGreenVision: boolean,
differentToolWindowBackground: boolean,
compactMode: boolean,
/** IDEA "Use smaller indents in trees": tool window trees indent less per level. */
smallTreeIndents: boolean,
fullPathInTitle: boolean,
projectColorsInToolbar: boolean,
dndWithAltOnly: boolean,
menuIcons: boolean,
/** `hamburger` | `merged` | `toolbar` (a separate row): main menu presentation in the frameless title bar. */
mainMenuStyle: string,
/** A lone Alt press focuses or opens the main menu (IDEA `ide.windows.main.menu.focus.on.alt`). */
mainMenuAlt: boolean,
/** `subpixel` | `greyscale` | `none` */
ideAntialiasing: string,
editorAntialiasing: string,
showToolWindowBars: boolean,
showToolWindowNames: boolean,
showToolWindowBadges: boolean,
widescreenToolWindows: boolean,
/** View › Appearance › Toolbar; hidden leaves IDEA's compact window header. */
showMainToolbar: boolean,
showStatusBar: boolean,
/**
* View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
* the status bar by default, above the editor, or not at all.
*/
navigationBar: NavigationBarPosition,
/**
* Status bar widgets shown (`true`) or hidden (`false`) by the user
* (status bar context menu); widgets not listed use their default.
*/
statusBarWidgets: { [key in string]: boolean },
/** Customize Main Toolbar; `None` = the default toolbar. */
mainToolbar: MainToolbarSettings | null,
};
export type ApplyOutcome = {
/** Repository-relative paths changed without conflicts. */
applied: string[],
@@ -9067,7 +9131,7 @@ export type SettingsScope = "global" | "project";
export type Settings_Deserialize = {
general?: GeneralSettings,
appearance?: AppearanceSettings,
appearance?: AppearanceSettings_Deserialize,
editor?: EditorSettings_Deserialize,
keymap?: string,
files?: FileSettings,
@@ -9131,7 +9195,7 @@ export type Settings_Deserialize = {
export type Settings_Serialize = {
general: GeneralSettings,
appearance: AppearanceSettings,
appearance: AppearanceSettings_Serialize,
editor: EditorSettings_Serialize,
keymap: string,
files: FileSettings,
@@ -320,6 +320,25 @@ describe("store", () => {
}
});
it("leaves a theme another window selected alone until this window's settings change", async () => {
const stop = [syncProjection(), followThemeSetting()];
try {
useProject.setState({ settings: { appearance: { theme: "plugin:theme-harbor-night/harbor-night" } } as never });
await refreshPlugins();
await waitFor(() => expect(usePlugins.getState().activeTheme?.theme.id).toBe("harbor-night"));
const setTheme = vi.spyOn(backend, "setTheme");
// Another window picked a built-in theme; this window's settings still name the plugin's.
act(() => usePlugins.setState({ activeTheme: null }));
await Promise.resolve();
expect(setTheme).not.toHaveBeenCalled();
act(() => useProject.setState({ settings: { appearance: { theme: "dark" } } as never }));
expect(setTheme).not.toHaveBeenCalled();
} finally {
stop.forEach((s) => s());
useProject.setState({ settings: null as never });
}
});
it("installs from a folder, its plugin.toml, or a package file unpacked first", async () => {
setConsentPrompt(async () => ({ optional: [] }));
for (const [picked, unpacked] of [
@@ -150,4 +150,14 @@ describe("plugin theme at start-up", () => {
expect(setFrame).toHaveBeenCalledTimes(2);
stop();
});
it("keeps the stylesheet when the same theme is applied again", () => {
applyPluginTheme(ONE_DARK);
const style = document.getElementById(PLUGIN_THEME_STYLE_ID);
applyPluginTheme(structuredClone(ONE_DARK));
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)).toBe(style);
applyPluginTheme(SOLARIZED_LIGHT);
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)).not.toBe(style);
expect(document.documentElement.dataset.pluginTheme).toBe("theme-solarized/solarized-light");
});
});
+11 -4
View File
@@ -104,7 +104,12 @@ export function pluginThemeCss(theme: ActiveTheme["theme"]): string {
*/
export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement = document.documentElement) {
const doc = root.ownerDocument;
doc.getElementById(PLUGIN_THEME_STYLE_ID)?.remove();
const current = doc.getElementById(PLUGIN_THEME_STYLE_ID);
const id = theme && `${theme.pluginId}/${theme.theme.id}`;
const css = theme && pluginThemeCss(theme.theme);
// The same theme again (each window hears every selection): replacing its stylesheet restyles the page.
if (theme && current?.textContent === css && root.dataset.pluginTheme === id) return;
current?.remove();
const had = root.dataset.pluginTheme !== undefined;
if (!theme) {
delete root.dataset.pluginTheme;
@@ -113,9 +118,9 @@ export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement =
} else {
const style = doc.createElement("style");
style.id = PLUGIN_THEME_STYLE_ID;
style.textContent = pluginThemeCss(theme.theme);
style.textContent = css;
doc.head.appendChild(style);
root.dataset.pluginTheme = `${theme.pluginId}/${theme.theme.id}`;
root.dataset.pluginTheme = id!;
root.dataset.pluginThemeKind = theme.theme.kind === "light" ? "light" : "dark";
applyThemeAttributes(currentThemeId(), root);
}
@@ -240,8 +245,10 @@ export function followThemeSetting(): () => void {
};
reconcile();
const disposers = [
// Not on the host's theme changing: another window selects it before this one's settings
// say so, and selecting this window's choice back would flip it until they agree.
usePlugins.subscribe((s, prev) => {
if (s.loaded !== prev.loaded || s.activeTheme !== prev.activeTheme) reconcile();
if (s.loaded !== prev.loaded) reconcile();
}),
useProject.subscribe((s, prev) => {
if (s.settings?.appearance !== prev.settings?.appearance) reconcile();
@@ -144,7 +144,7 @@ describe("palette themes", () => {
it("have a tokens.css block for every palette theme id, and no other", () => {
const blocks = [...css.matchAll(/:root\[data-color-theme="([a-z0-9-]+)"\]/g)].map((m) => m[1]);
expect([...new Set(blocks)].sort()).toEqual(palettes.map((t) => t.id).sort());
expect(palettes.length).toBeGreaterThanOrEqual(4);
expect(palettes.length).toBeGreaterThanOrEqual(2);
});
it.each(palettes)("$id defines every token or inherits it from its $kind base on purpose", ({ id, kind }) => {
@@ -2,7 +2,6 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import { applyThemeAttributes, currentThemeId, useThemeOverride } from "@/workbench/theme";
import { THEME_PREVIEW_DELAY } from "@/workbench/quickSwitch";
import { DEFAULT_SETTINGS } from "../model/schema";
import { openSettings, resetPage } from "../model/staging";
import { PageContext } from "../components/context";
@@ -49,25 +48,21 @@ describe("Settings › Appearance › Theme preview", () => {
expect(shown()).toEqual(["dark", "solarized-dark"]);
});
it("goes back to the selected theme when it is picked again before the hover preview shows", () => {
it("goes back to the selected theme when it is picked again", () => {
renderThemeRow();
expect(shown()).toEqual(["light", null]);
pick("Solarized Dark");
expect(shown()).toEqual(["dark", "solarized-dark"]);
// Clicked quicker than the hover delay: the list itself never previewed anything.
act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY / 2));
pick("Islands Light");
act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
expect(screen.getByRole("button", { name: "Theme" })).toHaveTextContent("Islands Light");
expect(shown()).toEqual(["light", null]);
});
it("goes back to the selected theme after hovering it long enough", () => {
it("goes back to the selected theme after hovering it", () => {
renderThemeRow();
pick("Solarized Dark");
fireEvent.click(screen.getByRole("button", { name: "Theme" }));
fireEvent.pointerEnter(within(screen.getByRole("listbox")).getByText("Islands Light"));
act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
fireEvent.click(within(screen.getByRole("listbox")).getByText("Islands Light"));
expect(shown()).toEqual(["light", null]);
});
+2 -7
View File
@@ -7,7 +7,7 @@
import type { ITheme, Terminal } from "@xterm/xterm";
import { fontWeightOf } from "@/platform/editorSettings";
import { familyFaces, fontsLoaded, monoStack, resolveMonoFont } from "@/platform/fonts";
import { parseColor, type Rgba } from "@/platform/cssColor";
import { computedTokenColor, parseColor, type Rgba } from "@/platform/cssColor";
export { parseColor, type Rgba };
@@ -133,12 +133,7 @@ export function documentTokenReader(): TokenReader {
return (name) => {
const raw = style.getPropertyValue(name).trim();
if (!raw || parseColor(raw)) return raw;
const probe = Object.assign(document.createElement("span"), { hidden: true });
probe.style.color = `var(${name})`;
root.appendChild(probe);
const resolved = getComputedStyle(probe).color;
probe.remove();
return resolved;
return computedTokenColor(name, root);
};
}
@@ -264,7 +264,7 @@ describe("mermaid in the markdown preview", () => {
const changed = vi.fn();
const off = subscribeTheme(changed);
try {
root.dataset.colorTheme = "one-dark";
root.dataset.colorTheme = "solarized-light";
const firstKey = themeKey();
await renderMermaidIn(preview(SEQ)).done;
root.dataset.colorTheme = "solarized-dark";
@@ -380,7 +380,8 @@ describe("mermaid in the markdown preview", () => {
}),
}),
);
expect(document.querySelector("span[hidden]")).toBeNull();
// One probe element serves every token.
expect(document.querySelectorAll("span[hidden]")).toHaveLength(1);
} finally {
computed.mockRestore();
}
@@ -488,7 +489,7 @@ describe("mermaid in the markdown preview", () => {
const html = document.documentElement;
const previous = html.dataset.colorTheme;
try {
html.dataset.colorTheme = "one-dark";
html.dataset.colorTheme = "solarized-light";
const root = preview(SEQ);
await renderMermaidIn(root).done;
root.querySelector<HTMLButtonElement>('[data-mermaid-tools] button[aria-label="Full screen"]')!.click();
@@ -510,7 +511,7 @@ describe("mermaid in the markdown preview", () => {
expect(pan.style.transform).toBe(zoomed);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
html.dataset.colorTheme = "one-dark";
html.dataset.colorTheme = "solarized-light";
await renderMermaidIn(root).done;
expect(document.querySelector("[data-mermaid-preview]")).toBeNull();
} finally {
+2 -2
View File
@@ -246,7 +246,7 @@ describe("color schemes", () => {
});
it("switches a chosen scheme of the other kind back to the theme's own when the theme turns light or dark", async () => {
expect(schemeAfterThemeChange("one-dark", "light")).toBe("theme-default");
expect(schemeAfterThemeChange("solarized-dark", "light")).toBe("theme-default");
expect(schemeAfterThemeChange("solarized-light", "light")).toBeNull();
expect(schemeAfterThemeChange("theme-default", "light")).toBeNull();
const customs = { "custom-night": { name: "Night", parent: "dark" as const, attributes: {} } };
@@ -257,7 +257,7 @@ describe("color schemes", () => {
root.dataset.theme = "dark";
const previous = useProject.getState().settings;
const scheme = () => useProject.getState().settings?.editor?.colorScheme;
useProject.setState({ settings: { editor: { colorScheme: "one-dark" } } as unknown as Settings });
useProject.setState({ settings: { editor: { colorScheme: "solarized-dark" } } as unknown as Settings });
const dispose = installColorScheme();
try {
root.dataset.theme = "light";
+20 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseColor, resolveTokenColor, toHex, toRgb } from "./cssColor";
import { computedTokenColor, parseColor, resolveTokenColor, toHex, toRgb } from "./cssColor";
afterEach(() => vi.restoreAllMocks());
@@ -31,7 +31,24 @@ describe("css colours", () => {
expect(resolveTokenColor("--hex", root)).toEqual([7, 54, 66, 1]);
expect(resolveTokenColor("--hsl", root)).toEqual([33, 37, 43, 1]);
expect(resolveTokenColor("--none", root)).toBeNull();
// The probe element is removed again.
expect(root.querySelector("span[hidden]")).toBeNull();
});
it("resolves every token through one probe element, adding none per token", () => {
const root = document.documentElement;
vi.spyOn(globalThis, "getComputedStyle").mockImplementation(
(el) =>
(el === root
? { getPropertyValue: () => "hsl(220, 13%, 15%)" }
: { color: "rgb(33, 37, 43)" }) as unknown as CSSStyleDeclaration,
);
root.querySelector("[data-token-probe]")?.remove();
const added = vi.spyOn(root, "appendChild");
for (const name of ["--a", "--b", "--c"]) expect(resolveTokenColor(name, root)).toEqual([33, 37, 43, 1]);
expect(added).toHaveBeenCalledTimes(1);
expect(root.querySelectorAll("[data-token-probe]")).toHaveLength(1);
// Replaced when something removed it.
root.querySelector("[data-token-probe]")!.remove();
expect(computedTokenColor("--a", root)).toBe("rgb(33, 37, 43)");
expect(root.querySelectorAll("[data-token-probe]")).toHaveLength(1);
});
});
+20 -9
View File
@@ -35,20 +35,31 @@ export function parseColor(input: string): Rgba | null {
return null;
}
const probes = new WeakMap<HTMLElement, HTMLElement>();
/**
* `var(name)` (a `color-mix()`, `hsl()`, …) as the browser computes it under `root`. One hidden
* element per root stays for it: inserting one per token restyled the whole page each time.
*/
export function computedTokenColor(name: string, root: HTMLElement = document.documentElement): string {
let probe = probes.get(root);
if (probe?.parentElement !== root) {
probe = root.ownerDocument.createElement("span");
probe.hidden = true;
probe.dataset.tokenProbe = "";
root.appendChild(probe);
probes.set(root, probe);
}
probe.style.color = `var(${name})`;
return getComputedStyle(probe).color;
}
/** The computed colour of token `name` on `root`, whatever syntax the theme wrote it in. */
export function resolveTokenColor(name: string, root: HTMLElement = document.documentElement): Rgba | null {
if (typeof getComputedStyle === "undefined") return null;
const raw = getComputedStyle(root).getPropertyValue(name).trim();
if (!raw) return null;
const direct = parseColor(raw);
if (direct) return direct;
const probe = root.ownerDocument.createElement("span");
probe.hidden = true;
probe.style.color = `var(${name})`;
root.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return parseColor(color);
return parseColor(raw) ?? parseColor(computedTokenColor(name, root));
}
const byte = (v: number) => Math.min(255, Math.max(0, Math.round(v)));
-2
View File
@@ -3939,8 +3939,6 @@
"Cannot exclude {name}": "No se puede excluir {name}",
"Solarized Light": "Solarized claro",
"Solarized Dark": "Solarized oscuro",
"One Light": "One Light",
"One Dark": "One Dark",
"Copied reference: {reference}": "Referencia copiada: {reference}",
"{count} lines": "{count} líneas",
"Preview of {path}": "Vista previa de {path}",
-2
View File
@@ -3806,8 +3806,6 @@
"Cannot exclude {name}": "Impossible d'exclure {name}",
"Solarized Light": "Solarized clair",
"Solarized Dark": "Solarized sombre",
"One Light": "One Light",
"One Dark": "One Dark",
"Copied reference: {reference}": "Référence copiée : {reference}",
"{count} lines": "{count} lignes",
"Preview of {path}": "Aperçu de {path}",
-2
View File
@@ -3999,8 +3999,6 @@
"Cannot exclude {name}": "{name}을(를) 제외할 수 없습니다",
"Solarized Light": "Solarized 라이트",
"Solarized Dark": "Solarized 다크",
"One Light": "One 라이트",
"One Dark": "One 다크",
"Copied reference: {reference}": "참조 복사됨: {reference}",
"{count} lines": "{count}줄",
"Preview of {path}": "{path} 미리 보기",
-2
View File
@@ -3899,8 +3899,6 @@
"Cannot exclude {name}": "Nie można wykluczyć {name}",
"Solarized Light": "Solarized Jasny",
"Solarized Dark": "Solarized Ciemny",
"One Light": "One Light",
"One Dark": "One Dark",
"Copied reference: {reference}": "Skopiowano odnośnik: {reference}",
"{count} lines": "Liczba wierszy: {count}",
"Preview of {path}": "Podgląd {path}",
-2
View File
@@ -3905,8 +3905,6 @@
"Cannot exclude {name}": "Não é possível excluir {name}",
"Solarized Light": "Solarized Claro",
"Solarized Dark": "Solarized Escuro",
"One Light": "One Light",
"One Dark": "One Dark",
"Copied reference: {reference}": "Referência copiada: {reference}",
"{count} lines": "{count} linhas",
"Preview of {path}": "Pré-visualização de {path}",
-2
View File
@@ -4521,8 +4521,6 @@
"Cannot exclude {name}": "无法排除 {name}",
"Solarized Light": "Solarized 浅色",
"Solarized Dark": "Solarized 深色",
"One Light": "One 浅色",
"One Dark": "One 深色",
"Copied reference: {reference}": "已复制引用:{reference}",
"{count} lines": "{count} 行",
"Preview of {path}": "{path} 的预览",
+6 -8
View File
@@ -61,30 +61,28 @@ describe("built-in palettes", () => {
expect(schemeTokens("solarized-dark").get("--bg-editor")).toBe("#002b36");
expect(schemeTokens("solarized-dark").get("--syn-string")).toBe("#2aa198");
expect(schemeTokens("solarized-light").get("--ansi-bright-blue")).toBe("#839496");
expect(schemeTokens("one-light").get("--syn-keyword")).toBe("hsl(301, 63%, 40%)");
expect(schemeTokens("one-dark").get("--syn-string")).toBe("hsl(95, 38%, 62%)");
});
it("keeps explicit editor schemes and per-scheme edits independent of UI palettes", () => {
applyThemeAttributes("solarized-light");
expect(schemeCss("one-dark", undefined)).toContain("--bg-editor: hsl(220, 13%, 18%) !important");
expect(schemeCss("solarized-dark", undefined)).toContain("--bg-editor: #002b36 !important");
expect(schemeCss("theme-default", { "solarized-light": { keyword: { foreground: "#123456" } } })).toContain(
"--syn-keyword: #123456 !important",
);
applyThemeAttributes("one-light");
applyThemeAttributes("solarized-dark");
expect(schemeCss("theme-default", { "solarized-light": { keyword: { foreground: "#123456" } } })).toBe("");
});
it("clears the palette for plugins and restores it when the plugin is removed", () => {
const root = document.documentElement;
applyThemeAttributes("one-dark", root);
applyThemeAttributes("solarized-dark", root);
root.dataset.pluginThemeKind = "light";
applyThemeAttributes("one-dark", root);
applyThemeAttributes("solarized-dark", root);
expect(root.dataset.colorTheme).toBeUndefined();
expect(root.dataset.theme).toBe("light");
delete root.dataset.pluginThemeKind;
applyThemeAttributes("one-dark", root);
expect(root.dataset.colorTheme).toBe("one-dark");
applyThemeAttributes("solarized-dark", root);
expect(root.dataset.colorTheme).toBe("solarized-dark");
applyThemeAttributes("dark-hc", root);
expect(root.dataset.colorTheme).toBeUndefined();
expect(root.dataset.contrast).toBe("high");
-2
View File
@@ -7,8 +7,6 @@ export const BUILTIN_THEMES = [
{ id: "dark-hc", label: "High Contrast", kind: "dark" },
{ id: "solarized-light", label: "Solarized Light", kind: "light" },
{ id: "solarized-dark", label: "Solarized Dark", kind: "dark" },
{ id: "one-light", label: "One Light", kind: "light" },
{ id: "one-dark", label: "One Dark", kind: "dark" },
] as const;
export type BuiltinThemeId = (typeof BUILTIN_THEMES)[number]["id"];
-86
View File
@@ -1430,92 +1430,6 @@ html {
--bg-editor-selection-inactive: #073642;
}
:root[data-color-theme="one-light"] {
--palette-bg: hsl(230, 1%, 98%);
--palette-panel: hsl(230, 1%, 92%);
--palette-popup: hsl(230, 1%, 95%);
--palette-fg: hsl(230, 8%, 24%);
--palette-muted: hsl(230, 6%, 44%);
--palette-border: hsl(230, 1%, 86%);
--palette-hover: hsl(230, 1%, 90%);
--palette-selection: hsl(230, 1%, 90%);
--palette-line: color-mix(in srgb, hsl(230, 8%, 24%) 5%, hsl(230, 1%, 98%));
--palette-accent: hsl(230, 100%, 66%);
--palette-on-accent: #ffffff;
--palette-cyan: hsl(198, 99%, 37%);
--palette-blue: hsl(221, 87%, 60%);
--palette-magenta: hsl(301, 63%, 40%);
--palette-green: hsl(119, 34%, 47%);
--palette-red: hsl(5, 74%, 59%);
--palette-orange: hsl(41, 99%, 30%);
--palette-yellow: hsl(41, 99%, 38%);
--palette-comment: hsl(230, 4%, 64%);
--palette-keyword: hsl(301, 63%, 40%);
--palette-type: hsl(41, 99%, 38%);
--palette-number: hsl(41, 99%, 30%);
--palette-variable: hsl(5, 74%, 59%);
--palette-property: hsl(230, 8%, 24%);
--palette-ansi-black: hsl(230, 8%, 24%);
--palette-ansi-red: hsl(5, 74%, 59%);
--palette-ansi-green: hsl(119, 34%, 47%);
--palette-ansi-yellow: hsl(41, 99%, 38%);
--palette-ansi-blue: hsl(221, 87%, 60%);
--palette-ansi-magenta: hsl(301, 63%, 40%);
--palette-ansi-cyan: hsl(198, 99%, 37%);
--palette-ansi-white: hsl(230, 6%, 44%);
--palette-ansi-bright-black: hsl(230, 6%, 44%);
--palette-ansi-bright-red: hsl(5, 74%, 59%);
--palette-ansi-bright-green: hsl(119, 34%, 47%);
--palette-ansi-bright-yellow: hsl(41, 99%, 38%);
--palette-ansi-bright-blue: hsl(221, 87%, 60%);
--palette-ansi-bright-magenta: hsl(301, 63%, 40%);
--palette-ansi-bright-cyan: hsl(198, 99%, 37%);
--palette-ansi-bright-white: hsl(230, 8%, 24%);
}
:root[data-color-theme="one-dark"] {
--palette-bg: hsl(220, 13%, 18%);
--palette-panel: hsl(220, 13%, 15%);
--palette-popup: hsl(220, 13%, 16.5%);
--palette-fg: hsl(220, 14%, 71%);
--palette-muted: hsl(220, 9%, 55%);
--palette-border: hsl(220, 13%, 10.8%);
--palette-hover: hsl(220, 13%, 22%);
--palette-selection: hsl(220, 13%, 28%);
--palette-line: color-mix(in srgb, hsl(220, 100%, 80%) 4%, hsl(220, 13%, 18%));
--palette-accent: hsl(220, 100%, 66%);
--palette-on-accent: #000000;
--palette-cyan: hsl(187, 47%, 55%);
--palette-blue: hsl(207, 82%, 66%);
--palette-magenta: hsl(286, 60%, 67%);
--palette-green: hsl(95, 38%, 62%);
--palette-red: hsl(355, 65%, 65%);
--palette-orange: hsl(29, 54%, 61%);
--palette-yellow: hsl(39, 67%, 69%);
--palette-comment: hsl(220, 10%, 40%);
--palette-keyword: hsl(286, 60%, 67%);
--palette-type: hsl(39, 67%, 69%);
--palette-number: hsl(29, 54%, 61%);
--palette-variable: hsl(355, 65%, 65%);
--palette-property: hsl(220, 14%, 71%);
--palette-ansi-black: hsl(220, 13%, 15%);
--palette-ansi-red: hsl(355, 65%, 65%);
--palette-ansi-green: hsl(95, 38%, 62%);
--palette-ansi-yellow: hsl(39, 67%, 69%);
--palette-ansi-blue: hsl(207, 82%, 66%);
--palette-ansi-magenta: hsl(286, 60%, 67%);
--palette-ansi-cyan: hsl(187, 47%, 55%);
--palette-ansi-white: hsl(220, 14%, 71%);
--palette-ansi-bright-black: hsl(220, 9%, 55%);
--palette-ansi-bright-red: hsl(355, 65%, 65%);
--palette-ansi-bright-green: hsl(95, 38%, 62%);
--palette-ansi-bright-yellow: hsl(39, 67%, 69%);
--palette-ansi-bright-blue: hsl(207, 82%, 66%);
--palette-ansi-bright-magenta: hsl(286, 60%, 67%);
--palette-ansi-bright-cyan: hsl(187, 47%, 55%);
--palette-ansi-bright-white: #ffffff;
}
/*
* Appearance options (Settings › Appearance), applied as attributes on :root
* by the settings runtime.
+10 -19
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { ContextMenuHost, hideContextMenu, MenuList } from "@/ui";
import { showQuickSwitch, THEME_PREVIEW_DELAY, themePreview } from "./quickSwitch";
import { showQuickSwitch, themePreview } from "./quickSwitch";
import {
applyThemeAttributes,
currentThemeId,
@@ -40,20 +40,15 @@ describe("menu items", () => {
});
describe("Quick Switch theme preview", () => {
it("shows the active theme after a moment, in this window only, and takes it back", () => {
it("shows the active theme right away, in this window only, and takes it back", () => {
syncWindowBackground();
const startup = localStorage.getItem(STARTUP_THEME_KEY);
const before = shown();
const preview = themePreview();
preview.preview("solarized-dark");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY - 1);
expect(shown()).toEqual(before);
// Moving on restarts the delay: only the theme the user stays on shows.
preview.preview("one-light");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY - 1);
expect(shown()).toEqual(before);
vi.advanceTimersByTime(1);
expect(shown()).toEqual(["light", "one-light"]);
expect(shown()).toEqual(["dark", "solarized-dark"]);
preview.preview("solarized-light");
expect(shown()).toEqual(["light", "solarized-light"]);
// Nothing is selected or saved, not even as the next start-up's theme.
expect(useThemeOverride.getState().theme).toBeNull();
expect(localStorage.getItem(STARTUP_THEME_KEY)).toBe(startup);
@@ -62,20 +57,18 @@ describe("Quick Switch theme preview", () => {
expect(shown()).toEqual(before);
expect(localStorage.getItem(STARTUP_THEME_KEY)).toBe(startup);
preview.preview("solarized-dark");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY);
preview.end();
expect(shown()).toEqual(before);
});
it("selects the chosen theme and keeps it when the menu closes", () => {
const preview = themePreview();
preview.preview("solarized-dark");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY);
preview.choose("one-dark");
expect(useThemeOverride.getState().theme).toBe("one-dark");
expect(shown()).toEqual(["dark", "one-dark"]);
preview.preview("solarized-light");
preview.choose("solarized-dark");
expect(useThemeOverride.getState().theme).toBe("solarized-dark");
expect(shown()).toEqual(["dark", "solarized-dark"]);
preview.end();
expect(shown()).toEqual(["dark", "one-dark"]);
expect(shown()).toEqual(["dark", "solarized-dark"]);
});
it("previews from the Theme submenu and takes the preview back when the menu closes", async () => {
@@ -85,7 +78,6 @@ describe("Quick Switch theme preview", () => {
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Theme" }));
act(() => vi.advanceTimersByTime(200));
fireEvent.pointerEnter(screen.getByRole("menuitemcheckbox", { name: "Solarized Light" }));
act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
expect(shown()).toEqual(["light", "solarized-light"]);
// Back on another entry: the selected theme again.
@@ -95,7 +87,6 @@ describe("Quick Switch theme preview", () => {
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Theme" }));
act(() => vi.advanceTimersByTime(200));
fireEvent.pointerEnter(screen.getByRole("menuitemcheckbox", { name: "Solarized Light" }));
act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
act(() => hideContextMenu());
await act(async () => {});
expect(shown()).toEqual(before);
+6 -15
View File
@@ -22,14 +22,10 @@ async function setGlobal(key: string, value: unknown, what: string) {
export const selectKeymap = (id: string) => setGlobal("keymap", id, "keymap");
/** How long a theme stays under the pointer or keyboard before Quick Switch shows it (IDEA's delay). */
export const THEME_PREVIEW_DELAY = 500;
/**
* IDEA's instant theme switch (`ide.instant.theme.switch`): in Quick Switch
* the active theme shows after a moment, in this window only; choosing one
* selects it, and closing the menu without a choice brings the selected
* theme back.
* Instant theme switch: in Quick Switch the active theme shows right away, in
* this window only; choosing one selects it, and closing the menu without a
* choice brings the selected theme back.
*/
export interface ThemePreview {
preview(id: ThemeChoiceId): void;
@@ -41,25 +37,20 @@ export interface ThemePreview {
}
export function themePreview(): ThemePreview {
let timer: ReturnType<typeof setTimeout> | undefined;
let shown: ThemeChoiceId | null = null;
let chosen = false;
const restore = () => {
clearTimeout(timer);
if (shown !== null && !chosen) previewTheme(null);
shown = null;
};
return {
preview(id) {
clearTimeout(timer);
timer = setTimeout(() => {
shown = id;
previewTheme(id);
}, THEME_PREVIEW_DELAY);
if (shown === id) return;
shown = id;
previewTheme(id);
},
restore,
choose(id) {
clearTimeout(timer);
chosen = true;
// Shown right away; selecting it then changes nothing visible.
if (shown !== id) previewTheme(id);