From 7f0fd870e84bb3c61169583c53846cdfd0dd0016 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:46:12 +0800 Subject: [PATCH 01/29] fix(themes): Define the chart token in the high-contrast dark theme (#69) contrast.test.ts requires every plain colour token in high-contrast dark; #63 added --chart-series to the dark and light themes only. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/styles/tokens.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/styles/tokens.css b/src/styles/tokens.css index cd7444f5..00b43124 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -645,6 +645,8 @@ html { --link: #a7c5ff; --link-hover: #d0dffe; + --chart-series: #ff9b9f; + --danger: #ff9b9f; --warning: #f2c55c; --success: #8ec39d; From 12418e0184952b198670f34fa2bff32b7a19daa4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:23:23 +0800 Subject: [PATCH 02/29] =?UTF-8?q?fix(editor):=20Show=20the=20=E2=8C=98/Ctr?= =?UTF-8?q?l-hover=20link=20without=20waiting=20for=20documentation=20(#71?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When you hold ⌘ (Ctrl on Windows/Linux) and hover a symbol, the navigation link had three problems: - **Slow to appear.** The underline and pointer cursor only showed after the hover (documentation) request came back. A slow or stuck documentation request held the link back. - **Errors were remembered.** If the definition request was cancelled or the server returned an error, the word was cached as having no target. Hovering it again never retried. - **Stuck requests blocked the word.** A definition request that never answered blocked any new request for that word. ## Changes (`src/editor/lsp/navigation.ts`) - The link is underlined as soon as a definition is found. The short summary is filled in when the hover request returns. - Cancellation and server errors count as temporary failures. They are no longer cached, so the next hover retries. - A definition request expires after 3 seconds and can be retried. If its response arrives later, it is ignored. - Responses for a word the pointer has already left, or that arrive after the editor plugin is destroyed, are dropped. ## Tests - Four new tests in `src/editor/lsp/syntaxFallback.test.ts`: the link shows without waiting for documentation, a temporary error is retried, a stuck request is retried and its late answer ignored, and a late answer for the previous word is ignored. The first three fail without this change. - `pnpm vitest run src/editor/lsp`: 179 passed. `pnpm typecheck` and eslint report no errors. - Checked in the running app (`fixtures/ts-app`, `cart.ts`): ⌘-hover over `Cents` showed the underline and pointer cursor after 312 ms and the type summary after 356 ms. ⌘-click opened `money.ts` at the definition. --- src/editor/lsp/navigation.ts | 70 ++++++++++++++++++---- src/editor/lsp/syntaxFallback.test.ts | 86 ++++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 13 deletions(-) diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 55ff239a..ee774825 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -320,15 +320,14 @@ async function probeNavigation(view: EditorView, offset: number): Promise<{ info try { navigable = (await lspRequest(() => commands.lspGoto(doc.doc, pos, "definition"))).length > 0; } catch (e) { - if (isCancelled(e)) return null; - if (isUnavailable(e)) navigable = !!(await gotoFallback(view, doc, pos))?.length; + if (!isUnavailable(e)) throw e; + navigable = !!(await gotoFallback(view, doc, pos))?.length; } if (!navigable && WEB_LANGUAGES.has(doc.language) && !doc.virtual) navigable = !!(await lspRequest(() => commands.webGoto(doc.doc, pos)).catch(() => null))?.targets.length; } if (!navigable) return null; - const hover = await requestHover(view, offset).catch(() => null); - return { info: hover ? declarationSummary(hover.contents) : null }; + return { info: null }; } const linkPlugin = ViewPlugin.fromClass( @@ -339,6 +338,10 @@ const linkPlugin = ViewPlugin.fromClass( private probedDoc: Text | null = null; private probing: string | null = null; private off: () => void; + private disposed = false; + private hovered: string | null = null; + private probeVersion = 0; + private probeTimer: ReturnType | undefined; constructor(readonly view: EditorView) { // Window-wide, so ⌘/Ctrl over an unfocused editor underlines too; released anywhere clears it. this.off = onNavigationModifier((held) => { @@ -347,10 +350,16 @@ const linkPlugin = ViewPlugin.fromClass( } destroy() { this.off(); + this.disposed = true; + this.probeVersion++; + clearTimeout(this.probeTimer); } private set(value: LinkState | null) { const current = this.view.state.field(linkField); - if (current === value || (current && value && current.from === value.from && current.to === value.to && current.info === value.info)) + if ( + current === value || + (current && value && current.from === value.from && current.to === value.to && current.info === value.info) + ) return; if (!current && !value) return; this.view.dispatch({ effects: setLink.of(value) }); @@ -361,10 +370,19 @@ const linkPlugin = ViewPlugin.fromClass( // Underline whole links once the server's links are in. if (doc && !doc.virtual && !knownLinks(view)) void documentLinks(view, doc).then(() => { - if (view.state.field(linkField)) this.show(true); + if (!this.disposed && view.state.field(linkField)) this.show(true); }); const target = meta && this.last ? linkAt(view, this.last.x, this.last.y) : null; + const hovered = target ? `${target.from}:${target.to}` : null; + if (hovered !== this.hovered) { + this.hovered = hovered; + this.probeVersion++; + clearTimeout(this.probeTimer); + this.probing = null; + } if (!target) { + this.probeVersion++; + clearTimeout(this.probeTimer); this.probing = null; this.set(null); return; @@ -388,13 +406,41 @@ const linkPlugin = ViewPlugin.fromClass( if (current && (current.from !== target.from || current.to !== target.to)) this.set(null); if (this.probing === key) return; this.probing = key; + const version = ++this.probeVersion; + clearTimeout(this.probeTimer); + // A timed-out probe must be retryable, rather than cached as "no target". + this.probeTimer = setTimeout(() => { + if (this.probeVersion === version) { + this.probeVersion++; + this.probing = null; + } + }, 3000); const docAt = view.state.doc; - void probeNavigation(view, target.from).then((result) => { - if (view.state.doc !== docAt) return; - this.probed.set(key, result); - if (this.probing !== key) return; - this.probing = null; - if (result) this.set({ from: target.from, to: target.to, info: result.info }); + void probeNavigation(view, target.from) + .then((result) => { + if (this.disposed || view.state.doc !== docAt || this.probeVersion !== version) return; + clearTimeout(this.probeTimer); + this.probed.set(key, result); + if (this.probing !== key) return; + this.probing = null; + if (result) { + this.set({ from: target.from, to: target.to, info: null }); + // Documentation must never delay the link cursor or underline. + void requestHover(view, target.from) + .then((hover) => { + if (this.disposed || view.state.doc !== docAt || this.probeVersion !== version || !hover) return; + const info = declarationSummary(hover.contents); + this.probed.set(key, { info }); + this.set({ from: target.from, to: target.to, info }); + }) + .catch(() => {}); + } + }) + .catch(() => { + if (this.disposed || this.probeVersion !== version) return; + clearTimeout(this.probeTimer); + this.probing = null; + // Cancellation and server errors are transient, not negative results. }); } }, diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 5524891c..903e5438 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { EditorState, Text } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import type { IdeError, Locals, NodeAt, Range, SymbolMatch, SyntaxNode } from "@/bindings"; @@ -8,6 +8,9 @@ const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); const unavailable = () => Promise.resolve({ status: "error" as const, error: { kind: "unavailable", message: "no server" } as IdeError }); const cmd = { + lspHover: vi.fn((): R => ok(null)), + lspDocumentLinks: vi.fn(() => ok([])), + cancelRequest: vi.fn(() => ok(null)), lspGoto: vi.fn((): R => unavailable()), lspDocumentHighlights: vi.fn((): R => unavailable()), lspPrepareRename: vi.fn((): R => unavailable()), @@ -390,3 +393,84 @@ describe("rename wiring", () => { expect(useLspPopup.getState().popup?.kind).toBe("rename"); }); }); + +describe("modifier-hover navigation recovery", () => { + const target = { path: "/p/target.ts", range: r(0, 0, 1), uri: null }; + let mac = false; + beforeAll(async () => { + mac = (await import("@/platform/keymap")).isMacHost(); + }); + function move(view: EditorView, pos = 0, primary = true) { + vi.spyOn(view, "posAtCoords").mockReturnValue(pos); + view.contentDOM.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + metaKey: primary && mac, + ctrlKey: primary && !mac, + }), + ); + } + + it("shows the link without waiting for documentation", async () => { + cmd.lspGoto.mockReturnValueOnce(ok([target])); + cmd.lspHover.mockReturnValueOnce(new Promise(() => {})); + const view = editor("foo"); + move(view); + await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo")); + expect(cmd.lspHover).toHaveBeenCalled(); + }); + + it("retries a transient server error instead of caching no target", async () => { + cmd.lspGoto.mockReturnValueOnce( + Promise.resolve({ status: "error", error: { kind: "internal", message: "timeout" } }), + ); + const view = editor("foo"); + move(view); + await new Promise((resolve) => setTimeout(resolve, 20)); + cmd.lspGoto.mockReturnValueOnce(ok([target])); + move(view); + await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo")); + expect(cmd.lspGoto).toHaveBeenCalledTimes(2); + }); + + it("retries a stuck probe and ignores its eventual response", async () => { + let finish!: (value: Awaited>) => void; + cmd.lspGoto.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = editor("foo"); + vi.useFakeTimers(); + try { + move(view); + await vi.advanceTimersByTimeAsync(3001); + cmd.lspGoto.mockReturnValueOnce(ok([target])); + move(view); + expect(cmd.lspGoto).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo")); + finish({ status: "ok", data: [] }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo"); + }); + + it("ignores a late probe after moving to a different word", async () => { + let finish!: (value: Awaited>) => void; + cmd.lspGoto.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + cmd.lspGoto.mockReturnValueOnce(ok([target])); + const view = editor("foo bar"); + move(view); + move(view, 4); + await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("bar")); + finish({ status: "ok", data: [target] }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("bar"); + }); +}); From 566b4fdeb10087ce549c1444d1e97fa6612f60f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=BF=80?= <1020327414@qq.com> Date: Tue, 27 Oct 2026 14:27:24 +0800 Subject: [PATCH 03/29] Make static symbol italics configurable (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python language servers mark class attributes `static`, so their declarations and `self.` references rendered italic, unlike PyCharm. Adds Color Scheme attributes for static symbols: Language Defaults › Identifiers › Static symbol (italic by default, as IDEA's static members) and Python › Static symbol (upright by default), applied to semantic tokens with the `static` modifier. Verified in the app with basedpyright: a class attribute renders upright by default and italic when Python › Static symbol › Italic is on, applied live. Maintainer follow-up: formatted colorScheme.tsx. https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/editor/lsp/features.test.ts | 6 +++++ src/editor/lsp/semantic.ts | 5 ++-- src/editor/lsp/theme.ts | 3 ++- .../settings/pages/editor/colorScheme.tsx | 27 ++++++++++++++----- src/platform/colorScheme.test.ts | 4 +++ src/platform/colorScheme.ts | 5 ++++ 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/editor/lsp/features.test.ts b/src/editor/lsp/features.test.ts index 1f23db66..cd0dac84 100644 --- a/src/editor/lsp/features.test.ts +++ b/src/editor/lsp/features.test.ts @@ -26,6 +26,12 @@ describe("semanticRanges", () => { { from: 34, to: 37, className: "cm-sem-constant" }, ]); }); + + it("applies the Python-specific static style without changing other languages", () => { + const input = { tokenTypes: ["property"], tokenModifiers: ["static"], data: [0, 0, 5, 0, 1] }; + expect(semanticRanges(doc, input, false, "python")[0]?.className).toBe("cm-sem-property cm-sem-static cm-sem-python-static"); + expect(semanticRanges(doc, input, false, "java")[0]?.className).toBe("cm-sem-property cm-sem-static"); + }); }); describe("semantic highlighting (rainbow identifiers)", () => { diff --git a/src/editor/lsp/semantic.ts b/src/editor/lsp/semantic.ts index 5ab2a19f..18aa60af 100644 --- a/src/editor/lsp/semantic.ts +++ b/src/editor/lsp/semantic.ts @@ -80,7 +80,7 @@ export interface SemanticRange { } /** Decodes absolute `[line, col, len, type, modifiers]` quintuples into styled ranges. */ -export function semanticRanges(doc: Text, tokens: SemanticTokens, rainbow = false): SemanticRange[] { +export function semanticRanges(doc: Text, tokens: SemanticTokens, rainbow = false, language?: string): SemanticRange[] { const out: SemanticRange[] = []; const { data, tokenTypes, tokenModifiers } = tokens; // Servers with a `local` modifier (TypeScript) tell module-level variables apart. @@ -102,6 +102,7 @@ export function semanticRanges(doc: Text, tokens: SemanticTokens, rainbow = fals if (constant && (type === "variable" || type === "property")) base = "constant"; if (base && base !== "variable") classes.push(`cm-sem-${base}`); for (const n of names) if (MODIFIER_CLASS[n]) classes.push(`cm-sem-${MODIFIER_CLASS[n]}`); + if (language === "python" && names.includes("static")) classes.push("cm-sem-python-static"); const local = rainbow && !constant && @@ -163,7 +164,7 @@ const semanticPlugin = ViewPlugin.fromClass( } decorate(doc: Text, tokens: SemanticTokens | null) { this.last = { doc, tokens }; - const ranges = tokens ? semanticRanges(doc, tokens, this.rainbow) : []; + const ranges = tokens ? semanticRanges(doc, tokens, this.rainbow, docOf(this.view)?.language) : []; this.view.dispatch({ effects: setSemantic.of(Decoration.set(ranges.map((r) => markFor(r.className).range(r.from, r.to)), true)), }); diff --git a/src/editor/lsp/theme.ts b/src/editor/lsp/theme.ts index b0464091..0246c835 100644 --- a/src/editor/lsp/theme.ts +++ b/src/editor/lsp/theme.ts @@ -348,7 +348,8 @@ export const lspTheme = EditorView.theme({ ".cm-sem-self": { color: "var(--syn-self, var(--syn-keyword))" }, ".cm-sem-keyword": { color: "var(--syn-keyword)", fontWeight: "var(--syn-keyword-bold, inherit)" }, ".cm-sem-builtin:not(.cm-sem-function)": { color: "var(--syn-builtin, inherit)" }, - ".cm-sem-static": { fontStyle: "italic" }, + ".cm-sem-static": { fontStyle: "var(--syn-static-italic, italic)" }, + ".cm-sem-python-static": { fontStyle: "var(--syn-python-static-italic, normal)" }, ".cm-sem-mutable": { textDecoration: "underline", textDecorationColor: "var(--fg-faint)", textUnderlineOffset: "3px" }, ".cm-sem-deprecated": { textDecoration: "line-through" }, // Semantic highlighting: after the base classes so a parameter's colour gives way, and over the diff --git a/src/features/settings/pages/editor/colorScheme.tsx b/src/features/settings/pages/editor/colorScheme.tsx index 05bc9fe9..128ab419 100644 --- a/src/features/settings/pages/editor/colorScheme.tsx +++ b/src/features/settings/pages/editor/colorScheme.tsx @@ -87,6 +87,8 @@ function previewVars(id: SchemeId, o: Record | u /** 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.italicOnly) + return { fontStyle: `var(--syn-${attr.token}-italic, ${attr.defaultItalic === false ? "normal" : "italic"})` }; const t = attr.token; return { color: `var(--syn-${t})`, @@ -111,6 +113,8 @@ const SAMPLE: Seg[][] = [ [" = ", "operator"], ["3", "number"], ], + [[" static_field", "staticSymbol"], [" = 3"]], + [[" python_field", "pyStaticSymbol"], [" = 3"]], [ [" def ", "keyword"], ["greet", "functionDeclaration"], @@ -150,7 +154,16 @@ function AttributeEditor({ }; return (
- {attr.token && ( + {attr.italicOnly && ( +
+ set({ italic: b === (attr.defaultItalic ?? true) ? undefined : b })} + label="Italic" + /> +
+ )} + {attr.token && !attr.italicOnly && ( <>
set({ bold: b || undefined })} label="Bold" /> @@ -159,11 +172,11 @@ function AttributeEditor({ )} {attr.fg && set({ foreground: c })} />} - {(attr.bg || attr.token) && ( + {(attr.bg || (attr.token && !attr.italicOnly)) && ( set({ background: c })} /> )} {attr.effect && set({ effectColor: c })} />} - {attr.token && ( + {attr.token && !attr.italicOnly && (
)} -
- Unchecked values are inherited from the scheme. -
+ {!attr.italicOnly && ( +
+ Unchecked values are inherited from the scheme. +
+ )}
); } diff --git a/src/platform/colorScheme.test.ts b/src/platform/colorScheme.test.ts index 9a3cde95..d41867cc 100644 --- a/src/platform/colorScheme.test.ts +++ b/src/platform/colorScheme.test.ts @@ -26,6 +26,10 @@ 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 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); + expect(pythonItalic).toContain("--syn-python-static-italic: italic !important"); }); it("keeps editor weights out of the schemes", () => { diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index a0f82948..b89c30e4 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -29,6 +29,9 @@ export interface AttributeDef { effect?: string; /** Syntax token: bold / italic / background / effect come from `--syn--*`. */ token?: string; + /** A semantic modifier whose only configurable attribute is italic. */ + italicOnly?: boolean; + defaultItalic?: boolean; } const syn = (id: string, name: string, path: string[], token: string): AttributeDef => ({ @@ -81,6 +84,7 @@ export const ATTRIBUTES: readonly AttributeDef[] = [ syn("parameter", "Identifiers › Parameter", [LD], "param"), syn("constant", "Identifiers › Constant", [LD], "constant"), 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"), syn("functionCall", "Identifiers › Function call", [LD], "function-call"), syn("predefined", "Identifiers › Predefined symbol", [LD], "builtin"), @@ -98,6 +102,7 @@ export const ATTRIBUTES: readonly AttributeDef[] = [ ...[1, 2, 3].map((n) => syn(`bracket${n}`, `Rainbow brackets › Level ${n}`, [LD], `bracket-${n}`)), syn("pySelf", "Self parameter", ["Python"], "self"), + { id: "pyStaticSymbol", name: "Static symbol", path: ["Python"], token: "python-static", italicOnly: true, defaultItalic: false }, syn("pyKeywordArg", "Keyword argument", ["Python"], "keyword-arg"), syn("pyDocstring", "Docstring", ["Python"], "docstring"), syn("pyBuiltin", "Built-in name", ["Python"], "predefined"), From b4742b730b656ccbc26a0ce6933ea063b356778c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=BF=80?= <1020327414@qq.com> Date: Tue, 27 Oct 2026 14:38:42 +0800 Subject: [PATCH 04/29] Support custom editor font weights in steps of 50 (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JetBrains Mono at 500 looks heavier than PyCharm's rendering, and 450 was rounded up to 500. Adds a Custom option with an input to the Main and Bold font weight rows; weights are clamped to 100–900 and rounded to the nearest 50 (the terminal, which follows the editor weights, gets the same steps). Verified in the app: Custom → 450 shows the input and the preview renders at 450. Maintainer follow-up: formatted the dialog test. Related: #32 https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- .../settings/__tests__/dialog.test.tsx | 20 ++++++++ src/features/settings/pages/editor/font.tsx | 48 ++++++++++++------- src/platform/colorScheme.test.ts | 3 +- src/platform/editorSettings.ts | 4 +- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index 42591fcd..32ceed56 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -128,6 +128,26 @@ describe("Settings dialog", () => { ]); }); + it("edits and saves independent custom font weights", async () => { + const dialog = await openDialog("editor.font"); + for (const [label, value] of [ + ["Main font weight", "449"], + ["Bold font weight", "650"], + ]) { + fireEvent.click(within(dialog).getByRole("button", { name: label })); + fireEvent.click(screen.getByRole("option", { name: "Custom" })); + fireEvent.change(within(dialog).getByLabelText(`${label} custom value`), { target: { value } }); + } + const preview = within(dialog).getByLabelText("Font preview"); + expect(preview.style.getPropertyValue("--fw-code")).toBe("450"); + expect(preview.style.getPropertyValue("--fw-code-bold")).toBe("650"); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + expect(settingsSet.mock.calls).toEqual([ + ["global", "editor.boldFontWeight", "650"], + ["global", "editor.fontWeight", "450"], + ]); + }); + it("Cancel discards staged edits", async () => { const dialog = await openDialog("editor.codeStyle.other"); fireEvent.change(within(dialog).getByLabelText("Tab size"), { target: { value: "2" } }); diff --git a/src/features/settings/pages/editor/font.tsx b/src/features/settings/pages/editor/font.tsx index 3c422d86..25e0eb20 100644 --- a/src/features/settings/pages/editor/font.tsx +++ b/src/features/settings/pages/editor/font.tsx @@ -6,7 +6,7 @@ import { useMemo, useState } from "react"; import { Checkbox, type SelectOption, T } from "@/ui"; import { monoFontFamily } from "@/platform/fonts"; import { NumberInput, Row, SearchLabel, Select } from "../../components/Form"; -import { useLabelMatch, useSetting } from "../../components/context"; +import { useLabelMatch, usePageContext, useSetting } from "../../components/context"; import { FONT_WEIGHTS, fontWeightOf, fontWeightVars } from "@/platform/editorSettings"; import { LIMITS } from "../../model/schema"; import { editorPage } from "./kit"; @@ -135,7 +135,32 @@ function FallbackRow() { ); } -const WEIGHT_OPTIONS: SelectOption[] = FONT_WEIGHTS.map(([w, name]) => ({ value: String(w), label: name, detail: String(w) })); +const WEIGHT_OPTIONS: SelectOption[] = [ + ...FONT_WEIGHTS.map(([w, name]) => ({ value: String(w), label: name, detail: String(w) })), + { value: "custom", label: "Custom" }, +]; + +function WeightRow({ label, value, onChange, defaultWeight, hint }: { label: string; value: number; onChange: (value: number) => void; defaultWeight: number; hint?: string }) { + const [customSelected, setCustomSelected] = useState(false); + const weight = fontWeightOf(value, defaultWeight); + const custom = customSelected || !FONT_WEIGHTS.some(([w]) => w === weight); + return ( + + setMain(Number(v))} - aria-label="Main font weight" - /> - - -