дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
11 files changed
+209
-20
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(" ") }) : [],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 运行配置使用。",
|
||||
|
||||
Reference in new issue
Заблокувати користувача