@silvery/tea 0.3.0
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/package.json +54 -0
- package/src/core/index.ts +225 -0
- package/src/core/slice.ts +69 -0
- package/src/create-command-registry.ts +106 -0
- package/src/effects.ts +145 -0
- package/src/focus-events.ts +243 -0
- package/src/focus-manager.ts +491 -0
- package/src/focus-queries.ts +241 -0
- package/src/index.ts +213 -0
- package/src/keys.ts +1382 -0
- package/src/pipe.ts +110 -0
- package/src/plugins.ts +119 -0
- package/src/store/index.ts +306 -0
- package/src/streams/index.ts +405 -0
- package/src/tea/README.md +208 -0
- package/src/tea/index.ts +174 -0
- package/src/text-cursor.ts +206 -0
- package/src/text-decorations.ts +253 -0
- package/src/text-ops.ts +150 -0
- package/src/tree-utils.ts +27 -0
- package/src/types.ts +670 -0
- package/src/with-commands.ts +337 -0
- package/src/with-diagnostics.ts +955 -0
- package/src/with-dom-events.ts +168 -0
- package/src/with-focus.ts +162 -0
- package/src/with-keybindings.ts +180 -0
- package/src/with-react.ts +92 -0
- package/src/with-render.ts +92 -0
- package/src/with-terminal.ts +219 -0
package/src/text-ops.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Operations -- invertible, composable text mutations.
|
|
3
|
+
*
|
|
4
|
+
* Every text change produces a TextOp that can be inverted (for undo)
|
|
5
|
+
* and composed (for merging consecutive typing). Foundation for
|
|
6
|
+
* operations-based undo/redo without document snapshots.
|
|
7
|
+
*
|
|
8
|
+
* Architecture layer 0 -- no state, no hooks, no components.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { applyTextOp, invertTextOp, mergeTextOps } from '@silvery/react'
|
|
13
|
+
*
|
|
14
|
+
* const op: TextOp = { type: "insert", offset: 5, text: "hello" }
|
|
15
|
+
* const result = applyTextOp("world", op) // "worlhellod"
|
|
16
|
+
* const inv = invertTextOp(op) // { type: "delete", offset: 5, text: "hello" }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Types
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An invertible text operation.
|
|
26
|
+
*
|
|
27
|
+
* - `insert`: text was inserted at `offset`
|
|
28
|
+
* - `delete`: text was deleted starting at `offset` (the deleted content is
|
|
29
|
+
* stored in `text` so the operation can be inverted)
|
|
30
|
+
* - `replace`: text at `offset` was replaced — `deleted` holds the old text,
|
|
31
|
+
* `text` holds the new text. The inverse swaps them.
|
|
32
|
+
*/
|
|
33
|
+
export type TextOp =
|
|
34
|
+
| { type: "insert"; offset: number; text: string }
|
|
35
|
+
| { type: "delete"; offset: number; text: string }
|
|
36
|
+
| { type: "replace"; offset: number; text: string; deleted: string }
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// Core Functions
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Apply a text operation to a string, returning the modified string.
|
|
44
|
+
*
|
|
45
|
+
* Throws if the offset is out of bounds or if a delete operation's stored
|
|
46
|
+
* text doesn't match what's actually in the string at that position.
|
|
47
|
+
*/
|
|
48
|
+
export function applyTextOp(text: string, op: TextOp): string {
|
|
49
|
+
if (op.offset < 0 || op.offset > text.length) {
|
|
50
|
+
throw new RangeError(`TextOp offset ${op.offset} out of bounds for text of length ${text.length}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (op.type === "insert") {
|
|
54
|
+
return text.slice(0, op.offset) + op.text + text.slice(op.offset)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (op.type === "replace") {
|
|
58
|
+
const end = op.offset + op.deleted.length
|
|
59
|
+
if (end > text.length) {
|
|
60
|
+
throw new RangeError(
|
|
61
|
+
`TextOp replace extends past end: offset=${op.offset}, deleteLen=${op.deleted.length}, textLen=${text.length}`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
const actual = text.slice(op.offset, end)
|
|
65
|
+
if (actual !== op.deleted) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`TextOp replace mismatch at offset ${op.offset}: expected ${JSON.stringify(op.deleted)}, got ${JSON.stringify(actual)}`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
return text.slice(0, op.offset) + op.text + text.slice(end)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// delete
|
|
74
|
+
const end = op.offset + op.text.length
|
|
75
|
+
if (end > text.length) {
|
|
76
|
+
throw new RangeError(
|
|
77
|
+
`TextOp delete extends past end: offset=${op.offset}, deleteLen=${op.text.length}, textLen=${text.length}`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
const actual = text.slice(op.offset, end)
|
|
81
|
+
if (actual !== op.text) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`TextOp delete mismatch at offset ${op.offset}: expected ${JSON.stringify(op.text)}, got ${JSON.stringify(actual)}`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
return text.slice(0, op.offset) + text.slice(end)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Invert a text operation (insert becomes delete and vice versa).
|
|
91
|
+
*
|
|
92
|
+
* The inverse of an insert at offset N is a delete of the same text at
|
|
93
|
+
* offset N; the inverse of a delete is an insert.
|
|
94
|
+
*/
|
|
95
|
+
export function invertTextOp(op: TextOp): TextOp {
|
|
96
|
+
if (op.type === "insert") {
|
|
97
|
+
return { type: "delete", offset: op.offset, text: op.text }
|
|
98
|
+
}
|
|
99
|
+
if (op.type === "replace") {
|
|
100
|
+
return { type: "replace", offset: op.offset, text: op.deleted, deleted: op.text }
|
|
101
|
+
}
|
|
102
|
+
return { type: "insert", offset: op.offset, text: op.text }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Attempt to merge two consecutive text operations into one.
|
|
107
|
+
*
|
|
108
|
+
* Returns the merged operation, or `null` if the operations can't be merged.
|
|
109
|
+
*
|
|
110
|
+
* Merge rules:
|
|
111
|
+
* - Two inserts where `b` starts exactly where `a` ends -> single insert
|
|
112
|
+
* - Two deletes where `b` ends exactly where `a` starts (backspace sequence)
|
|
113
|
+
* -> single delete covering both ranges
|
|
114
|
+
* - Two deletes where `b` starts at `a`'s offset (forward-delete sequence)
|
|
115
|
+
* -> single delete covering both ranges
|
|
116
|
+
* - Otherwise -> null (can't merge)
|
|
117
|
+
*/
|
|
118
|
+
export function mergeTextOps(a: TextOp, b: TextOp): TextOp | null {
|
|
119
|
+
// insert + insert: b inserts right after a's inserted text
|
|
120
|
+
if (a.type === "insert" && b.type === "insert") {
|
|
121
|
+
if (b.offset === a.offset + a.text.length) {
|
|
122
|
+
return { type: "insert", offset: a.offset, text: a.text + b.text }
|
|
123
|
+
}
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// delete + delete
|
|
128
|
+
if (a.type === "delete" && b.type === "delete") {
|
|
129
|
+
// Backspace sequence: b deletes the character just before a's range.
|
|
130
|
+
// After a deletes at offset X, the next backspace deletes at offset X-1.
|
|
131
|
+
if (b.offset + b.text.length === a.offset) {
|
|
132
|
+
return { type: "delete", offset: b.offset, text: b.text + a.text }
|
|
133
|
+
}
|
|
134
|
+
// Forward-delete sequence: b deletes at the same position as a (because
|
|
135
|
+
// after a removed its text, the next character slid into the same offset).
|
|
136
|
+
if (b.offset === a.offset) {
|
|
137
|
+
return { type: "delete", offset: a.offset, text: a.text + b.text }
|
|
138
|
+
}
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// insert + delete that exactly cancels the insert
|
|
143
|
+
if (a.type === "insert" && b.type === "delete") {
|
|
144
|
+
if (b.offset === a.offset && b.text === a.text) {
|
|
145
|
+
return null // operations cancel out
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tree utilities for silvery event systems.
|
|
3
|
+
*
|
|
4
|
+
* Functions used by both focus-events.ts and mouse-events.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { TeaNode, Rect } from "./types.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Collect the ancestor path from target to root (inclusive).
|
|
11
|
+
*/
|
|
12
|
+
export function getAncestorPath(node: TeaNode): TeaNode[] {
|
|
13
|
+
const path: TeaNode[] = []
|
|
14
|
+
let current: TeaNode | null = node
|
|
15
|
+
while (current) {
|
|
16
|
+
path.push(current)
|
|
17
|
+
current = current.parent
|
|
18
|
+
}
|
|
19
|
+
return path
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a point is inside a rect.
|
|
24
|
+
*/
|
|
25
|
+
export function pointInRect(x: number, y: number, rect: Rect): boolean {
|
|
26
|
+
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
|
|
27
|
+
}
|