blots-editor 0.0.10 → 0.0.12

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.
@@ -7,6 +7,9 @@
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",
@@ -7,6 +7,9 @@
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": "普通",
@@ -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. Locks the view in place (Ctrl+wheel
97
- * browsing) until the cursor moves.
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));
@@ -163,8 +165,19 @@ export class Document {
163
165
  /** Set the cursor, clamping out-of-range values (line to `[0, lineCount-1]`, column to end of line). */
164
166
  setCursor(line, logical) {
165
167
  const l = Math.max(0, Math.min(line, this._lines.length - 1));
166
- const len = this._lines[l].text.length;
167
- this._cursor = { line: l, logical: Math.max(0, Math.min(logical, len)) };
168
+ const text = this._lines[l].text;
169
+ let col = Math.max(0, Math.min(logical, text.length));
170
+ // The cursor must never sit between the halves of a surrogate pair —
171
+ // typing there would split the emoji — so snap it to the pair start.
172
+ if (col > 0 &&
173
+ col < text.length &&
174
+ text.charCodeAt(col) >= 0xdc00 &&
175
+ text.charCodeAt(col) <= 0xdfff &&
176
+ text.charCodeAt(col - 1) >= 0xd800 &&
177
+ text.charCodeAt(col - 1) <= 0xdbff) {
178
+ col--;
179
+ }
180
+ this._cursor = { line: l, logical: col };
168
181
  // Any cursor movement releases the manual view-scroll lock so the
169
182
  // viewport follows the cursor again.
170
183
  this._viewScrollTop = null;
@@ -189,13 +202,28 @@ export class Document {
189
202
  moveLeft() {
190
203
  const { line, logical } = this._cursor;
191
204
  if (logical > 0) {
192
- this.setCursor(line, logical - 1);
205
+ let target = logical - 1;
206
+ // Step over the whole emoji: stopping at its trailing half would
207
+ // leave the cursor inside the pair.
208
+ const ch = this._lines[line].text.charCodeAt(target);
209
+ if (target > 0 && ch >= 0xdc00 && ch <= 0xdfff) {
210
+ target--;
211
+ }
212
+ this.setCursor(line, target);
193
213
  }
194
214
  }
195
215
  moveRight() {
196
216
  const { line, logical } = this._cursor;
197
- if (logical < this._lines[line].text.length) {
198
- this.setCursor(line, logical + 1);
217
+ const text = this._lines[line].text;
218
+ if (logical < text.length) {
219
+ let target = logical + 1;
220
+ // Step over the whole emoji: stopping at its leading half would
221
+ // leave the cursor inside the pair.
222
+ const ch = text.charCodeAt(logical);
223
+ if (target < text.length && ch >= 0xd800 && ch <= 0xdbff) {
224
+ target++;
225
+ }
226
+ this.setCursor(line, target);
199
227
  }
200
228
  }
201
229
  /** Vertical movement aligns by visual column, so wide-character lines do not drift. */
@@ -272,7 +300,10 @@ export class Document {
272
300
  }
273
301
  /** Page up: move the cursor up by `height - 1` visual lines (one line of overlap), keeping the visual column. */
274
302
  movePageUp(height) {
275
- const target = Math.max(0, this.cursorVisualLine - Math.max(1, height - 1));
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);
276
307
  const seg = this.visualLineAt(target);
277
308
  if (!seg) {
278
309
  return;
@@ -280,7 +311,8 @@ export class Document {
280
311
  this.moveToSegment(seg);
281
312
  }
282
313
  movePageDown(height) {
283
- const target = Math.min(this.visualLineCount - 1, this.cursorVisualLine + Math.max(1, height - 1));
314
+ const step = Math.max(1, Math.round(height) - 1);
315
+ const target = Math.min(this.visualLineCount - 1, this.cursorVisualLine + step);
284
316
  const seg = this.visualLineAt(target);
285
317
  if (!seg) {
286
318
  return;
@@ -289,15 +321,19 @@ export class Document {
289
321
  }
290
322
  /**
291
323
  * Scroll the viewport by `delta` visual lines without moving the cursor,
292
- * clamped to the document range. Locks the view in place (Ctrl+wheel
293
- * browsing) until the cursor moves.
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.
294
327
  */
295
328
  scrollView(delta, height) {
296
329
  const total = this.visualLineCount;
297
330
  const effectiveH = height > 0 ? height : total;
298
331
  const maxScroll = Math.max(0, total - effectiveH);
299
332
  const base = this._viewScrollTop ?? this._scrollTop;
300
- const vs = Math.max(0, Math.min(base + delta, maxScroll));
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));
301
337
  this._viewScrollTop = vs;
302
338
  this._scrollTop = vs;
303
339
  return vs;
@@ -26,10 +26,15 @@ export class DeleteBeforeOp {
26
26
  const { line, logical } = doc.cursor;
27
27
  if (logical > 0) {
28
28
  const cur = doc.getLine(line);
29
- this._deletedChar = cur[logical - 1];
29
+ // Deleting only the trailing half of a surrogate pair would leave a
30
+ // broken half-rendering char, so delete the whole code point.
31
+ const last = cur.charCodeAt(logical - 1);
32
+ const isTrail = last >= 0xdc00 && last <= 0xdfff && logical >= 2;
33
+ const units = isTrail ? 2 : 1;
34
+ this._deletedChar = cur.slice(logical - units, logical);
30
35
  this._joined = false;
31
- doc.setLine(line, cur.slice(0, logical - 1) + cur.slice(logical));
32
- doc.setCursor(line, logical - 1);
36
+ doc.setLine(line, cur.slice(0, logical - units) + cur.slice(logical));
37
+ doc.setCursor(line, logical - units);
33
38
  }
34
39
  else if (line > 0) {
35
40
  const prev = doc.getLine(line - 1);
@@ -45,7 +50,7 @@ export class DeleteBeforeOp {
45
50
  const { line, logical } = doc.cursor;
46
51
  const cur = doc.getLine(line);
47
52
  doc.setLine(line, cur.slice(0, logical) + this._deletedChar + cur.slice(logical));
48
- doc.setCursor(line, logical + 1);
53
+ doc.setCursor(line, logical + this._deletedChar.length);
49
54
  }
50
55
  else {
51
56
  // Cursor sits at the end of the merged line; split it back at that point.
@@ -65,9 +70,14 @@ export class DeleteAfterOp {
65
70
  const { line, logical } = doc.cursor;
66
71
  const cur = doc.getLine(line);
67
72
  if (logical < cur.length) {
68
- this._deletedChar = cur[logical];
73
+ // Deleting only the leading half of a surrogate pair would leave a
74
+ // broken half-rendering char, so delete the whole code point.
75
+ const first = cur.charCodeAt(logical);
76
+ const isLead = first >= 0xd800 && first <= 0xdbff && logical + 1 < cur.length;
77
+ const units = isLead ? 2 : 1;
78
+ this._deletedChar = cur.slice(logical, logical + units);
69
79
  this._joined = false;
70
- doc.setLine(line, cur.slice(0, logical) + cur.slice(logical + 1));
80
+ doc.setLine(line, cur.slice(0, logical) + cur.slice(logical + units));
71
81
  }
72
82
  else if (line < doc.lineCount - 1) {
73
83
  const next = doc.getLine(line + 1);
@@ -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: "en", fallbackLanguage: "en" },
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)))));
@@ -2,6 +2,7 @@ import { Box, Text, useBoxMetrics, useCursor } from "ink";
2
2
  import { applyElementToModalLayer, 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";
@@ -26,6 +27,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
26
27
  const { setCursorPosition } = useCursor();
27
28
  const { boundKeyboard, enableWildcardPriority, getCurrentMode, registryCompositionKey, removeCompositionKey, setMode, } = useKeyboard();
28
29
  const { t } = useI18n();
30
+ const { settings } = useSettings();
29
31
  // The engine keeps modes in a ref (no React notification), so we mirror
30
32
  // switches locally to keep the status bar live.
31
33
  const [mode, setLocalMode] = useState(() => getCurrentMode());
@@ -142,14 +144,17 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
142
144
  return;
143
145
  }
144
146
  if (event.ctrl) {
147
+ // Ctrl+wheel scrolls the view at `view` lines per notch
148
+ // (clamped to the document); the cursor does not move.
145
149
  controller.execute("view.scroll", {
146
- delta: up ? -1 : 1,
150
+ delta: up ? -settings.wheel.view : settings.wheel.view,
147
151
  height,
148
152
  });
149
153
  return;
150
154
  }
155
+ // Plain wheel moves the cursor at `cursor` lines per notch.
151
156
  controller.execute(up ? "cursor.pageUp" : "cursor.pageDown", {
152
- height: 1,
157
+ height: settings.wheel.cursor,
153
158
  });
154
159
  },
155
160
  });
@@ -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
+ }
@@ -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
- const ref = useMouseRegion({ onEnter, onLeave, onClick });
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
- setLanguage(LANGUAGES[index].code);
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, setLanguage]);
53
+ }, [applyLanguage, boundKeyboard, closeModalLayer, ctx, index]);
44
54
  const apply = (code) => {
45
55
  if (!ctx) {
46
56
  return;
47
57
  }
48
- setLanguage(code);
58
+ applyLanguage(code);
49
59
  closeModalLayer(ctx.modalLayer.layerId);
50
60
  };
51
- return (React.createElement(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "center" },
52
- React.createElement(Box, { flexDirection: "column", borderStyle: "bold", borderColor: "white", backgroundColor: "black", width: 36, alignItems: "center", paddingX: 4, paddingY: 1, gap: 1 },
53
- React.createElement(Text, { bold: true }, t("settings.language")),
54
- 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) }))),
55
- React.createElement(Text, { dimColor: true },
56
- t("settings.back"),
57
- " (Esc)"))));
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 [hovered, setHovered] = useState(false);
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(["return"], () => openLanguagePicker()),
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, openLanguagePicker]);
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"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blots-editor",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
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",