feat(editor): Smooth caret blinking and movement (#146)

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 <[email protected]>
Цей коміт міститься в:
Jaw authored and GitHub committed 2026-10-29 00:18:14 +08:00
1 джерело 811da13a76
коміт 4a15936acb
11 files changed
+209 -20

No files matched your search

+9
Переглянути файл
@@ -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,
+6
Переглянути файл
@@ -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`. */
+31 -1
Переглянути файл
@@ -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);
});
});
+48 -1
Переглянути файл
@@ -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(" ") }) : [],
];
}
+32 -11
Переглянути файл
@@ -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",
},
+14
Переглянути файл
@@ -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`,
+3 -2
Переглянути файл
@@ -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 ? (
<Input
className={styles.inlineText}
value={inline.options.find((o) => 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<Item, { t: "select" }> }) {
{disabled ? (
<Input
className={styles.inlineText}
value={item.options.find((o) => o.value === value)?.label ?? value}
value={tx(item.options.find((o) => o.value === value)?.label ?? value)}
disabled
readOnly
aria-label={item.label}
+32
Переглянути файл
@@ -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);
});
});
+27 -5
Переглянути файл
@@ -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]),
+3
Переглянути файл
@@ -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,
+4
Переглянути файл
@@ -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 运行配置使用。",