From 04d5d9bd58655ea6df22a5aa099a85ace445e6fd Mon Sep 17 00:00:00 2001 From: Muu Date: Fri, 13 Nov 2026 20:15:01 +0800 Subject: [PATCH] feat(color scheme): edit text style for errors and warnings (#3154) Errors and Warnings attributes (Error, Warning, Typo, Unused symbol, Deprecated symbol) now offer bold, italic and effect choices like syntax tokens, written to --wave-*/--fg-* variables the editor reads; Typo and Deprecated symbol are listed and previewed. Deprecated symbol keeps the text colour and draws only its effect; an effect set to none draws none. Custom and imported schemes keep the font style and effect of token-less attributes (Errors and Warnings, Git), and the preview shows them. Co-authored-by: muutot <92625353+muutot@users.noreply.github.com> --- src/editor/lsp/diagnostics.ts | 19 +++- .../settings/pages/editor/colorScheme.tsx | 40 ++++++--- src/platform/colorScheme.test.ts | 40 +++++++++ src/platform/colorScheme.ts | 87 +++++++++++++++---- src/styles/tokens.css | 8 ++ 5 files changed, 164 insertions(+), 30 deletions(-) diff --git a/src/editor/lsp/diagnostics.ts b/src/editor/lsp/diagnostics.ts index 64c7edd2a..4be134765 100644 --- a/src/editor/lsp/diagnostics.ts +++ b/src/editor/lsp/diagnostics.ts @@ -235,12 +235,16 @@ const lintTheme = EditorView.theme({ ".cm-lintRange-error": { textDecoration: "var(--editor-error-decoration, underline wavy var(--danger))", WebkitTextDecoration: "var(--editor-error-decoration, underline wavy var(--danger))", + fontWeight: "var(--wave-error-bold, inherit)", + fontStyle: "var(--wave-error-italic, inherit)", textDecorationSkipInk: "none", textUnderlineOffset: "3px", }, ".cm-lintRange-warning": { textDecoration: "var(--editor-warning-decoration, underline wavy var(--warning))", WebkitTextDecoration: "var(--editor-warning-decoration, underline wavy var(--warning))", + fontWeight: "var(--wave-warning-bold, inherit)", + fontStyle: "var(--wave-warning-italic, inherit)", backgroundColor: "var(--editor-warning-background, transparent)", textDecorationSkipInk: "none", textUnderlineOffset: "3px", @@ -254,8 +258,10 @@ const lintTheme = EditorView.theme({ ".cm-lintRange-hint": { textDecoration: "none" }, // Typo level: IDEA's green wavy underline. ".cm-lintRange.cm-lsp-typo": { - textDecoration: "underline wavy color-mix(in srgb, var(--success) 85%, transparent)", - WebkitTextDecoration: "underline wavy color-mix(in srgb, var(--success) 85%, transparent)", + textDecoration: "var(--wave-typo-effect, underline wavy var(--wave-typo, var(--success)))", + WebkitTextDecoration: "var(--wave-typo-effect, underline wavy var(--wave-typo, var(--success)))", + fontWeight: "var(--wave-typo-bold, inherit)", + fontStyle: "var(--wave-typo-italic, inherit)", textDecorationSkipInk: "none", textUnderlineOffset: "3px", }, @@ -263,9 +269,16 @@ const lintTheme = EditorView.theme({ textDecoration: "none", backgroundColor: "transparent", color: "var(--fg-unused, var(--fg-faint))", + fontWeight: "var(--fg-unused-bold, inherit)", + fontStyle: "var(--fg-unused-italic, inherit)", }, ".cm-lintRange.cm-lsp-unnecessary *": { color: "var(--fg-unused, var(--fg-faint))" }, - ".cm-lintRange.cm-lsp-deprecated": { textDecoration: "line-through", textDecorationColor: "var(--fg-muted)" }, + ".cm-lintRange.cm-lsp-deprecated": { + textDecoration: "var(--fg-deprecated-effect, line-through var(--fg-deprecated, var(--fg-muted)))", + WebkitTextDecoration: "var(--fg-deprecated-effect, line-through var(--fg-deprecated, var(--fg-muted)))", + fontWeight: "var(--fg-deprecated-bold, inherit)", + fontStyle: "var(--fg-deprecated-italic, inherit)", + }, // An error or warning on the same range (merged into one mark) keeps its underline. ".cm-lintRange-error.cm-lsp-typo, .cm-lintRange-error.cm-lsp-unnecessary": { textDecoration: "var(--editor-error-decoration, underline wavy var(--danger))", diff --git a/src/features/settings/pages/editor/colorScheme.tsx b/src/features/settings/pages/editor/colorScheme.tsx index 9aae9b4b0..2b4f86a18 100644 --- a/src/features/settings/pages/editor/colorScheme.tsx +++ b/src/features/settings/pages/editor/colorScheme.tsx @@ -10,6 +10,7 @@ import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride } import { ATTRIBUTES, SCHEMES, + EFFECT_CSS, overrideDeclarations, effectiveScheme, resolvedSchemeTokens, @@ -123,6 +124,8 @@ function previewVars( k.startsWith("--border-") || k.startsWith("--ansi-") || k.startsWith("--git-") || + k.startsWith("--wave-") || + k.startsWith("--editor-") || k === "--caret" ) vars[k] = v; @@ -137,20 +140,32 @@ function previewVars( /** Text styled like an attribute in the preview. */ function tokenStyle(attr: AttributeDef | undefined): CSSProperties { - if (!attr?.token) - return { + const base = attr?.fg ?? attr?.effect; + if (!attr?.token) { + const colour: CSSProperties = { ...(attr?.fg ? { color: `var(${attr.fg})` } : null), ...(attr?.bg ? { backgroundColor: `var(${attr.bg})` } : null), - // Token-less attributes with a font-style renderer carry it on `---*`, the same - // declarations `overrideDeclarations` emits for their overrides. - ...(attr?.fontStyle && (attr.fg ?? attr.effect) - ? { - fontWeight: `var(${attr.fg ?? attr.effect}-bold, inherit)`, - fontStyle: `var(${attr.fg ?? attr.effect}-italic, inherit)`, - textDecoration: `var(${attr.effect ?? attr.fg}-effect, none)`, - } + }; + // Token-less attributes with a font-style renderer carry it on `---*`, the same + // declarations `overrideDeclarations` emits for their overrides. + if (!attr?.fontStyle || !base) return colour; + const effectColor = `var(${attr.effect ?? attr.fg ?? "--fg-muted"})`; + const effect: TextAttributesOverride["effect"] = attr.defaultEffect ?? (attr.effect ? "wave" : undefined); + const decoration = + effect && effect !== "none" + ? `var(${attr.effect ?? base}-effect, ${EFFECT_CSS[effect]?.(effectColor) ?? "none"})` + : undefined; + return { + ...colour, + fontWeight: `var(${base}-bold, ${ + attr.defaultBold ? "var(--fw-code-bold, 700)" : "inherit" + })` as CSSProperties["fontWeight"], + fontStyle: `var(${base}-italic, ${attr.defaultItalic ? "italic" : "inherit"})`, + ...(decoration + ? { textDecoration: decoration, ...({ WebkitTextDecoration: decoration } as CSSProperties) } : null), }; + } if (attr.italicOnly) return { fontStyle: `var(--syn-${attr.token}-italic, ${attr.defaultItalic === false ? "normal" : "italic"})` }; const t = attr.token; @@ -206,6 +221,11 @@ const SAMPLES: Record = { [" ", "text"], ["legacyCall", "unused"], ], + [ + ["typo", "typo"], + [" ", "text"], + ["deprecatedCall", "deprecated"], + ], [ ["bad\u203dchar", "invalid"], [" \u00b7 ", "whitespaces"], diff --git a/src/platform/colorScheme.test.ts b/src/platform/colorScheme.test.ts index 3f6318d03..56d7a9c5d 100644 --- a/src/platform/colorScheme.test.ts +++ b/src/platform/colorScheme.test.ts @@ -4,6 +4,7 @@ import { ATTRIBUTES, CLEAR_COLOR, _setSchemeSource, + overrideDeclarations, resolvedAttribute, schemeAfterThemeChange, schemeCss, @@ -341,4 +342,43 @@ describe("color schemes", () => { expect(v.codeStyle.languages.go).toEqual({ useTab: true }); expect(v.general.softWrapFiles).toContain("*.md"); }); + + it("styles Errors and Warnings attributes beyond colour", () => { + const decls = overrideDeclarations({ + error: { bold: true, italic: true, effect: "strikeout", effectColor: "#ff0000" }, + unused: { italic: true }, + }); + const map = new Map(decls); + expect(map.get("--wave-error-bold")).toBe("var(--fw-code-bold, 700)"); + expect(map.get("--wave-error-italic")).toBe("italic"); + expect(map.get("--wave-error-effect")).toBe("line-through #ff0000"); + expect(map.get("--editor-error-decoration")).toBe("line-through #ff0000"); + expect(map.get("--wave-error")).toBe("#ff0000"); + expect(map.get("--fg-unused-italic")).toBe("italic"); + const none = new Map(overrideDeclarations({ warning: { effect: "none" }, deprecated: { effect: "underline" } })); + expect(none.get("--editor-warning-decoration")).toBe("none"); + expect(none.get("--fg-deprecated-effect")).toBe("underline var(--fg-deprecated)"); + }); + + it("keeps the font style of Errors and Warnings in custom schemes", () => { + const customs = { + "custom-x": { + name: "X", + parent: "dark" as const, + attributes: { error: { bold: true }, deprecated: { effect: "underline" as const } }, + }, + }; + const css = schemeCss("custom-x", undefined, null, customs); + expect(css).toContain("--wave-error-bold: var(--fw-code-bold, 700)"); + expect(css).toContain("--fg-deprecated-effect: underline var(--fg-deprecated)"); + }); + + it("lists typo and deprecated symbol under Errors and Warnings", () => { + const ids = ATTRIBUTES.filter((a) => a.path.join("/") === "General/Errors and Warnings").map((a) => a.id); + expect(ids).toEqual(["error", "warning", "weakWarning", "typo", "unused", "deprecated", "invalid"]); + expect(resolvedAttribute("dark", "typo")).toMatchObject({ effect: "wave" }); + expect(resolvedAttribute("dark", "deprecated")).toMatchObject({ effect: "strikeout", effectColor: "#8c8f95" }); + expect(resolvedAttribute("dark", "deprecated").foreground).toBeUndefined(); + expect(resolvedAttribute("dark", "error")).toMatchObject({ effect: "wave", bold: false, italic: false }); + }); }); diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index 8c45ad90f..46b39f32c 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -31,7 +31,8 @@ export interface AttributeDef { token?: string; /** A semantic modifier whose only configurable attribute is italic. */ italicOnly?: boolean; - /** The attribute's renderer applies font style and effects (tokens, the Git surfaces). */ + /** The attribute's renderer applies font style and effects (tokens, the Git surfaces, + * Errors and Warnings): overrides land on `---*`. */ fontStyle?: true; defaultBold?: boolean; defaultItalic?: boolean; @@ -139,10 +140,46 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [ path: ["General", "Search Results"], bg: "--bg-search-match-current", }, - { id: "error", name: "Error", path: ["General", "Errors and Warnings"], effect: "--wave-error" }, - { id: "warning", name: "Warning", path: ["General", "Errors and Warnings"], effect: "--wave-warning" }, + { + id: "error", + name: "Error", + path: ["General", "Errors and Warnings"], + effect: "--wave-error", + defaultEffect: "wave", + fontStyle: true, + }, + { + id: "warning", + name: "Warning", + path: ["General", "Errors and Warnings"], + effect: "--wave-warning", + defaultEffect: "wave", + fontStyle: true, + }, { id: "weakWarning", name: "Weak warning", path: ["General", "Errors and Warnings"], fg: "--fg-weak-warning" }, - { id: "unused", name: "Unused symbol", path: ["General", "Errors and Warnings"], fg: "--fg-unused" }, + { + id: "typo", + name: "Typo", + path: ["General", "Errors and Warnings"], + effect: "--wave-typo", + defaultEffect: "wave", + fontStyle: true, + }, + { + id: "unused", + name: "Unused symbol", + path: ["General", "Errors and Warnings"], + fg: "--fg-unused", + fontStyle: true, + }, + { + id: "deprecated", + name: "Deprecated symbol", + path: ["General", "Errors and Warnings"], + effect: "--fg-deprecated", + defaultEffect: "strikeout", + fontStyle: true, + }, syn("invalid", "Bad character", ["General", "Errors and Warnings"], "invalid"), syn("todo", "TODO defaults", ["General", "Code"], "todo"), { id: "inlineValue", name: "Inline debugger values", path: ["Debugger"], fg: "--syn-inline-value" }, @@ -339,6 +376,11 @@ export const ATTRIBUTES: readonly AttributeDef[] = [ /** Variables a scheme sets: every attribute variable plus the editor tokens derived from them. */ const SCHEME_VARS = new Set([ ...ATTRIBUTES.flatMap((a) => [a.fg, a.bg, a.effect].filter((v): v is string => !!v)), + // Font style of token-less attributes (`overrideDeclarations` writes them for custom schemes too). + ...ATTRIBUTES.flatMap((a) => { + const base = a.fg ?? a.effect; + return !a.token && a.fontStyle && base ? [`${base}-bold`, `${base}-italic`, `${a.effect ?? base}-effect`] : []; + }), "--syn-comment-style", "--syn-doc-comment-tag-effect", "--editor-error-decoration", @@ -477,7 +519,7 @@ export function boldWeight(bold: boolean): string { return bold ? "var(--fw-code-bold, 700)" : "var(--fw-code, 400)"; } -const EFFECT_CSS: Record string> = { +export const EFFECT_CSS: Record string> = { underline: (c) => `underline ${c}`, wave: (c) => `underline wavy ${c}`, strikeout: (c) => `line-through ${c}`, @@ -507,9 +549,12 @@ export function overrideDeclarations( } 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}`]); + if (attr.effect && (setOrCleared(o.effectColor) || o.effect)) { + const style = o.effect ?? attr.defaultEffect ?? "wave"; + const color = setOrCleared(o.effectColor) ? o.effectColor! : `var(${attr.effect})`; + const decoration = EFFECT_CSS[style]?.(color) ?? "none"; + if (attr.id === "error") out.push(["--editor-error-decoration", decoration]); + if (attr.id === "warning") out.push(["--editor-warning-decoration", decoration]); } if (attr.token) { const t = attr.token; @@ -534,7 +579,9 @@ export function overrideDeclarations( if (o.effect) out.push([ `${attr.effect ?? base}-effect`, - EFFECT_CSS[o.effect]?.(setOrCleared(o.effectColor) ? o.effectColor! : "currentColor") ?? "none", + EFFECT_CSS[o.effect]?.( + setOrCleared(o.effectColor) ? o.effectColor! : attr.effect ? `var(${attr.effect})` : "currentColor", + ) ?? "none", ]); } } @@ -646,14 +693,20 @@ export function resolvedAttribute( 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 (attr.fontStyle) { - const base = attr.fg ?? attr.effect; - const bold = base ? vars.get(`${base}-bold`) : undefined; - const italic = base ? vars.get(`${base}-italic`) : undefined; - if (bold !== undefined) out.bold = boldFromCss(bold) ?? false; - if (italic !== undefined) out.italic = italicFromCss(italic) ?? false; - } else if (effect) out.effect = effect; - if (token && effect) out.effect = effect; + if (effect) out.effect = effect; + } else { + // Token-less attributes whose renderer applies font style (the Git segments, Errors and + // Warnings) carry their overrides on `---*`, the same declarations + // `overrideDeclarations` emits. + if (attr.fontStyle) { + const base = attr.fg ?? attr.effect; + const bold = base ? vars.get(`${base}-bold`) : undefined; + const italic = base ? vars.get(`${base}-italic`) : undefined; + out.bold = boldFromCss(bold) ?? attr.defaultBold ?? false; + out.italic = italicFromCss(italic) ?? attr.defaultItalic ?? false; + } + if (effect) out.effect = effect; + } return out; } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index f878d3143..063218c13 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -204,6 +204,8 @@ html { --fg-fold-marker: #6f737a; --fg-whitespace: #6f737a; --fg-unused: #6f737a; + --wave-typo: #59a869; + --fg-deprecated: #8c8f95; --bg-identifier-under-caret: #373b39; --bg-identifier-under-caret-write: #402f33; --fg-inlay: #868a91; @@ -643,6 +645,8 @@ html { --fg-fold-marker: #8b8e94; --fg-whitespace: #b5b7bd; --fg-unused: #8c8c8c; + --wave-typo: #3d9950; + --fg-deprecated: #6c707e; --bg-identifier-under-caret: #edebfc; --bg-identifier-under-caret-write: #fce8f4; --fg-inlay: #73767c; @@ -935,6 +939,8 @@ html { --fg-fold-marker: #9fa2a9; --fg-whitespace: #8b8e95; --fg-unused: #8b8e95; + --wave-typo: #6cbb7d; + --fg-deprecated: #b6b8be; --bg-identifier-under-caret: #3f4a44; --bg-identifier-under-caret-write: #553840; --fg-inlay: #ccced3; @@ -1147,6 +1153,8 @@ html { --fg-fold-marker: var(--palette-muted); --fg-whitespace: var(--palette-muted); --fg-unused: var(--palette-muted); + --wave-typo: var(--palette-green); + --fg-deprecated: var(--palette-muted); --fg-inlay: var(--palette-muted); --fg-weak-warning: var(--palette-muted); --syn-inline-value: var(--palette-muted);