blots-editor 0.0.16 → 0.0.18

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.
@@ -24,8 +24,13 @@ export type FileNode = {
24
24
  };
25
25
  /** Expand a leading `~` to the user's home directory. */
26
26
  export declare function expandHome(path: string): string;
27
- /** Resolve the file-tree root from settings: startup dir or custom path. */
28
- export declare function resolveFileTreeRoot(settings: FileTreeSettings, startupDir: string): string | null;
27
+ /**
28
+ * Resolve the file-tree root from settings: the custom path, or the startup
29
+ * directory. A custom mode with a blank path falls back to the startup
30
+ * directory — the picker lets you select custom before typing anything, and an
31
+ * empty root should not read as "no directory".
32
+ */
33
+ export declare function resolveFileTreeRoot(settings: FileTreeSettings, startupDir: string): string;
29
34
  /**
30
35
  * Recursively scan a directory into a tree. Symlinks are never followed
31
36
  * (they can loop), dotfiles and known dependency/VCS dirs are skipped.
@@ -43,11 +43,18 @@ const isHidden = (name) => name.startsWith(".");
43
43
  export function expandHome(path) {
44
44
  return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
45
45
  }
46
- /** Resolve the file-tree root from settings: startup dir or custom path. */
46
+ /**
47
+ * Resolve the file-tree root from settings: the custom path, or the startup
48
+ * directory. A custom mode with a blank path falls back to the startup
49
+ * directory — the picker lets you select custom before typing anything, and an
50
+ * empty root should not read as "no directory".
51
+ */
47
52
  export function resolveFileTreeRoot(settings, startupDir) {
48
53
  if (settings.root === "custom") {
49
54
  const custom = expandHome(settings.customPath.trim());
50
- return custom.length > 0 ? custom : null;
55
+ if (custom.length > 0) {
56
+ return custom;
57
+ }
51
58
  }
52
59
  return startupDir;
53
60
  }
@@ -73,6 +73,9 @@ export class EditorSession {
73
73
  return { ok: false, error: "Binary file" };
74
74
  }
75
75
  this._controller.document.setText(text);
76
+ // Recorded positions refer to the previous text, so the history is
77
+ // meaningless once the document is replaced.
78
+ this._controller.history.clear();
76
79
  this._filePath = filePath;
77
80
  // Hash the NORMALIZED document text, not the raw disk bytes: the
78
81
  // document normalizes CRLF to LF, so hashing the raw text would
@@ -9,6 +9,16 @@ import { z } from "zod";
9
9
  */
10
10
  /** Wheel sensitivity in visual lines per notch, 1.0–10.0, 0.5 steps. */
11
11
  export declare const sensitivitySchema: z.ZodDefault<z.ZodNumber>;
12
+ /** Undo coalescing range in ms; `0` disables merging. */
13
+ export declare const MERGE_WINDOW_MIN = 0;
14
+ export declare const MERGE_WINDOW_MAX = 2000;
15
+ export declare const MERGE_WINDOW_STEP = 100;
16
+ export declare const MERGE_WINDOW_DEFAULT = 500;
17
+ /**
18
+ * Consecutive mergeable edits closer than this (ms) collapse into one undo
19
+ * step. `0` turns merging off, so every edit is its own step.
20
+ */
21
+ export declare const mergeWindowSchema: z.ZodDefault<z.ZodNumber>;
12
22
  /** File-tree root source: the process startup directory or a custom path. */
13
23
  export declare const fileTreeRootSchema: z.ZodDefault<z.ZodEnum<{
14
24
  startup: "startup";
@@ -27,10 +37,14 @@ export declare const settingsSchema: z.ZodObject<{
27
37
  }>>;
28
38
  customPath: z.ZodDefault<z.ZodString>;
29
39
  }, z.core.$strip>>;
40
+ history: z.ZodDefault<z.ZodObject<{
41
+ mergeWindow: z.ZodDefault<z.ZodNumber>;
42
+ }, z.core.$strip>>;
30
43
  }, z.core.$strip>;
31
44
  export type EditorSettings = z.infer<typeof settingsSchema>;
32
45
  export type WheelSensitivity = EditorSettings["wheel"];
33
46
  export type FileTreeSettings = EditorSettings["fileTree"];
47
+ export type HistorySettings = EditorSettings["history"];
34
48
  export declare const DEFAULT_SETTINGS: EditorSettings;
