@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/tea/index.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* silvery/tea — Zustand middleware for TEA (The Elm Architecture) effects.
|
|
3
|
+
*
|
|
4
|
+
* A ~30-line middleware that extends Zustand reducers to optionally return
|
|
5
|
+
* [state, effects]. Gradual adoption: return plain state (Level 3) or
|
|
6
|
+
* [state, effects] (Level 4) on a per-case basis.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { createStore } from "zustand"
|
|
11
|
+
* import { tea, collect } from "@silvery/tea/tea"
|
|
12
|
+
*
|
|
13
|
+
* // Define effects as plain data
|
|
14
|
+
* const log = (msg: string) => ({ type: "log" as const, msg })
|
|
15
|
+
* const httpPost = (url: string, body: unknown) => ({ type: "http" as const, url, body })
|
|
16
|
+
*
|
|
17
|
+
* type MyEffect = ReturnType<typeof log> | ReturnType<typeof httpPost>
|
|
18
|
+
*
|
|
19
|
+
* interface State {
|
|
20
|
+
* count: number
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* type Op = { type: "increment" } | { type: "save" }
|
|
24
|
+
*
|
|
25
|
+
* // Reducer: return state (no effects) or [state, effects]
|
|
26
|
+
* function reducer(state: State, op: Op): TeaResult<State, MyEffect> {
|
|
27
|
+
* switch (op.type) {
|
|
28
|
+
* case "increment":
|
|
29
|
+
* return { ...state, count: state.count + 1 } // Level 3: plain state
|
|
30
|
+
* case "save":
|
|
31
|
+
* return [{ ...state }, [httpPost("/api", state), log("saved")]] // Level 4: [state, effects]
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* // Effect runners (swappable: production, test, replay)
|
|
36
|
+
* const runners: EffectRunners<MyEffect, Op> = {
|
|
37
|
+
* log: (e) => console.log(e.msg),
|
|
38
|
+
* http: async (e, dispatch) => {
|
|
39
|
+
* const res = await fetch(e.url, { method: "POST", body: JSON.stringify(e.body) })
|
|
40
|
+
* dispatch({ type: "loaded", data: await res.json() })
|
|
41
|
+
* },
|
|
42
|
+
* }
|
|
43
|
+
*
|
|
44
|
+
* // Wire up
|
|
45
|
+
* const store = createStore(tea({ count: 0 }, reducer, { runners }))
|
|
46
|
+
* store.getState().dispatch({ type: "increment" })
|
|
47
|
+
*
|
|
48
|
+
* // Test: collect() normalizes output for assertions
|
|
49
|
+
* const [state, effects] = collect(reducer(initial, { type: "save" }))
|
|
50
|
+
* expect(effects).toContainEqual(httpPost("/api", initial))
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* @packageDocumentation
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import type { StateCreator } from "zustand"
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Types
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
/** An effect is a plain object with a `type` discriminant. */
|
|
63
|
+
export type EffectLike = { type: string }
|
|
64
|
+
|
|
65
|
+
/** Reducer result: plain state (no effects) or [state, effects]. */
|
|
66
|
+
export type TeaResult<S, E extends EffectLike = EffectLike> = S | readonly [S, E[]]
|
|
67
|
+
|
|
68
|
+
/** A reducer that takes state + operation and returns TeaResult. */
|
|
69
|
+
export type TeaReducer<S, Op, E extends EffectLike = EffectLike> = (state: S, op: Op) => TeaResult<S, E>
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Effect runners keyed by effect `type`.
|
|
73
|
+
*
|
|
74
|
+
* Each runner receives the effect and a dispatch function for round-trip
|
|
75
|
+
* communication (Elm's Cmd Msg pattern).
|
|
76
|
+
*/
|
|
77
|
+
export type EffectRunners<E extends EffectLike, Op = unknown> = {
|
|
78
|
+
[K in E["type"]]?: (effect: Extract<E, { type: K }>, dispatch: (op: Op) => void) => void | Promise<void>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Options for the tea() middleware. */
|
|
82
|
+
export interface TeaOptions<E extends EffectLike, Op> {
|
|
83
|
+
/** Effect runners. Keyed by effect type. Unmatched effects are silently dropped. */
|
|
84
|
+
runners?: EffectRunners<E, Op>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The store shape produced by the tea() middleware.
|
|
89
|
+
*
|
|
90
|
+
* `dispatch(op)` runs the reducer, updates state, and executes effects.
|
|
91
|
+
* All domain state fields from S are spread at the top level alongside dispatch.
|
|
92
|
+
*/
|
|
93
|
+
export type TeaSlice<S, Op> = S & {
|
|
94
|
+
/** Dispatch an operation through the reducer. */
|
|
95
|
+
dispatch: (op: Op) => void
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// =============================================================================
|
|
99
|
+
// Core: tea() middleware
|
|
100
|
+
// =============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Zustand state creator that adds TEA-style dispatch + effects.
|
|
104
|
+
*
|
|
105
|
+
* The reducer can return plain state (Level 3) or `[state, effects]` (Level 4).
|
|
106
|
+
* Array.isArray detects which — safe because Zustand state is always an object.
|
|
107
|
+
*
|
|
108
|
+
* Effects are executed after state update. Each effect is routed to a runner
|
|
109
|
+
* by its `type` field. Runners receive a `dispatch` callback for round-trip
|
|
110
|
+
* communication (Elm's Cmd Msg pattern).
|
|
111
|
+
*/
|
|
112
|
+
export function tea<S extends object, Op, E extends EffectLike = EffectLike>(
|
|
113
|
+
initialState: S,
|
|
114
|
+
reducer: TeaReducer<S, Op, E>,
|
|
115
|
+
options?: TeaOptions<E, Op>,
|
|
116
|
+
): StateCreator<TeaSlice<S, Op>> {
|
|
117
|
+
return (set, get) => {
|
|
118
|
+
const dispatch = (op: Op): void => {
|
|
119
|
+
// Extract domain state (everything except dispatch)
|
|
120
|
+
const { dispatch: _, ...currentState } = get()
|
|
121
|
+
const result = reducer(currentState as unknown as S, op)
|
|
122
|
+
|
|
123
|
+
// Detect: plain state vs [state, effects]
|
|
124
|
+
const [newState, effects] = Array.isArray(result) ? (result as [S, E[]]) : [result as S, [] as E[]]
|
|
125
|
+
|
|
126
|
+
// Update Zustand store (spread domain state, keep dispatch)
|
|
127
|
+
set(newState as Partial<TeaSlice<S, Op>>)
|
|
128
|
+
|
|
129
|
+
// Execute effects
|
|
130
|
+
if (effects.length > 0 && options?.runners) {
|
|
131
|
+
for (const effect of effects) {
|
|
132
|
+
const runner = options.runners[effect.type as E["type"]]
|
|
133
|
+
if (runner) {
|
|
134
|
+
;(runner as (e: E, d: (op: Op) => void) => void)(effect, dispatch)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
...initialState,
|
|
142
|
+
dispatch,
|
|
143
|
+
} as TeaSlice<S, Op>
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// =============================================================================
|
|
148
|
+
// Test helper: collect()
|
|
149
|
+
// =============================================================================
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Normalize a reducer result to `[state, effects]` tuple.
|
|
153
|
+
*
|
|
154
|
+
* Use in tests to uniformly assert on both state and effects regardless of
|
|
155
|
+
* whether the reducer returned plain state or a tuple.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* const [state, effects] = collect(reducer(initial, { type: "save" }))
|
|
160
|
+
* expect(state.saving).toBe(true)
|
|
161
|
+
* expect(effects).toContainEqual(httpPost("/api", initial))
|
|
162
|
+
*
|
|
163
|
+
* // Also works for Level 3 (no effects):
|
|
164
|
+
* const [state2, effects2] = collect(reducer(initial, { type: "increment" }))
|
|
165
|
+
* expect(state2.count).toBe(1)
|
|
166
|
+
* expect(effects2).toEqual([])
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
export function collect<S, E extends EffectLike = EffectLike>(result: TeaResult<S, E>): [S, E[]] {
|
|
170
|
+
if (Array.isArray(result)) {
|
|
171
|
+
return result as [S, E[]]
|
|
172
|
+
}
|
|
173
|
+
return [result as S, []]
|
|
174
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Cursor Utilities
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for mapping between flat character offsets and visual
|
|
5
|
+
* (row, col) positions in word-wrapped text. Uses the same wrapText()
|
|
6
|
+
* function as the rendering pipeline, guaranteeing cursor positions
|
|
7
|
+
* match what's displayed on screen.
|
|
8
|
+
*
|
|
9
|
+
* Architecture layer 0 — no state, no hooks, no components.
|
|
10
|
+
* Used by: TextArea (layer 3), useTextEdit (layer 1), and apps
|
|
11
|
+
* that need cursor math without the full component stack.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { cursorToRowCol, cursorMoveDown } from '@silvery/react'
|
|
16
|
+
*
|
|
17
|
+
* const { row, col } = cursorToRowCol("hello world", 5, 8)
|
|
18
|
+
* // row=0, col=5 (fits in 8-wide line)
|
|
19
|
+
*
|
|
20
|
+
* const next = cursorMoveDown("hello world\nfoo", 3, 8)
|
|
21
|
+
* // next = 12 (moved to row 1, col 3 → "foo"[3] = end)
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import { type Measurer, wrapText } from "@silvery/term/unicode"
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Types
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
export interface WrappedLine {
|
|
31
|
+
/** The text content of this visual line */
|
|
32
|
+
line: string
|
|
33
|
+
/** Character offset in the original text where this line starts */
|
|
34
|
+
startOffset: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Core Functions
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Convert a flat cursor offset to visual (row, col) in word-wrapped text.
|
|
43
|
+
*
|
|
44
|
+
* Uses wrapText() from unicode.ts — the same function the render pipeline
|
|
45
|
+
* uses — so cursor positions always match what's displayed on screen.
|
|
46
|
+
*/
|
|
47
|
+
export function cursorToRowCol(
|
|
48
|
+
text: string,
|
|
49
|
+
cursor: number,
|
|
50
|
+
wrapWidth: number,
|
|
51
|
+
measurer?: Measurer,
|
|
52
|
+
): { row: number; col: number } {
|
|
53
|
+
if (wrapWidth <= 0) return { row: 0, col: 0 }
|
|
54
|
+
return cursorToRowColFromLines(getWrappedLines(text, wrapWidth, measurer), cursor)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Internal: compute row/col from pre-computed wrapped lines. */
|
|
58
|
+
function cursorToRowColFromLines(lines: WrappedLine[], cursor: number): { row: number; col: number } {
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
const line = lines[i]!
|
|
61
|
+
const lineEnd = line.startOffset + line.line.length
|
|
62
|
+
const isLast = i === lines.length - 1
|
|
63
|
+
|
|
64
|
+
if (cursor <= lineEnd || isLast) {
|
|
65
|
+
const col = Math.max(0, Math.min(cursor - line.startOffset, line.line.length))
|
|
66
|
+
return { row: i, col }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { row: Math.max(0, lines.length - 1), col: 0 }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get all wrapped display lines with their starting character offsets.
|
|
75
|
+
*
|
|
76
|
+
* Each entry represents one visual line on screen. The startOffset can be
|
|
77
|
+
* used to convert a (row, col) back to a flat cursor position:
|
|
78
|
+
* `flatOffset = lines[row].startOffset + col`
|
|
79
|
+
*/
|
|
80
|
+
export function getWrappedLines(text: string, wrapWidth: number, measurer?: Measurer): WrappedLine[] {
|
|
81
|
+
if (wrapWidth <= 0) return [{ line: "", startOffset: 0 }]
|
|
82
|
+
|
|
83
|
+
const logicalLines = text.split("\n")
|
|
84
|
+
const result: WrappedLine[] = []
|
|
85
|
+
let offset = 0
|
|
86
|
+
// Use explicit measurer when available, fall back to module-level convenience function
|
|
87
|
+
const wt = measurer ? measurer.wrapText.bind(measurer) : wrapText
|
|
88
|
+
|
|
89
|
+
for (let li = 0; li < logicalLines.length; li++) {
|
|
90
|
+
const line = logicalLines[li]!
|
|
91
|
+
// Use trim=true to match the renderer's wrapping behavior.
|
|
92
|
+
// The renderer uses wrapText(text, width, true, true), so cursor math
|
|
93
|
+
// must produce the same visual lines to keep positions synchronized.
|
|
94
|
+
const wrapped = wt(line, wrapWidth, false, true)
|
|
95
|
+
const lines = wrapped.length === 0 ? [""] : wrapped
|
|
96
|
+
|
|
97
|
+
for (const wLine of lines) {
|
|
98
|
+
// Skip whitespace in the original text that was trimmed:
|
|
99
|
+
// - Leading spaces on continuation lines (trimmed by renderer)
|
|
100
|
+
// - Trailing space at break point (consumed as separator by renderer)
|
|
101
|
+
while (offset < text.length && text[offset] === " " && wLine.length > 0 && text[offset] !== wLine[0]) {
|
|
102
|
+
offset++
|
|
103
|
+
}
|
|
104
|
+
result.push({ line: wLine, startOffset: offset })
|
|
105
|
+
offset += wLine.length
|
|
106
|
+
}
|
|
107
|
+
// Skip any remaining trailing spaces before the newline
|
|
108
|
+
while (offset < text.length && text[offset] === " ") {
|
|
109
|
+
offset++
|
|
110
|
+
}
|
|
111
|
+
offset++ // for \n
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return result
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Convert visual (row, col) to a flat cursor offset.
|
|
119
|
+
*
|
|
120
|
+
* Clamps col to the line length if the target column exceeds it
|
|
121
|
+
* (important for stickyX behavior on short lines).
|
|
122
|
+
*/
|
|
123
|
+
export function rowColToCursor(text: string, row: number, col: number, wrapWidth: number, measurer?: Measurer): number {
|
|
124
|
+
const lines = getWrappedLines(text, wrapWidth, measurer)
|
|
125
|
+
if (row < 0) return 0
|
|
126
|
+
if (row >= lines.length) return text.length
|
|
127
|
+
const line = lines[row]!
|
|
128
|
+
return line.startOffset + Math.min(col, line.line.length)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Move cursor up one visual line.
|
|
133
|
+
*
|
|
134
|
+
* Returns the new cursor offset, or null if already on the first visual line
|
|
135
|
+
* (indicating a boundary — the caller should handle cross-block navigation).
|
|
136
|
+
*
|
|
137
|
+
* @param stickyX - Preferred column position for vertical movement.
|
|
138
|
+
* When moving through lines of different lengths, the cursor tries to
|
|
139
|
+
* stay at this column. Pass the col from the original position before
|
|
140
|
+
* the first vertical move in a sequence.
|
|
141
|
+
*/
|
|
142
|
+
export function cursorMoveUp(
|
|
143
|
+
text: string,
|
|
144
|
+
cursor: number,
|
|
145
|
+
wrapWidth: number,
|
|
146
|
+
stickyX?: number,
|
|
147
|
+
measurer?: Measurer,
|
|
148
|
+
): number | null {
|
|
149
|
+
if (wrapWidth <= 0) return cursor > 0 ? 0 : null
|
|
150
|
+
|
|
151
|
+
const lines = getWrappedLines(text, wrapWidth, measurer)
|
|
152
|
+
const { row, col } = cursorToRowColFromLines(lines, cursor)
|
|
153
|
+
|
|
154
|
+
if (row === 0) return null // at first visual line — boundary
|
|
155
|
+
|
|
156
|
+
const targetX = stickyX ?? col
|
|
157
|
+
// Try successive lines upward: if the target position equals the current cursor
|
|
158
|
+
// (happens at wrap boundaries), keep going up to make real progress.
|
|
159
|
+
for (let prevRow = row - 1; prevRow >= 0; prevRow--) {
|
|
160
|
+
const targetLine = lines[prevRow]!
|
|
161
|
+
const next = targetLine.startOffset + Math.min(targetX, targetLine.line.length)
|
|
162
|
+
if (next !== cursor) return next
|
|
163
|
+
}
|
|
164
|
+
return null // all preceding lines map to same position — boundary
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Move cursor down one visual line.
|
|
169
|
+
*
|
|
170
|
+
* Returns the new cursor offset, or null if already on the last visual line
|
|
171
|
+
* (indicating a boundary — the caller should handle cross-block navigation).
|
|
172
|
+
*
|
|
173
|
+
* @param stickyX - Preferred column position for vertical movement.
|
|
174
|
+
*/
|
|
175
|
+
export function cursorMoveDown(
|
|
176
|
+
text: string,
|
|
177
|
+
cursor: number,
|
|
178
|
+
wrapWidth: number,
|
|
179
|
+
stickyX?: number,
|
|
180
|
+
measurer?: Measurer,
|
|
181
|
+
): number | null {
|
|
182
|
+
if (wrapWidth <= 0) return cursor < text.length ? text.length : null
|
|
183
|
+
|
|
184
|
+
const lines = getWrappedLines(text, wrapWidth, measurer)
|
|
185
|
+
const { row, col } = cursorToRowColFromLines(lines, cursor)
|
|
186
|
+
|
|
187
|
+
if (row >= lines.length - 1) return null // at last visual line — boundary
|
|
188
|
+
|
|
189
|
+
const targetX = stickyX ?? col
|
|
190
|
+
// Try successive lines: if the target position equals the current cursor
|
|
191
|
+
// (happens at wrap boundaries where end-of-line-N == start-of-line-N+1),
|
|
192
|
+
// advance to the next line to make real progress.
|
|
193
|
+
for (let nextRow = row + 1; nextRow < lines.length; nextRow++) {
|
|
194
|
+
const targetLine = lines[nextRow]!
|
|
195
|
+
const next = targetLine.startOffset + Math.min(targetX, targetLine.line.length)
|
|
196
|
+
if (next !== cursor) return next
|
|
197
|
+
}
|
|
198
|
+
return null // all remaining lines map to same position — boundary
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Count total visual lines after word wrapping.
|
|
203
|
+
*/
|
|
204
|
+
export function countVisualLines(text: string, wrapWidth: number, measurer?: Measurer): number {
|
|
205
|
+
return getWrappedLines(text, wrapWidth, measurer).length
|
|
206
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Decorations — SlateJS-style overlay ranges for styled text.
|
|
3
|
+
*
|
|
4
|
+
* Decorations overlay visual styles on text without modifying the underlying
|
|
5
|
+
* content. Use cases: search highlighting, syntax coloring, spell-check
|
|
6
|
+
* underlines, diff markers, collaborative cursors.
|
|
7
|
+
*
|
|
8
|
+
* Architecture layer 0 — no state, no hooks, no components. Pure functions.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { splitIntoSegments, type Decoration } from '@silvery/tea/text-decorations'
|
|
13
|
+
*
|
|
14
|
+
* const decorations: Decoration[] = [
|
|
15
|
+
* { from: 0, to: 5, style: { backgroundColor: "yellow" } },
|
|
16
|
+
* { from: 10, to: 15, style: { bold: true } },
|
|
17
|
+
* ]
|
|
18
|
+
*
|
|
19
|
+
* // Split a line range into styled segments
|
|
20
|
+
* const segments = splitIntoSegments(0, 20, decorations, null)
|
|
21
|
+
* // => [
|
|
22
|
+
* // { from: 0, to: 5, style: { backgroundColor: "yellow" } },
|
|
23
|
+
* // { from: 5, to: 10, style: {} },
|
|
24
|
+
* // { from: 10, to: 15, style: { bold: true } },
|
|
25
|
+
* // { from: 15, to: 20, style: {} },
|
|
26
|
+
* // ]
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Types
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Style properties for a decoration. Matches silvery Text component props.
|
|
36
|
+
* All properties are optional — only specified properties are applied.
|
|
37
|
+
*/
|
|
38
|
+
export interface DecorationStyle {
|
|
39
|
+
/** Foreground color (hex, named, or $token) */
|
|
40
|
+
color?: string
|
|
41
|
+
/** Background color (hex, named, or $token) */
|
|
42
|
+
backgroundColor?: string
|
|
43
|
+
/** Bold text */
|
|
44
|
+
bold?: boolean
|
|
45
|
+
/** Italic text */
|
|
46
|
+
italic?: boolean
|
|
47
|
+
/** Underline text */
|
|
48
|
+
underline?: boolean
|
|
49
|
+
/** Strikethrough text */
|
|
50
|
+
strikethrough?: boolean
|
|
51
|
+
/** Dim (reduced intensity) */
|
|
52
|
+
dimColor?: boolean
|
|
53
|
+
/** Inverse (swap fg/bg) */
|
|
54
|
+
inverse?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A decoration range that overlays styles on text.
|
|
59
|
+
*
|
|
60
|
+
* Ranges are half-open: [from, to) — `from` is inclusive, `to` is exclusive.
|
|
61
|
+
* Ranges refer to character offsets in the full text value.
|
|
62
|
+
*/
|
|
63
|
+
export interface Decoration {
|
|
64
|
+
/** Start offset in the text (inclusive) */
|
|
65
|
+
from: number
|
|
66
|
+
/** End offset in the text (exclusive) */
|
|
67
|
+
to: number
|
|
68
|
+
/** Style properties to apply to this range */
|
|
69
|
+
style: DecorationStyle
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A resolved segment with merged styles. Produced by splitIntoSegments().
|
|
74
|
+
* Segments are non-overlapping and sorted by position.
|
|
75
|
+
*/
|
|
76
|
+
export interface StyledSegment {
|
|
77
|
+
/** Start offset (inclusive) */
|
|
78
|
+
from: number
|
|
79
|
+
/** End offset (exclusive) */
|
|
80
|
+
to: number
|
|
81
|
+
/** Merged style from all overlapping decorations */
|
|
82
|
+
style: DecorationStyle
|
|
83
|
+
/** Whether this segment is within the selection */
|
|
84
|
+
selected?: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Selection range as [start, end) character offsets */
|
|
88
|
+
export interface SelectionRange {
|
|
89
|
+
start: number
|
|
90
|
+
end: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// =============================================================================
|
|
94
|
+
// Segment Splitting
|
|
95
|
+
// =============================================================================
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Split a line range into non-overlapping styled segments.
|
|
99
|
+
*
|
|
100
|
+
* Takes a range [lineStart, lineEnd), an array of decorations, and an optional
|
|
101
|
+
* selection range. Returns sorted, non-overlapping segments that cover the
|
|
102
|
+
* entire line range.
|
|
103
|
+
*
|
|
104
|
+
* Rules:
|
|
105
|
+
* - Selection takes precedence over decorations (selected text ignores decoration styles)
|
|
106
|
+
* - Later decorations override earlier ones for overlapping style properties
|
|
107
|
+
* - Empty segments (from === to) are omitted
|
|
108
|
+
* - Decorations outside [lineStart, lineEnd) are clipped
|
|
109
|
+
*
|
|
110
|
+
* @param lineStart - Start of the line range (inclusive, character offset in full text)
|
|
111
|
+
* @param lineEnd - End of the line range (exclusive)
|
|
112
|
+
* @param decorations - Array of decoration ranges (may be empty)
|
|
113
|
+
* @param selection - Optional selection range, or null
|
|
114
|
+
* @returns Array of non-overlapping StyledSegment objects covering [lineStart, lineEnd)
|
|
115
|
+
*/
|
|
116
|
+
export function splitIntoSegments(
|
|
117
|
+
lineStart: number,
|
|
118
|
+
lineEnd: number,
|
|
119
|
+
decorations: readonly Decoration[],
|
|
120
|
+
selection: SelectionRange | null,
|
|
121
|
+
): StyledSegment[] {
|
|
122
|
+
if (lineStart >= lineEnd) return []
|
|
123
|
+
|
|
124
|
+
// Collect all boundary points within the line range
|
|
125
|
+
const boundaries = new Set<number>()
|
|
126
|
+
boundaries.add(lineStart)
|
|
127
|
+
boundaries.add(lineEnd)
|
|
128
|
+
|
|
129
|
+
// Add decoration boundaries (clipped to line range)
|
|
130
|
+
for (const dec of decorations) {
|
|
131
|
+
if (dec.to <= lineStart || dec.from >= lineEnd) continue
|
|
132
|
+
boundaries.add(Math.max(dec.from, lineStart))
|
|
133
|
+
boundaries.add(Math.min(dec.to, lineEnd))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Add selection boundaries (clipped to line range)
|
|
137
|
+
if (selection && selection.start < lineEnd && selection.end > lineStart) {
|
|
138
|
+
boundaries.add(Math.max(selection.start, lineStart))
|
|
139
|
+
boundaries.add(Math.min(selection.end, lineEnd))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Sort boundaries
|
|
143
|
+
const sorted = Array.from(boundaries).sort((a, b) => a - b)
|
|
144
|
+
|
|
145
|
+
// Build segments
|
|
146
|
+
const segments: StyledSegment[] = []
|
|
147
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
148
|
+
const from = sorted[i]!
|
|
149
|
+
const to = sorted[i + 1]!
|
|
150
|
+
if (from >= to) continue
|
|
151
|
+
|
|
152
|
+
// Check if this segment is within the selection
|
|
153
|
+
const isSelected = selection !== null && from >= selection.start && to <= selection.end
|
|
154
|
+
|
|
155
|
+
// Merge styles from all overlapping decorations (later wins for conflicts)
|
|
156
|
+
const mergedStyle: DecorationStyle = {}
|
|
157
|
+
for (const dec of decorations) {
|
|
158
|
+
if (dec.from >= to || dec.to <= from) continue
|
|
159
|
+
// Merge: later decoration properties override earlier ones
|
|
160
|
+
Object.assign(mergedStyle, dec.style)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
segments.push({
|
|
164
|
+
from,
|
|
165
|
+
to,
|
|
166
|
+
style: mergedStyle,
|
|
167
|
+
...(isSelected ? { selected: true } : {}),
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return segments
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// =============================================================================
|
|
175
|
+
// Decoration Utilities
|
|
176
|
+
// =============================================================================
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Create decorations for all occurrences of a search string in text.
|
|
180
|
+
*
|
|
181
|
+
* @param text - The full text to search in
|
|
182
|
+
* @param query - The search string (case-insensitive)
|
|
183
|
+
* @param style - Style to apply to matches
|
|
184
|
+
* @returns Array of Decoration objects for all matches
|
|
185
|
+
*/
|
|
186
|
+
export function createSearchDecorations(
|
|
187
|
+
text: string,
|
|
188
|
+
query: string,
|
|
189
|
+
style: DecorationStyle = { backgroundColor: "yellow", color: "black" },
|
|
190
|
+
): Decoration[] {
|
|
191
|
+
if (!query || !text) return []
|
|
192
|
+
|
|
193
|
+
const decorations: Decoration[] = []
|
|
194
|
+
const lowerText = text.toLowerCase()
|
|
195
|
+
const lowerQuery = query.toLowerCase()
|
|
196
|
+
let pos = 0
|
|
197
|
+
|
|
198
|
+
while (pos < lowerText.length) {
|
|
199
|
+
const idx = lowerText.indexOf(lowerQuery, pos)
|
|
200
|
+
if (idx === -1) break
|
|
201
|
+
decorations.push({
|
|
202
|
+
from: idx,
|
|
203
|
+
to: idx + query.length,
|
|
204
|
+
style,
|
|
205
|
+
})
|
|
206
|
+
pos = idx + 1 // Allow overlapping matches
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return decorations
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Adjust decoration positions after a text edit operation.
|
|
214
|
+
*
|
|
215
|
+
* When text is inserted or deleted, decoration ranges need to shift
|
|
216
|
+
* to maintain their association with the correct text content.
|
|
217
|
+
*
|
|
218
|
+
* @param decorations - Existing decorations
|
|
219
|
+
* @param editStart - Position where the edit occurred
|
|
220
|
+
* @param deletedLength - Number of characters deleted (0 for pure insert)
|
|
221
|
+
* @param insertedLength - Number of characters inserted (0 for pure delete)
|
|
222
|
+
* @returns New array of adjusted decorations (removed decorations are filtered out)
|
|
223
|
+
*/
|
|
224
|
+
export function adjustDecorations(
|
|
225
|
+
decorations: readonly Decoration[],
|
|
226
|
+
editStart: number,
|
|
227
|
+
deletedLength: number,
|
|
228
|
+
insertedLength: number,
|
|
229
|
+
): Decoration[] {
|
|
230
|
+
const delta = insertedLength - deletedLength
|
|
231
|
+
const editEnd = editStart + deletedLength
|
|
232
|
+
|
|
233
|
+
return decorations
|
|
234
|
+
.map((dec) => {
|
|
235
|
+
// Decoration is entirely before the edit — no change
|
|
236
|
+
if (dec.to <= editStart) return dec
|
|
237
|
+
|
|
238
|
+
// Decoration is entirely after the edit — shift by delta
|
|
239
|
+
if (dec.from >= editEnd) {
|
|
240
|
+
return { ...dec, from: dec.from + delta, to: dec.to + delta }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Decoration overlaps the edit region — adjust boundaries
|
|
244
|
+
const newFrom = dec.from < editStart ? dec.from : editStart + insertedLength
|
|
245
|
+
const newTo = dec.to <= editEnd ? editStart + insertedLength : dec.to + delta
|
|
246
|
+
|
|
247
|
+
// If the decoration collapses to zero width, remove it
|
|
248
|
+
if (newFrom >= newTo) return null
|
|
249
|
+
|
|
250
|
+
return { ...dec, from: newFrom, to: newTo }
|
|
251
|
+
})
|
|
252
|
+
.filter((dec): dec is Decoration => dec !== null)
|
|
253
|
+
}
|