@effect-tui/react 0.12.3 → 0.14.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 +31 -7
- package/dist/jsx-runtime.d.ts +1 -2
- package/dist/jsx-runtime.d.ts.map +1 -1
- package/dist/src/components/Markdown.js +7 -7
- package/dist/src/components/Markdown.js.map +1 -1
- package/dist/src/components/MultilineTextInput.d.ts.map +1 -1
- package/dist/src/components/MultilineTextInput.js +11 -0
- package/dist/src/components/MultilineTextInput.js.map +1 -1
- package/dist/src/components/TextInput.d.ts.map +1 -1
- package/dist/src/components/TextInput.js +15 -0
- package/dist/src/components/TextInput.js.map +1 -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/base.d.ts +16 -0
- package/dist/src/hosts/base.d.ts.map +1 -1
- package/dist/src/hosts/base.js +30 -0
- package/dist/src/hosts/base.js.map +1 -1
- package/dist/src/hosts/box.d.ts.map +1 -1
- package/dist/src/hosts/box.js +7 -8
- package/dist/src/hosts/box.js.map +1 -1
- package/dist/src/hosts/canvas.d.ts.map +1 -1
- package/dist/src/hosts/canvas.js +5 -3
- package/dist/src/hosts/canvas.js.map +1 -1
- package/dist/src/hosts/codeblock.d.ts.map +1 -1
- package/dist/src/hosts/codeblock.js +5 -4
- package/dist/src/hosts/codeblock.js.map +1 -1
- package/dist/src/hosts/flex-container.d.ts.map +1 -1
- package/dist/src/hosts/flex-container.js +5 -8
- package/dist/src/hosts/flex-container.js.map +1 -1
- package/dist/src/hosts/index.d.ts +1 -1
- package/dist/src/hosts/index.d.ts.map +1 -1
- package/dist/src/hosts/index.js +2 -3
- package/dist/src/hosts/index.js.map +1 -1
- package/dist/src/hosts/overlay-item.js +2 -2
- package/dist/src/hosts/overlay-item.js.map +1 -1
- package/dist/src/hosts/overlay.d.ts.map +1 -1
- package/dist/src/hosts/overlay.js +6 -11
- package/dist/src/hosts/overlay.js.map +1 -1
- package/dist/src/hosts/scroll.d.ts +8 -0
- package/dist/src/hosts/scroll.d.ts.map +1 -1
- package/dist/src/hosts/scroll.js +50 -26
- package/dist/src/hosts/scroll.js.map +1 -1
- package/dist/src/hosts/spacer.d.ts.map +1 -1
- package/dist/src/hosts/spacer.js +1 -3
- package/dist/src/hosts/spacer.js.map +1 -1
- package/dist/src/hosts/text.d.ts +24 -45
- package/dist/src/hosts/text.d.ts.map +1 -1
- package/dist/src/hosts/text.js +69 -215
- package/dist/src/hosts/text.js.map +1 -1
- package/dist/src/hosts/zstack.d.ts.map +1 -1
- package/dist/src/hosts/zstack.js +4 -10
- package/dist/src/hosts/zstack.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/reconciler/types.d.ts +2 -0
- package/dist/src/reconciler/types.d.ts.map +1 -1
- package/dist/src/renderer/core/FrameBuilder.d.ts.map +1 -1
- package/dist/src/renderer/core/FrameBuilder.js +2 -0
- package/dist/src/renderer/core/FrameBuilder.js.map +1 -1
- package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
- package/dist/src/renderer/input/InputProcessor.js +13 -3
- 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/lifecycle/RenderCache.d.ts +3 -0
- package/dist/src/renderer/lifecycle/RenderCache.d.ts.map +1 -0
- package/dist/src/renderer/lifecycle/RenderCache.js +9 -0
- package/dist/src/renderer/lifecycle/RenderCache.js.map +1 -0
- package/dist/src/renderer/lifecycle/index.d.ts +1 -0
- package/dist/src/renderer/lifecycle/index.d.ts.map +1 -1
- package/dist/src/renderer/lifecycle/index.js +1 -0
- package/dist/src/renderer/lifecycle/index.js.map +1 -1
- package/dist/src/renderer-types.d.ts +9 -2
- 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 +42 -11
- 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/src/test/render-tui.d.ts.map +1 -1
- package/dist/src/test/render-tui.js +1 -0
- package/dist/src/test/render-tui.js.map +1 -1
- package/dist/src/utils/alignment.d.ts +4 -0
- package/dist/src/utils/alignment.d.ts.map +1 -1
- package/dist/src/utils/alignment.js +12 -0
- package/dist/src/utils/alignment.js.map +1 -1
- package/dist/src/utils/index.d.ts +3 -2
- package/dist/src/utils/index.d.ts.map +1 -1
- package/dist/src/utils/index.js +3 -2
- package/dist/src/utils/index.js.map +1 -1
- package/dist/src/utils/styles.d.ts +6 -1
- package/dist/src/utils/styles.d.ts.map +1 -1
- package/dist/src/utils/styles.js +9 -0
- package/dist/src/utils/styles.js.map +1 -1
- package/dist/src/utils/text-wrap.d.ts +10 -0
- package/dist/src/utils/text-wrap.d.ts.map +1 -0
- package/dist/src/utils/text-wrap.js +64 -0
- package/dist/src/utils/text-wrap.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/jsx-runtime.ts +1 -2
- package/package.json +2 -2
- package/src/components/Markdown.tsx +7 -7
- package/src/components/MultilineTextInput.tsx +14 -0
- package/src/components/TextInput.tsx +18 -0
- 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/base.ts +35 -0
- package/src/hosts/box.ts +7 -8
- package/src/hosts/canvas.ts +5 -3
- package/src/hosts/codeblock.ts +5 -4
- package/src/hosts/flex-container.ts +5 -7
- package/src/hosts/index.ts +1 -4
- package/src/hosts/overlay-item.ts +2 -2
- package/src/hosts/overlay.ts +6 -12
- package/src/hosts/scroll.ts +55 -26
- package/src/hosts/spacer.ts +1 -3
- package/src/hosts/text.ts +89 -256
- package/src/hosts/zstack.ts +4 -11
- package/src/index.ts +19 -6
- package/src/reconciler/types.ts +3 -0
- package/src/renderer/core/FrameBuilder.ts +3 -0
- package/src/renderer/input/InputProcessor.ts +13 -3
- package/src/renderer/lifecycle/EventBus.ts +14 -4
- package/src/renderer/lifecycle/RenderCache.ts +13 -0
- package/src/renderer/lifecycle/index.ts +1 -0
- package/src/renderer-types.ts +10 -2
- package/src/renderer.ts +85 -14
- package/src/shortcuts.ts +180 -0
- package/src/test/mock-streams.ts +0 -3
- package/src/test/render-tui.ts +1 -0
- package/src/utils/alignment.ts +18 -0
- package/src/utils/index.ts +3 -1
- package/src/utils/styles.ts +18 -1
- package/src/utils/text-wrap.ts +66 -0
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 */
|
|
@@ -75,7 +83,7 @@ export interface RendererOptions {
|
|
|
75
83
|
mode?: "fullscreen" | "inline"
|
|
76
84
|
/** Exit the process on Ctrl+C unless preventDefault was called. Defaults to true. */
|
|
77
85
|
exitOnCtrlC?: boolean
|
|
78
|
-
/** Handle SIGINT/SIGTERM and
|
|
86
|
+
/** Handle SIGINT/SIGTERM and process exit cleanup. Defaults to true. */
|
|
79
87
|
handleSignals?: boolean
|
|
80
88
|
/** Exit the process after handling SIGINT/SIGTERM. Defaults to true. */
|
|
81
89
|
exitOnSignal?: boolean
|
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"
|
|
@@ -10,12 +11,14 @@ import type { HostContext } from "./reconciler/types.js"
|
|
|
10
11
|
// Extracted modules
|
|
11
12
|
import { FrameBuilder, RendererState } from "./renderer/core/index.js"
|
|
12
13
|
import { InputProcessor } from "./renderer/input/index.js"
|
|
13
|
-
import { EventBus, TerminalSetup } from "./renderer/lifecycle/index.js"
|
|
14
|
+
import { EventBus, getRenderCache, 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() {
|
|
@@ -360,11 +370,11 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
|
|
|
360
370
|
renderer.stop()
|
|
361
371
|
}
|
|
362
372
|
|
|
363
|
-
// Handle normal process exit (synchronous - runs before exit completes)
|
|
364
|
-
onExit = () => cleanup()
|
|
365
|
-
process.on("exit", onExit)
|
|
366
|
-
|
|
367
373
|
if (handleSignals) {
|
|
374
|
+
// Handle normal process exit (synchronous - runs before exit completes)
|
|
375
|
+
onExit = () => cleanup()
|
|
376
|
+
process.on("exit", onExit)
|
|
377
|
+
|
|
368
378
|
// Handle SIGINT (Ctrl+C from shell, not from TUI input) and SIGTERM
|
|
369
379
|
onSignal = (signal: NodeJS.Signals) => {
|
|
370
380
|
cleanup()
|
|
@@ -445,11 +455,31 @@ 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
|
+
}
|
|
451
470
|
|
|
452
|
-
|
|
471
|
+
const stripQuery = (url: string): string => url.split("?")[0]
|
|
472
|
+
|
|
473
|
+
const createRenderInstance = (
|
|
474
|
+
renderer: TuiRenderer,
|
|
475
|
+
root: Root,
|
|
476
|
+
element: ReactNode,
|
|
477
|
+
stop: () => void,
|
|
478
|
+
skipInitialRender = false,
|
|
479
|
+
): RenderInstance => {
|
|
480
|
+
if (!skipInitialRender) {
|
|
481
|
+
root.render(element, true)
|
|
482
|
+
}
|
|
453
483
|
|
|
454
484
|
let resolved = false
|
|
455
485
|
let resolveExit: (() => void) | null = null
|
|
@@ -472,7 +502,7 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
|
|
|
472
502
|
process.off("exit", onExit)
|
|
473
503
|
if (!resolved) {
|
|
474
504
|
resolved = true
|
|
475
|
-
|
|
505
|
+
stop()
|
|
476
506
|
resolveExit?.()
|
|
477
507
|
}
|
|
478
508
|
}
|
|
@@ -482,10 +512,10 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
|
|
|
482
512
|
root.render(next)
|
|
483
513
|
}
|
|
484
514
|
|
|
485
|
-
// Clean quit function -
|
|
515
|
+
// Clean quit function - stop() + process.exit()
|
|
486
516
|
// The createRenderer exit handler will also run, but it's idempotent
|
|
487
517
|
const quit = (code = 0) => {
|
|
488
|
-
|
|
518
|
+
stop()
|
|
489
519
|
requestExit(code)
|
|
490
520
|
}
|
|
491
521
|
|
|
@@ -498,3 +528,44 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
|
|
|
498
528
|
quit,
|
|
499
529
|
}
|
|
500
530
|
}
|
|
531
|
+
|
|
532
|
+
export function render(element: ReactNode, options?: RenderOptions): RenderInstance {
|
|
533
|
+
if (options && !options.importMeta) {
|
|
534
|
+
throw new Error("[effect-tui] render(..., options) requires { importMeta: import.meta }")
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!options?.dev) {
|
|
538
|
+
const renderer = createRenderer(options)
|
|
539
|
+
const root = createRoot(renderer)
|
|
540
|
+
return createRenderInstance(renderer, root, element, () => renderer.stop())
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const importMeta = options.importMeta
|
|
544
|
+
if (!importMeta) {
|
|
545
|
+
throw new Error("[effect-tui] render(..., { dev: true, importMeta }) is required in dev mode")
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const baseUrl = stripQuery(importMeta.url)
|
|
549
|
+
const renderCache = getRenderCache<RenderInstance>()
|
|
550
|
+
const cached = renderCache.get(baseUrl)
|
|
551
|
+
if (cached) return cached
|
|
552
|
+
|
|
553
|
+
const renderer = createRenderer(options)
|
|
554
|
+
const root = createRoot(renderer)
|
|
555
|
+
const entryPath = fileURLToPath(new URL(baseUrl))
|
|
556
|
+
|
|
557
|
+
const devRuntime = startDevRuntime(entryPath, renderer, root, options)
|
|
558
|
+
|
|
559
|
+
const instance = createRenderInstance(
|
|
560
|
+
renderer,
|
|
561
|
+
root,
|
|
562
|
+
element,
|
|
563
|
+
() => {
|
|
564
|
+
void devRuntime.stop()
|
|
565
|
+
},
|
|
566
|
+
true,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
renderCache.set(baseUrl, instance)
|
|
570
|
+
return instance
|
|
571
|
+
}
|
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
|
package/src/test/render-tui.ts
CHANGED
package/src/utils/alignment.ts
CHANGED
|
@@ -48,3 +48,21 @@ export function alignInRect(
|
|
|
48
48
|
|
|
49
49
|
return { x, y }
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Calculate a child rect aligned within a container rect, clamped to container size.
|
|
54
|
+
*/
|
|
55
|
+
export function alignedChildRect(
|
|
56
|
+
rect: Rect,
|
|
57
|
+
size: Size,
|
|
58
|
+
hAlign: HAlign = "center",
|
|
59
|
+
vAlign: VAlign = "center",
|
|
60
|
+
): Rect {
|
|
61
|
+
const { x, y } = alignInRect(rect, size, hAlign, vAlign)
|
|
62
|
+
return {
|
|
63
|
+
x,
|
|
64
|
+
y,
|
|
65
|
+
w: Math.min(rect.w, size.w),
|
|
66
|
+
h: Math.min(rect.h, size.h),
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { alignInRect, type HAlign, type VAlign } from "./alignment.js"
|
|
1
|
+
export { alignInRect, alignedChildRect, type HAlign, type VAlign } from "./alignment.js"
|
|
2
2
|
export {
|
|
3
3
|
type BorderChars,
|
|
4
4
|
type BorderKind,
|
|
@@ -11,6 +11,7 @@ export {
|
|
|
11
11
|
export { type FlexAlignment, type FlexAxis, type FlexMeasureResult, layoutFlex, measureFlex } from "./flex-layout.js"
|
|
12
12
|
export { type Padding, type PaddingInput, resolvePadding } from "./padding.js"
|
|
13
13
|
export {
|
|
14
|
+
fillRectWithInheritedBg,
|
|
14
15
|
resolveBgStyle,
|
|
15
16
|
resolveInheritedBgStyle,
|
|
16
17
|
type StyleInput,
|
|
@@ -19,3 +20,4 @@ export {
|
|
|
19
20
|
styleSpecFromProps,
|
|
20
21
|
toColorValue,
|
|
21
22
|
} from "./styles.js"
|
|
23
|
+
export { wrapSpans } from "./text-wrap.js"
|
package/src/utils/styles.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Style utilities for building palette-compatible style objects.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import type { CellBuffer, Color, ColorValue, Palette, StyleSpec } from "@effect-tui/core"
|
|
6
|
+
import { parseColor } from "@effect-tui/core"
|
|
6
7
|
|
|
7
8
|
export interface StyleOptions {
|
|
8
9
|
fg?: ColorValue
|
|
@@ -64,6 +65,7 @@ export function resolveBgStyle(palette: Palette, bg?: Color): { value?: ColorVal
|
|
|
64
65
|
|
|
65
66
|
import { getInheritedBg } from "../hosts/base.js"
|
|
66
67
|
import type { HostInstance } from "../reconciler/types.js"
|
|
68
|
+
import type { Rect } from "../reconciler/types.js"
|
|
67
69
|
|
|
68
70
|
/**
|
|
69
71
|
* Resolve background style, inheriting from parent if not explicitly set.
|
|
@@ -77,3 +79,18 @@ export function resolveInheritedBgStyle(
|
|
|
77
79
|
const bg = explicitBg ?? getInheritedBg(parent)
|
|
78
80
|
return resolveBgStyle(palette, bg)
|
|
79
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fill a rect with background color, inheriting from parent if needed.
|
|
85
|
+
*/
|
|
86
|
+
export function fillRectWithInheritedBg(
|
|
87
|
+
buffer: CellBuffer,
|
|
88
|
+
palette: Palette,
|
|
89
|
+
rect: Rect,
|
|
90
|
+
explicitBg: Color | undefined,
|
|
91
|
+
parent: HostInstance | null,
|
|
92
|
+
): void {
|
|
93
|
+
if (rect.w <= 0 || rect.h <= 0) return
|
|
94
|
+
const { styleId } = resolveInheritedBgStyle(palette, explicitBg, parent)
|
|
95
|
+
buffer.fillRect(rect.x, rect.y, rect.w, rect.h, " ".codePointAt(0)!, styleId)
|
|
96
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { displayWidth } from "@effect-tui/core"
|
|
2
|
+
|
|
3
|
+
type SpanLike = { text: string }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wrap spans into lines, breaking at word boundaries.
|
|
7
|
+
* Preserves span styling by cloning span objects with updated text.
|
|
8
|
+
*/
|
|
9
|
+
export function wrapSpans<T extends SpanLike>(spans: T[], maxWidth: number): T[][] {
|
|
10
|
+
const lines: T[][] = [[]]
|
|
11
|
+
let lineWidth = 0
|
|
12
|
+
|
|
13
|
+
for (const span of spans) {
|
|
14
|
+
// Split span text into words (keeping whitespace as separate tokens)
|
|
15
|
+
const tokens = span.text.split(/(\s+)/)
|
|
16
|
+
|
|
17
|
+
for (const token of tokens) {
|
|
18
|
+
if (!token) continue
|
|
19
|
+
const tokenWidth = displayWidth(token)
|
|
20
|
+
const isWhitespace = /^\s+$/.test(token)
|
|
21
|
+
|
|
22
|
+
if (lineWidth + tokenWidth <= maxWidth) {
|
|
23
|
+
// Token fits on current line
|
|
24
|
+
lines[lines.length - 1].push({ ...span, text: token })
|
|
25
|
+
lineWidth += tokenWidth
|
|
26
|
+
} else if (isWhitespace) {
|
|
27
|
+
// Skip whitespace at line break
|
|
28
|
+
continue
|
|
29
|
+
} else if (tokenWidth <= maxWidth) {
|
|
30
|
+
// Start new line with this token
|
|
31
|
+
lines.push([{ ...span, text: token }])
|
|
32
|
+
lineWidth = tokenWidth
|
|
33
|
+
} else {
|
|
34
|
+
// Token is longer than maxWidth - break by character
|
|
35
|
+
let charLine = ""
|
|
36
|
+
let charLineWidth = 0
|
|
37
|
+
for (const ch of token) {
|
|
38
|
+
const chWidth = displayWidth(ch)
|
|
39
|
+
if (lineWidth + charLineWidth + chWidth > maxWidth && (charLine || lineWidth > 0)) {
|
|
40
|
+
if (charLine) {
|
|
41
|
+
lines[lines.length - 1].push({ ...span, text: charLine })
|
|
42
|
+
}
|
|
43
|
+
lines.push([])
|
|
44
|
+
lineWidth = 0
|
|
45
|
+
charLine = ch
|
|
46
|
+
charLineWidth = chWidth
|
|
47
|
+
} else {
|
|
48
|
+
charLine += ch
|
|
49
|
+
charLineWidth += chWidth
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (charLine) {
|
|
53
|
+
lines[lines.length - 1].push({ ...span, text: charLine })
|
|
54
|
+
lineWidth += charLineWidth
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Remove empty lines at the end
|
|
61
|
+
while (lines.length > 0 && lines[lines.length - 1].length === 0) {
|
|
62
|
+
lines.pop()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return lines.length > 0 ? lines : [[]]
|
|
66
|
+
}
|