35
49
  /** Parse an unknown persisted value, falling back to defaults on any failure. */
36
50
  export declare function parseSettings(raw: unknown): EditorSettings;
@@ -14,6 +14,22 @@ export const sensitivitySchema = z
14
14
  .max(10)
15
15
  .step(0.5)
16
16
  .default(1);
17
+ /** Undo coalescing range in ms; `0` disables merging. */
18
+ export const MERGE_WINDOW_MIN = 0;
19
+ export const MERGE_WINDOW_MAX = 2000;
20
+ export const MERGE_WINDOW_STEP = 100;
21
+ export const MERGE_WINDOW_DEFAULT = 500;
22
+ /**
23
+ * Consecutive mergeable edits closer than this (ms) collapse into one undo
24
+ * step. `0` turns merging off, so every edit is its own step.
25
+ */
26
+ export const mergeWindowSchema = z
27
+ .number()
28
+ .int()
29
+ .min(MERGE_WINDOW_MIN)
30
+ .max(MERGE_WINDOW_MAX)
31
+ .multipleOf(MERGE_WINDOW_STEP)
32
+ .default(MERGE_WINDOW_DEFAULT);
17
33
  const DEFAULT_WHEEL = { cursor: 1, view: 3 };
18
34
  /** File-tree root source: the process startup directory or a custom path. */
19
35
  export const fileTreeRootSchema = z.enum(["startup", "custom"]).default("startup");
@@ -36,11 +52,18 @@ export const settingsSchema = z.object({
36
52
  customPath: z.string().default(""),
37
53
  })
38
54
  .default({ root: "startup", customPath: "" }),
55
+ history: z
56
+ .object({
57
+ /** Undo coalescing window in ms; see {@link mergeWindowSchema}. */
58
+ mergeWindow: mergeWindowSchema,
59
+ })
60
+ .default({ mergeWindow: MERGE_WINDOW_DEFAULT }),
39
61
  });
40
62
  export const DEFAULT_SETTINGS = {
41
63
  language: "en",
42
64
  wheel: DEFAULT_WHEEL,
43
65
  fileTree: { root: "startup", customPath: "" },
66
+ history: { mergeWindow: MERGE_WINDOW_DEFAULT },
44
67
  };
45
68
  /** Parse an unknown persisted value, falling back to defaults on any failure. */
46
69
  export function parseSettings(raw) {
@@ -18,6 +18,10 @@ export type SettingsApi = {
18
18
  setLanguage: (code: string) => void;
19
19
  /** Persist the file-tree root settings. */
20
20
  setFileTree: (fileTree: EditorSettings["fileTree"]) => void;
21
+ /** Set the undo merge window (ms) and persist it immediately. */
22
+ setMergeWindow: (ms: number) => void;
23
+ /** Update the merge window in memory only (while dragging); commit on release. */
24
+ setMergeWindowDraft: (ms: number) => void;
21
25
  };
22
26
  /**
23
27
  * React binding for the shared {@link settingsStore}. Re-renders subscribers
@@ -27,6 +27,14 @@ export function useSettings() {
27
27
  commit: () => settingsStore.commit(),
28
28
  setLanguage: (code) => settingsStore.persist({ ...settings, language: code }),
29
29
  setFileTree: (fileTree) => settingsStore.persist({ ...settings, fileTree }),
30
+ setMergeWindow: (ms) => settingsStore.persist({
31
+ ...settings,
32
+ history: { ...settings.history, mergeWindow: ms },
33
+ }),
34
+ setMergeWindowDraft: (ms) => settingsStore.update({
35
+ ...settings,
36
+ history: { ...settings.history, mergeWindow: ms },
37
+ }),
30
38
  };
31
39
  }, [settings]);
32
40
  }
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { render } from "ink";
3
3
  import React from "react";
4
- import { CurrentScreen, KeyboardProvider, LanguageProvider, registerComponent, ScenarioManagementProvider, } from "ink-cartridge";
4
+ import { LanguageProvider } from "@cartridge-engine/i18n";
5
+ import { CurrentScreen, KeyboardProvider, registerComponent, ScenarioManagementProvider, } from "ink-cartridge";
5
6
  import { resources } from "./utils/view/i18n-resources.js";
6
7
  import { settingsStore } from "./core/settings/useSettings.js";
7
8
  import { MainMenu } from "./view/page/main-menu.js";
@@ -1,5 +1,6 @@
1
+ import { useI18n } from "@cartridge-engine/i18n";
1
2
  import { Box, Text } from "ink";
2
- import { back, ModalLayerElementContext, useI18n, useKeyboard, useScreenSystem, } from "ink-cartridge";
3
+ import { back, ModalLayerElementContext, useKeyboard, useScreenSystem, } from "ink-cartridge";
3
4
  import React, { useContext, useEffect, useRef, useState } from "react";
4
5
  /**
5
6
  * Modal command bar (like Helix's `:` prompt). Rendered as a modal layer
@@ -1,5 +1,6 @@
1
+ import { useI18n } from "@cartridge-engine/i18n";
1
2
  import { Box, Text } from "ink";
2
- import { applyElement, back, closeLayer, LayerElementContext, openLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
3
+ import { applyElement, back, closeLayer, LayerElementContext, openLayer, useKeyboard, useMouseRegion, } from "ink-cartridge";
3
4
  import React, { useCallback, useContext, useEffect, useState } from "react";
4
5
  import { SettingsEntries } from "../page/settings.js";
5
6
  import { ModalFrame } from "../utils/modal-frame.js";
@@ -1,13 +1,9 @@
1
1
  import React from "react";
2
- import type { FileTreeState } from "../../core/io/file-tree-model.js";
3
2
  import type { EditorSession } from "../../core/io/session.js";
4
3
  export type FileTreeProps = {
5
4
  /** The shared file session; clicking a file opens it here. */
6
5
  session: EditorSession;
7
6
  };
