From 4a15936acb75bf769336467433f0c07c798d0a55 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:18:14 +0800 Subject: [PATCH] feat(editor): Smooth caret blinking and movement (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Editor › General › Appearance gets "Use smooth caret blinking" and "Use smooth caret movement" (Snappy / Gliding), both off by default. Smooth movement animates only navigation and clicks; typing, edits and scrolling moves stay instant, and reduced motion turns it off. The diff and merge viewers now use the editor's caret and follow these settings instead of CodeMirror's default caret. Also translates a select's value shown while its checkbox is unchecked. Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- crates/ide-core/src/editor_settings.rs | 9 ++++ src/bindings.ts | 6 +++ src/editor/core/cm/ideaParity.test.ts | 32 +++++++++++- src/editor/core/cm/prefs.ts | 49 ++++++++++++++++++- src/editor/core/cm/theme.ts | 43 +++++++++++----- .../settings/pages/editor/general.tsx | 14 ++++++ src/features/settings/pages/editor/kit.tsx | 5 +- .../vcs/__tests__/viewerCaret.test.ts | 32 ++++++++++++ src/features/vcs/cm.ts | 32 ++++++++++-- src/platform/editorSettings.ts | 3 ++ src/platform/locales/text/zh.json | 4 ++ 11 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 src/features/vcs/__tests__/viewerCaret.test.ts diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs index 68b674e60..62789be44 100644 --- a/crates/ide-core/src/editor_settings.rs +++ b/crates/ide-core/src/editor_settings.rs @@ -55,7 +55,13 @@ pub struct EditorAppearanceSettings { pub caret_blinking: bool, /// Blink period in ms. pub caret_blink_period: u32, + /// Fade the caret in and out instead of toggling it; rounds its corners. + pub smooth_caret_blinking: bool, pub block_caret: bool, + /// Animate the caret between positions when it is moved. + pub smooth_caret_movement: bool, + /// Smooth caret movement curve: `snappy` | `gliding`. + pub caret_movement_style: String, pub line_numbers: bool, pub method_separators: bool, /// Sub-options of `editor.showWhitespace`. @@ -75,7 +81,10 @@ impl Default for EditorAppearanceSettings { Self { caret_blinking: true, caret_blink_period: 500, + smooth_caret_blinking: false, block_caret: false, + smooth_caret_movement: false, + caret_movement_style: "snappy".into(), line_numbers: true, method_separators: false, whitespace_leading: true, diff --git a/src/bindings.ts b/src/bindings.ts index 3915a0fc0..bcacf5b7d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2446,7 +2446,13 @@ export type EditorAppearanceSettings = { caretBlinking?: boolean, /** Blink period in ms. */ caretBlinkPeriod?: number, + /** Fade the caret in and out instead of toggling it; rounds its corners. */ + smoothCaretBlinking?: boolean, blockCaret?: boolean, + /** Animate the caret between positions when it is moved. */ + smoothCaretMovement?: boolean, + /** Smooth caret movement curve: `snappy` | `gliding`. */ + caretMovementStyle?: string, lineNumbers?: boolean, methodSeparators?: boolean, /** Sub-options of `editor.showWhitespace`. */ diff --git a/src/editor/core/cm/ideaParity.test.ts b/src/editor/core/cm/ideaParity.test.ts index 52fca803c..38640f8fd 100644 --- a/src/editor/core/cm/ideaParity.test.ts +++ b/src/editor/core/cm/ideaParity.test.ts @@ -14,7 +14,7 @@ import { selectAllOccurrences, unselectOccurrence, } from "./editing"; -import { pasteReindentFilter } from "./prefs"; +import { caretExtension, pasteReindentFilter } from "./prefs"; import { closesStringAt, skipClosingQuote } from "./smartKeys"; import { collapseAllEffects, @@ -27,6 +27,7 @@ import { nearestMark, unfoldAround } from "./lanes"; import { lineRangeSelection } from "./gutterDrag"; import { matchBraceTarget } from "./caretCommands"; import { overwriteField, toggleOverwrite } from "./overwrite"; +import { validateEditorExtras } from "@/platform/editorSettings"; const views: EditorView[] = []; afterEach(() => views.splice(0).forEach((v) => v.destroy())); @@ -329,3 +330,32 @@ describe("overwrite mode", () => { expect(show(v)).toBe("abXYZ|\nx"); }); }); + +describe("smooth caret", () => { + const caret = (appearance: object) => caretExtension(validateEditorExtras({ appearance })); + + it("marks the editor for smooth blinking and the chosen movement curve", () => { + const v = make( + "ab|c", + caret({ smoothCaretBlinking: true, smoothCaretMovement: true, caretMovementStyle: "gliding" }), + ); + expect(v.dom.classList.contains("cm-openidea-smoothCaretBlink")).toBe(true); + expect(v.dom.classList.contains("cm-openidea-caretMotion-gliding")).toBe(true); + const plain = make("ab|c", caret({ smoothCaretBlinking: false, smoothCaretMovement: false })); + expect(plain.dom.className).not.toMatch(/smoothCaretBlink|caretMotion/); + }); + + it("glides selection-only moves and puts the caret in place at once when typing", () => { + const v = make("ab|c", caret({ smoothCaretMovement: true })); + const layer = v.scrollDOM.querySelector(".cm-cursorLayer")!; + const flush = () => (v as unknown as { measure(): void }).measure(); + // jsdom has no layout: every caret is on screen. + v.coordsAtPos = () => ({ left: 0, right: 0, top: 0, bottom: 10 }); + v.scrollDOM.getBoundingClientRect = () => new DOMRect(0, 0, 100, 100); + v.dispatch({ selection: { anchor: 1 } }); + flush(); + expect(layer.classList.contains("cm-openidea-caretGlide")).toBe(true); + v.dispatch({ changes: { from: 1, insert: "x" }, selection: { anchor: 2 } }); + expect(layer.classList.contains("cm-openidea-caretGlide")).toBe(false); + }); +}); diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 7d9b54a5f..3767edce4 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -451,11 +451,58 @@ export interface PrefsOptions { commentStyle?: LineCommentStyle; } +function caretOnScreen(view: EditorView, pos: number): boolean { + const c = view.coordsAtPos(pos); + if (!c) return false; + const r = view.scrollDOM.getBoundingClientRect(); + return c.top >= r.top && c.bottom <= r.bottom; +} + +/** + * Use smooth caret movement. The cursor layer reuses its caret elements, so a + * CSS transition on their position animates a move (VS Code's + * `cursorSmoothCaretAnimation: "explicit"`). Only moves of the selection + * alone glide; typing, edits, a changed number of carets, layout changes and + * moves that scroll the editor put the caret in place at once. + */ +const caretGlide = ViewPlugin.fromClass( + class { + on = false; + constructor(readonly view: EditorView) {} + update(u: ViewUpdate) { + const from = u.startState.selection; + const to = u.state.selection; + if (!u.selectionSet || u.docChanged || u.geometryChanged || from.ranges.length !== to.ranges.length) { + this.set(false); + return; + } + // Measured before the editor scrolls the new caret into view. + u.view.requestMeasure({ + key: this, + read: (view) => caretOnScreen(view, from.main.head) && caretOnScreen(view, to.main.head), + write: (on) => this.set(on), + }); + } + set(on: boolean) { + if (on === this.on) return; + this.on = on; + this.view.scrollDOM.querySelector(":scope > .cm-cursorLayer")?.classList.toggle("cm-openidea-caretGlide", on); + } + }, +); + export function caretExtension(prefs: EditorExtras): Extension { const a = prefs.appearance; + const classes = [ + a.blockCaret && "cm-openidea-blockCaret", + a.smoothCaretBlinking && "cm-openidea-smoothCaretBlink", + a.smoothCaretMovement && `cm-openidea-caretMotion-${a.caretMovementStyle === "gliding" ? "gliding" : "snappy"}`, + ].filter(Boolean); return [ + // Before drawSelection: its measure request must run before the cursor layer redraws. + a.smoothCaretMovement ? caretGlide : [], drawSelection({ cursorBlinkRate: a.caretBlinking ? Math.max(100, Math.min(5000, a.caretBlinkPeriod)) * 2 : 0 }), - a.blockCaret ? EditorView.editorAttributes.of({ class: "cm-openidea-blockCaret" }) : [], + classes.length ? EditorView.editorAttributes.of({ class: classes.join(" ") }) : [], ]; } diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 707ec57a9..52e6c0f41 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -8,6 +8,37 @@ import { EditorView } from "@codemirror/view"; import { BRACKET_COLORS } from "./rainbowBrackets"; +/** The caret and Settings › Editor › General › Appearance caret options (`caretExtension`), also for viewers. */ +export const caretStyles = { + ".cm-cursor, .cm-dropCursor": { + borderLeft: "2px solid var(--caret, var(--fg-strong))", + marginLeft: "-1px", + }, + // Use block caret (Settings › Editor › General › Appearance) + "&.cm-openidea-blockCaret .cm-cursor": { + borderLeft: "none", + width: "1ch", + marginLeft: "0", + background: "color-mix(in srgb, var(--caret, var(--fg-strong)) 55%, transparent)", + }, + // Use smooth caret blinking: fade through CodeMirror's blink keyframes instead of stepping; rounded caret. + "&.cm-openidea-smoothCaretBlink.cm-focused > .cm-scroller > .cm-cursorLayer": { + animationTimingFunction: "cubic-bezier(0.7, 0, 0.3, 1)", + }, + "&.cm-openidea-smoothCaretBlink .cm-cursor": { borderRadius: "1px" }, + "&.cm-openidea-smoothCaretBlink.cm-openidea-blockCaret .cm-cursor": { borderRadius: "2px" }, + // Use smooth caret movement: Snappy lands at once and settles, Gliding travels visibly. + "&.cm-openidea-caretMotion-snappy": { "--openidea-caret-motion": "110ms cubic-bezier(0.16, 1, 0.3, 1)" }, + "&.cm-openidea-caretMotion-gliding": { "--openidea-caret-motion": "180ms cubic-bezier(0.45, 0, 0.25, 1)" }, + ".cm-cursorLayer.cm-openidea-caretGlide > .cm-cursor": { + transition: + "left var(--openidea-caret-motion), top var(--openidea-caret-motion), width var(--openidea-caret-motion), height var(--openidea-caret-motion)", + }, + "@media (prefers-reduced-motion: reduce)": { + ".cm-cursorLayer.cm-openidea-caretGlide > .cm-cursor": { transition: "none" }, + }, +}; + export const openideaTheme = EditorView.theme({ "&": { height: "100%", @@ -42,17 +73,7 @@ export const openideaTheme = EditorView.theme({ caretColor: "var(--caret, var(--fg-strong))", }, ".cm-line": { padding: "0 16px 0 6px" }, - ".cm-cursor, .cm-dropCursor": { - borderLeft: "2px solid var(--caret, var(--fg-strong))", - marginLeft: "-1px", - }, - // Use block caret (Settings › Editor › General › Appearance) - "&.cm-openidea-blockCaret .cm-cursor": { - borderLeft: "none", - width: "1ch", - marginLeft: "0", - background: "color-mix(in srgb, var(--caret, var(--fg-strong)) 55%, transparent)", - }, + ...caretStyles, "&.cm-openidea-ligatures .cm-scroller, &.cm-openidea-ligatures .cm-openidea-sticky": { fontVariantLigatures: "normal", }, diff --git a/src/features/settings/pages/editor/general.tsx b/src/features/settings/pages/editor/general.tsx index 0aaee6a17..901550a5e 100644 --- a/src/features/settings/pages/editor/general.tsx +++ b/src/features/settings/pages/editor/general.tsx @@ -215,7 +215,21 @@ const APPEARANCE = editorPage({ label: "Caret blinking (ms):", inline: { kind: "number", key: `${P}.appearance.caretBlinkPeriod`, min: 100, max: 2000 }, }, + { t: "check", key: `${P}.appearance.smoothCaretBlinking`, label: "Use smooth caret blinking" }, { t: "check", key: `${P}.appearance.blockCaret`, label: "Use block caret" }, + { + t: "check", + key: `${P}.appearance.smoothCaretMovement`, + label: "Use smooth caret movement", + inline: { + kind: "select", + key: `${P}.appearance.caretMovementStyle`, + options: [ + { value: "snappy", label: "Snappy" }, + { value: "gliding", label: "Gliding" }, + ], + }, + }, { t: "check", key: `${P}.appearance.hardWrapGuides`, diff --git a/src/features/settings/pages/editor/kit.tsx b/src/features/settings/pages/editor/kit.tsx index 1af1271c9..0d09798c9 100644 --- a/src/features/settings/pages/editor/kit.tsx +++ b/src/features/settings/pages/editor/kit.tsx @@ -8,6 +8,7 @@ import { createContext, useContext, useId, type ComponentType, type ReactNode } from "react"; import clsx from "clsx"; import { Checkbox, Input, type SelectOption } from "@/ui"; +import { tx } from "@/platform/i18n"; import { NumberInput, Row, SearchLabel, Section, Select } from "../../components/Form"; import { useLabelMatch, useSetting } from "../../components/context"; import type { PageSpec } from "../spec"; @@ -130,7 +131,7 @@ function InlineSelect({ {disabled ? ( o.value === value)?.label ?? value} + value={tx(inline.options.find((o) => o.value === value)?.label ?? value)} disabled readOnly aria-label={label} @@ -175,7 +176,7 @@ function SelectItem({ item }: { item: Extract }) { {disabled ? ( o.value === value)?.label ?? value} + value={tx(item.options.find((o) => o.value === value)?.label ?? value)} disabled readOnly aria-label={item.label} diff --git a/src/features/vcs/__tests__/viewerCaret.test.ts b/src/features/vcs/__tests__/viewerCaret.test.ts new file mode 100644 index 000000000..090aa1d1f --- /dev/null +++ b/src/features/vcs/__tests__/viewerCaret.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { useProject } from "@/platform/project"; +import type { Settings } from "@/platform/ipc"; +import { viewerBase } from "../cm"; + +const previous = useProject.getState().settings; +const views: EditorView[] = []; + +afterEach(() => { + views.splice(0).forEach((v) => v.destroy()); + useProject.setState({ settings: previous }); +}); + +const setAppearance = (appearance: object) => + useProject.setState({ settings: { editor: { appearance } } as unknown as Settings }); + +describe("viewer caret", () => { + it("follows the editor's caret settings, also when they change", () => { + setAppearance({ blockCaret: true }); + const view = new EditorView({ + state: EditorState.create({ doc: "a", extensions: viewerBase({ readOnly: true }) }), + }); + views.push(view); + expect(view.dom.classList.contains("cm-openidea-blockCaret")).toBe(true); + + setAppearance({ blockCaret: false, smoothCaretMovement: true, caretMovementStyle: "gliding" }); + expect(view.dom.classList.contains("cm-openidea-blockCaret")).toBe(false); + expect(view.dom.classList.contains("cm-openidea-caretMotion-gliding")).toBe(true); + }); +}); diff --git a/src/features/vcs/cm.ts b/src/features/vcs/cm.ts index e1e912113..f7b530b95 100644 --- a/src/features/vcs/cm.ts +++ b/src/features/vcs/cm.ts @@ -3,12 +3,11 @@ * based theme + syntax colours and lazy language loading by file name. */ import { HighlightStyle, LanguageDescription, syntaxHighlighting, type LanguageSupport } from "@codemirror/language"; -import { EditorState, RangeSetBuilder, type Extension, type Line } from "@codemirror/state"; +import { Compartment, EditorState, RangeSetBuilder, type Extension, type Line } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, - drawSelection, highlightActiveLine, keymap, lineNumbers, @@ -19,6 +18,10 @@ import { getChunks } from "@codemirror/merge"; import { defaultKeymap } from "@codemirror/commands"; import { search, searchKeymap } from "@codemirror/search"; import { tags as t } from "@lezer/highlight"; +import { caretExtension } from "@/editor/core/cm/prefs"; +import { caretStyles } from "@/editor/core/cm/theme"; +import { editorPrefs } from "@/platform/editorSettings"; +import { useProject } from "@/platform/project"; const wholeLine = Decoration.line({ class: "cm-diffWholeLine" }); @@ -100,8 +103,8 @@ export const viewerTheme = EditorView.theme( fontSize: "var(--fs-code)", }, ".cm-scroller": { fontFamily: "var(--font-mono)", fontWeight: "var(--fw-code, 400)", lineHeight: "var(--lh-code)" }, - ".cm-content": { caretColor: "var(--accent)", WebkitUserSelect: "text", userSelect: "text" }, - ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--accent)" }, + ".cm-content": { caretColor: "var(--caret, var(--fg-strong))", WebkitUserSelect: "text", userSelect: "text" }, + ...caretStyles, "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "var(--bg-selected-strong)", }, @@ -167,11 +170,30 @@ export const viewerTheme = EditorView.theme( { dark: true }, ); +const caretSlot = new Compartment(); + +/** The editor's caret (Settings › Editor › General › Appearance), kept in step with the settings. */ +function viewerCaret(): Extension { + return [ + caretSlot.of(caretExtension(editorPrefs())), + ViewPlugin.define((view) => { + let appearance = editorPrefs().appearance; + const unsubscribe = useProject.subscribe(() => { + const next = editorPrefs(); + if (JSON.stringify(next.appearance) === JSON.stringify(appearance)) return; + appearance = next.appearance; + view.dispatch({ effects: caretSlot.reconfigure(caretExtension(next)) }); + }); + return { destroy: unsubscribe }; + }), + ]; +} + /** Base extensions for a read-only (or editable) viewer pane. */ export function viewerBase(opts: { readOnly: boolean; lineNumbers?: boolean }): Extension[] { return [ opts.lineNumbers === false ? [] : lineNumbers(), - drawSelection(), + viewerCaret(), highlightActiveLine(), search({ top: true }), keymap.of([...searchKeymap, ...defaultKeymap]), diff --git a/src/platform/editorSettings.ts b/src/platform/editorSettings.ts index 6f4b52715..6c641297e 100644 --- a/src/platform/editorSettings.ts +++ b/src/platform/editorSettings.ts @@ -80,7 +80,10 @@ export const EDITOR_EXTRA_DEFAULTS = { appearance: { caretBlinking: true, caretBlinkPeriod: 500, + smoothCaretBlinking: false, blockCaret: false, + smoothCaretMovement: false, + caretMovementStyle: "snappy", lineNumbers: true, methodSeparators: false, whitespaceLeading: true, diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b6ca4bed5..d2cf77b62 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -789,6 +789,7 @@ "Git commands run by the IDE appear here.": "IDE 运行的 Git 命令会显示在这里。", "Git console": "Git 控制台", "GitHub CLI is not installed": "未安装 GitHub CLI", + "Gliding": "滑行", "Glob patterns (e.g. **/dist, *.log) hidden from the project tree and skipped by search and indexing.": "从项目树中隐藏并在搜索和索引时跳过的 Glob 模式(例如 **/dist、*.log)。", "Global Encoding": "全局编码", "Go SDK used by gopls and the go run / test configurations.": "gopls 以及 go run / test 配置使用的 Go SDK。", @@ -1633,6 +1634,7 @@ "Select an item to preview": "选择一项以预览", "Severity": "严重性", "Show Quick Fixes": "显示快速修复", + "Snappy": "迅捷", "Sort By": "排序依据", "Tags": "标签", "The start date is after the end date": "开始日期晚于结束日期", @@ -2191,6 +2193,8 @@ "Use original line's indent for wrapped parts": "换行部分使用原始行的缩进", "Use pretty formatting": "使用友好格式", "Use project colors in main toolbar": "在主工具栏中使用项目颜色", + "Use smooth caret blinking": "使用平滑光标闪烁", + "Use smooth caret movement": "使用平滑光标移动", "Use soft wraps in console": "在控制台中使用软换行", "Use tab character": "使用制表符", "Used by the npm run configurations detected from package.json scripts.": "供从 package.json 脚本检测到的 npm 运行配置使用。",