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": "下划线",