8
- export type FileTreeDirState = FileTreeState | {
9
- noDir?: boolean;
10
- };
11
7
  /**
12
8
  * VSCode-style file tree pinned to the right edge of the terminal (regular
13
9
  * layer element). Recursively scans the configured root directory once per
@@ -1,5 +1,6 @@
1
+ import { useI18n } from "@cartridge-engine/i18n";
1
2
  import { Box, Text, useWindowSize } from "ink";
2
- import { applyElementToModalLayer, ModalLayerElementContext, openModalLayer, useI18n, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
3
+ import { applyElementToModalLayer, ModalLayerElementContext, openModalLayer, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
3
4
  import React, { useCallback, useContext, useEffect, useMemo, useState, } from "react";
4
5
  import { basename } from "node:path";
5
6
  import stringWidth from "string-width";
@@ -36,10 +37,7 @@ export function FileTree({ session }) {
36
37
  setRoot({ scanning: true });
37
38
  (async () => {
38
39
  const resolved = resolveFileTreeRoot(settings.fileTree, process.cwd());
39
- const tree = resolved
40
- ? await scanDirectoryCached(resolved)
41
- : { noDir: true };
42
- setRoot(tree);
40
+ setRoot(await scanDirectoryCached(resolved));
43
41
  })();
44
42
  }, [settings.fileTree, scanTick]);
45
43
  // A new scan (root changed) starts collapsed at the top — reset during
@@ -114,8 +112,7 @@ export function FileTree({ session }) {
114
112
  React.createElement(Box, { ref: refreshRef, marginLeft: 1 },
115
113
  React.createElement(Text, { color: refreshHovered ? "green" : "gray" }, "\u21BB"))),
116
114
  "scanning" in root ? (React.createElement(Box, { paddingLeft: 1 },
117
- React.createElement(Text, { dimColor: true }, t("fileTree.scanning")))) : "noDir" in root ? (React.createElement(Box, { paddingLeft: 1 },
118
- React.createElement(Text, { dimColor: true }, t("fileTree.noDir")))) : "fail" in root ? (React.createElement(Box, { paddingLeft: 1 },
115
+ React.createElement(Text, { dimColor: true }, t("fileTree.scanning")))) : "fail" in root ? (React.createElement(Box, { paddingLeft: 1 },
119
116
  React.createElement(Text, { dimColor: true }, t("fileTree.fail")))) : (visible.map((row) => (React.createElement(TreeRow, { key: row.node.path, node: row.node, depth: row.depth, expanded: expanded.has(row.node.path), hovered: hoveredPath === row.node.path, onEnter: () => setHoveredPath(row.node.path), onLeave: () => setHoveredPath((h) => (h === row.node.path ? null : h)), onWheel: handleWheel, onClick: () => {
120
117
  if (row.node.isDir) {
121
118
  toggleDir(row.node.path);
@@ -1,4 +1,5 @@
1
- import { useI18n, useKeyboard, useMouseRegion } from "ink-cartridge";
1
+ import { useI18n } from "@cartridge-engine/i18n";
2
+ import { useKeyboard, useMouseRegion } from "ink-cartridge";
2
3
  import React, { useCallback, useEffect, useMemo, useRef } from "react";
3
4
  import { useState } from "react";
4
5
  import { Box, measureElement, Text, useWindowSize } from "ink";
@@ -1,5 +1,6 @@
1
+ import { useI18n } from "@cartridge-engine/i18n";
1
2
  import { Box, Text, useBoxMetrics, useCursor } from "ink";
2
- import { applyElement, applyElementToModalLayer, closeLayer, eraseElement, openLayer, openModalLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
3
+ import { applyElement, applyElementToModalLayer, closeLayer, eraseElement, openLayer, openModalLayer, useKeyboard, useMouseRegion, } from "ink-cartridge";
3
4
  import React, { useCallback, useEffect, useRef, useState } from "react";
4
5
  import { EditorSession } from "../../core/io/session.js";
5
6
  import { useSettings } from "../../core/settings/useSettings.js";
@@ -104,7 +105,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
104
105
  session,
105
106
  openFileTree: () => setFileTreeOpen((open) => !open),
106
107
  openSettings: () => setSettingsOpen((open) => !open),
107
- fileTreeOpen
108
+ fileTreeOpen,
108
109
  },
109
110
  });
110
111
  }, 0);
@@ -151,6 +152,11 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
151
152
  useEffect(() => {
152
153
  return boundKeyboard(["ctrl+tab"], () => setToolbarOpen((open) => !open));
153
154
  }, [boundKeyboard]);
155
+ // Keep the coalescing window in step with the persisted setting; edits
156
+ // closer together than it share one undo step (0 disables merging).
157
+ useEffect(() => {
158
+ controller.history.setMergeWindow(settings.history.mergeWindow);
159
+ }, [controller, settings.history.mergeWindow]);
154
160
  useEffect(() => {
155
161
  const removeWildcard = enableWildcardPriority();
156
162
  const unbinds = [];
@@ -158,7 +164,9 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
158
164
  unbinds.push(boundKeyboard(keys, handler, options));
159
165
  };
160
166
  // insert mode: classic editing
161
- bind(["*"], (input) => controller.execute("editor.insertText", { text: input }), { mode: "insert" });
167
+ bind(["*"], (input) => controller.execute("editor.insertText", { text: input }), {
168
+ mode: "insert",
169
+ });
162
170
  bind(["return"], () => controller.execute("editor.splitLine"), { mode: "insert" });
163
171
  bind(["right"], () => controller.execute("cursor.moveRight"), { mode: "insert" });
164
172
  bind(["left"], () => controller.execute("cursor.moveLeft"), { mode: "insert" });
@@ -201,13 +209,63 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
201
209
  return { ...ctx, lastFlag: null };
202
210
  },
203
211
  });
212
+ // Vim-style count prefix: digits accumulate in the chain's `value`, then a
213
+ // direction key spends it (`5j`, `12k`). Composition runs ahead of the
214
+ // screen bindings, so a bare digit arms a chain instead of moving.
215
+ const countKeys = [];
216
+ for (const digit of "1234567890") {
217
+ countKeys.push(digit);
218
+ registryCompositionKey({
219
+ key: digit,
220
+ flags: [],
221
+ alternativeFlag: "number",
222
+ needs: ["number"],
223
+ // `0` cannot open a count, so a bare `0` keeps meaning line-start
224
+ // via its binding below; it may still extend one (`10j`).
225
+ optional: digit !== "0",
226
+ mode: "normal",
227
+ execute: (ctx) => ({
228
+ ...ctx,
229
+ value: (typeof ctx.value === "number" ? ctx.value : 0) * 10 + Number(digit),
230
+ lastFlag: "number",
231
+ }),
232
+ });
233
+ }
234
+ // Counts only pay off on the vertical keys; h/l/left/right stay untouched.
235
+ const directions = [
236
+ ["k", "cursor.moveUp"],
237
+ ["up", "cursor.moveUp"],
238
+ ["j", "cursor.moveDown"],
239
+ ["down", "cursor.moveDown"],
240
+ ];
241
+ for (const [key, command] of directions) {
242
+ countKeys.push(key);
243
+ registryCompositionKey({
244
+ key,
245
+ flags: [],
246
+ alternativeFlag: "direction",
247
+ needs: ["number"],
248
+ mode: "normal",
249
+ // The count is spent here and the chain ends, so swallow the key —
250
+ // otherwise the plain binding below would move one extra line.
251
+ KeyReleaseWhenChainInterrupted: true,
252
+ execute: (ctx) => {
253
+ controller.execute(command, { count: ctx.value });
254
+ return null;
255
+ },
256
+ });
257
+ }
204
258
  unbinds.push(() => removeCompositionKey("g"));
259
+ unbinds.push(() => countKeys.forEach((key) => removeCompositionKey(key)));
205
260
  // Ink marks "G" as shift+g, so the normalized key name is "shift+G".
206
261
  bind(["shift+G"], () => controller.execute("cursor.documentEnd"), { mode: "normal" });
207
262
  bind(["i"], () => switchMode("insert"), { mode: "normal" });
208
263
  bind([":"], () => openCommandBar(), { mode: "normal" });
209
264
  // Ctrl+S saves in normal mode (Vim-style; insert stays untouched).
210
265
  bind(["ctrl+s"], () => session.save(), { mode: "normal" });
266
+ // Undo/redo also live in normal mode; insert stays untouched.
267
+ bind(["u"], () => controller.execute("history.undo"), { mode: "normal" });
268
+ bind(["ctrl+r"], () => controller.execute("history.redo"), { mode: "normal" });
211
269
  return () => {
212
270
  removeWildcard();
213
271
  unbinds.forEach((fn) => fn());
@@ -281,7 +339,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
281
339
  return null;
282
340
  }
283
341
  return (React.createElement(Box, { key: vline, flexDirection: "row" },
284
- React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing }, seg.first ? (React.createElement(Text, { bold: cursor.line === seg.line }, seg.line)) : null),
342
+ React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing }, seg.first ? React.createElement(Text, { bold: cursor.line === seg.line }, seg.line) : null),
285
343
  React.createElement(Text, null, seg.text)));
286
344
  })))));
287
345
  }
@@ -1,5 +1,6 @@
1
+ import { useI18n } from "@cartridge-engine/i18n";
1
2
  import { Box, Text, useWindowSize } from "ink";
2
- import { useI18n, useKeyboard, useMouseRegion } from "ink-cartridge";
3
+ import { useKeyboard, useMouseRegion } from "ink-cartridge";
3
4
  import React, { useEffect, useState } from "react";
4
5
  import { Editor } from "./editor.js";
5
6
  import { Settings } from "./settings.js";
@@ -1,9 +1,11 @@
1
+ import { useI18n } from "@cartridge-engine/i18n";
1
2
  import { Box, Text } from "ink";
2
- import { back, ModalLayerElementContext, useI18n, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
3
+ import { back, ModalLayerElementContext, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
3
4
  import React, { useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
5
+ import { MERGE_WINDOW_MAX, MERGE_WINDOW_MIN, MERGE_WINDOW_STEP, } from "../../core/settings/schema.js";
4
6
  import { useSettings } from "../../core/settings/useSettings.js";
5
7
  import { ModalFrame } from "../utils/modal-frame.js";
6
- import { SensitivityBar, snapSensitivity } from "../utils/sensitivity-bar.js";
8
+ import { SensitivityBar, Slider, snapSensitivity, snapValue, } from "../utils/sensitivity-bar.js";
7
9
  const LANGUAGES = [
8
10
  { code: "en", labelKey: "settings.language.en" },
9
11
  { code: "zh", labelKey: "settings.language.zh" },
@@ -95,6 +97,42 @@ function SensitivityPicker({ sensitivityKey }) {
95
97
  value.toFixed(1),
96
98
  "\u00D7")));
97
99
  }
100
+ /**
101
+ * Modal slider for the undo merge window. The bar is drag/click driven;
102
+ * dragging updates in memory only, release/click persists. `0` disables
103
+ * merging, so the label reads "off" instead of the bare number.
104
+ */
105
+ function MergeWindowPicker() {
106
+ const ctx = useContext(ModalLayerElementContext);
107
+ const { t } = useI18n();
108
+ const { boundKeyboard } = useKeyboard();
109
+ const { closeModalLayer } = useScreenSystem();
110
+ const { settings, setMergeWindow, setMergeWindowDraft, commit } = useSettings();
111
+ const value = settings.history.mergeWindow;
112
+ useEffect(() => {
113
+ if (!ctx) {
114
+ return;
115
+ }
116
+ const snap = (dir) => snapValue(value, dir, MERGE_WINDOW_MIN, MERGE_WINDOW_MAX, MERGE_WINDOW_STEP);
117
+ const unbinds = [
118
+ boundKeyboard(["left"], () => setMergeWindow(snap(-1)), {
119
+ elementId: ctx.id,
120
+ }),
121
+ boundKeyboard(["right"], () => setMergeWindow(snap(1)), {
122
+ elementId: ctx.id,
123
+ }),
124
+ boundKeyboard(["escape"], () => closeModalLayer(ctx.modalLayer.layerId), { elementId: ctx.id }),
125
+ ];
126
+ return () => unbinds.forEach((fn) => fn());
127
+ }, [boundKeyboard, closeModalLayer, ctx, setMergeWindow, value]);
128
+ return (React.createElement(ModalFrame, { title: t("settings.mergeWindow"), footer: React.createElement(Text, { dimColor: true },
129
+ t("settings.back"),
130
+ " (Esc)") },
131
+ React.createElement(Slider, { value: value, min: MERGE_WINDOW_MIN, max: MERGE_WINDOW_MAX, step: MERGE_WINDOW_STEP, onChange: setMergeWindowDraft, onCommit: commit, hint: t("settings.slider.hint") }),
132
+ React.createElement(Text, null, value === MERGE_WINDOW_MIN
133
+ ? t("settings.mergeWindow.off")
134
+ : `${value} ms`)));
135
+ }
98
136
  /** Small clickable button used inside the file-tree-root picker. */
