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