From bbbdcb465e507fc1c6b608b586044103743fc341 Mon Sep 17 00:00:00 2001 From: Muu Date: Sat, 7 Nov 2026 20:33:10 +0800 Subject: [PATCH] feat(settings): restructure the color scheme page into JetBrains-style sub-pages (#1734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): JetBrains-style color scheme sub-pages Move General, Debugger, Language Defaults and each language onto its own page under Color Scheme, drop the redundant scope root from the attribute tree, and turn the Color Scheme root page into a landing with links to the sub-pages. * feat(settings): per-scope preview samples with click-to-reveal Give every scope page a code sample covering as much of its token set as possible, render bg-only attributes in the preview, and let a click on a preview token select its attribute and expand its tree ancestors. * refactor(settings): fold attribute name groups into collapsible tree levels Split the "›" groups of an attribute display name (Identifiers, String, Comments, ...) into collapsible intermediate nodes so the tree matches IDEA instead of repeating the group in every leaf label. * feat(editor): preview the scheme's actual attribute state in settings Attribute controls now mirror the scheme's effective appearance instead of only showing user overrides: checkboxes and colours resolve imported scheme values, theme tokens and built-in defaults. Removing an inherited colour stores an explicit transparent override instead of falling back silently, and redundant overrides matching the inherited value are dropped. Also drops the stale "Incoming commits" catalog key. * feat(settings): move the effect style onto its own right-aligned row Put the effect checkbox and its colour on one line and the style select on a narrower row below, right-aligned with the hex fields, which now stretch to the panel edge. * refactor(settings): render attribute tree labels in the plain font Drop the per-token styling of the tree labels; IDEA lists them as normal text, the preview keeps the colours. * feat(settings): scroll the attribute tree to a preview-clicked token Preview clicks now also reveal the attribute: after the ancestors expand, the tree selects the row and centres it in the viewport. * feat(settings): mark overridden attributes in blue up to the root Show overridden attributes and every group containing one in the accent colour, like IDEA, instead of a dot on the leaf alone. * feat(settings): use the JetBrains demo as the Python preview and reveal cross-page tokens Replace the Python sample with the classic syntax-highlighting demo (semantic spectrum, f-strings, byte strings, escapes) while keeping coverage for every Python attribute. Preview tokens outside the current sub-page now navigate to their owning page and reveal the attribute there. * fix(settings): keep locale entries, English preview text and default styles in the colour scheme preview - Restore the "Incoming commits" translations that were dropped from other locales - Replace the Chinese docstring lines in the Python preview with English text - Use the existing Java operator attribute for separators in the Java preview - Render default bold, italic and underline in the preview, matching the editor --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- .../settings/__tests__/colorScheme.test.tsx | 133 +- src/features/settings/components/Form.tsx | 13 +- .../settings/pages/editor/Editor.module.css | 56 +- .../settings/pages/editor/colorScheme.tsx | 1151 +++++++++++++++-- src/features/settings/pages/editor/index.ts | 4 +- src/platform/colorScheme.test.ts | 40 +- src/platform/colorScheme.ts | 106 +- src/platform/locales/text/es.json | 1 - src/platform/locales/text/fr.json | 1 - src/platform/locales/text/ko.json | 1 - src/platform/locales/text/pl.json | 1 - src/platform/locales/text/pt.json | 1 - src/platform/locales/text/zh.json | 3 +- 13 files changed, 1338 insertions(+), 173 deletions(-) diff --git a/src/features/settings/__tests__/colorScheme.test.tsx b/src/features/settings/__tests__/colorScheme.test.tsx index 4d9a40c4d..3f7b51f2a 100644 --- a/src/features/settings/__tests__/colorScheme.test.tsx +++ b/src/features/settings/__tests__/colorScheme.test.tsx @@ -1,12 +1,13 @@ import { readFileSync } from "node:fs"; -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { executeAction } from "@/platform/actions"; +import { CLEAR_COLOR } from "@/platform/colorScheme"; import { useProject } from "@/platform/project"; import { DEFAULT_SETTINGS } from "../model/schema"; import { openSettings, stage, stagedValue, useSettingsDialog } from "../model/staging"; import { PageContext } from "../components/context"; -import { COLOR_SCHEME_PAGE } from "../pages/editor/colorScheme"; +import { COLOR_SCHEME_PAGES } from "../pages/editor/colorScheme"; const mocks = vi.hoisted(() => ({ open: vi.fn(), @@ -28,6 +29,15 @@ const run = (name: string) => act(async () => { await executeAction(`editor.colorScheme.${name}`); }); +const schemePage = (id: string) => { + const page = COLOR_SCHEME_PAGES.find((p) => p.id === id)!; + const Page = page.component!; + render( + + + , + ); +}; beforeEach(() => { vi.resetAllMocks(); @@ -40,15 +50,10 @@ beforeEach(() => { mocks.write.mockResolvedValue({ status: "ok", data: null }); useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: null }); openSettings("editor.colorScheme"); - const Page = COLOR_SCHEME_PAGE.component!; - render( - - - , - ); }); describe("scheme actions", () => { + beforeEach(() => schemePage("editor.colorScheme")); it("imports and selects in global scope, exports and confirms deletion", async () => { await run("import"); const id = value("colorScheme") as string; @@ -114,3 +119,115 @@ describe("scheme actions", () => { expect(useSettingsDialog.getState().staged).toEqual({}); }); }); + +describe("scheme attribute state", () => { + beforeEach(() => schemePage("editor.colorScheme.language-defaults")); + it("shows checked attributes and inherited colours from the selected scheme", () => { + act(() => { + stage("editor.colorScheme", "global", "editor.customSchemes", { + "custom-test": { + name: "Test", + parent: "dark", + attributes: { + keyword: { + foreground: "#139839", + bold: true, + italic: false, + effect: "wave", + effectColor: "#abcdef", + }, + }, + }, + }); + stage("editor.colorScheme", "global", "editor.colorScheme", "custom-test"); + }); + + expect(screen.getByRole("checkbox", { name: "Bold" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Italic" })).not.toBeChecked(); + expect(screen.getByRole("textbox", { name: "Foreground hex" })).toHaveValue("#139839"); + expect(screen.queryByRole("textbox", { name: "Background hex" })).not.toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Effects" })).toBeChecked(); + expect(screen.getByRole("button", { name: "Effect" })).toHaveTextContent("Underwaved"); + expect(screen.getByRole("textbox", { name: "Effect color hex" })).toHaveValue("#abcdef"); + }); + + it("selects an empty background and clears it back to the scheme state", () => { + const background = screen.getByRole("checkbox", { name: "Background" }); + act(() => fireEvent.click(background)); + + expect(background).toBeChecked(); + expect(screen.getByRole("textbox", { name: "Background hex" })).toHaveValue("#808080"); + expect(Object.values(value("colorOverrides") as object)).toEqual([{ keyword: { background: "#808080" } }]); + + act(() => fireEvent.click(background)); + + expect(background).not.toBeChecked(); + expect(screen.queryByRole("textbox", { name: "Background hex" })).not.toBeInTheDocument(); + expect(value("colorOverrides")).toEqual({}); + expect(useSettingsDialog.getState().staged["global|editor.colorOverrides"]).toBeUndefined(); + }); + + it("clears a background inherited from the selected scheme", () => { + act(() => { + stage("editor.colorScheme", "global", "editor.customSchemes", { + "custom-test": { + name: "Test", + parent: "dark", + attributes: { keyword: { background: "#2b2d30" } }, + }, + }); + stage("editor.colorScheme", "global", "editor.colorScheme", "custom-test"); + }); + + const background = screen.getByRole("checkbox", { name: "Background" }); + expect(background).toBeChecked(); + expect(background).toBeEnabled(); + expect(screen.getByRole("textbox", { name: "Background hex" })).toHaveValue("#2b2d30"); + + act(() => fireEvent.click(background)); + expect(background).not.toBeChecked(); + expect(screen.queryByRole("textbox", { name: "Background hex" })).not.toBeInTheDocument(); + expect(Object.values(value("colorOverrides") as object)).toEqual([{ keyword: { background: CLEAR_COLOR } }]); + + act(() => fireEvent.click(background)); + expect(background).toBeChecked(); + expect(screen.getByRole("textbox", { name: "Background hex" })).toHaveValue("#2b2d30"); + expect(value("colorOverrides")).toEqual({}); + }); + + it("removes an effect override when the scheme value is selected again", () => { + act(() => { + stage("editor.colorScheme", "global", "editor.customSchemes", { + "custom-test": { + name: "Test", + parent: "dark", + attributes: { keyword: { effect: "wave", effectColor: "#abcdef" } }, + }, + }); + stage("editor.colorScheme", "global", "editor.colorScheme", "custom-test"); + }); + + const effects = screen.getByRole("checkbox", { name: "Effects" }); + act(() => fireEvent.click(effects)); + expect(value("colorOverrides")).not.toEqual({}); + + act(() => fireEvent.click(effects)); + expect(effects).toBeChecked(); + expect(value("colorOverrides")).toEqual({}); + expect(useSettingsDialog.getState().staged["global|editor.colorOverrides"]).toBeUndefined(); + }); + + it("clears a newly selected effect back to an unmodified state", () => { + const effects = screen.getByRole("checkbox", { name: "Effects" }); + expect(effects).not.toBeChecked(); + + act(() => fireEvent.click(effects)); + expect(effects).toBeChecked(); + expect(value("colorOverrides")).not.toEqual({}); + + act(() => fireEvent.click(effects)); + expect(effects).not.toBeChecked(); + expect(value("colorOverrides")).toEqual({}); + expect(useSettingsDialog.getState().staged["global|editor.colorOverrides"]).toBeUndefined(); + }); +}); diff --git a/src/features/settings/components/Form.tsx b/src/features/settings/components/Form.tsx index ad6fbc358..a5ea9a0ea 100644 --- a/src/features/settings/components/Form.tsx +++ b/src/features/settings/components/Form.tsx @@ -172,10 +172,19 @@ export interface SelectProps { onChange: (value: V) => void; disabled?: boolean; onPreview?: (value: V | null) => void; + className?: string; "aria-label"?: string; } -export function Select({ value, options, onChange, disabled, onPreview, ...aria }: SelectProps) { +export function Select({ + value, + options, + onChange, + disabled, + onPreview, + className, + ...aria +}: SelectProps) { return ( value={value} @@ -183,7 +192,7 @@ export function Select({ value, options, onChange, disabled, o onChange={onChange} disabled={disabled} onPreview={onPreview} - className={styles.select} + className={clsx(styles.select, className)} aria-label={aria["aria-label"]} /> ); diff --git a/src/features/settings/pages/editor/Editor.module.css b/src/features/settings/pages/editor/Editor.module.css index 2f76363b0..513591e0d 100644 --- a/src/features/settings/pages/editor/Editor.module.css +++ b/src/features/settings/pages/editor/Editor.module.css @@ -153,19 +153,57 @@ gap: 8px; margin-bottom: 10px; } +/* Landing links of the Color Scheme root page (JetBrains-style sub-page list). */ +.schemeLinks { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + margin-top: 4px; +} +.schemeLink { + border: 0; + background: none; + padding: 2px 4px; + border-radius: var(--radius-small); + color: var(--accent); + font: inherit; + cursor: pointer; +} +.schemeLink:hover { + background: var(--bg-hover); + text-decoration: underline; +} .schemeBody { display: grid; - grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); - gap: 16px; - min-height: 300px; + grid-template-columns: minmax(0, 1fr) minmax(220px, 260px); + gap: 14px; + min-height: 320px; } .attrTree { - max-height: 340px; + max-height: 360px; overflow: auto; border-radius: var(--radius-small); box-shadow: inset 0 0 0 1px var(--border-strong); padding: 2px 0; } +.treeLabel { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +/* Overridden attributes and their ancestor groups show in accent, up to the root. */ +.attrModified { + color: var(--accent); +} +.attrPanel { + box-shadow: inset 0 0 0 1px var(--border-strong); + border-radius: var(--radius-small); + padding: 10px; + align-self: start; + overflow: auto; + max-height: 360px; +} .attrEditor { display: flex; flex-direction: column; @@ -180,8 +218,16 @@ .attrRow > :first-child { min-width: 110px; } +/* Effect style select on its own row, right-aligned with the colour hex fields. */ +.effectSelect { + align-self: flex-end; + min-width: 0; + width: 150px; + margin-top: 8px; +} .swatchInput { - width: 90px; + flex: 1 1 90px; + min-width: 90px; } .colorBox { position: relative; diff --git a/src/features/settings/pages/editor/colorScheme.tsx b/src/features/settings/pages/editor/colorScheme.tsx index 6831c494a..4422b98cc 100644 --- a/src/features/settings/pages/editor/colorScheme.tsx +++ b/src/features/settings/pages/editor/colorScheme.tsx @@ -5,7 +5,7 @@ * `editor.colorOverrides[scheme][attribute]`; `editor/core` injects them. */ import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; -import { Button, Checkbox, Input, Tree, type SelectOption, T } from "@/ui"; +import { Button, Checkbox, Input, Tree, type SelectOption, type TreeHandle, T } from "@/ui"; import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride } from "@/platform/editorSettings"; import { ATTRIBUTES, @@ -13,6 +13,8 @@ import { overrideDeclarations, effectiveScheme, resolvedSchemeTokens, + resolvedAttribute, + CLEAR_COLOR, listSchemes, customSchemes, type AttributeDef, @@ -21,12 +23,14 @@ import { HEX_COLOR } from "../../model/schema"; import { Row, Select } from "../../components/Form"; import { useSetting } from "../../components/context"; import { editorPage } from "./kit"; +import type { PageSpec } from "../spec"; import styles from "./Editor.module.css"; import { executeAction, registerActions } from "@/platform/actions"; import { call, commands } from "@/platform/ipc"; import { ask, confirm, showError } from "@/workbench/messages"; import { parseIcls, importScheme, deleteScheme, exportIcls } from "@/platform/icls"; import { tx } from "@/platform/i18n"; +import { selectPage } from "../../model/staging"; type Overrides = Record>; @@ -37,24 +41,70 @@ interface Node { children?: Node[]; } +/** Tree segments of an attribute: its scope path plus any "›" groups in its display name. */ +function attrSegs(attr: AttributeDef): string[] { + const parts = attr.name.split(" › "); + return [...attr.path, ...parts.slice(0, -1)]; +} + +/** Tree keys of every ancestor group of an attribute, from the scope root down. */ +function ancestorKeys(attr: AttributeDef): string[] { + const keys: string[] = []; + let key = ""; + for (const seg of attrSegs(attr)) { + key += `/${seg}`; + keys.push(key); + } + return keys; +} + function buildTree(): Node[] { const roots: Node[] = []; for (const attr of ATTRIBUTES) { + const parts = attr.name.split(" › "); let level = roots; let key = ""; - for (const seg of attr.path) { + for (const seg of attrSegs(attr)) { key += `/${seg}`; let n = level.find((x) => x.key === key); if (!n) level.push((n = { key, label: seg, children: [] })); level = n.children!; } - level.push({ key: attr.id, label: attr.name, attr }); + level.push({ key: attr.id, label: parts.at(-1)!, attr }); } return roots; } const TREE = buildTree(); +/** Roots of one page's attribute tree, in JetBrains fashion: scopes are pages, not tree panes. */ +function pageTree(scopes: readonly string[]): Node[] { + return TREE.filter((n) => scopes.includes(n.label)); +} + +function firstLeafId(nodes: readonly Node[]): string { + const n = nodes[0]!; + return n.attr ? n.attr.id : firstLeafId(n.children!); +} + +function treeIds(nodes: readonly Node[], out = new Set()): Set { + for (const n of nodes) { + if (n.attr) out.add(n.attr.id); + if (n.children) treeIds(n.children, out); + } + return out; +} + +/** Settings sub-page that owns an attribute, for preview clicks that cross pages. */ +function attrPageId(attr: AttributeDef): string { + return `editor.colorScheme.${ + attr.path[0] === "Language Defaults" ? "language-defaults" : attr.path[0]!.toLowerCase() + }`; +} + +/** Preview click on another sub-page: the target page selects and reveals the attribute. */ +let crossPageReveal: string | null = null; + /** * CSS custom properties of a scheme plus pending overrides (for the preview); with * `language`, that language's own colours on top, as its editors show them. @@ -87,72 +137,670 @@ function previewVars( /** Text styled like an attribute in the preview. */ function tokenStyle(attr: AttributeDef | undefined): CSSProperties { - if (!attr?.token) return attr?.fg ? { color: `var(${attr.fg})` } : {}; + if (!attr?.token) + return { + ...(attr?.fg ? { color: `var(${attr.fg})` } : null), + ...(attr?.bg ? { backgroundColor: `var(${attr.bg})` } : null), + }; if (attr.italicOnly) return { fontStyle: `var(--syn-${attr.token}-italic, ${attr.defaultItalic === false ? "normal" : "italic"})` }; const t = attr.token; + const italic = t === "comment" ? "var(--syn-comment-style)" : attr.defaultItalic ? "italic" : "inherit"; + const bold = attr.defaultBold ? "var(--fw-code-bold, 700)" : "inherit"; + const effect = attr.defaultEffect === "underline" ? "underline" : "none"; return { color: `var(--syn-${t})`, - fontWeight: `var(--syn-${t}-bold, inherit)` as CSSProperties["fontWeight"], - fontStyle: `var(--syn-${t}-italic, ${t === "comment" ? "var(--syn-comment-style)" : t === "doc-comment" || t === "docstring" ? "italic" : "inherit"})`, + fontWeight: `var(--syn-${t}-bold, ${bold})` as CSSProperties["fontWeight"], + fontStyle: `var(--syn-${t}-italic, ${italic})`, backgroundColor: `var(--syn-${t}-bg, transparent)`, - textDecoration: `var(--syn-${t}-effect, none)`, + textDecoration: `var(--syn-${t}-effect, ${effect})`, // WebKit takes a decoration with a style or colour only through the prefixed property. - ...({ WebkitTextDecoration: `var(--syn-${t}-effect, none)` } as CSSProperties), + ...({ WebkitTextDecoration: `var(--syn-${t}-effect, ${effect})` } as CSSProperties), }; } const A = (id: string) => ATTRIBUTES.find((a) => a.id === id); type Seg = [string, string?]; -const SAMPLE: Seg[][] = [ - [["# OpenIDEA color scheme preview", "lineComment"]], - [["import ", "keyword"], ["os"]], - [["@dataclass", "pyDecorator"]], - [["class ", "keyword"], ["Greeter", "className"], [":"]], - [[' """Says hello."""', "pyDocstring"]], - [ - [" MAX_RETRIES", "constant"], - [" = ", "operator"], - ["3", "number"], - ], - [[" static_field", "staticSymbol"], [" = 3"]], - [[" python_field", "pyStaticSymbol"], [" = 3"]], - [ - [" def ", "keyword"], - ["greet", "functionDeclaration"], - ["("], - ["self", "pySelf"], - [", "], - ["name", "parameter"], - ["):"], - ], - [ - [" text", "identifier"], - [" = ", "operator"], - ['"Hello, ', "string"], - ["\\n", "escape"], - ['"', "string"], - [" + name"], - ], - [[" "], ["print", "pyBuiltin"], ["(text, "], ["end", "pyKeywordArg"], ["="], ['""', "string"], [")"]], - [[" return ", "keyword"], ["len", "functionCall"], ["(text)"]], - [["# TODO: localize", "todo"]], -]; +interface CodeSample { + code: Seg[][]; + /** Line shown with the caret-row background. */ + caret?: number; +} + +/** One sample per page, each covering as much of that scope's token set as possible. */ +const SAMPLES: Record = { + General: { + code: [ + [["# General: text, gutter, hints and matches", "lineComment"]], + [ + ["TODO", "todo"], + [" check the wave decorations below", "text"], + ], + [ + ["erroneous", "error"], + [" ", "text"], + ["suspicious", "warning"], + [" ", "text"], + ["could be const", "weakWarning"], + [" ", "text"], + ["legacyCall", "unused"], + ], + [ + ["bad\u203dchar", "invalid"], + [" \u00b7 ", "whitespaces"], + ["\u2192 indent guides \u2192", "indentGuide"], + ], + [ + ["const", "inlay"], + [" name", "text"], + [" = ", "operator"], + ["value", "text"], + [" ", "text"], + ["\u2026", "folded"], + [" folded region ", "folded"], + ["\u2026", "folded"], + ], + [ + ["search hit ", "text"], + ["token", "searchResult"], + [" and current ", "text"], + ["token", "searchResultCurrent"], + ], + [ + ["matched ", "text"], + ["()", "matchedBrace"], + [" identifier under caret ", "text"], + ["name", "identifierUnderCaret"], + [" / ", "text"], + ["name", "identifierUnderCaretWrite"], + ], + ], + }, + Debugger: { + code: [ + [["# Debugger: inline values and breakpoint rows", "lineComment"]], + [ + ["len", "inlineValue"], + [" = ", "text"], + ["3", "text"], + [" ", "text"], + ["name", "inlineValue"], + [" = ", "text"], + ['"world"', "string"], + ], + [ + ["return", "keyword"], + [" len(text)", "text"], + ], + ], + caret: 2, + }, + "Language Defaults": { + code: [ + [["# Language Defaults", "lineComment"]], + [["@Entity", "metadata"]], + [ + ["class ", "keyword"], + ["Session", "className"], + [" implements ", "keyword"], + ["Runnable", "className"], + [" {", "operator"], + ], + [ + [" /**", "docComment"], + [" doc comment ", "docComment"], + ["@param", "docComment"], + [" id", "docComment"], + [" */", "docComment"], + ], + [ + [" static", "staticSymbol"], + [" MAX", "constant"], + [" = ", "operator"], + ["0xff", "number"], + [";", "operator"], + ], + [ + [" field", "field"], + [" = ", "operator"], + ['"a', "string"], + ["\\n", "escape"], + ["\\t", "escape"], + ["z", "string"], + ['\\"', "escape"], + ['"', "string"], + [";", "operator"], + ], + [ + [" def ", "keyword"], + ["greet", "functionDeclaration"], + ["(", "operator"], + ["name", "parameter"], + [", ", "operator"], + ["GLOBAL", "globalVariable"], + ["):", "operator"], + ], + [ + [" ", "text"], + ["greet", "functionCall"], + ["(", "operator"], + ["predefined", "predefined"], + [", ", "operator"], + ["local", "identifier"], + [")", "operator"], + ], + [ + [" x", "identifier"], + [" = ", "operator"], + ["[", "bracket1"], + ["(", "bracket2"], + ["{", "bracket3"], + ["1", "number"], + ["}", "bracket3"], + [")", "bracket2"], + ["]", "bracket1"], + [";", "operator"], + ], + [ + [" s1", "semantic1"], + [" s2", "semantic2"], + [" s3", "semantic3"], + [" s4", "semantic4"], + [" s5", "semantic5"], + ], + [ + [" <", "operator"], + ["div", "markupTag"], + [" ", "text"], + ["class", "markupAttribute"], + ["=", "operator"], + ['"row"', "string"], + [">", "operator"], + ["\u2026", "text"], + ["", "operator"], + ], + [["}", "operator"]], + ], + }, + Python: { + caret: 4, + code: [ + [ + ["import ", "keyword"], + ["os", "pyStaticSymbol"], + ], + [ + ["MAX_RETRIES", "pyConstant"], + [" = ", "operator"], + ["3", "number"], + ], + [], + [ + ["@decorator", "pyDecorator"], + ["(", "pyParentheses"], + ["param", "pyKeywordArg"], + ["=", "operator"], + ["1", "number"], + [")", "pyParentheses"], + ], + [ + ["def ", "keyword"], + ["f", "functionDeclaration"], + ["(", "pyParentheses"], + ["x", "parameter"], + [")", "pyParentheses"], + [":", "operator"], + ], + [[' """', "pyDocstring"]], + [[" Syntax Highlighting Demo", "pyDocstring"]], + [[" @param x Parameter", "pyDocstring"]], + [], + [[" Semantic highlighting:", "pyDocstring"]], + [[" Generated spectrum to pick colors for local variables and parameters:", "pyDocstring"]], + [ + [" ", "pyDocstring"], + ["Color#1 ", "semantic1"], + ["SC1.1 SC1.2 SC1.3 SC1.4 ", "semantic1"], + ["Color#2 ", "semantic2"], + ["SC2.1 SC2.2 SC2.3 SC2.4 ", "semantic2"], + ["Color#3", "semantic3"], + ], + [ + [" ", "pyDocstring"], + ["Color#3 ", "semantic3"], + ["SC3.1 SC3.2 SC3.3 SC3.4 ", "semantic3"], + ["Color#4 ", "semantic4"], + ["SC4.1 SC4.2 SC4.3 SC4.4 ", "semantic4"], + ["Color#5", "semantic5"], + ], + [[' """', "pyDocstring"]], + [], + [ + [" def ", "keyword"], + ["nested_func", "pyNestedFunction"], + ["(", "pyParentheses"], + ["y", "parameter"], + [")", "pyParentheses"], + [":", "operator"], + ], + [ + [" ", "text"], + ["print", "pyPredefinedUsage"], + ["(", "pyParentheses"], + ["y", "identifier"], + [" + ", "operator"], + ["1", "number"], + [")", "pyParentheses"], + ], + [], + [ + [" s", "pyLocalVariable"], + [" = ", "operator"], + ["(", "pyParentheses"], + ['"Test"', "string"], + [", ", "operator"], + ["2", "number"], + ["+", "operator"], + ["3", "number"], + [", ", "operator"], + ["{", "pyBraces"], + ["'a'", "string"], + [": ", "operator"], + ["'b'", "string"], + ["}", "pyBraces"], + [",", "operator"], + ], + [ + [" ", "text"], + ["f", "pyFString"], + ["'{", "pyFString"], + ["x", "parameter"], + ["!s:", "pyFormat"], + ["{", "pyFormat"], + ['"^10"', "string"], + ["}", "pyFormat"], + ["}'", "pyFString"], + [", ", "operator"], + ["f", "pyFString"], + ['"{', "pyFString"], + ["1", "number"], + [":", "pyFString"], + ["01d", "pyFormat"], + ['}"', "pyFString"], + [")", "pyParentheses"], + [" ", "text"], + ["# Comment", "lineComment"], + ], + [ + [" ", "text"], + ["f", "functionCall"], + ["(", "pyParentheses"], + ["s", "pyLocalVariable"], + ["[", "pyBrackets"], + ["0", "number"], + ["]", "pyBrackets"], + [".", "operator"], + ["lower", "pyPredefinedUsage"], + ["(", "pyParentheses"], + [")", "pyParentheses"], + [")", "pyParentheses"], + ], + [ + [" ", "text"], + ["nested_func", "functionCall"], + ["(", "pyParentheses"], + ["42", "number"], + [")", "pyParentheses"], + ], + [], + [ + ["class ", "keyword"], + ["Foo", "className"], + [":", "operator"], + ], + [ + [" tags", "pyClassAttribute"], + [": ", "text"], + ["List", "pyAnnotation"], + ["[", "pyBrackets"], + ["str", "pyAnnotation"], + ["]", "pyBrackets"], + ], + [], + [ + [" def ", "keyword"], + ["__init__", "pyPredefinedDefinition"], + ["(", "pyParentheses"], + ["self", "pySelf"], + [": ", "text"], + ["Foo", "pyAnnotation"], + [")", "pyParentheses"], + [":", "operator"], + ], + [ + [" byte_string", "pyLocalVariable"], + [": ", "text"], + ["bytes", "pyAnnotation"], + [" = ", "operator"], + ["b'newline:", "pyBytes"], + ["\\n", "escape"], + [" also newline:", "pyBytes"], + ["\\x0a", "escape"], + ["'", "pyBytes"], + ], + [ + [" text_string", "pyLocalVariable"], + [" = ", "operator"], + ['u"Cyrillic Я is ', "string"], + ["\\u042f", "escape"], + [". Oops: ", "string"], + ["\\u042g", "weakWarning"], + ['"', "string"], + ], + [ + [" ", "text"], + ["self", "pySelf"], + [".", "operator"], + ["make_sense", "pyMethodCall"], + ["(", "pyParentheses"], + ["whatever", "pyKeywordArg"], + ["=", "operator"], + ["1", "number"], + [")", "pyParentheses"], + ], + [], + [["@property", "pyDecorator"]], + [ + [" def ", "keyword"], + ["alias", "pyProperty"], + ["(", "pyParentheses"], + ["self", "pySelf"], + [")", "pyParentheses"], + [":", "operator"], + ], + [ + [" return ", "keyword"], + ["self", "pySelf"], + [".", "operator"], + ["sense", "pyInstanceAttribute"], + ], + [], + [ + [" def ", "keyword"], + ["make_sense", "functionDeclaration"], + ["[", "pyBrackets"], + ["T", "pyTypeParameter"], + ["]", "pyBrackets"], + ["(", "pyParentheses"], + ["self", "pySelf"], + [", ", "operator"], + ["whatever", "parameter"], + [": ", "text"], + ["T", "pyAnnotation"], + [")", "pyParentheses"], + [":", "operator"], + ], + [ + [" ", "text"], + ["self", "pySelf"], + [".", "operator"], + ["sense", "pyInstanceAttribute"], + [" = ", "operator"], + ["whatever", "parameter"], + ], + [], + [ + ["x", "pyGlobalVariable"], + [" = ", "operator"], + ["len", "pyPredefinedUsage"], + ["(", "pyParentheses"], + ['"abc"', "string"], + [")", "pyParentheses"], + ], + [ + ["type ", "keyword"], + ["my_int", "pyGlobalVariable"], + [" = ", "operator"], + ["int", "pyBuiltin"], + ], + [ + ["print", "pyPredefinedUsage"], + ["(", "pyParentheses"], + ["f", "functionCall"], + [".", "operator"], + ["__doc__", "pyInstanceAttribute"], + [")", "pyParentheses"], + ], + ], + }, + Markdown: { + code: [ + [["# Heading level one", "mdHeading"]], + [["## Heading level two", "mdHeading"]], + [ + ["Inline ", "text"], + ["`code`", "mdCode"], + [" and ", "text"], + ["[link](https://example.com)", "mdLink"], + [" text", "text"], + ], + [ + ["<", "operator"], + ["em", "markupTag"], + [">", "operator"], + ["emphasis", "text"], + ["", "operator"], + ], + [ + ["- ", "operator"], + ["list item ", "text"], + ["`x`", "mdCode"], + ], + ], + }, + RegExp: { + code: [ + [["# Regular expressions", "lineComment"]], + [ + ["^[a-z]", "regexp"], + ["+", "operator"], + ["(\\d{2,3})", "regexp"], + ["?", "operator"], + ["$", "regexp"], + ], + [ + ["(?P", "regexp"], + ["\\d{4}", "regexp"], + [")", "regexp"], + ["-", "operator"], + ["(\\d{2})", "regexp"], + ], + [ + ["\\bword\\b", "regexp"], + [" | ", "operator"], + ["[[:alpha:]]", "regexp"], + ], + ], + }, + Java: { + code: [ + [["// Java", "java.lineComment"]], + [["@Override", "java.metadata"]], + [ + ["public class ", "java.keyword"], + ["Greeter", "java.className"], + [" {", "java.operator"], + ], + [ + [" private static final ", "java.keyword"], + ["int", "java.keyword"], + [" MAX", "java.constant"], + [" = ", "java.operator"], + ["3", "java.number"], + [";", "java.operator"], + ], + [ + [" private ", "java.keyword"], + ["String", "java.className"], + [" name", "java.field"], + [" = ", "java.operator"], + ['"a', "java.string"], + ["\\n", "java.escape"], + ['"', "java.string"], + [";", "java.operator"], + ], + [[" /** doc comment */", "java.docComment"]], + [ + [" public ", "java.keyword"], + ["void", "java.keyword"], + ["greet", "java.functionDeclaration"], + ["(", "java.operator"], + ["String", "java.className"], + [" name", "java.parameter"], + [")", "java.operator"], + [" {", "java.operator"], + ], + [ + [" ", "text"], + ["System", "java.predefined"], + [".", "java.operator"], + ["out", "java.field"], + [".", "java.operator"], + ["println", "java.functionCall"], + ["(", "java.operator"], + ["name", "java.identifier"], + [")", "java.operator"], + [";", "java.operator"], + ], + [ + [" ", "text"], + ["Greeter", "java.className"], + [".", "java.operator"], + ["MAX", "java.staticSymbol"], + ], + [[" }", "java.operator"]], + [["}", "java.operator"]], + ], + }, + Kotlin: { + code: [ + [["// Kotlin", "kotlin.lineComment"]], + [["@JvmStatic", "kotlin.metadata"]], + [ + ["class ", "kotlin.keyword"], + ["Greeter", "kotlin.className"], + ["(", "kotlin.operator"], + ["val", "kotlin.keyword"], + [" name", "kotlin.parameter"], + [": ", "text"], + ["String", "kotlin.className"], + [")", "kotlin.operator"], + [" {", "kotlin.operator"], + ], + [ + [" companion ", "kotlin.keyword"], + ["object", "kotlin.keyword"], + [" {", "kotlin.operator"], + [" const ", "kotlin.keyword"], + ["val", "kotlin.keyword"], + [" MAX", "kotlin.constant"], + [" = ", "kotlin.operator"], + ["3", "kotlin.number"], + [" }", "kotlin.operator"], + ], + [ + [" fun ", "kotlin.keyword"], + ["greet", "kotlin.functionDeclaration"], + ["(", "kotlin.operator"], + ["name", "kotlin.parameter"], + [": ", "text"], + ["String", "kotlin.className"], + ["): ", "text"], + ["String", "kotlin.className"], + [" = ", "kotlin.operator"], + ['"a', "kotlin.string"], + ["\\n", "kotlin.escape"], + ['"', "kotlin.string"], + ], + [ + [" ", "text"], + ["println", "kotlin.predefined"], + ["(", "kotlin.operator"], + ["name", "kotlin.identifier"], + [")", "kotlin.operator"], + ], + [["}", "kotlin.operator"]], + ], + }, + PHP: { + code: [ + [[" void; }) { const v = value ?? {}; const set = (patch: Partial) => { const next: TextAttributesOverride = { ...v, ...patch }; - for (const k of Object.keys(next) as Array) if (next[k] === undefined) delete next[k]; + for (const k of Object.keys(next) as Array) + if (next[k] === undefined || next[k] === inherited[k]) delete next[k]; onChange(Object.keys(next).length ? next : undefined); }; return ( @@ -160,8 +808,8 @@ function AttributeEditor({ {attr.italicOnly && (
set({ italic: b === (attr.defaultItalic ?? true) ? undefined : b })} + checked={v.italic ?? inherited.italic ?? attr.defaultItalic ?? true} + onChange={(b) => set({ italic: b === (inherited.italic ?? attr.defaultItalic ?? true) ? undefined : b })} label="Italic" />
@@ -169,49 +817,88 @@ function AttributeEditor({ {attr.token && !attr.italicOnly && ( <>
- set({ bold: b || undefined })} label="Bold" /> - set({ italic: b || undefined })} label="Italic" /> + set({ bold: b === (inherited.bold ?? false) ? undefined : b })} + label="Bold" + /> + set({ italic: b === (inherited.italic ?? false) ? undefined : b })} + label="Italic" + />
)} - {attr.fg && set({ foreground: c })} />} + {attr.fg && ( + set({ foreground: c })} + /> + )} {(attr.bg || (attr.token && !attr.italicOnly)) && ( - set({ background: c })} /> + set({ background: c })} + /> + )} + {attr.effect && ( + set({ effectColor: c })} + /> )} - {attr.effect && set({ effectColor: c })} />} {attr.token && !attr.italicOnly && ( -
- - set({ effect: b ? "underline" : undefined, effectColor: b ? (v.effectColor ?? "#808080") : undefined }) - } - label="Effects" - /> - {v.effect && v.effect !== "none" && ( - <> - set({ effect: e as TextAttributesOverride["effect"] })} + className={styles.effectSelect} + aria-label="Effect" + /> )} -
+ )} {!attr.italicOnly && (
- Unchecked values are inherited from the scheme. + The controls show the scheme's actual appearance; changes override it.
)} @@ -257,21 +944,39 @@ function ColorInput({ function ColorRow({ label, value, + inherited, onChange, }: { label: string; value: string | undefined; + inherited: string | undefined; onChange: (c: string | undefined) => void; }) { + const cleared = value === CLEAR_COLOR; + const shown = cleared ? undefined : (value ?? inherited); return (
- onChange(on ? "#808080" : undefined)} label={label} /> - {value !== undefined && } + + onChange( + on + ? cleared + ? (inherited ?? "#808080") + : (shown ?? "#808080") + : inherited !== undefined + ? CLEAR_COLOR + : undefined, + ) + } + label={label} + /> + {shown !== undefined && }
); } -function ColorSchemePage() { +function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landing?: boolean }) { const [scheme, setScheme] = useSetting("editor.colorScheme", "global"); const [overrides, setOverrides] = useSetting("editor.colorOverrides", "global"); const [weight] = useSetting("editor.fontWeight"); @@ -280,15 +985,74 @@ function ColorSchemePage() { const customs = useMemo(() => customSchemes(rawCustoms), [rawCustoms]); const [report, setReport] = useState(""); const [busy, setBusy] = useState(false); - const [selected, setSelected] = useState("keyword"); + // One scope per page: the scope's own name is the page, not a tree level. + const roots = useMemo(() => pageTree(scopes)[0]?.children ?? [], [scopes]); + const ids = useMemo(() => treeIds(roots), [roots]); + // A preview click on another sub-page starts this page on that attribute. + const crossReveal = + crossPageReveal && ids.has(crossPageReveal) ? ATTRIBUTES.find((x) => x.id === crossPageReveal) : undefined; + const [selected, setSelected] = useState(() => crossReveal?.id ?? "keyword"); + const [expanded, setExpanded] = useState>(() => + crossReveal ? new Set(ancestorKeys(crossReveal)) : new Set(), + ); + const [expandedFor, setExpandedFor] = useState(scopes); + if (expandedFor !== scopes) { + // Scope pages share this component; restart expansion on page switch. + setExpandedFor(scopes); + setExpanded(new Set(roots.filter((n) => n.children?.length).map((n) => n.key))); + } + // Language pages have their own attributes; keep the editor on a row of this page. + if (roots.length && !ids.has(selected)) setSelected(firstLeafId(roots)); + // Preview clicks reveal the attribute once the ancestors expanded (after the next render). + const treeRef = useRef(null); + const pendingReveal = useRef(null); + const reveal = (attrId: string) => { + const a = ATTRIBUTES.find((x) => x.id === attrId); + if (!a) return; + setSelected(attrId); + setExpanded((prev) => { + const next = new Set(prev); + for (const key of ancestorKeys(a)) next.add(key); + return next; + }); + pendingReveal.current = attrId; + }; + useEffect(() => { + if (pendingReveal.current) { + treeRef.current?.reveal(pendingReveal.current); + pendingReveal.current = null; + } else if (crossReveal) { + treeRef.current?.reveal(crossReveal.id); + } + crossPageReveal = null; + }); const id = effectiveScheme(scheme, undefined, customs); const mine = overrides?.[id]; + /** Keys of every group containing an overridden attribute, so the blue marks the path to the root. */ + const modifiedKeys = useMemo(() => { + const keys = new Set(); + for (const attrId of Object.keys(mine ?? {})) { + const a = ATTRIBUTES.find((x) => x.id === attrId); + if (!a) continue; + let key = ""; + for (const seg of attrSegs(a)) { + key += `/${seg}`; + keys.add(key); + } + } + return keys; + }, [mine]); const attr = ATTRIBUTES.find((a) => a.id === selected); const language = attr?.language; + const inherited = useMemo(() => (attr ? resolvedAttribute(id, attr.id, customs) : {}), [id, attr, customs]); const vars = useMemo( () => ({ ...previewVars(id, mine, customs, language), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties, [id, mine, customs, language, weight, boldFontWeight], ); + const baseVars = useMemo( + () => ({ ...previewVars(id, mine, customs), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties, + [id, mine, customs, weight, boldFontWeight], + ); const update = (attrId: string, v: TextAttributesOverride | undefined) => { const next: Overrides = { ...(overrides ?? {}) }; const forScheme = { ...(next[id] ?? {}) }; @@ -299,6 +1063,7 @@ function ColorSchemePage() { setOverrides(next); }; const schemes = listSchemes(customs); + const sample = SAMPLES[scopes[0] ?? ""] ?? SAMPLES["Language Defaults"]!; const schemeName = schemes.find((s) => s.id === id)?.name ?? id; const options: SelectOption[] = [ { value: "theme-default", label: "Theme default" }, @@ -436,57 +1201,120 @@ function ColorSchemePage() { {scheme === "theme-default" && (
{tx`Follows the IDE theme: currently ${schemeName}.`}
)} -
-
- - aria-label="Color scheme attributes" - roots={TREE} - getKey={(n) => n.key} - getLabel={(n) => n.label} - getChildren={(n) => n.children} - isExpandable={(n) => !!n.children} - defaultExpanded={["/Language Defaults", "/General", "/General/Code"]} - multiSelect={false} - selected={new Set([selected])} - onSelectionChange={(_s, lead) => lead?.attr && setSelected(lead.attr.id)} - getColor={(n) => (n.attr && mine?.[n.attr.id] ? "var(--accent)" : undefined)} - speedSearch - /> -
- {attr ? ( - update(attr.id, v)} - /> - ) : ( -
- )} -
-
-        {SAMPLE.map((line, i) => (
-          
- {i + 1} - - {line.map(([text, attrId], j) => ( - setSelected(attrId) : undefined} - > - {text} - - ))} - + {landing ? ( + + ) : ( + <> +
+
+ + aria-label="Color scheme attributes" + roots={roots} + getKey={(n) => n.key} + getLabel={(n) => n.label} + getChildren={(n) => n.children} + isExpandable={(n) => !!n.children?.length} + expanded={expanded} + onExpandedChange={setExpanded} + handleRef={treeRef} + multiSelect={false} + selected={new Set([selected])} + onSelectionChange={(_s, lead) => lead?.attr && setSelected(lead.attr.id)} + renderNode={(n) => ( + + {n.label} + + )} + speedSearch + /> +
+ {attr ? ( +
+ update(attr.id, v)} + /> +
+ ) : ( +
+ )}
- ))} -
+
+            {sample.code.map((line, i) => (
+              
+ {i + 1} + + {line.map(([text, attrId], j) => { + const attr = attrId !== undefined ? A(attrId) : undefined; + const inPage = attr !== undefined && ids.has(attrId!); + return ( + { + if (inPage) reveal(attrId!); + else { + crossPageReveal = attrId!; + selectPage(attrPageId(attr)); + } + } + : undefined + } + > + {text} + + ); + })} + +
+ ))} +
+ + )}
); } +const LANGUAGES = [...new Set(ATTRIBUTES.map((a) => a.path[0]!))].filter( + (s) => !["General", "Debugger", "Language Defaults"].includes(s), +); + +/** Sub-pages linked from the Color Scheme landing page, in sidebar order. */ +const SCHEME_SUBPAGES = [ + { id: "editor.colorScheme.general", title: "General" }, + { id: "editor.colorScheme.language-defaults", title: "Language Defaults" }, + { id: "editor.colorScheme.debugger", title: "Debugger" }, + ...LANGUAGES.map((l) => ({ id: `editor.colorScheme.${l.toLowerCase()}`, title: l })), +]; + +/** The scope chooser, import/export buttons and preview are identical on every sub-page. */ +function colorSchemeSection(scopes: readonly string[]) { + return { + items: [ + { + t: "custom" as const, + label: "Scheme", + keywords: ATTRIBUTES.filter((a) => scopes.includes(a.path[0]!)).map((a) => a.name), + component: () => , + }, + ], + }; +} + export const COLOR_SCHEME_PAGE = editorPage({ id: "editor.colorScheme", title: "Color Scheme", @@ -500,27 +1328,76 @@ export const COLOR_SCHEME_PAGE = editorPage({ "background", "bold", "italic", - "rainbow", ...SCHEMES.map((s) => s.name), ], sections: [ - { items: [{ t: "custom", label: "Scheme", keywords: ATTRIBUTES.map((a) => a.name), component: ColorSchemePage }] }, { - title: "Language Defaults", items: [ { - t: "check", - key: "editor.semanticHighlighting", - label: "Semantic highlighting", - hint: "Local variables and parameters get a colour derived from their name (needs a language server).", - }, - { - t: "check", - key: "editor.rainbowBrackets", - label: "Rainbow brackets", - hint: "Color (), [] and {} by nesting depth; unmatched brackets use the error color.", + t: "custom" as const, + label: "Scheme", + keywords: SCHEMES.map((s) => s.name), + component: () => , }, ], }, ], }); + +/** JetBrains layout: scopes and languages live in the settings tree under Color Scheme, one page each. */ +export const COLOR_SCHEME_PAGES: PageSpec[] = [ + COLOR_SCHEME_PAGE, + editorPage({ + id: "editor.colorScheme.general", + title: "General", + order: 1, + projectScope: false, + keywords: ATTRIBUTES.filter((a) => a.path[0] === "General").map((a) => a.name), + sections: [colorSchemeSection(["General"])], + }), + editorPage({ + id: "editor.colorScheme.language-defaults", + title: "Language Defaults", + order: 2, + projectScope: false, + keywords: ["semantic highlighting", "rainbow"], + sections: [ + colorSchemeSection(["Language Defaults"]), + { + title: "Language Defaults", + items: [ + { + t: "check", + key: "editor.semanticHighlighting", + label: "Semantic highlighting", + hint: "Local variables and parameters get a colour derived from their name (needs a language server).", + }, + { + t: "check", + key: "editor.rainbowBrackets", + label: "Rainbow brackets", + hint: "Color (), [] and {} by nesting depth; unmatched brackets use the error color.", + }, + ], + }, + ], + }), + editorPage({ + id: "editor.colorScheme.debugger", + title: "Debugger", + order: 3, + projectScope: false, + keywords: ["breakpoint", "inline values"], + sections: [colorSchemeSection(["Debugger"])], + }), + ...LANGUAGES.map((language, i) => + editorPage({ + id: `editor.colorScheme.${language.toLowerCase()}`, + title: language, + order: 4 + i, + projectScope: false, + keywords: ATTRIBUTES.filter((a) => a.path[0] === language).map((a) => a.name), + sections: [colorSchemeSection([language])], + }), + ), +]; diff --git a/src/features/settings/pages/editor/index.ts b/src/features/settings/pages/editor/index.ts index 0b4b51e1b..5d4390e67 100644 --- a/src/features/settings/pages/editor/index.ts +++ b/src/features/settings/pages/editor/index.ts @@ -2,7 +2,7 @@ import type { PageSpec } from "../spec"; import { GENERAL_PAGES } from "./general"; import { FONT_PAGE } from "./font"; -import { COLOR_SCHEME_PAGE } from "./colorScheme"; +import { COLOR_SCHEME_PAGES } from "./colorScheme"; import { CODE_STYLE_PAGES } from "./codeStyle"; import { INSPECTIONS_PAGE } from "./inspections"; import { NATURAL_LANGUAGES_PAGE, SPELLING_PAGE } from "./spelling"; @@ -12,7 +12,7 @@ export { codeStyleLanguagePages } from "./codeStyle"; export const EDITOR_PAGES: PageSpec[] = [ ...GENERAL_PAGES, FONT_PAGE, - COLOR_SCHEME_PAGE, + ...COLOR_SCHEME_PAGES, ...CODE_STYLE_PAGES, INSPECTIONS_PAGE, NATURAL_LANGUAGES_PAGE, diff --git a/src/platform/colorScheme.test.ts b/src/platform/colorScheme.test.ts index 267b9bad7..39d1f506d 100644 --- a/src/platform/colorScheme.test.ts +++ b/src/platform/colorScheme.test.ts @@ -1,6 +1,13 @@ import { readFileSync } from "node:fs"; import { beforeAll, describe, expect, it } from "vitest"; -import { _setSchemeSource, schemeAfterThemeChange, schemeCss, schemeTokens } from "./colorScheme"; +import { + CLEAR_COLOR, + _setSchemeSource, + resolvedAttribute, + schemeAfterThemeChange, + schemeCss, + schemeTokens, +} from "./colorScheme"; import { fontWeightVars, validateEditorExtras } from "./editorSettings"; import type { Settings } from "./ipc"; import { useProject } from "./project"; @@ -37,6 +44,35 @@ describe("color schemes", () => { expect(imported).toMatch(/\.cm-editor\[data-language="PHP"\] \{\n {2}--syn-variable: #48d8ee !important;/); }); + it("resolves scheme attribute state and editor defaults", () => { + expect(resolvedAttribute("light", "lineComment")).toMatchObject({ + foreground: "#8c8c8c", + italic: true, + }); + expect(resolvedAttribute("dark", "lineComment")).toMatchObject({ + foreground: "#7a7e85", + italic: false, + }); + expect(resolvedAttribute("dark", "mdHeading")).toMatchObject({ bold: true }); + + const customs = { + "custom-test": { + name: "Test", + parent: "dark" as const, + attributes: { + todo: { foreground: "#139839", background: "#101010", bold: true, italic: false, effect: "wave" as const }, + }, + }, + }; + expect(resolvedAttribute("custom-test", "todo", customs)).toMatchObject({ + foreground: "#139839", + background: "#101010", + bold: true, + italic: false, + effect: "wave", + }); + }); + it("emits scheme and override declarations", () => { const root = document.documentElement; root.dataset.theme = "light"; @@ -49,6 +85,8 @@ describe("color schemes", () => { expect(over).toContain("--syn-keyword-bold: var(--fw-code-bold, 700) !important"); const regular = schemeCss("theme-default", { light: { keyword: { bold: false } } }, root); expect(regular).toContain("--syn-keyword-bold: var(--fw-code, 400) !important"); + const cleared = schemeCss("theme-default", { light: { keyword: { background: CLEAR_COLOR } } }, root); + expect(cleared).toContain("--syn-keyword-bg: transparent !important"); const upright = schemeCss("theme-default", { light: { staticSymbol: { italic: false } } }, root); expect(upright).toContain("--syn-static-italic: normal !important"); const pythonItalic = schemeCss("theme-default", { light: { pyStaticSymbol: { italic: true } } }, root); diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index d201cb357..99cfbb20e 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -31,17 +31,26 @@ export interface AttributeDef { token?: string; /** A semantic modifier whose only configurable attribute is italic. */ italicOnly?: boolean; + defaultBold?: boolean; defaultItalic?: boolean; + defaultEffect?: TextAttributesOverride["effect"]; /** CodeMirror language name; unset attributes are language defaults. */ language?: string; } -const syn = (id: string, name: string, path: string[], token: string): AttributeDef => ({ +const syn = ( + id: string, + name: string, + path: string[], + token: string, + defaults: Pick = {}, +): AttributeDef => ({ id, name, path, token, fg: `--syn-${token}`, + ...defaults, }); const LD = "Language Defaults"; @@ -116,7 +125,7 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [ syn("identifier", "Identifiers › Local variable", [LD], "variable"), syn("parameter", "Identifiers › Parameter", [LD], "param"), syn("globalVariable", "Identifiers › Global variable", [LD], "global"), - syn("constant", "Identifiers › Constant", [LD], "constant"), + syn("constant", "Identifiers › Constant", [LD], "constant", { defaultItalic: true }), syn("field", "Identifiers › Instance field", [LD], "property"), { id: "staticSymbol", name: "Identifiers › Static symbol", path: [LD], token: "static", italicOnly: true }, syn("functionDeclaration", "Identifiers › Function declaration", [LD], "function"), @@ -127,7 +136,7 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [ syn("string", "String › String text", [LD], "string"), syn("escape", "String › Valid escape sequence", [LD], "escape"), syn("lineComment", "Comments › Line comment", [LD], "comment"), - syn("docComment", "Comments › Doc comment", [LD], "doc-comment"), + syn("docComment", "Comments › Doc comment", [LD], "doc-comment", { defaultItalic: true }), syn("metadata", "Metadata", [LD], "decorator"), syn("operator", "Braces and Operators", [LD], "punct"), syn("markupTag", "Markup › Tag", [LD], "tag"), @@ -156,7 +165,7 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [ defaultItalic: false, }, syn("pyKeywordArg", "Keyword argument", ["Python"], "keyword-arg"), - syn("pyDocstring", "Docstring", ["Python"], "docstring"), + syn("pyDocstring", "Docstring", ["Python"], "docstring", { defaultItalic: true }), syn("pyBuiltin", "Built-in name", ["Python"], "python-builtin"), syn("pyDecorator", "Decorator", ["Python"], "annotation"), syn("pyLocalVariable", "Local variable", ["Python"], "python-local-variable"), @@ -166,9 +175,9 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [ syn("pyConstant", "Constant", ["Python"], "python-constant"), syn("pyProperty", "Property", ["Python"], "python-property"), syn("pyTypeParameter", "Type parameter", ["Python"], "python-type-parameter"), - syn("mdHeading", "Heading", ["Markdown"], "markup-heading"), + syn("mdHeading", "Heading", ["Markdown"], "markup-heading", { defaultBold: true }), syn("mdCode", "Code span", ["Markdown"], "markup-code"), - syn("mdLink", "Link", ["Markdown"], "link"), + syn("mdLink", "Link", ["Markdown"], "link", { defaultEffect: "underline" }), syn("regexp", "Regular expression", ["RegExp"], "regexp"), ]; @@ -341,6 +350,11 @@ const EFFECT_CSS: Record string> = { none: () => "none", }; +/** Explicitly removes a scheme colour; unlike an unset field, this must survive as an override. */ +export const CLEAR_COLOR = "transparent"; + +const setOrCleared = (value: string | undefined) => isHexColor(value) || value === CLEAR_COLOR; + /** Declarations for the overrides of one scheme. */ export function overrideDeclarations( overrides: Record | undefined, @@ -352,10 +366,10 @@ export function overrideDeclarations( if (attr.language !== language) continue; const o = overrides[attr.id]; if (!o) continue; - if (isHexColor(o.foreground) && attr.fg) out.push([attr.fg, o.foreground]); - if (isHexColor(o.background) && attr.bg) out.push([attr.bg, o.background]); - if (isHexColor(o.effectColor) && attr.effect) out.push([attr.effect, o.effectColor]); - if (attr.effect && isHexColor(o.effectColor)) { + if (setOrCleared(o.foreground) && attr.fg) out.push([attr.fg, o.foreground]); + if (setOrCleared(o.background) && attr.bg) out.push([attr.bg, o.background]); + if (setOrCleared(o.effectColor) && attr.effect) out.push([attr.effect, o.effectColor]); + if (attr.effect && setOrCleared(o.effectColor)) { if (attr.id === "error") out.push(["--editor-error-decoration", `underline wavy ${o.effectColor}`]); if (attr.id === "warning") out.push(["--editor-warning-decoration", `underline wavy ${o.effectColor}`]); } @@ -366,11 +380,11 @@ export function overrideDeclarations( out.push([`--syn-${t}-italic`, o.italic ? "italic" : "normal"]); if (t === "comment") out.push(["--syn-comment-style", o.italic ? "italic" : "normal"]); } - if (isHexColor(o.background)) out.push([`--syn-${t}-bg`, o.background]); + if (setOrCleared(o.background)) out.push([`--syn-${t}-bg`, o.background]); if (o.effect) out.push([ `--syn-${t}-effect`, - EFFECT_CSS[o.effect]?.(isHexColor(o.effectColor) ? o.effectColor : "currentColor") ?? "none", + EFFECT_CSS[o.effect]?.(setOrCleared(o.effectColor) ? o.effectColor! : "currentColor") ?? "none", ]); } } @@ -418,6 +432,74 @@ export function resolvedSchemeTokens(id: string, customs: CustomColorSchemes = { return tokens; } +const hexIn = (value: string | undefined): string | undefined => + value?.match(/#(?:[0-9a-f]{3}|[0-9a-f]{6})\b/i)?.[0]?.toLowerCase(); + +const effectFromCss = (value: string | undefined): TextAttributesOverride["effect"] => { + if (!value) return undefined; + if (/line-through/i.test(value)) return "strikeout"; + if (/wavy/i.test(value)) return "wave"; + if (/underline/i.test(value)) return "underline"; + if (/none/i.test(value)) return "none"; + return undefined; +}; + +const boldFromCss = (value: string | undefined): boolean | undefined => { + if (!value) return undefined; + if (/bold|[6-9]00/i.test(value)) return true; + if (/normal|[1-5]00/i.test(value)) return false; + return undefined; +}; + +const italicFromCss = (value: string | undefined): boolean | undefined => { + if (!value) return undefined; + if (/italic|oblique/i.test(value)) return true; + if (/normal/i.test(value)) return false; + return undefined; +}; + +/** + * The attributes an editor shows for a scheme before user overrides: imported + * values where the scheme sets them, otherwise the theme token and the editor's + * built-in defaults. + */ +export function resolvedAttribute( + id: string, + attrId: string, + customs: CustomColorSchemes = {}, +): TextAttributesOverride { + const attr = ATTRIBUTES.find((a) => a.id === attrId); + if (!attr) return {}; + const custom = customSchemes(customs)[id]; + const own = custom?.attributes[attr.id]; + const vars = resolvedSchemeTokens(id, customs); + if (attr.language) + for (const [key, value] of overrideDeclarations(custom?.attributes, attr.language)) vars.set(key, value); + + const token = attr.token; + const foreground = attr.fg ? vars.get(attr.fg) : undefined; + const background = attr.bg ? vars.get(attr.bg) : token ? vars.get(`--syn-${token}-bg`) : undefined; + const effectCss = attr.effect ? vars.get(attr.effect) : token ? vars.get(`--syn-${token}-effect`) : undefined; + const effect = own?.effect ?? effectFromCss(effectCss) ?? attr.defaultEffect; + const out: TextAttributesOverride = {}; + if (isHexColor(foreground)) out.foreground = foreground.toLowerCase(); + if (isHexColor(background)) out.background = background.toLowerCase(); + if (own?.effectColor && isHexColor(own.effectColor)) out.effectColor = own.effectColor.toLowerCase(); + else { + const effectColor = hexIn(effectCss); + if (effectColor) out.effectColor = effectColor; + } + if (token) { + if (!attr.italicOnly) + out.bold = own?.bold ?? boldFromCss(vars.get(`--syn-${token}-bold`)) ?? attr.defaultBold ?? false; + const italicCss = + vars.get(`--syn-${token}-italic`) ?? (token === "comment" ? vars.get("--syn-comment-style") : undefined); + out.italic = own?.italic ?? italicFromCss(italicCss) ?? attr.defaultItalic ?? false; + } else if (effect) out.effect = effect; + if (token && effect) out.effect = effect; + return out; +} + const SAFE = /^[^;{}<>]*$/; /** Stylesheet text for the chosen scheme and its overrides ("" when the theme's own is used unchanged). */ diff --git a/src/platform/locales/text/es.json b/src/platform/locales/text/es.json index ec1d5d607..990b2a494 100644 --- a/src/platform/locales/text/es.json +++ b/src/platform/locales/text/es.json @@ -2340,7 +2340,6 @@ "UI font size": "Tamaño de fuente de la interfaz", "Uninstalled {name}": "Se desinstaló {name}", "URL:": "URL:", - "Unchecked values are inherited from the scheme.": "Los valores desmarcados se heredan del esquema.", "Uncommitted changes are stashed before the update and restored afterwards.": "Los cambios sin confirmar se guardan en el stash antes de la actualización y se restauran después.", "Underline": "Subrayado", "Underscored": "Subrayado simple", diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index 1a6f1fdbc..323f185f5 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -2237,7 +2237,6 @@ "UI font size": "Taille de police de l'interface", "Uninstalled {name}": "{name} désinstallé", "URL:": "URL :", - "Unchecked values are inherited from the scheme.": "Les valeurs non cochées sont héritées du schéma.", "Uncommitted changes are stashed before the update and restored afterwards.": "Les modifications non validées sont remisées avant la mise à jour et restaurées ensuite.", "Underline": "Souligné", "Underscored": "Souligné d'un trait", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index 847280f0e..2ee95ca92 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -2396,7 +2396,6 @@ "UI font": "UI 글꼴", "UI font size": "UI 글꼴 크기", "Uninstalled {name}": "{name} 제거됨", - "Unchecked values are inherited from the scheme.": "선택 해제된 값은 구성표에서 상속됩니다.", "Uncommitted changes are stashed before the update and restored afterwards.": "커밋되지 않은 변경 사항은 업데이트 전에 스태시되었다가 이후에 복원됩니다.", "Underline": "밑줄", "Underscored": "밑줄 표시", diff --git a/src/platform/locales/text/pl.json b/src/platform/locales/text/pl.json index dff586ba7..baca72a8c 100644 --- a/src/platform/locales/text/pl.json +++ b/src/platform/locales/text/pl.json @@ -2319,7 +2319,6 @@ "UI font size": "Rozmiar czcionki interfejsu użytkownika", "Uninstalled {name}": "Odinstalowano {name}", "URL:": "Adres URL:", - "Unchecked values are inherited from the scheme.": "Niezaznaczone wartości są dziedziczone ze schematu.", "Uncommitted changes are stashed before the update and restored afterwards.": "Niezatwierdzone zmiany są odkładane do schowka przed aktualizacją i przywracane po jej zakończeniu.", "Underline": "Podkreślenie", "Underscored": "Podkreślony równo", diff --git a/src/platform/locales/text/pt.json b/src/platform/locales/text/pt.json index 81f418cfb..0431c0c77 100644 --- a/src/platform/locales/text/pt.json +++ b/src/platform/locales/text/pt.json @@ -2320,7 +2320,6 @@ "UI font size": "Tamanho da fonte da interface", "Uninstalled {name}": "{name} desinstalado", "URL:": "URL:", - "Unchecked values are inherited from the scheme.": "Os valores desmarcados são herdados do esquema.", "Uncommitted changes are stashed before the update and restored afterwards.": "As alterações não commitadas são guardadas em stash antes da atualização e restauradas depois.", "Underline": "Sublinhado", "Underscored": "Sublinhado simples", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 9232f653b..8bfd3df9d 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -516,6 +516,7 @@ "Collapse Unchanged Fragments": "折叠未更改的片段", "Color Scheme": "配色方案", "Color scheme attributes": "配色方案属性", + "Color scheme pages": "配色方案页面", "Color scheme preview": "配色方案预览", "Color theme": "颜色主题", "Column": "列", @@ -2425,7 +2426,7 @@ "UI font size": "UI 字体大小", "Uninstalled {name}": "已卸载 {name}", "URL:": "URL:", - "Unchecked values are inherited from the scheme.": "未勾选的值继承自方案。", + "The controls show the scheme's actual appearance; changes override it.": "控件显示方案的实际外观;修改即覆盖方案。", "Uncommitted changes are stashed before the update and restored afterwards.": "更新前会储藏未提交的更改,并在之后恢复。", "Underline": "下划线", "Underscored": "下划线",