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 运行配置使用。",