blots-editor 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/asset/language/en.json +5 -1
- package/asset/language/zh.json +5 -1
- package/dist/core/document/document.d.ts +4 -2
- package/dist/core/document/document.js +15 -5
- package/dist/core/settings/schema.d.ts +23 -0
- package/dist/core/settings/schema.js +38 -0
- package/dist/core/settings/store.d.ts +34 -0
- package/dist/core/settings/store.js +82 -0
- package/dist/core/settings/useSettings.d.ts +25 -0
- package/dist/core/settings/useSettings.js +27 -0
- package/dist/index.js +2 -1
- package/dist/view/editor.js +44 -3
- package/dist/view/frame-clamp.d.ts +10 -0
- package/dist/view/frame-clamp.js +12 -0
- package/dist/view/modal-frame.d.ts +16 -0
- package/dist/view/modal-frame.js +43 -0
- package/dist/view/sensitivity-bar.d.ts +24 -0
- package/dist/view/sensitivity-bar.js +48 -0
- package/dist/view/settings.js +99 -20
- package/dist/view/tool-bar.d.ts +51 -0
- package/dist/view/tool-bar.js +92 -0
- package/package.json +3 -2
package/asset/language/en.json
CHANGED
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
"settings.language": "Language",
|
|
8
8
|
"settings.language.en": "English",
|
|
9
9
|
"settings.language.zh": "中文",
|
|
10
|
+
"settings.sensitivity.cursor": "Cursor Wheel Sensitivity",
|
|
11
|
+
"settings.sensitivity.view": "View Scroll Sensitivity",
|
|
12
|
+
"settings.sensitivity.hint": "Click or drag to adjust",
|
|
10
13
|
"settings.back": "Back",
|
|
11
14
|
"editor.mode.insert": "INSERT",
|
|
12
15
|
"editor.mode.normal": "NORMAL",
|
|
13
16
|
"command.prompt": "Command",
|
|
14
|
-
"command.unknown": "Unknown command: {cmd}"
|
|
17
|
+
"command.unknown": "Unknown command: {cmd}",
|
|
18
|
+
"toolbar.fileTree": "File Tree"
|
|
15
19
|
}
|
package/asset/language/zh.json
CHANGED
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
"settings.language": "语言",
|
|
8
8
|
"settings.language.en": "英文",
|
|
9
9
|
"settings.language.zh": "中文",
|
|
10
|
+
"settings.sensitivity.cursor": "光标滚动灵敏度",
|
|
11
|
+
"settings.sensitivity.view": "视图滚动灵敏度",
|
|
12
|
+
"settings.sensitivity.hint": "点击或拖动调整",
|
|
10
13
|
"settings.back": "返回",
|
|
11
14
|
"editor.mode.insert": "插入",
|
|
12
15
|
"editor.mode.normal": "普通",
|
|
13
16
|
"command.prompt": "命令",
|
|
14
|
-
"command.unknown": "未知命令:{cmd}"
|
|
17
|
+
"command.unknown": "未知命令:{cmd}",
|
|
18
|
+
"toolbar.fileTree": "文件树"
|
|
15
19
|
}
|
|
@@ -19,6 +19,7 @@ export declare class Document {
|
|
|
19
19
|
private _scrollTop;
|
|
20
20
|
private _wrapWidth;
|
|
21
21
|
private _viewScrollTop;
|
|
22
|
+
private _scrollRemainder;
|
|
22
23
|
readonly indentWidth: number;
|
|
23
24
|
constructor(text: string, options?: DocumentOptions);
|
|
24
25
|
get lineCount(): number;
|
|
@@ -93,8 +94,9 @@ export declare class Document {
|
|
|
93
94
|
movePageDown(height: number): void;
|
|
94
95
|
/**
|
|
95
96
|
* Scroll the viewport by `delta` visual lines without moving the cursor,
|
|
96
|
-
* clamped to the document range.
|
|
97
|
-
*
|
|
97
|
+
* clamped to the document range. Fractional deltas (wheel sensitivity)
|
|
98
|
+
* accumulate and round to whole visual lines over successive notches.
|
|
99
|
+
* Locks the view in place (Ctrl+wheel browsing) until the cursor moves.
|
|
98
100
|
*/
|
|
99
101
|
scrollView(delta: number, height: number): number;
|
|
100
102
|
/**
|
|
@@ -18,6 +18,8 @@ export class Document {
|
|
|
18
18
|
// Non-null while the user is scrolling the view manually (Ctrl+wheel):
|
|
19
19
|
// updateScroll keeps this position instead of following the cursor.
|
|
20
20
|
_viewScrollTop = null;
|
|
21
|
+
// Fractional view-scroll remainder, so e.g. 3.5 cells/notch scrolls 3,4,3,4…
|
|
22
|
+
_scrollRemainder = 0;
|
|
21
23
|
indentWidth;
|
|
22
24
|
constructor(text, options = {}) {
|
|
23
25
|
this._lines = text.split(/\r?\n/).map((t) => new TextLine(t));
|
|
@@ -298,7 +300,10 @@ export class Document {
|
|
|
298
300
|
}
|
|
299
301
|
/** Page up: move the cursor up by `height - 1` visual lines (one line of overlap), keeping the visual column. */
|
|
300
302
|
movePageUp(height) {
|
|
301
|
-
|
|
303
|
+
// `height` may be fractional (wheel sensitivity): round to whole
|
|
304
|
+
// visual lines, keeping the one-line overlap.
|
|
305
|
+
const step = Math.max(1, Math.round(height) - 1);
|
|
306
|
+
const target = Math.max(0, this.cursorVisualLine - step);
|
|
302
307
|
const seg = this.visualLineAt(target);
|
|
303
308
|
if (!seg) {
|
|
304
309
|
return;
|
|
@@ -306,7 +311,8 @@ export class Document {
|
|
|
306
311
|
this.moveToSegment(seg);
|
|
307
312
|
}
|
|
308
313
|
movePageDown(height) {
|
|
309
|
-
const
|
|
314
|
+
const step = Math.max(1, Math.round(height) - 1);
|
|
315
|
+
const target = Math.min(this.visualLineCount - 1, this.cursorVisualLine + step);
|
|
310
316
|
const seg = this.visualLineAt(target);
|
|
311
317
|
if (!seg) {
|
|
312
318
|
return;
|
|
@@ -315,15 +321,19 @@ export class Document {
|
|
|
315
321
|
}
|
|
316
322
|
/**
|
|
317
323
|
* Scroll the viewport by `delta` visual lines without moving the cursor,
|
|
318
|
-
* clamped to the document range.
|
|
319
|
-
*
|
|
324
|
+
* clamped to the document range. Fractional deltas (wheel sensitivity)
|
|
325
|
+
* accumulate and round to whole visual lines over successive notches.
|
|
326
|
+
* Locks the view in place (Ctrl+wheel browsing) until the cursor moves.
|
|
320
327
|
*/
|
|
321
328
|
scrollView(delta, height) {
|
|
322
329
|
const total = this.visualLineCount;
|
|
323
330
|
const effectiveH = height > 0 ? height : total;
|
|
324
331
|
const maxScroll = Math.max(0, total - effectiveH);
|
|
325
332
|
const base = this._viewScrollTop ?? this._scrollTop;
|
|
326
|
-
|
|
333
|
+
this._scrollRemainder += delta;
|
|
334
|
+
const move = Math.trunc(this._scrollRemainder);
|
|
335
|
+
this._scrollRemainder -= move;
|
|
336
|
+
const vs = Math.max(0, Math.min(base + move, maxScroll));
|
|
327
337
|
this._viewScrollTop = vs;
|
|
328
338
|
this._scrollTop = vs;
|
|
329
339
|
return vs;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Editor settings schema.
|
|
4
|
+
*
|
|
5
|
+
* This is the single source of truth for persisted user settings: extending
|
|
6
|
+
* the editor with a new option means adding a field here (plus a defaults
|
|
7
|
+
* entry and a UI row in the settings screen) — validation, persistence and
|
|
8
|
+
* reactivity all flow from this schema.
|
|
9
|
+
*/
|
|
10
|
+
/** Wheel sensitivity in visual lines per notch, 1.0–10.0, 0.5 steps. */
|
|
11
|
+
export declare const sensitivitySchema: z.ZodDefault<z.ZodNumber>;
|
|
12
|
+
export declare const settingsSchema: z.ZodObject<{
|
|
13
|
+
language: z.ZodDefault<z.ZodString>;
|
|
14
|
+
wheel: z.ZodDefault<z.ZodObject<{
|
|
15
|
+
cursor: z.ZodDefault<z.ZodNumber>;
|
|
16
|
+
view: z.ZodDefault<z.ZodNumber>;
|
|
17
|
+
}, z.core.$strip>>;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
export type EditorSettings = z.infer<typeof settingsSchema>;
|
|
20
|
+
export type WheelSensitivity = EditorSettings["wheel"];
|
|
21
|
+
export declare const DEFAULT_SETTINGS: EditorSettings;
|
|
22
|
+
/** Parse an unknown persisted value, falling back to defaults on any failure. */
|
|
23
|
+
export declare function parseSettings(raw: unknown): EditorSettings;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Editor settings schema.
|
|
4
|
+
*
|
|
5
|
+
* This is the single source of truth for persisted user settings: extending
|
|
6
|
+
* the editor with a new option means adding a field here (plus a defaults
|
|
7
|
+
* entry and a UI row in the settings screen) — validation, persistence and
|
|
8
|
+
* reactivity all flow from this schema.
|
|
9
|
+
*/
|
|
10
|
+
/** Wheel sensitivity in visual lines per notch, 1.0–10.0, 0.5 steps. */
|
|
11
|
+
export const sensitivitySchema = z
|
|
12
|
+
.number()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(10)
|
|
15
|
+
.step(0.5)
|
|
16
|
+
.default(1);
|
|
17
|
+
const DEFAULT_WHEEL = { cursor: 1, view: 3 };
|
|
18
|
+
export const settingsSchema = z.object({
|
|
19
|
+
/** Persisted UI language (validated at the app layer; unknown codes fall back). */
|
|
20
|
+
language: z.string().min(1).default("en"),
|
|
21
|
+
wheel: z
|
|
22
|
+
.object({
|
|
23
|
+
/** Plain wheel: how many visual lines the CURSOR moves per notch. */
|
|
24
|
+
cursor: sensitivitySchema,
|
|
25
|
+
/** Ctrl+wheel: how many visual lines the VIEW scrolls per notch. */
|
|
26
|
+
view: sensitivitySchema,
|
|
27
|
+
})
|
|
28
|
+
.default(DEFAULT_WHEEL),
|
|
29
|
+
});
|
|
30
|
+
export const DEFAULT_SETTINGS = {
|
|
31
|
+
language: "en",
|
|
32
|
+
wheel: DEFAULT_WHEEL,
|
|
33
|
+
};
|
|
34
|
+
/** Parse an unknown persisted value, falling back to defaults on any failure. */
|
|
35
|
+
export function parseSettings(raw) {
|
|
36
|
+
const parsed = settingsSchema.safeParse(raw);
|
|
37
|
+
return parsed.success ? parsed.data : structuredClone(DEFAULT_SETTINGS);
|
|
38
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type EditorSettings } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* JSON persistence for editor settings at `~/.config/blots-editor/settings.json`.
|
|
4
|
+
*
|
|
5
|
+
* A plain observable store (no React): the view layer wraps it with
|
|
6
|
+
* `useSyncExternalStore`. Reads are lazy and tolerant — a missing, corrupt or
|
|
7
|
+
* schema-invalid file falls back to defaults; writes are best-effort and never
|
|
8
|
+
* throw into the render path.
|
|
9
|
+
*/
|
|
10
|
+
export declare class SettingsStore {
|
|
11
|
+
private _settings;
|
|
12
|
+
private readonly listeners;
|
|
13
|
+
private _filePath;
|
|
14
|
+
/** Injectable for tests; defaults to `~/.config/blots-editor/settings.json`. */
|
|
15
|
+
constructor(filePath?: string);
|
|
16
|
+
get filePath(): string;
|
|
17
|
+
/** Current settings (lazily loaded on first access). */
|
|
18
|
+
get settings(): EditorSettings;
|
|
19
|
+
private load;
|
|
20
|
+
/** Update in memory and notify subscribers; no disk write (drag in progress). */
|
|
21
|
+
update(settings: EditorSettings): void;
|
|
22
|
+
/** Persist to disk, update memory, and notify subscribers. */
|
|
23
|
+
persist(settings: EditorSettings): void;
|
|
24
|
+
/** Persist whatever is currently in memory (drag end / click). */
|
|
25
|
+
commit(): void;
|
|
26
|
+
/**
|
|
27
|
+
* Test helper: repoint the store at a fresh file and drop cached state,
|
|
28
|
+
* so tests never touch the real `~/.config` file.
|
|
29
|
+
*/
|
|
30
|
+
reset(filePath: string): void;
|
|
31
|
+
subscribe: (listener: () => void) => (() => void);
|
|
32
|
+
/** Notify React subscribers that settings changed. */
|
|
33
|
+
notify(): void;
|
|
34
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { DEFAULT_SETTINGS, parseSettings } from "./schema.js";
|
|
5
|
+
/**
|
|
6
|
+
* JSON persistence for editor settings at `~/.config/blots-editor/settings.json`.
|
|
7
|
+
*
|
|
8
|
+
* A plain observable store (no React): the view layer wraps it with
|
|
9
|
+
* `useSyncExternalStore`. Reads are lazy and tolerant — a missing, corrupt or
|
|
10
|
+
* schema-invalid file falls back to defaults; writes are best-effort and never
|
|
11
|
+
* throw into the render path.
|
|
12
|
+
*/
|
|
13
|
+
export class SettingsStore {
|
|
14
|
+
_settings = null;
|
|
15
|
+
listeners = new Set();
|
|
16
|
+
_filePath = null;
|
|
17
|
+
/** Injectable for tests; defaults to `~/.config/blots-editor/settings.json`. */
|
|
18
|
+
constructor(filePath) {
|
|
19
|
+
this._filePath = filePath ?? null;
|
|
20
|
+
}
|
|
21
|
+
get filePath() {
|
|
22
|
+
this._filePath ??= join(homedir(), ".config", "blots-editor", "settings.json");
|
|
23
|
+
return this._filePath;
|
|
24
|
+
}
|
|
25
|
+
/** Current settings (lazily loaded on first access). */
|
|
26
|
+
get settings() {
|
|
27
|
+
if (!this._settings) {
|
|
28
|
+
this._settings = this.load();
|
|
29
|
+
}
|
|
30
|
+
return this._settings;
|
|
31
|
+
}
|
|
32
|
+
load() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = JSON.parse(readFileSync(this.filePath, "utf8"));
|
|
35
|
+
return parseSettings(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Missing file, unreadable JSON, or an fs error — start from defaults.
|
|
39
|
+
return structuredClone(DEFAULT_SETTINGS);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Update in memory and notify subscribers; no disk write (drag in progress). */
|
|
43
|
+
update(settings) {
|
|
44
|
+
this._settings = settings;
|
|
45
|
+
this.notify();
|
|
46
|
+
}
|
|
47
|
+
/** Persist to disk, update memory, and notify subscribers. */
|
|
48
|
+
persist(settings) {
|
|
49
|
+
this._settings = settings;
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
52
|
+
writeFileSync(this.filePath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Persistence is best-effort: a read-only home dir must not crash
|
|
56
|
+
// the editor or block the settings screen.
|
|
57
|
+
}
|
|
58
|
+
this.notify();
|
|
59
|
+
}
|
|
60
|
+
/** Persist whatever is currently in memory (drag end / click). */
|
|
61
|
+
commit() {
|
|
62
|
+
if (this._settings) {
|
|
63
|
+
this.persist(this._settings);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Test helper: repoint the store at a fresh file and drop cached state,
|
|
68
|
+
* so tests never touch the real `~/.config` file.
|
|
69
|
+
*/
|
|
70
|
+
reset(filePath) {
|
|
71
|
+
this._filePath = filePath;
|
|
72
|
+
this._settings = null;
|
|
73
|
+
}
|
|
74
|
+
subscribe = (listener) => {
|
|
75
|
+
this.listeners.add(listener);
|
|
76
|
+
return () => this.listeners.delete(listener);
|
|
77
|
+
};
|
|
78
|
+
/** Notify React subscribers that settings changed. */
|
|
79
|
+
notify() {
|
|
80
|
+
this.listeners.forEach((fn) => fn());
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { EditorSettings, WheelSensitivity } from "./schema.js";
|
|
2
|
+
import { SettingsStore } from "./store.js";
|
|
3
|
+
/**
|
|
4
|
+
* Shared settings store for the whole editor. One instance serves the
|
|
5
|
+
* settings screen (writes) and the editor view (reads), so changes propagate
|
|
6
|
+
* instantly.
|
|
7
|
+
*/
|
|
8
|
+
export declare const settingsStore: SettingsStore;
|
|
9
|
+
export type SettingsApi = {
|
|
10
|
+
settings: EditorSettings;
|
|
11
|
+
/** Set a sensitivity and persist it immediately (click / keyboard). */
|
|
12
|
+
setSensitivity: (key: keyof WheelSensitivity, value: number) => void;
|
|
13
|
+
/** Update in memory only (while dragging); disk write happens on commit. */
|
|
14
|
+
setDraft: (key: keyof WheelSensitivity, value: number) => void;
|
|
15
|
+
/** Persist the in-memory settings (drag end / click). */
|
|
16
|
+
commit: () => void;
|
|
17
|
+
/** Persist the UI language (called alongside the i18n `setLanguage`). */
|
|
18
|
+
setLanguage: (code: string) => void;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* React binding for the shared {@link settingsStore}. Re-renders subscribers
|
|
22
|
+
* whenever the store changes, keeping the editor view and the settings screen
|
|
23
|
+
* in sync.
|
|
24
|
+
*/
|
|
25
|
+
export declare function useSettings(): SettingsApi;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { SettingsStore } from "./store.js";
|
|
3
|
+
/**
|
|
4
|
+
* Shared settings store for the whole editor. One instance serves the
|
|
5
|
+
* settings screen (writes) and the editor view (reads), so changes propagate
|
|
6
|
+
* instantly.
|
|
7
|
+
*/
|
|
8
|
+
export const settingsStore = new SettingsStore();
|
|
9
|
+
/**
|
|
10
|
+
* React binding for the shared {@link settingsStore}. Re-renders subscribers
|
|
11
|
+
* whenever the store changes, keeping the editor view and the settings screen
|
|
12
|
+
* in sync.
|
|
13
|
+
*/
|
|
14
|
+
export function useSettings() {
|
|
15
|
+
const settings = useSyncExternalStore(settingsStore.subscribe, () => settingsStore.settings, () => settingsStore.settings);
|
|
16
|
+
const next = (key, value) => ({
|
|
17
|
+
...settings,
|
|
18
|
+
wheel: { ...settings.wheel, [key]: value },
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
settings,
|
|
22
|
+
setSensitivity: (key, value) => settingsStore.persist(next(key, value)),
|
|
23
|
+
setDraft: (key, value) => settingsStore.update(next(key, value)),
|
|
24
|
+
commit: () => settingsStore.commit(),
|
|
25
|
+
setLanguage: (code) => settingsStore.persist({ ...settings, language: code }),
|
|
26
|
+
};
|
|
27
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { render } from "ink";
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { CurrentScreen, KeyboardProvider, LanguageProvider, registerComponent, ScenarioManagementProvider, } from "ink-cartridge";
|
|
5
5
|
import { resources } from "./i18n-resources.js";
|
|
6
|
+
import { settingsStore } from "./core/settings/useSettings.js";
|
|
6
7
|
import { MainMenu } from "./view/main-menu.js";
|
|
7
8
|
import { Editor } from "./view/editor.js";
|
|
8
9
|
import { Settings } from "./view/settings.js";
|
|
@@ -10,6 +11,6 @@ registerComponent(MainMenu, {});
|
|
|
10
11
|
registerComponent(Editor, {}, { parent: MainMenu });
|
|
11
12
|
registerComponent(Settings, {}, { parent: MainMenu });
|
|
12
13
|
render(React.createElement(ScenarioManagementProvider, { defaultScreen: MainMenu, fullScreen: true },
|
|
13
|
-
React.createElement(LanguageProvider, { resources: resources, defaultLanguage:
|
|
14
|
+
React.createElement(LanguageProvider, { resources: resources, defaultLanguage: settingsStore.settings.language, fallbackLanguage: "en" },
|
|
14
15
|
React.createElement(KeyboardProvider, { autoTab: false, mouse: true, modes: ["insert", "normal"], defaultMode: "insert" },
|
|
15
16
|
React.createElement(CurrentScreen, null)))));
|
package/dist/view/editor.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { Box, Text, useBoxMetrics, useCursor } from "ink";
|
|
2
|
-
import { applyElementToModalLayer, openModalLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
|
|
2
|
+
import { applyElement, applyElementToModalLayer, closeLayer, openLayer, openModalLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
|
|
3
3
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { EditorController } from "../core/editor-controller.js";
|
|
5
|
+
import { useSettings } from "../core/settings/useSettings.js";
|
|
5
6
|
import { clickToPosition } from "./click-mapping.js";
|
|
6
7
|
import { CommandBar } from "./command-bar.js";
|
|
7
8
|
import { InformationBar } from "./information-bar.js";
|
|
9
|
+
import { ToolBar } from "./tool-bar.js";
|
|
10
|
+
/** Layer hosting the floating toolbar; below modal layers so it never
|
|
11
|
+
* intercepts keys meant for a modal (e.g. the command bar). */
|
|
12
|
+
const TOOLBAR_LAYER_ID = "toolbar";
|
|
13
|
+
const TOOLBAR_Z_INDEX = 1;
|
|
8
14
|
/**
|
|
9
15
|
* Main editor view: renders the status bar + line numbers + text, routes
|
|
10
16
|
* every keystroke to the controller as a named command, and splits
|
|
@@ -26,6 +32,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
26
32
|
const { setCursorPosition } = useCursor();
|
|
27
33
|
const { boundKeyboard, enableWildcardPriority, getCurrentMode, registryCompositionKey, removeCompositionKey, setMode, } = useKeyboard();
|
|
28
34
|
const { t } = useI18n();
|
|
35
|
+
const { settings } = useSettings();
|
|
29
36
|
// The engine keeps modes in a ref (no React notification), so we mirror
|
|
30
37
|
// switches locally to keep the status bar live.
|
|
31
38
|
const [mode, setLocalMode] = useState(() => getCurrentMode());
|
|
@@ -40,6 +47,37 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
40
47
|
element: CommandBar,
|
|
41
48
|
});
|
|
42
49
|
}, []);
|
|
50
|
+
// The toolbar lives in a regular layer (below modal layers) so it floats
|
|
51
|
+
// above the document without taking over the keyboard. The editor owns the
|
|
52
|
+
// layer lifecycle and the ctrl+tab toggle because the layer's element
|
|
53
|
+
// unmounts when it closes — a binding inside it could never re-open it.
|
|
54
|
+
const [toolbarOpen, setToolbarOpen] = useState(true);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
return () => closeLayer(TOOLBAR_LAYER_ID);
|
|
57
|
+
}, []);
|
|
58
|
+
// Opening must wait one tick: passive effects run child-first on mount, so
|
|
59
|
+
// when the editor IS the initial screen this effect races the provider's
|
|
60
|
+
// dispatcher registration and openLayer would throw. ctrl+tab toggles hit
|
|
61
|
+
// the same deferred path — the one-tick delay is imperceptible. Closing is
|
|
62
|
+
// safe immediately because the dispatcher is registered by then.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!toolbarOpen) {
|
|
65
|
+
closeLayer(TOOLBAR_LAYER_ID);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
openLayer(TOOLBAR_LAYER_ID, TOOLBAR_Z_INDEX);
|
|
70
|
+
applyElement(TOOLBAR_LAYER_ID, {
|
|
71
|
+
elementId: "toolbar",
|
|
72
|
+
element: ToolBar,
|
|
73
|
+
props: { mode },
|
|
74
|
+
});
|
|
75
|
+
}, 0);
|
|
76
|
+
return () => clearTimeout(timer);
|
|
77
|
+
}, [toolbarOpen, mode]);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
return boundKeyboard(["ctrl+tab"], () => setToolbarOpen((open) => !open));
|
|
80
|
+
}, [boundKeyboard]);
|
|
43
81
|
useEffect(() => {
|
|
44
82
|
const removeWildcard = enableWildcardPriority();
|
|
45
83
|
const unbinds = [];
|
|
@@ -142,14 +180,17 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
142
180
|
return;
|
|
143
181
|
}
|
|
144
182
|
if (event.ctrl) {
|
|
183
|
+
// Ctrl+wheel scrolls the view at `view` lines per notch
|
|
184
|
+
// (clamped to the document); the cursor does not move.
|
|
145
185
|
controller.execute("view.scroll", {
|
|
146
|
-
delta: up ? -
|
|
186
|
+
delta: up ? -settings.wheel.view : settings.wheel.view,
|
|
147
187
|
height,
|
|
148
188
|
});
|
|
149
189
|
return;
|
|
150
190
|
}
|
|
191
|
+
// Plain wheel moves the cursor at `cursor` lines per notch.
|
|
151
192
|
controller.execute(up ? "cursor.pageUp" : "cursor.pageDown", {
|
|
152
|
-
height:
|
|
193
|
+
height: settings.wheel.cursor,
|
|
153
194
|
});
|
|
154
195
|
},
|
|
155
196
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clamp a frame's top-left corner (0-based terminal coordinates) so the
|
|
3
|
+
* whole frame stays inside the terminal. `Math.max(0, ...)` on the max side
|
|
4
|
+
* keeps the frame at 0 when the terminal is smaller than the frame itself —
|
|
5
|
+
* a negative position would push the border off-screen on both sides.
|
|
6
|
+
*/
|
|
7
|
+
export declare function clampFrame(left: number, top: number, columns: number, rows: number, width: number, height: number): {
|
|
8
|
+
left: number;
|
|
9
|
+
top: number;
|
|
10
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clamp a frame's top-left corner (0-based terminal coordinates) so the
|
|
3
|
+
* whole frame stays inside the terminal. `Math.max(0, ...)` on the max side
|
|
4
|
+
* keeps the frame at 0 when the terminal is smaller than the frame itself —
|
|
5
|
+
* a negative position would push the border off-screen on both sides.
|
|
6
|
+
*/
|
|
7
|
+
export function clampFrame(left, top, columns, rows, width, height) {
|
|
8
|
+
return {
|
|
9
|
+
left: Math.min(Math.max(0, left), Math.max(0, columns - width)),
|
|
10
|
+
top: Math.min(Math.max(0, top), Math.max(0, rows - height)),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React, { type ReactNode } from "react";
|
|
2
|
+
type ModalFrameProps = {
|
|
3
|
+
title: string;
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
footer?: ReactNode;
|
|
6
|
+
width?: number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Shared draggable modal frame used by the language and sensitivity pickers.
|
|
10
|
+
*
|
|
11
|
+
* Centered by flex layout until the user grabs it, then switches to absolute
|
|
12
|
+
* positioning and follows the cursor (clamped to the terminal). Border turns
|
|
13
|
+
* green while dragging. Callers provide the title, body and optional footer.
|
|
14
|
+
*/
|
|
15
|
+
export declare function ModalFrame({ title, children, footer, width, }: ModalFrameProps): React.JSX.Element;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Box, Text, useWindowSize } from "ink";
|
|
2
|
+
import { useMouseRegion } from "ink-cartridge";
|
|
3
|
+
import React, { useRef, useState } from "react";
|
|
4
|
+
import { clampFrame } from "./frame-clamp.js";
|
|
5
|
+
/**
|
|
6
|
+
* Shared draggable modal frame used by the language and sensitivity pickers.
|
|
7
|
+
*
|
|
8
|
+
* Centered by flex layout until the user grabs it, then switches to absolute
|
|
9
|
+
* positioning and follows the cursor (clamped to the terminal). Border turns
|
|
10
|
+
* green while dragging. Callers provide the title, body and optional footer.
|
|
11
|
+
*/
|
|
12
|
+
export function ModalFrame({ title, children, footer, width = 36, }) {
|
|
13
|
+
const { columns, rows } = useWindowSize();
|
|
14
|
+
// null = centered by flex layout; set once dragging starts so the frame
|
|
15
|
+
// keeps following the cursor via absolute positioning (0-based coords).
|
|
16
|
+
const [pos, setPos] = useState(null);
|
|
17
|
+
const [dragging, setDragging] = useState(false);
|
|
18
|
+
// Where inside the frame the cursor was grabbed, so the frame doesn't
|
|
19
|
+
// jump so its top-left lands under the cursor on the first drag move.
|
|
20
|
+
const grabOffsetRef = useRef({ dx: 0, dy: 0 });
|
|
21
|
+
const frameRef = useMouseRegion({
|
|
22
|
+
onDragStart: (event, rect) => {
|
|
23
|
+
grabOffsetRef.current = {
|
|
24
|
+
dx: event.x - rect.x,
|
|
25
|
+
dy: event.y - rect.y,
|
|
26
|
+
};
|
|
27
|
+
// Switch from flex centering to absolute positioning at the
|
|
28
|
+
// current rect — seamless, no jump on the first frame.
|
|
29
|
+
setPos({ top: rect.y - 1, left: rect.x - 1 });
|
|
30
|
+
setDragging(true);
|
|
31
|
+
},
|
|
32
|
+
onDragMove: (event, rect) => {
|
|
33
|
+
const { dx, dy } = grabOffsetRef.current;
|
|
34
|
+
setPos(clampFrame(event.x - dx - 1, event.y - dy - 1, columns, rows, rect.width, rect.height));
|
|
35
|
+
},
|
|
36
|
+
onDragEnd: () => setDragging(false),
|
|
37
|
+
});
|
|
38
|
+
return (React.createElement(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "center" },
|
|
39
|
+
React.createElement(Box, { ref: frameRef, position: pos ? "absolute" : undefined, top: pos?.top, left: pos?.left, flexDirection: "column", borderStyle: "bold", borderColor: dragging ? "green" : "white", backgroundColor: "black", width: width, alignItems: "center", paddingX: 4, paddingY: 1, gap: 1 },
|
|
40
|
+
React.createElement(Text, { bold: true }, title),
|
|
41
|
+
children,
|
|
42
|
+
footer)));
|
|
43
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export declare const SENSITIVITY_MIN = 1;
|
|
3
|
+
export declare const SENSITIVITY_MAX = 10;
|
|
4
|
+
export declare const SENSITIVITY_STEP = 0.5;
|
|
5
|
+
/** Bar cells: (10 - 1) / 0.5 steps, so every cell maps to exactly one step. */
|
|
6
|
+
export declare const BAR_WIDTH: number;
|
|
7
|
+
/** Map a 0..1 ratio to a sensitivity value in 0.5 steps. */
|
|
8
|
+
export declare function valueFromRatio(ratio: number): number;
|
|
9
|
+
/** Clamp value + dir*step to the valid 1..10 range (0.5 steps). */
|
|
10
|
+
export declare function snapSensitivity(value: number, dir: 1 | -1): number;
|
|
11
|
+
type SensitivityBarProps = {
|
|
12
|
+
value: number;
|
|
13
|
+
/** Called on click/drag with the new value (in-memory draft). */
|
|
14
|
+
onChange: (value: number) => void;
|
|
15
|
+
/** Called when the interaction ends — persist the draft. */
|
|
16
|
+
onCommit: () => void;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Mouse-driven sensitivity slider. Clicking or dragging anywhere on the bar
|
|
20
|
+
* sets the value proportionally (0.5 steps); the fuller the bar, the higher
|
|
21
|
+
* the sensitivity, and the 1..10 range caps it by construction.
|
|
22
|
+
*/
|
|
23
|
+
export declare function SensitivityBar({ value, onChange, onCommit }: SensitivityBarProps): React.JSX.Element;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { useI18n, useMouseRegion } from "ink-cartridge";
|
|
3
|
+
import React from "react";
|
|
4
|
+
export const SENSITIVITY_MIN = 1;
|
|
5
|
+
export const SENSITIVITY_MAX = 10;
|
|
6
|
+
export const SENSITIVITY_STEP = 0.5;
|
|
7
|
+
/** Bar cells: (10 - 1) / 0.5 steps, so every cell maps to exactly one step. */
|
|
8
|
+
export const BAR_WIDTH = (SENSITIVITY_MAX - SENSITIVITY_MIN) / SENSITIVITY_STEP;
|
|
9
|
+
/** Map a 0..1 ratio to a sensitivity value in 0.5 steps. */
|
|
10
|
+
export function valueFromRatio(ratio) {
|
|
11
|
+
const r = Math.max(0, Math.min(1, ratio));
|
|
12
|
+
// BAR_WIDTH cells span exactly the 1..10 range (18 steps of 0.5).
|
|
13
|
+
return SENSITIVITY_MIN + Math.round(r * BAR_WIDTH) * SENSITIVITY_STEP;
|
|
14
|
+
}
|
|
15
|
+
/** Clamp value + dir*step to the valid 1..10 range (0.5 steps). */
|
|
16
|
+
export function snapSensitivity(value, dir) {
|
|
17
|
+
return Math.min(SENSITIVITY_MAX, Math.max(SENSITIVITY_MIN, value + dir * SENSITIVITY_STEP));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Mouse-driven sensitivity slider. Clicking or dragging anywhere on the bar
|
|
21
|
+
* sets the value proportionally (0.5 steps); the fuller the bar, the higher
|
|
22
|
+
* the sensitivity, and the 1..10 range caps it by construction.
|
|
23
|
+
*/
|
|
24
|
+
export function SensitivityBar({ value, onChange, onCommit }) {
|
|
25
|
+
const { t } = useI18n();
|
|
26
|
+
// priority 1: the bar sits inside the draggable ModalFrame, whose region
|
|
27
|
+
// overlaps it — the child control must always win the hit test, no matter
|
|
28
|
+
// the registration order after frame drags.
|
|
29
|
+
const ref = useMouseRegion({
|
|
30
|
+
onClick: (event, rect) => {
|
|
31
|
+
onChange(valueFromX(event.x - rect.x));
|
|
32
|
+
onCommit();
|
|
33
|
+
},
|
|
34
|
+
onDragStart: (event, rect) => onChange(valueFromX(event.x - rect.x)),
|
|
35
|
+
onDragMove: (event, rect) => onChange(valueFromX(event.x - rect.x)),
|
|
36
|
+
onDragEnd: () => onCommit(),
|
|
37
|
+
}, { priority: 1 });
|
|
38
|
+
const filled = Math.round((value - SENSITIVITY_MIN) / SENSITIVITY_STEP);
|
|
39
|
+
return (React.createElement(Box, { flexDirection: "column", alignItems: "center", gap: 1 },
|
|
40
|
+
React.createElement(Box, { ref: ref, flexDirection: "row" },
|
|
41
|
+
React.createElement(Text, null, filled > 0 ? "█".repeat(filled) : ""),
|
|
42
|
+
React.createElement(Text, { dimColor: true }, "░".repeat(BAR_WIDTH - filled))),
|
|
43
|
+
React.createElement(Text, { dimColor: true }, t("settings.sensitivity.hint"))));
|
|
44
|
+
}
|
|
45
|
+
/** Value for a click at `localX` cells inside the bar (0-based). */
|
|
46
|
+
function valueFromX(localX) {
|
|
47
|
+
return valueFromRatio(localX / BAR_WIDTH);
|
|
48
|
+
}
|
package/dist/view/settings.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { back, ModalLayerElementContext, useI18n, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
|
|
3
|
-
import React, { useCallback, useContext, useEffect, useState } from "react";
|
|
3
|
+
import React, { useCallback, useContext, useEffect, useMemo, useState, } from "react";
|
|
4
|
+
import { useSettings } from "../core/settings/useSettings.js";
|
|
5
|
+
import { ModalFrame } from "./modal-frame.js";
|
|
6
|
+
import { SensitivityBar, snapSensitivity } from "./sensitivity-bar.js";
|
|
4
7
|
const LANGUAGES = [
|
|
5
8
|
{ code: "en", labelKey: "settings.language.en" },
|
|
6
9
|
{ code: "zh", labelKey: "settings.language.zh" },
|
|
7
10
|
];
|
|
8
11
|
/** Selectable language row with the same hover-highlight style as the menu. */
|
|
9
12
|
function LanguageRow({ label, selected, active, onEnter, onLeave, onClick, }) {
|
|
10
|
-
|
|
13
|
+
// priority 1: rows live inside the draggable ModalFrame; the child must
|
|
14
|
+
// always win the hit test over the overlapping frame region.
|
|
15
|
+
const ref = useMouseRegion({ onEnter, onLeave, onClick }, { priority: 1 });
|
|
11
16
|
return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1 },
|
|
12
17
|
React.createElement(Text, { inverse: active },
|
|
13
18
|
active ? "> " : " ",
|
|
@@ -21,11 +26,16 @@ function LanguageRow({ label, selected, active, onEnter, onLeave, onClick, }) {
|
|
|
21
26
|
*/
|
|
22
27
|
function LanguagePicker() {
|
|
23
28
|
const ctx = useContext(ModalLayerElementContext);
|
|
24
|
-
const { t, setLanguage, currentLanguage } = useI18n();
|
|
29
|
+
const { t, setLanguage: setI18nLanguage, currentLanguage } = useI18n();
|
|
30
|
+
const { setLanguage: persistLanguage } = useSettings();
|
|
25
31
|
const { boundKeyboard } = useKeyboard();
|
|
26
32
|
const { closeModalLayer } = useScreenSystem();
|
|
27
33
|
const [index, setIndex] = useState(0);
|
|
28
34
|
const [hovered, setHovered] = useState(null);
|
|
35
|
+
const applyLanguage = useCallback((code) => {
|
|
36
|
+
setI18nLanguage(code);
|
|
37
|
+
persistLanguage(code);
|
|
38
|
+
}, [setI18nLanguage, persistLanguage]);
|
|
29
39
|
useEffect(() => {
|
|
30
40
|
if (!ctx) {
|
|
31
41
|
return;
|
|
@@ -34,27 +44,56 @@ function LanguagePicker() {
|
|
|
34
44
|
boundKeyboard(["up"], () => setIndex((i) => Math.max(0, i - 1)), { elementId: ctx.id }),
|
|
35
45
|
boundKeyboard(["down"], () => setIndex((i) => Math.min(LANGUAGES.length - 1, i + 1)), { elementId: ctx.id }),
|
|
36
46
|
boundKeyboard(["return"], () => {
|
|
37
|
-
|
|
47
|
+
applyLanguage(LANGUAGES[index].code);
|
|
38
48
|
closeModalLayer(ctx.modalLayer.layerId);
|
|
39
49
|
}, { elementId: ctx.id }),
|
|
40
50
|
boundKeyboard(["escape"], () => closeModalLayer(ctx.modalLayer.layerId), { elementId: ctx.id }),
|
|
41
51
|
];
|
|
42
52
|
return () => unbinds.forEach((fn) => fn());
|
|
43
|
-
}, [boundKeyboard, closeModalLayer, ctx, index
|
|
53
|
+
}, [applyLanguage, boundKeyboard, closeModalLayer, ctx, index]);
|
|
44
54
|
const apply = (code) => {
|
|
45
55
|
if (!ctx) {
|
|
46
56
|
return;
|
|
47
57
|
}
|
|
48
|
-
|
|
58
|
+
applyLanguage(code);
|
|
49
59
|
closeModalLayer(ctx.modalLayer.layerId);
|
|
50
60
|
};
|
|
51
|
-
return (React.createElement(
|
|
52
|
-
|
|
53
|
-
React.createElement(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
return (React.createElement(ModalFrame, { title: t("settings.language"), footer: React.createElement(Text, { dimColor: true },
|
|
62
|
+
t("settings.back"),
|
|
63
|
+
" (Esc)") }, LANGUAGES.map((lang, i) => (React.createElement(LanguageRow, { key: lang.code, label: t(lang.labelKey), selected: currentLanguage === lang.code, active: i === index || hovered === i, onEnter: () => setHovered(i), onLeave: () => setHovered((h) => (h === i ? null : h)), onClick: () => apply(lang.code) })))));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Modal slider for one wheel sensitivity (cursor or view). The bar is
|
|
67
|
+
* drag/click driven; dragging updates in memory only, release/click persists.
|
|
68
|
+
*/
|
|
69
|
+
function SensitivityPicker({ sensitivityKey }) {
|
|
70
|
+
const ctx = useContext(ModalLayerElementContext);
|
|
71
|
+
const { t } = useI18n();
|
|
72
|
+
const { boundKeyboard } = useKeyboard();
|
|
73
|
+
const { closeModalLayer } = useScreenSystem();
|
|
74
|
+
const { settings, setDraft, setSensitivity, commit } = useSettings();
|
|
75
|
+
const value = settings.wheel[sensitivityKey];
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!ctx) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const unbinds = [
|
|
81
|
+
boundKeyboard(["left"], () => setSensitivity(sensitivityKey, snapSensitivity(value, -1)), { elementId: ctx.id }),
|
|
82
|
+
boundKeyboard(["right"], () => setSensitivity(sensitivityKey, snapSensitivity(value, 1)), { elementId: ctx.id }),
|
|
83
|
+
boundKeyboard(["escape"], () => closeModalLayer(ctx.modalLayer.layerId), { elementId: ctx.id }),
|
|
84
|
+
];
|
|
85
|
+
return () => unbinds.forEach((fn) => fn());
|
|
86
|
+
}, [boundKeyboard, closeModalLayer, ctx, sensitivityKey, value, setSensitivity]);
|
|
87
|
+
const labelKey = sensitivityKey === "cursor"
|
|
88
|
+
? "settings.sensitivity.cursor"
|
|
89
|
+
: "settings.sensitivity.view";
|
|
90
|
+
return (React.createElement(ModalFrame, { title: t(labelKey), footer: React.createElement(Text, { dimColor: true },
|
|
91
|
+
t("settings.back"),
|
|
92
|
+
" (Esc)") },
|
|
93
|
+
React.createElement(SensitivityBar, { value: value, onChange: (v) => setDraft(sensitivityKey, v), onCommit: commit }),
|
|
94
|
+
React.createElement(Text, null,
|
|
95
|
+
value.toFixed(1),
|
|
96
|
+
"\u00D7")));
|
|
58
97
|
}
|
|
59
98
|
/** A single settings entry: label plus its current value; opens a picker on activation. */
|
|
60
99
|
function SettingRow({ label, value, active, onEnter, onLeave, onClick, }) {
|
|
@@ -69,7 +108,9 @@ export function Settings() {
|
|
|
69
108
|
const { t, currentLanguage } = useI18n();
|
|
70
109
|
const { boundKeyboard } = useKeyboard();
|
|
71
110
|
const { openModalLayer, applyElementToModalLayer } = useScreenSystem();
|
|
72
|
-
const
|
|
111
|
+
const { settings } = useSettings();
|
|
112
|
+
const [index, setIndex] = useState(0);
|
|
113
|
+
const [hovered, setHovered] = useState(null);
|
|
73
114
|
const openLanguagePicker = useCallback(() => {
|
|
74
115
|
openModalLayer("language", 50);
|
|
75
116
|
applyElementToModalLayer("language", {
|
|
@@ -77,19 +118,57 @@ export function Settings() {
|
|
|
77
118
|
element: LanguagePicker,
|
|
78
119
|
});
|
|
79
120
|
}, [applyElementToModalLayer, openModalLayer]);
|
|
121
|
+
const openSensitivityPicker = useCallback((key) => {
|
|
122
|
+
const id = `sensitivity-${key}`;
|
|
123
|
+
// `element` is a component type; wrap so the picker receives its key.
|
|
124
|
+
const Picker = () => React.createElement(SensitivityPicker, { sensitivityKey: key });
|
|
125
|
+
openModalLayer(id, 50);
|
|
126
|
+
applyElementToModalLayer(id, {
|
|
127
|
+
elementId: id,
|
|
128
|
+
element: Picker,
|
|
129
|
+
});
|
|
130
|
+
}, [applyElementToModalLayer, openModalLayer]);
|
|
131
|
+
const currentLabel = LANGUAGES.find((lang) => lang.code === currentLanguage)?.labelKey ??
|
|
132
|
+
"settings.language.en";
|
|
133
|
+
const entries = useMemo(() => [
|
|
134
|
+
{
|
|
135
|
+
id: "language",
|
|
136
|
+
label: t("settings.language"),
|
|
137
|
+
value: t(currentLabel),
|
|
138
|
+
open: openLanguagePicker,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: "cursor",
|
|
142
|
+
label: t("settings.sensitivity.cursor"),
|
|
143
|
+
value: `${settings.wheel.cursor.toFixed(1)}×`,
|
|
144
|
+
open: () => openSensitivityPicker("cursor"),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "view",
|
|
148
|
+
label: t("settings.sensitivity.view"),
|
|
149
|
+
value: `${settings.wheel.view.toFixed(1)}×`,
|
|
150
|
+
open: () => openSensitivityPicker("view"),
|
|
151
|
+
},
|
|
152
|
+
], [
|
|
153
|
+
t,
|
|
154
|
+
currentLabel,
|
|
155
|
+
settings.wheel.cursor,
|
|
156
|
+
settings.wheel.view,
|
|
157
|
+
openLanguagePicker,
|
|
158
|
+
openSensitivityPicker,
|
|
159
|
+
]);
|
|
80
160
|
useEffect(() => {
|
|
81
161
|
const unbinds = [
|
|
82
|
-
boundKeyboard(["
|
|
162
|
+
boundKeyboard(["up"], () => setIndex((i) => Math.max(0, i - 1))),
|
|
163
|
+
boundKeyboard(["down"], () => setIndex((i) => Math.min(entries.length - 1, i + 1))),
|
|
164
|
+
boundKeyboard(["return"], () => entries[index].open()),
|
|
83
165
|
boundKeyboard(["escape", "backspace"], () => back()),
|
|
84
166
|
];
|
|
85
167
|
return () => unbinds.forEach((fn) => fn());
|
|
86
|
-
}, [boundKeyboard,
|
|
87
|
-
const currentLabel = LANGUAGES.find((lang) => lang.code === currentLanguage)?.labelKey ??
|
|
88
|
-
"settings.language.en";
|
|
168
|
+
}, [boundKeyboard, entries, index]);
|
|
89
169
|
return (React.createElement(Box, { flexDirection: "column", justifyContent: "center", alignItems: "center", width: "100%", height: "100%", gap: 2 },
|
|
90
170
|
React.createElement(Text, { bold: true }, t("settings.title")),
|
|
91
|
-
React.createElement(Box, { flexDirection: "column", gap: 2, marginTop: 2 },
|
|
92
|
-
React.createElement(SettingRow, { label: t("settings.language"), value: t(currentLabel), active: hovered, onEnter: () => setHovered(true), onLeave: () => setHovered(false), onClick: openLanguagePicker })),
|
|
171
|
+
React.createElement(Box, { flexDirection: "column", gap: 2, marginTop: 2 }, entries.map((entry, i) => (React.createElement(SettingRow, { key: entry.id, label: entry.label, value: entry.value, active: i === index || hovered === i, onEnter: () => setHovered(i), onLeave: () => setHovered((h) => (h === i ? null : h)), onClick: entry.open })))),
|
|
93
172
|
React.createElement(Box, { marginTop: 4 },
|
|
94
173
|
React.createElement(Text, { dimColor: true },
|
|
95
174
|
t("settings.back"),
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { XtermMouseEvent } from "@cartridge-engine/keyboard-engine";
|
|
3
|
+
/**
|
|
4
|
+
* Props for a single tool button.
|
|
5
|
+
*
|
|
6
|
+
* @prop priority - Hit-test priority over whatever sits underneath (the editor
|
|
7
|
+
* surface). Buttons must win so clicking them does not also place the cursor.
|
|
8
|
+
* @prop onClick - Called on mouse click and on ctrl+enter selection; the event
|
|
9
|
+
* is absent for the keyboard path.
|
|
10
|
+
* @prop text - The text to display on the button.
|
|
11
|
+
* @prop focused - Keyboard focus (normal-mode navigation); overrides hover.
|
|
12
|
+
* @prop onDragStart/onDragMove/onDragEnd - Forwarded from the toolbar. The
|
|
13
|
+
* engine captures a drag on the pressed region only, so buttons must carry
|
|
14
|
+
* the drag handlers or dragging from a button would not move the bar.
|
|
15
|
+
* @prop dragging - The bar is being dragged; buttons tint green like hover.
|
|
16
|
+
*/
|
|
17
|
+
export type ToolButtonProps = {
|
|
18
|
+
priority: number;
|
|
19
|
+
onClick: (event?: XtermMouseEvent) => void;
|
|
20
|
+
text: string;
|
|
21
|
+
focused?: boolean;
|
|
22
|
+
onDragStart?: (event: XtermMouseEvent) => void;
|
|
23
|
+
onDragMove?: (event: XtermMouseEvent) => void;
|
|
24
|
+
onDragEnd?: (event: XtermMouseEvent) => void;
|
|
25
|
+
dragging?: boolean;
|
|
26
|
+
};
|
|
27
|
+
/** One tool entry; the list is the extension point for future tools. */
|
|
28
|
+
export type Tool = {
|
|
29
|
+
id: string;
|
|
30
|
+
/** i18n key for the button label. */
|
|
31
|
+
labelKey: string;
|
|
32
|
+
/** Placeholder until the file-tree feature lands. */
|
|
33
|
+
onClick: (event?: XtermMouseEvent) => void;
|
|
34
|
+
};
|
|
35
|
+
export type ToolBarProps = {
|
|
36
|
+
/** Current keyboard mode, passed down by the editor; drives focus display. */
|
|
37
|
+
mode?: string | null;
|
|
38
|
+
/** Tools to render, centered in the bar. Defaults to the file-tree entry. */
|
|
39
|
+
tools?: Tool[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Floating toolbar, bottom-center of the terminal until the user drags it.
|
|
43
|
+
* Dragging switches it to absolute positioning and clamps it inside the
|
|
44
|
+
* terminal view (clampFrame). Purely presentational for now: one centered
|
|
45
|
+
* button with hover/focus states. The editor owns the layer that hosts this
|
|
46
|
+
* bar (and the ctrl+tab toggle); this component only adds the normal-mode
|
|
47
|
+
* keyboard navigation (ctrl+left/ctrl+right move the focus ring, ctrl+enter
|
|
48
|
+
* selects). Insert mode stays mouse-only because those bindings are
|
|
49
|
+
* mode-restricted and never fire there.
|
|
50
|
+
*/
|
|
51
|
+
export declare function ToolBar({ mode, tools }: ToolBarProps): React.JSX.Element;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Box, Text, measureElement, useWindowSize } from "ink";
|
|
2
|
+
import { useI18n, useKeyboard, useMouseRegion } from "ink-cartridge";
|
|
3
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { clampFrame } from "./frame-clamp.js";
|
|
5
|
+
function ToolButton({ priority, onClick, text, focused = false, onDragStart, onDragMove, onDragEnd, dragging = false, }) {
|
|
6
|
+
const [hover, setHover] = useState(false);
|
|
7
|
+
const ref = useMouseRegion({
|
|
8
|
+
onClick: (event) => onClick(event),
|
|
9
|
+
onEnter: () => setHover(true),
|
|
10
|
+
onLeave: () => setHover(false),
|
|
11
|
+
onDragStart,
|
|
12
|
+
onDragMove,
|
|
13
|
+
onDragEnd,
|
|
14
|
+
}, { priority });
|
|
15
|
+
// Focus is the keyboard state, so it outranks the transient hover color.
|
|
16
|
+
const borderColor = focused ? "blue" : hover || dragging ? "green" : "white";
|
|
17
|
+
return (React.createElement(Box, { ref: ref, borderColor: borderColor, borderStyle: "bold",
|
|
18
|
+
// Match the editor's surface so the floating bar reads as part of it.
|
|
19
|
+
backgroundColor: "#1e1e1e", height: "100%", justifyContent: "center", alignItems: "center", paddingLeft: 2, paddingRight: 2 },
|
|
20
|
+
React.createElement(Text, null, text)));
|
|
21
|
+
}
|
|
22
|
+
const DEFAULT_TOOLS = [
|
|
23
|
+
{
|
|
24
|
+
id: "file-tree",
|
|
25
|
+
labelKey: "toolbar.fileTree",
|
|
26
|
+
onClick: () => {
|
|
27
|
+
// Business logic (opening the file tree) lands here later.
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* Floating toolbar, bottom-center of the terminal until the user drags it.
|
|
33
|
+
* Dragging switches it to absolute positioning and clamps it inside the
|
|
34
|
+
* terminal view (clampFrame). Purely presentational for now: one centered
|
|
35
|
+
* button with hover/focus states. The editor owns the layer that hosts this
|
|
36
|
+
* bar (and the ctrl+tab toggle); this component only adds the normal-mode
|
|
37
|
+
* keyboard navigation (ctrl+left/ctrl+right move the focus ring, ctrl+enter
|
|
38
|
+
* selects). Insert mode stays mouse-only because those bindings are
|
|
39
|
+
* mode-restricted and never fire there.
|
|
40
|
+
*/
|
|
41
|
+
export function ToolBar({ mode = null, tools = DEFAULT_TOOLS }) {
|
|
42
|
+
const { rows, columns } = useWindowSize();
|
|
43
|
+
const { t } = useI18n();
|
|
44
|
+
const { boundKeyboard } = useKeyboard();
|
|
45
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
46
|
+
// null = bar sits at the bottom-center via flex layout; once the user
|
|
47
|
+
// grabs it, absolute positioning takes over so it follows the cursor.
|
|
48
|
+
const [pos, setPos] = useState(null);
|
|
49
|
+
const [dragging, setDragging] = useState(false);
|
|
50
|
+
const barRef = useRef(null);
|
|
51
|
+
// Grab offset (where inside the bar the cursor went down) plus the bar's
|
|
52
|
+
// size at grab time, kept in a ref so drag moves need no re-measurement.
|
|
53
|
+
const grabRef = useRef({ dx: 0, dy: 0, width: 0, height: 0 });
|
|
54
|
+
const startDrag = useCallback((event) => {
|
|
55
|
+
const node = barRef.current;
|
|
56
|
+
if (!node) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Measure the bar itself, not the hit region — a button may be the
|
|
60
|
+
// captured target, and its rect would put the bar's corner at the
|
|
61
|
+
// button's corner.
|
|
62
|
+
const m = measureElement(node);
|
|
63
|
+
grabRef.current = {
|
|
64
|
+
dx: event.x - m.x - 1,
|
|
65
|
+
dy: event.y - m.y - 1,
|
|
66
|
+
width: m.width,
|
|
67
|
+
height: m.height,
|
|
68
|
+
};
|
|
69
|
+
// Switch from flex centering to absolute positioning at the current
|
|
70
|
+
// rect — seamless, no jump on the first frame.
|
|
71
|
+
setPos({ top: m.y, left: m.x });
|
|
72
|
+
setDragging(true);
|
|
73
|
+
}, []);
|
|
74
|
+
const moveDrag = useCallback((event) => {
|
|
75
|
+
const { dx, dy, width, height } = grabRef.current;
|
|
76
|
+
setPos(clampFrame(event.x - dx - 1, event.y - dy - 1, columns, rows, width, height));
|
|
77
|
+
}, [columns, rows]);
|
|
78
|
+
const endDrag = useCallback(() => setDragging(false), []);
|
|
79
|
+
const showFocus = mode === "normal";
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const unbinds = [
|
|
82
|
+
boundKeyboard(["ctrl+left"], () => setActiveIndex((i) => (i - 1 + tools.length) % tools.length), { mode: "normal" }),
|
|
83
|
+
boundKeyboard(["ctrl+right"], () => setActiveIndex((i) => (i + 1) % tools.length), { mode: "normal" }),
|
|
84
|
+
boundKeyboard(["ctrl+return"], () => tools[activeIndex]?.onClick(), {
|
|
85
|
+
mode: "normal",
|
|
86
|
+
}),
|
|
87
|
+
];
|
|
88
|
+
return () => unbinds.forEach((fn) => fn());
|
|
89
|
+
}, [boundKeyboard, tools, activeIndex]);
|
|
90
|
+
return (React.createElement(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "flex-end" },
|
|
91
|
+
React.createElement(Box, { ref: barRef, position: pos ? "absolute" : undefined, top: pos?.top, left: pos?.left, height: 4, flexDirection: "row", justifyContent: "center", alignItems: "center" }, tools.map((tool, index) => (React.createElement(ToolButton, { key: tool.id, text: t(tool.labelKey), priority: 1, focused: showFocus && index === activeIndex, onClick: tool.onClick, dragging: dragging, onDragStart: startDrag, onDragMove: moveDrag, onDragEnd: endDrag }))))));
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blots-editor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "A lightweight Markdown editor for the terminal, built on ink-cartridge",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"gradient-string": "^2.0.2",
|
|
57
57
|
"ink-cartridge": "*",
|
|
58
58
|
"oh-my-logo": "^0.5.0",
|
|
59
|
-
"string-width": "^8.2.2"
|
|
59
|
+
"string-width": "^8.2.2",
|
|
60
|
+
"zod": "^4.4.3"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@types/gradient-string": "^1.1.6",
|