@effect-tui/react 0.12.3 → 0.13.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/README.md +21 -1
- package/dist/src/dev.d.ts +22 -32
- package/dist/src/dev.d.ts.map +1 -1
- package/dist/src/dev.js +42 -96
- package/dist/src/dev.js.map +1 -1
- package/dist/src/hooks/index.d.ts +3 -0
- package/dist/src/hooks/index.d.ts.map +1 -1
- package/dist/src/hooks/index.js +1 -0
- package/dist/src/hooks/index.js.map +1 -1
- package/dist/src/hooks/use-mouse.d.ts +10 -1
- package/dist/src/hooks/use-mouse.d.ts.map +1 -1
- package/dist/src/hooks/use-mouse.js +10 -2
- package/dist/src/hooks/use-mouse.js.map +1 -1
- package/dist/src/hooks/use-paste.d.ts +13 -3
- package/dist/src/hooks/use-paste.d.ts.map +1 -1
- package/dist/src/hooks/use-paste.js +15 -5
- package/dist/src/hooks/use-paste.js.map +1 -1
- package/dist/src/hooks/use-scroll.d.ts +59 -24
- package/dist/src/hooks/use-scroll.d.ts.map +1 -1
- package/dist/src/hooks/use-scroll.js +238 -79
- package/dist/src/hooks/use-scroll.js.map +1 -1
- package/dist/src/hooks/use-shortcut.d.ts +16 -0
- package/dist/src/hooks/use-shortcut.d.ts.map +1 -0
- package/dist/src/hooks/use-shortcut.js +29 -0
- package/dist/src/hooks/use-shortcut.js.map +1 -0
- package/dist/src/hosts/scroll.d.ts +4 -0
- package/dist/src/hosts/scroll.d.ts.map +1 -1
- package/dist/src/hosts/scroll.js +18 -2
- package/dist/src/hosts/scroll.js.map +1 -1
- package/dist/src/index.d.ts +6 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
- package/dist/src/renderer/input/InputProcessor.js +8 -1
- package/dist/src/renderer/input/InputProcessor.js.map +1 -1
- package/dist/src/renderer/lifecycle/EventBus.d.ts +2 -2
- package/dist/src/renderer/lifecycle/EventBus.d.ts.map +1 -1
- package/dist/src/renderer/lifecycle/EventBus.js +13 -1
- package/dist/src/renderer/lifecycle/EventBus.js.map +1 -1
- package/dist/src/renderer-types.d.ts +8 -1
- package/dist/src/renderer-types.d.ts.map +1 -1
- package/dist/src/renderer.d.ts +13 -2
- package/dist/src/renderer.d.ts.map +1 -1
- package/dist/src/renderer.js +48 -7
- package/dist/src/renderer.js.map +1 -1
- package/dist/src/shortcuts.d.ts +15 -0
- package/dist/src/shortcuts.d.ts.map +1 -0
- package/dist/src/shortcuts.js +149 -0
- package/dist/src/shortcuts.js.map +1 -0
- package/dist/src/test/mock-streams.d.ts.map +1 -1
- package/dist/src/test/mock-streams.js +0 -3
- package/dist/src/test/mock-streams.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/dev.tsx +59 -107
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-mouse.ts +19 -3
- package/src/hooks/use-paste.ts +24 -5
- package/src/hooks/use-scroll.ts +345 -105
- package/src/hooks/use-shortcut.ts +54 -0
- package/src/hosts/scroll.ts +21 -2
- package/src/index.ts +19 -6
- package/src/renderer/input/InputProcessor.ts +8 -1
- package/src/renderer/lifecycle/EventBus.ts +14 -4
- package/src/renderer-types.ts +9 -1
- package/src/renderer.ts +96 -9
- package/src/shortcuts.ts +180 -0
- package/src/test/mock-streams.ts +0 -3
package/src/hosts/scroll.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface ScrollProps extends CommonProps {
|
|
|
28
28
|
onViewportSize?: (width: number, height: number) => void
|
|
29
29
|
/** Called when effective offset changes (for syncing with useScroll when sticky adjusts) */
|
|
30
30
|
onEffectiveOffset?: (offset: number) => void
|
|
31
|
+
/** Called when effective horizontal offset changes */
|
|
32
|
+
onEffectiveOffsetX?: (offsetX: number) => void
|
|
31
33
|
/** Called when layout rect changes (for hit testing) */
|
|
32
34
|
onRect?: (x: number, y: number, w: number, h: number) => void
|
|
33
35
|
}
|
|
@@ -46,6 +48,7 @@ export class ScrollHost extends SingleChildHost {
|
|
|
46
48
|
onContentSize?: (width: number, height: number) => void
|
|
47
49
|
onViewportSize?: (width: number, height: number) => void
|
|
48
50
|
onEffectiveOffset?: (offset: number) => void
|
|
51
|
+
onEffectiveOffsetX?: (offsetX: number) => void
|
|
49
52
|
onRect?: (x: number, y: number, w: number, h: number) => void
|
|
50
53
|
|
|
51
54
|
// Measured content dimensions (full size before clipping)
|
|
@@ -58,6 +61,7 @@ export class ScrollHost extends SingleChildHost {
|
|
|
58
61
|
private lastRectY = -1
|
|
59
62
|
// Track if we were at end (for sticky behavior)
|
|
60
63
|
private wasAtEnd = true
|
|
64
|
+
private wasAtEndX = true
|
|
61
65
|
// Effective offset after sticky adjustment (used for rendering)
|
|
62
66
|
private effectiveOffset = 0
|
|
63
67
|
private effectiveOffsetX = 0
|
|
@@ -133,10 +137,10 @@ export class ScrollHost extends SingleChildHost {
|
|
|
133
137
|
let scrollY = this.offset
|
|
134
138
|
let scrollX = this.offsetX
|
|
135
139
|
|
|
136
|
-
// Sticky scroll logic:
|
|
140
|
+
// Sticky scroll logic (vertical):
|
|
137
141
|
// - If user manually scrolled away from end (offset < effectiveOffset), unstick
|
|
138
142
|
// - If at end (or was at end and content grew), stay stuck
|
|
139
|
-
if (this.sticky) {
|
|
143
|
+
if (this.sticky && (this.axis === "vertical" || this.axis === "both")) {
|
|
140
144
|
// Detect if user scrolled away (offset prop is less than where we rendered)
|
|
141
145
|
const userScrolledAway = this.offset < this.effectiveOffset - 1
|
|
142
146
|
|
|
@@ -149,8 +153,20 @@ export class ScrollHost extends SingleChildHost {
|
|
|
149
153
|
}
|
|
150
154
|
}
|
|
151
155
|
|
|
156
|
+
// Sticky scroll logic (horizontal)
|
|
157
|
+
if (this.sticky && (this.axis === "horizontal" || this.axis === "both")) {
|
|
158
|
+
const userScrolledAway = this.offsetX < this.effectiveOffsetX - 1
|
|
159
|
+
|
|
160
|
+
if (userScrolledAway) {
|
|
161
|
+
this.wasAtEndX = false
|
|
162
|
+
} else if (this.wasAtEndX) {
|
|
163
|
+
scrollX = maxScrollX
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
152
167
|
// Track if we're at end for next frame
|
|
153
168
|
this.wasAtEnd = scrollY >= maxScrollY - 1
|
|
169
|
+
this.wasAtEndX = scrollX >= maxScrollX - 1
|
|
154
170
|
|
|
155
171
|
// Clamp offsets
|
|
156
172
|
scrollY = Math.max(0, Math.min(maxScrollY, scrollY))
|
|
@@ -161,6 +177,9 @@ export class ScrollHost extends SingleChildHost {
|
|
|
161
177
|
if (scrollY !== this.effectiveOffset || scrollY !== this.offset) {
|
|
162
178
|
this.onEffectiveOffset?.(scrollY)
|
|
163
179
|
}
|
|
180
|
+
if (scrollX !== this.effectiveOffsetX || scrollX !== this.offsetX) {
|
|
181
|
+
this.onEffectiveOffsetX?.(scrollX)
|
|
182
|
+
}
|
|
164
183
|
this.effectiveOffset = scrollY
|
|
165
184
|
this.effectiveOffsetX = scrollX
|
|
166
185
|
|
package/src/index.ts
CHANGED
|
@@ -29,10 +29,8 @@ export { DiagnosticsPanel } from "./debug/DiagnosticsPanel.js"
|
|
|
29
29
|
// Development utilities (HMR)
|
|
30
30
|
export {
|
|
31
31
|
autoHmr,
|
|
32
|
-
type
|
|
33
|
-
type
|
|
34
|
-
devMain,
|
|
35
|
-
devRender,
|
|
32
|
+
type DevOptions,
|
|
33
|
+
type DevRuntime,
|
|
36
34
|
DevWrapper,
|
|
37
35
|
type DevWrapperProps,
|
|
38
36
|
hmr,
|
|
@@ -48,17 +46,23 @@ export {
|
|
|
48
46
|
} from "./highlight.js"
|
|
49
47
|
export type {
|
|
50
48
|
ScrollState,
|
|
49
|
+
ShortcutHandler,
|
|
50
|
+
ShortcutMap,
|
|
51
51
|
TimerStatus,
|
|
52
52
|
TimerType,
|
|
53
53
|
UseKeyboardOptions,
|
|
54
54
|
UseMouseOptions,
|
|
55
|
+
UsePasteOptions,
|
|
56
|
+
UseShortcutOptions,
|
|
55
57
|
UseScrollOptions,
|
|
56
58
|
UseScrollReturn,
|
|
57
59
|
UseTimerConfig,
|
|
58
60
|
UseTimerReturn,
|
|
59
61
|
} from "./hooks/index.js"
|
|
62
|
+
export type { Shortcut, ShortcutMatchOptions, ShortcutSpec } from "./shortcuts.js"
|
|
60
63
|
// Hooks
|
|
61
|
-
export { useKeyboard, useMouse, usePaste, useQuit, useScroll, useTimer } from "./hooks/index.js"
|
|
64
|
+
export { useKeyboard, useMouse, usePaste, useQuit, useScroll, useShortcut, useTimer } from "./hooks/index.js"
|
|
65
|
+
export { isKey } from "./shortcuts.js"
|
|
62
66
|
export { useFrameStats } from "./hooks/useFrameStats.js"
|
|
63
67
|
export type { BorderKind, BoxProps } from "./hosts/box.js"
|
|
64
68
|
export type { CanvasProps, DrawContext } from "./hosts/canvas.js"
|
|
@@ -85,7 +89,16 @@ export {
|
|
|
85
89
|
} from "./motion/index.js"
|
|
86
90
|
// Types
|
|
87
91
|
export type { CommonProps, HostContext, HostInstance, Rect, Size } from "./reconciler/types.js"
|
|
88
|
-
export type {
|
|
92
|
+
export type {
|
|
93
|
+
FrameStats,
|
|
94
|
+
ImportMetaLike,
|
|
95
|
+
PasteMsg,
|
|
96
|
+
RenderInstance,
|
|
97
|
+
RenderOptions,
|
|
98
|
+
RendererOptions,
|
|
99
|
+
Root,
|
|
100
|
+
TuiRenderer,
|
|
101
|
+
} from "./renderer.js"
|
|
89
102
|
// Renderer
|
|
90
103
|
export {
|
|
91
104
|
createRenderer,
|
|
@@ -75,8 +75,15 @@ export class InputProcessor {
|
|
|
75
75
|
|
|
76
76
|
for (const event of events) {
|
|
77
77
|
if (event.type === "mouse") {
|
|
78
|
+
const wrapped: MouseMsg = {
|
|
79
|
+
...event,
|
|
80
|
+
defaultPrevented: false,
|
|
81
|
+
preventDefault() {
|
|
82
|
+
wrapped.defaultPrevented = true
|
|
83
|
+
},
|
|
84
|
+
}
|
|
78
85
|
this.config.flushSync(() => {
|
|
79
|
-
this.config.dispatchMouse(
|
|
86
|
+
this.config.dispatchMouse(wrapped)
|
|
80
87
|
})
|
|
81
88
|
} else {
|
|
82
89
|
const wrapped: KeyMsg = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { KeyMsg, MouseMsg } from "@effect-tui/core"
|
|
2
|
-
import type { FrameStats } from "../../renderer-types.js"
|
|
2
|
+
import type { FrameStats, PasteMsg } from "../../renderer-types.js"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Centralized event subscription management for the renderer.
|
|
@@ -8,7 +8,7 @@ import type { FrameStats } from "../../renderer-types.js"
|
|
|
8
8
|
export class EventBus {
|
|
9
9
|
private keyHandlers = new Set<(key: KeyMsg) => void>()
|
|
10
10
|
private mouseHandlers = new Set<(mouse: MouseMsg) => void>()
|
|
11
|
-
private pasteHandlers = new Set<(
|
|
11
|
+
private pasteHandlers = new Set<(paste: PasteMsg) => void>()
|
|
12
12
|
private resizeHandlers = new Set<(width: number, height: number) => void>()
|
|
13
13
|
private frameHandlers = new Set<(stats: FrameStats) => void>()
|
|
14
14
|
|
|
@@ -22,7 +22,7 @@ export class EventBus {
|
|
|
22
22
|
return () => this.mouseHandlers.delete(handler)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
onPaste(handler: (
|
|
25
|
+
onPaste(handler: (paste: PasteMsg) => void): () => void {
|
|
26
26
|
this.pasteHandlers.add(handler)
|
|
27
27
|
return () => this.pasteHandlers.delete(handler)
|
|
28
28
|
}
|
|
@@ -53,6 +53,7 @@ export class EventBus {
|
|
|
53
53
|
*/
|
|
54
54
|
dispatchMouse(mouse: MouseMsg): void {
|
|
55
55
|
for (const handler of this.mouseHandlers) {
|
|
56
|
+
if (mouse.defaultPrevented) break
|
|
56
57
|
handler(mouse)
|
|
57
58
|
}
|
|
58
59
|
}
|
|
@@ -61,8 +62,17 @@ export class EventBus {
|
|
|
61
62
|
* Dispatch a paste event to all handlers.
|
|
62
63
|
*/
|
|
63
64
|
dispatchPaste(text: string): void {
|
|
65
|
+
const paste: PasteMsg = {
|
|
66
|
+
type: "paste",
|
|
67
|
+
text,
|
|
68
|
+
defaultPrevented: false,
|
|
69
|
+
preventDefault() {
|
|
70
|
+
paste.defaultPrevented = true
|
|
71
|
+
},
|
|
72
|
+
}
|
|
64
73
|
for (const handler of this.pasteHandlers) {
|
|
65
|
-
|
|
74
|
+
if (paste.defaultPrevented) break
|
|
75
|
+
handler(paste)
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
|
package/src/renderer-types.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { KeyMsg, MouseMsg } from "@effect-tui/core"
|
|
2
2
|
import type { HostContext, HostInstance } from "./reconciler/types.js"
|
|
3
3
|
|
|
4
|
+
export type PasteMsg = {
|
|
5
|
+
type: "paste"
|
|
6
|
+
text: string
|
|
7
|
+
/** Set by renderer to allow handlers to stop further propagation. */
|
|
8
|
+
defaultPrevented?: boolean
|
|
9
|
+
preventDefault?: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
/** Minimal write stream interface for renderer output */
|
|
5
13
|
export interface TuiWriteStream {
|
|
6
14
|
write(s: string): void
|
|
@@ -33,7 +41,7 @@ export interface TuiRenderer {
|
|
|
33
41
|
/** Subscribe to mouse events (clicks, scroll, etc.) */
|
|
34
42
|
onMouse(handler: (mouse: MouseMsg) => void): () => void
|
|
35
43
|
/** Subscribe to paste events (bracketed paste mode). */
|
|
36
|
-
onPaste?(handler: (
|
|
44
|
+
onPaste?(handler: (paste: PasteMsg) => void): () => void
|
|
37
45
|
/** Subscribe to resize events */
|
|
38
46
|
onResize(handler: (width: number, height: number) => void): () => void
|
|
39
47
|
/** Stop the renderer */
|
package/src/renderer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks"
|
|
2
|
+
import { fileURLToPath } from "node:url"
|
|
2
3
|
import { ANSI, bufferToString, type KeyMsg, type MouseMsg } from "@effect-tui/core"
|
|
3
4
|
import React, { type ReactNode } from "react"
|
|
4
5
|
import { createTerminalWriter } from "./console/ConsoleCapture.js"
|
|
@@ -13,9 +14,11 @@ import { InputProcessor } from "./renderer/input/index.js"
|
|
|
13
14
|
import { EventBus, TerminalSetup } from "./renderer/lifecycle/index.js"
|
|
14
15
|
import { FullscreenRenderer, InlineRenderer, StaticContentRenderer } from "./renderer/modes/index.js"
|
|
15
16
|
import { RendererContext } from "./renderer-context.js"
|
|
17
|
+
import { startDevRuntime, type DevOptions } from "./dev.js"
|
|
16
18
|
import type {
|
|
17
19
|
Container,
|
|
18
20
|
FrameStats,
|
|
21
|
+
PasteMsg,
|
|
19
22
|
RendererOptions,
|
|
20
23
|
TuiReadStream,
|
|
21
24
|
TuiRenderer,
|
|
@@ -25,7 +28,14 @@ import type {
|
|
|
25
28
|
|
|
26
29
|
export { RendererContext, useRenderer, useTerminalSize } from "./renderer-context.js"
|
|
27
30
|
// Re-export types and context for backwards compatibility
|
|
28
|
-
export type {
|
|
31
|
+
export type {
|
|
32
|
+
FrameStats,
|
|
33
|
+
PasteMsg,
|
|
34
|
+
RendererOptions,
|
|
35
|
+
TuiReadStream,
|
|
36
|
+
TuiRenderer,
|
|
37
|
+
TuiWriteStream,
|
|
38
|
+
} from "./renderer-types.js"
|
|
29
39
|
|
|
30
40
|
type HandledSignal = "SIGINT" | "SIGTERM"
|
|
31
41
|
|
|
@@ -261,7 +271,7 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
|
|
|
261
271
|
},
|
|
262
272
|
onKey: (handler: (key: KeyMsg) => void) => events.onKey(handler),
|
|
263
273
|
onMouse: (handler: (mouse: MouseMsg) => void) => events.onMouse(handler),
|
|
264
|
-
onPaste: (handler: (
|
|
274
|
+
onPaste: (handler: (paste: PasteMsg) => void) => events.onPaste(handler),
|
|
265
275
|
onResize: (handler: (width: number, height: number) => void) => events.onResize(handler),
|
|
266
276
|
onFrameStats: (handler: (stats: FrameStats) => void) => events.onFrameStats(handler),
|
|
267
277
|
stop() {
|
|
@@ -445,11 +455,43 @@ export interface RenderInstance {
|
|
|
445
455
|
quit(code?: number): void
|
|
446
456
|
}
|
|
447
457
|
|
|
448
|
-
export
|
|
449
|
-
|
|
450
|
-
|
|
458
|
+
export type ImportMetaLike = {
|
|
459
|
+
url: string
|
|
460
|
+
main?: boolean
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export type RenderOptions = RendererOptions &
|
|
464
|
+
DevOptions & {
|
|
465
|
+
/** Enable dev runtime (HMR, console overlay, remote control). */
|
|
466
|
+
dev?: boolean
|
|
467
|
+
/** Required when passing options. */
|
|
468
|
+
importMeta: ImportMetaLike
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
type RenderCache = Map<string, RenderInstance>
|
|
472
|
+
|
|
473
|
+
const getRenderCache = (): RenderCache => {
|
|
474
|
+
const globalAny = globalThis as typeof globalThis & {
|
|
475
|
+
__effectTuiRenderCache?: RenderCache
|
|
476
|
+
}
|
|
477
|
+
if (!globalAny.__effectTuiRenderCache) {
|
|
478
|
+
globalAny.__effectTuiRenderCache = new Map()
|
|
479
|
+
}
|
|
480
|
+
return globalAny.__effectTuiRenderCache
|
|
481
|
+
}
|
|
451
482
|
|
|
452
|
-
|
|
483
|
+
const stripQuery = (url: string): string => url.split("?")[0]
|
|
484
|
+
|
|
485
|
+
const createRenderInstance = (
|
|
486
|
+
renderer: TuiRenderer,
|
|
487
|
+
root: Root,
|
|
488
|
+
element: ReactNode,
|
|
489
|
+
stop: () => void,
|
|
490
|
+
skipInitialRender = false,
|
|
491
|
+
): RenderInstance => {
|
|
492
|
+
if (!skipInitialRender) {
|
|
493
|
+
root.render(element, true)
|
|
494
|
+
}
|
|
453
495
|
|
|
454
496
|
let resolved = false
|
|
455
497
|
let resolveExit: (() => void) | null = null
|
|
@@ -472,7 +514,7 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
|
|
|
472
514
|
process.off("exit", onExit)
|
|
473
515
|
if (!resolved) {
|
|
474
516
|
resolved = true
|
|
475
|
-
|
|
517
|
+
stop()
|
|
476
518
|
resolveExit?.()
|
|
477
519
|
}
|
|
478
520
|
}
|
|
@@ -482,10 +524,10 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
|
|
|
482
524
|
root.render(next)
|
|
483
525
|
}
|
|
484
526
|
|
|
485
|
-
// Clean quit function -
|
|
527
|
+
// Clean quit function - stop() + process.exit()
|
|
486
528
|
// The createRenderer exit handler will also run, but it's idempotent
|
|
487
529
|
const quit = (code = 0) => {
|
|
488
|
-
|
|
530
|
+
stop()
|
|
489
531
|
requestExit(code)
|
|
490
532
|
}
|
|
491
533
|
|
|
@@ -498,3 +540,48 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
|
|
|
498
540
|
quit,
|
|
499
541
|
}
|
|
500
542
|
}
|
|
543
|
+
|
|
544
|
+
export function render(element: ReactNode, options?: RenderOptions): RenderInstance {
|
|
545
|
+
if (options && !options.importMeta) {
|
|
546
|
+
throw new Error("[effect-tui] render(..., options) requires { importMeta: import.meta }")
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (!options?.dev) {
|
|
550
|
+
const renderer = createRenderer(options)
|
|
551
|
+
const root = createRoot(renderer)
|
|
552
|
+
return createRenderInstance(renderer, root, element, () => renderer.stop())
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const importMeta = options.importMeta
|
|
556
|
+
if (!importMeta) {
|
|
557
|
+
throw new Error("[effect-tui] render(..., { dev: true, importMeta }) is required in dev mode")
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const baseUrl = stripQuery(importMeta.url)
|
|
561
|
+
const renderCache = getRenderCache()
|
|
562
|
+
const cached = renderCache.get(baseUrl)
|
|
563
|
+
if (cached) return cached
|
|
564
|
+
|
|
565
|
+
if (!importMeta.main) {
|
|
566
|
+
throw new Error("[effect-tui] render(..., { dev: true }) must be called from the entry module")
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const renderer = createRenderer(options)
|
|
570
|
+
const root = createRoot(renderer)
|
|
571
|
+
const entryPath = fileURLToPath(new URL(baseUrl))
|
|
572
|
+
|
|
573
|
+
const devRuntime = startDevRuntime(entryPath, renderer, root, options)
|
|
574
|
+
|
|
575
|
+
const instance = createRenderInstance(
|
|
576
|
+
renderer,
|
|
577
|
+
root,
|
|
578
|
+
element,
|
|
579
|
+
() => {
|
|
580
|
+
void devRuntime.stop()
|
|
581
|
+
},
|
|
582
|
+
true,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
renderCache.set(baseUrl, instance)
|
|
586
|
+
return instance
|
|
587
|
+
}
|
package/src/shortcuts.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { KeyMsg, KeyName } from "@effect-tui/core"
|
|
2
|
+
|
|
3
|
+
export type ShortcutSpec = {
|
|
4
|
+
key: KeyName | "char"
|
|
5
|
+
text?: string
|
|
6
|
+
ctrl?: boolean
|
|
7
|
+
meta?: boolean
|
|
8
|
+
shift?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type Shortcut = string | ShortcutSpec
|
|
12
|
+
|
|
13
|
+
export type ShortcutMatchOptions = {
|
|
14
|
+
/** Require that unspecified modifiers are not pressed (default: true). */
|
|
15
|
+
exact?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ParsedShortcut = {
|
|
19
|
+
name: KeyName | "char"
|
|
20
|
+
text?: string
|
|
21
|
+
ctrl?: boolean
|
|
22
|
+
meta?: boolean
|
|
23
|
+
shift?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const parseCache = new Map<string, ParsedShortcut>()
|
|
27
|
+
|
|
28
|
+
const normalizeKeyToken = (token: string): { name: KeyName | "char"; text?: string } => {
|
|
29
|
+
const lower = token.toLowerCase()
|
|
30
|
+
switch (lower) {
|
|
31
|
+
case "enter":
|
|
32
|
+
case "return":
|
|
33
|
+
return { name: "enter" }
|
|
34
|
+
case "esc":
|
|
35
|
+
case "escape":
|
|
36
|
+
return { name: "escape" }
|
|
37
|
+
case "tab":
|
|
38
|
+
return { name: "tab" }
|
|
39
|
+
case "shift-tab":
|
|
40
|
+
return { name: "shift-tab" }
|
|
41
|
+
case "backspace":
|
|
42
|
+
case "bs":
|
|
43
|
+
return { name: "backspace" }
|
|
44
|
+
case "delete":
|
|
45
|
+
case "del":
|
|
46
|
+
return { name: "delete" }
|
|
47
|
+
case "home":
|
|
48
|
+
case "end":
|
|
49
|
+
case "up":
|
|
50
|
+
case "down":
|
|
51
|
+
case "left":
|
|
52
|
+
case "right":
|
|
53
|
+
case "pageup":
|
|
54
|
+
case "pagedown":
|
|
55
|
+
case "insert":
|
|
56
|
+
return { name: lower as KeyName }
|
|
57
|
+
case "space":
|
|
58
|
+
case "spacebar":
|
|
59
|
+
return { name: "char", text: " " }
|
|
60
|
+
default:
|
|
61
|
+
if (token.length === 1) {
|
|
62
|
+
return { name: "char", text: token }
|
|
63
|
+
}
|
|
64
|
+
return { name: "char", text: token }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const parseShortcutString = (shortcut: string): ParsedShortcut => {
|
|
69
|
+
const cached = parseCache.get(shortcut)
|
|
70
|
+
if (cached) return cached
|
|
71
|
+
|
|
72
|
+
const raw = shortcut.trim()
|
|
73
|
+
if (!raw) {
|
|
74
|
+
throw new Error("[effect-tui] Shortcut cannot be empty")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parts = raw.split("+").map((part) => part.trim()).filter(Boolean)
|
|
78
|
+
let keyToken: string | null = null
|
|
79
|
+
let ctrl: boolean | undefined
|
|
80
|
+
let meta: boolean | undefined
|
|
81
|
+
let shift: boolean | undefined
|
|
82
|
+
|
|
83
|
+
for (const part of parts) {
|
|
84
|
+
const lower = part.toLowerCase()
|
|
85
|
+
switch (lower) {
|
|
86
|
+
case "ctrl":
|
|
87
|
+
case "control":
|
|
88
|
+
ctrl = true
|
|
89
|
+
break
|
|
90
|
+
case "meta":
|
|
91
|
+
case "cmd":
|
|
92
|
+
case "command":
|
|
93
|
+
case "alt":
|
|
94
|
+
case "option":
|
|
95
|
+
meta = true
|
|
96
|
+
break
|
|
97
|
+
case "shift":
|
|
98
|
+
shift = true
|
|
99
|
+
break
|
|
100
|
+
default:
|
|
101
|
+
keyToken = part
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!keyToken) {
|
|
107
|
+
throw new Error(`[effect-tui] Shortcut missing key: \"${shortcut}\"`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const normalized = normalizeKeyToken(keyToken)
|
|
111
|
+
const parsed: ParsedShortcut = {
|
|
112
|
+
name: normalized.name,
|
|
113
|
+
text: normalized.text,
|
|
114
|
+
ctrl,
|
|
115
|
+
meta,
|
|
116
|
+
shift,
|
|
117
|
+
}
|
|
118
|
+
parseCache.set(shortcut, parsed)
|
|
119
|
+
return parsed
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const parseShortcut = (shortcut: Shortcut): ParsedShortcut => {
|
|
123
|
+
if (typeof shortcut === "string") {
|
|
124
|
+
return parseShortcutString(shortcut)
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
name: shortcut.key,
|
|
128
|
+
text: shortcut.text,
|
|
129
|
+
ctrl: shortcut.ctrl,
|
|
130
|
+
meta: shortcut.meta,
|
|
131
|
+
shift: shortcut.shift,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const matchesModifiers = (key: KeyMsg, parsed: ParsedShortcut, exact: boolean): boolean => {
|
|
136
|
+
const keyCtrl = !!key.ctrl
|
|
137
|
+
const keyMeta = !!key.meta
|
|
138
|
+
const keyShift = !!key.shift || key.name === "shift-tab"
|
|
139
|
+
|
|
140
|
+
if (parsed.ctrl !== undefined) {
|
|
141
|
+
if (parsed.ctrl !== keyCtrl) return false
|
|
142
|
+
} else if (exact && keyCtrl) {
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (parsed.meta !== undefined) {
|
|
147
|
+
if (parsed.meta !== keyMeta) return false
|
|
148
|
+
} else if (exact && keyMeta) {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (parsed.shift !== undefined) {
|
|
153
|
+
if (parsed.shift !== keyShift) return false
|
|
154
|
+
} else if (exact && keyShift) {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function isKey(key: KeyMsg, shortcut: Shortcut, options: ShortcutMatchOptions = {}): boolean {
|
|
162
|
+
const parsed = parseShortcut(shortcut)
|
|
163
|
+
const effective = parsed.name === "char" && parsed.shift ? { ...parsed, shift: undefined } : parsed
|
|
164
|
+
const exact = options.exact ?? true
|
|
165
|
+
|
|
166
|
+
if (!matchesModifiers(key, effective, exact)) return false
|
|
167
|
+
|
|
168
|
+
if (effective.name === "char") {
|
|
169
|
+
if (key.name !== "char") return false
|
|
170
|
+
return effective.text === undefined ? true : key.text === effective.text
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (effective.name === "tab" && effective.shift) {
|
|
174
|
+
if (key.name === "shift-tab") return true
|
|
175
|
+
if (key.name !== "tab") return false
|
|
176
|
+
return !!key.shift
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return key.name === effective.name
|
|
180
|
+
}
|
package/src/test/mock-streams.ts
CHANGED
|
@@ -112,12 +112,9 @@ export function encodeKey(key: KeyMsg): Buffer {
|
|
|
112
112
|
case "tab":
|
|
113
113
|
return Buffer.from([0x09])
|
|
114
114
|
case "enter":
|
|
115
|
-
case "return":
|
|
116
115
|
return Buffer.from([0x0d])
|
|
117
116
|
case "backspace":
|
|
118
117
|
return Buffer.from([0x7f])
|
|
119
|
-
case "space":
|
|
120
|
-
return Buffer.from([0x20])
|
|
121
118
|
}
|
|
122
119
|
|
|
123
120
|
// Regular character
|