99
137
  function PickerButton({ label, active, onEnter, onLeave, onClick }) {
100
138
  const ref = useMouseRegion({ onEnter, onLeave, onClick }, { priority: 1 });
@@ -220,6 +258,13 @@ export function SettingsEntries({ onExit }) {
220
258
  element: FileTreePicker,
221
259
  });
222
260
  }, [applyElementToModalLayer, openModalLayer]);
261
+ const openMergeWindowPicker = useCallback(() => {
262
+ openModalLayer("merge-window", 50);
263
+ applyElementToModalLayer("merge-window", {
264
+ elementId: "merge-window-picker",
265
+ element: MergeWindowPicker,
266
+ });
267
+ }, [applyElementToModalLayer, openModalLayer]);
223
268
  const currentLabel = LANGUAGES.find((lang) => lang.code === currentLanguage)?.labelKey ??
224
269
  "settings.language.en";
225
270
  const entries = useMemo(() => [
@@ -249,6 +294,14 @@ export function SettingsEntries({ onExit }) {
249
294
  : t("settings.fileTreeRoot.startup"),
250
295
  open: openFileTreePicker,
251
296
  },
297
+ {
298
+ id: "merge-window",
299
+ label: t("settings.mergeWindow"),
300
+ value: settings.history.mergeWindow === MERGE_WINDOW_MIN
301
+ ? t("settings.mergeWindow.off")
302
+ : `${settings.history.mergeWindow} ms`,
303
+ open: openMergeWindowPicker,
304
+ },
252
305
  ], [
253
306
  t,
254
307
  currentLabel,
@@ -256,9 +309,11 @@ export function SettingsEntries({ onExit }) {
256
309
  settings.wheel.view,
257
310
  settings.fileTree.root,
258
311
  settings.fileTree.customPath,
312
+ settings.history.mergeWindow,
259
313
  openLanguagePicker,
260
314
  openSensitivityPicker,
261
315
  openFileTreePicker,
316
+ openMergeWindowPicker,
262
317
  ]);
263
318
  useEffect(() => {
264
319
  const unbinds = [
@@ -2,23 +2,57 @@ import React from "react";
2
2
  export declare const SENSITIVITY_MIN = 1;
3
3
  export declare const SENSITIVITY_MAX = 10;
4
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;
5
+ /** Number of steps a `min..max` range of `step` size spans. */
6
+ export declare function stepsFor(min: number, max: number, step: number): number;
7
+ /** Map a 0..1 ratio to a value snapped to `step`, clamped to `min..max`. */
8
+ export declare function valueFromRatio(ratio: number, min?: number, max?: number, step?: number): number;
9
9
  /** Clamp value + dir*step to the valid 1..10 range (0.5 steps). */
10
10
  export declare function snapSensitivity(value: number, dir: 1 | -1): number;
11
- type SensitivityBarProps = {
11
+ /** Clamp value + dir*step to an arbitrary `min..max` range. */
12
+ export declare function snapValue(value: number, dir: 1 | -1, min: number, max: number, step: number): number;
13
+ /**
14
+ * Value for a click on cell `localX` (0-based) of a bar that renders exactly
15
+ * `steps + 1` cells — one per value from `min` to `max` inclusive. The ratio
16
+ * therefore runs `localX / steps`, which is exactly one value per cell; using
17
+ * `steps` cells instead would have to skip an interior value to still reach
18
+ * `max` (e.g. 1000 ms, or 5.5×, would be unreachable by mouse).
19
+ */
20
+ export declare function valueFromBarX(localX: number, min: number, max: number, step: number): number;
21
+ /**
22
+ * Filled-cell count for `value` on the `min..max` bar, matching
23
+ * {@link valueFromBarX}: cell `i` selects `min + i*step`, so the value's own
24
+ * cell is filled and everything left of it too — one cell at `min`, the whole
25
+ * bar at `max`. Deriving the count from that integer cell index — rather than
26
+ * from the value's fraction of the range — keeps the fill aligned with the
27
+ * click mapping: a proportional `fraction * (steps + 1)` rounds unevenly and
28
+ * skips a cell at the midpoint.
29
+ */
30
+ export declare function filledCells(value: number, min: number, max: number, step: number): number;
31
+ type SliderProps = {
12
32
  value: number;
33
+ min: number;
34
+ max: number;
35
+ step: number;
13
36
  /** Called on click/drag with the new value (in-memory draft). */
14
37
  onChange: (value: number) => void;
15
38
  /** Called when the interaction ends — persist the draft. */
16
39
  onCommit: () => void;
40
+ /** Localized hint rendered under the bar. */
41
+ hint: string;
17
42
  };
18
43
  /**
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.
44
+ * Mouse-driven slider over an arbitrary `min..max`/`step` range. Clicking or
45
+ * dragging anywhere on the bar sets the value proportionally, snapped to
46
+ * `step`; the fuller the bar, the higher the value.
22
47
  */
48
+ export declare function Slider({ value, min, max, step, onChange, onCommit, hint }: SliderProps): React.JSX.Element;
49
+ type SensitivityBarProps = {
50
+ value: number;
51
+ /** Called on click/drag with the new value (in-memory draft). */
52
+ onChange: (value: number) => void;
53
+ /** Called when the interaction ends — persist the draft. */
54
+ onCommit: () => void;
55
+ };
56
+ /** Slider preset for the 1..10 wheel sensitivity range. */
23
57
  export declare function SensitivityBar({ value, onChange, onCommit }: SensitivityBarProps): React.JSX.Element;
24
58
  export {};
@@ -1,28 +1,61 @@
1
+ import { useI18n } from "@cartridge-engine/i18n";
1
2
  import { Box, Text } from "ink";
2
- import { useI18n, useMouseRegion } from "ink-cartridge";
3
+ import { useMouseRegion } from "ink-cartridge";
3
4
  import React from "react";
4
5
  export const SENSITIVITY_MIN = 1;
5
6
  export const SENSITIVITY_MAX = 10;
6
7
  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) {
8
+ /** Number of steps a `min..max` range of `step` size spans. */
9
+ export function stepsFor(min, max, step) {
10
+ return Math.round((max - min) / step);
11
+ }
12
+ /** Map a 0..1 ratio to a value snapped to `step`, clamped to `min..max`. */
13
+ export function valueFromRatio(ratio, min = SENSITIVITY_MIN, max = SENSITIVITY_MAX, step = SENSITIVITY_STEP) {
11
14
  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;
15
+ return min + Math.round(r * stepsFor(min, max, step)) * step;
14
16
  }
15
17
  /** Clamp value + dir*step to the valid 1..10 range (0.5 steps). */
16
18
  export function snapSensitivity(value, dir) {
17
- return Math.min(SENSITIVITY_MAX, Math.max(SENSITIVITY_MIN, value + dir * SENSITIVITY_STEP));
19
+ return snapValue(value, dir, SENSITIVITY_MIN, SENSITIVITY_MAX, SENSITIVITY_STEP);
20
+ }
21
+ /** Clamp value + dir*step to an arbitrary `min..max` range. */
22
+ export function snapValue(value, dir, min, max, step) {
23
+ return Math.min(max, Math.max(min, value + dir * step));
18
24
  }
19
25
  /**
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.
26
+ * Value for a click on cell `localX` (0-based) of a bar that renders exactly
27
+ * `steps + 1` cells — one per value from `min` to `max` inclusive. The ratio
28
+ * therefore runs `localX / steps`, which is exactly one value per cell; using
29
+ * `steps` cells instead would have to skip an interior value to still reach
30
+ * `max` (e.g. 1000 ms, or 5.5×, would be unreachable by mouse).
23
31
  */
24
- export function SensitivityBar({ value, onChange, onCommit }) {
25
- const { t } = useI18n();
32
+ export function valueFromBarX(localX, min, max, step) {
33
+ const steps = stepsFor(min, max, step);
34
+ // A zero-width bar (single value) can only mean `min`; avoid /0.
35
+ return steps <= 0 ? min : valueFromRatio(localX / steps, min, max, step);
36
+ }
37
+ /**
38
+ * Filled-cell count for `value` on the `min..max` bar, matching
39
+ * {@link valueFromBarX}: cell `i` selects `min + i*step`, so the value's own
40
+ * cell is filled and everything left of it too — one cell at `min`, the whole
41
+ * bar at `max`. Deriving the count from that integer cell index — rather than
42
+ * from the value's fraction of the range — keeps the fill aligned with the
43
+ * click mapping: a proportional `fraction * (steps + 1)` rounds unevenly and
44
+ * skips a cell at the midpoint.
45
+ */
46
+ export function filledCells(value, min, max, step) {
47
+ const total = stepsFor(min, max, step) + 1;
48
+ const index = Math.round((value - min) / step);
49
+ return Math.min(total, Math.max(1, index + 1));
50
+ }
51
+ /**
52
+ * Mouse-driven slider over an arbitrary `min..max`/`step` range. Clicking or
53
+ * dragging anywhere on the bar sets the value proportionally, snapped to
54
+ * `step`; the fuller the bar, the higher the value.
55
+ */
56
+ export function Slider({ value, min, max, step, onChange, onCommit, hint }) {
57
+ const steps = stepsFor(min, max, step);
58
+ const valueFromX = (localX) => valueFromBarX(localX, min, max, step);
26
59
  // priority 1: the bar sits inside the draggable ModalFrame, whose region
27
60
  // overlaps it — the child control must always win the hit test, no matter
28
61
  // the registration order after frame drags.
@@ -35,14 +68,18 @@ export function SensitivityBar({ value, onChange, onCommit }) {
35
68
  onDragMove: (event, rect) => onChange(valueFromX(event.x - rect.x)),
36
69
  onDragEnd: () => onCommit(),
37
70
  }, { priority: 1 });
38
- const filled = Math.round((value - SENSITIVITY_MIN) / SENSITIVITY_STEP);
71
+ // One cell per value (min..max inclusive); the fill is derived from the
72
+ // value's own cell, so it matches what a click on that cell would select.
73
+ const total = steps + 1;
74
+ const filled = filledCells(value, min, max, step);
39
75
  return (React.createElement(Box, { flexDirection: "column", alignItems: "center", gap: 1 },
40
76
  React.createElement(Box, { ref: ref, flexDirection: "row" },
41
77
  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"))));
78
+ React.createElement(Text, { dimColor: true }, "░".repeat(total - filled))),
79
+ React.createElement(Text, { dimColor: true }, hint)));
44
80
  }
45
- /** Value for a click at `localX` cells inside the bar (0-based). */
46
- function valueFromX(localX) {
47
- return valueFromRatio(localX / BAR_WIDTH);
81
+ /** Slider preset for the 1..10 wheel sensitivity range. */
82
+ export function SensitivityBar({ value, onChange, onCommit }) {
83
+ const { t } = useI18n();
84
+ return (React.createElement(Slider, { value: value, min: SENSITIVITY_MIN, max: SENSITIVITY_MAX, step: SENSITIVITY_STEP, onChange: onChange, onCommit: onCommit, hint: t("settings.slider.hint") }));
48
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blots-editor",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "A lightweight Markdown editor for the terminal, built on ink-cartridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -51,6 +51,7 @@
51
51
  "react": ">=18"
52
52
  },
53
53
  "dependencies": {
54
+ "@cartridge-engine/i18n": "*",
54
55
  "cfonts": "^3.3.1",
55
56
  "chalk": "^4.1.2",
56
57
  "gradient-string": "^2.0.2",