@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.
Files changed (69) hide show
  1. package/README.md +21 -1
  2. package/dist/src/dev.d.ts +22 -32
  3. package/dist/src/dev.d.ts.map +1 -1
  4. package/dist/src/dev.js +42 -96
  5. package/dist/src/dev.js.map +1 -1
  6. package/dist/src/hooks/index.d.ts +3 -0
  7. package/dist/src/hooks/index.d.ts.map +1 -1
  8. package/dist/src/hooks/index.js +1 -0
  9. package/dist/src/hooks/index.js.map +1 -1
  10. package/dist/src/hooks/use-mouse.d.ts +10 -1
  11. package/dist/src/hooks/use-mouse.d.ts.map +1 -1
  12. package/dist/src/hooks/use-mouse.js +10 -2
  13. package/dist/src/hooks/use-mouse.js.map +1 -1
  14. package/dist/src/hooks/use-paste.d.ts +13 -3
  15. package/dist/src/hooks/use-paste.d.ts.map +1 -1
  16. package/dist/src/hooks/use-paste.js +15 -5
  17. package/dist/src/hooks/use-paste.js.map +1 -1
  18. package/dist/src/hooks/use-scroll.d.ts +59 -24
  19. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  20. package/dist/src/hooks/use-scroll.js +238 -79
  21. package/dist/src/hooks/use-scroll.js.map +1 -1
  22. package/dist/src/hooks/use-shortcut.d.ts +16 -0
  23. package/dist/src/hooks/use-shortcut.d.ts.map +1 -0
  24. package/dist/src/hooks/use-shortcut.js +29 -0
  25. package/dist/src/hooks/use-shortcut.js.map +1 -0
  26. package/dist/src/hosts/scroll.d.ts +4 -0
  27. package/dist/src/hosts/scroll.d.ts.map +1 -1
  28. package/dist/src/hosts/scroll.js +18 -2
  29. package/dist/src/hosts/scroll.js.map +1 -1
  30. package/dist/src/index.d.ts +6 -4
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/index.js +3 -2
  33. package/dist/src/index.js.map +1 -1
  34. package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
  35. package/dist/src/renderer/input/InputProcessor.js +8 -1
  36. package/dist/src/renderer/input/InputProcessor.js.map +1 -1
  37. package/dist/src/renderer/lifecycle/EventBus.d.ts +2 -2
  38. package/dist/src/renderer/lifecycle/EventBus.d.ts.map +1 -1
  39. package/dist/src/renderer/lifecycle/EventBus.js +13 -1
  40. package/dist/src/renderer/lifecycle/EventBus.js.map +1 -1
  41. package/dist/src/renderer-types.d.ts +8 -1
  42. package/dist/src/renderer-types.d.ts.map +1 -1
  43. package/dist/src/renderer.d.ts +13 -2
  44. package/dist/src/renderer.d.ts.map +1 -1
  45. package/dist/src/renderer.js +48 -7
  46. package/dist/src/renderer.js.map +1 -1
  47. package/dist/src/shortcuts.d.ts +15 -0
  48. package/dist/src/shortcuts.d.ts.map +1 -0
  49. package/dist/src/shortcuts.js +149 -0
  50. package/dist/src/shortcuts.js.map +1 -0
  51. package/dist/src/test/mock-streams.d.ts.map +1 -1
  52. package/dist/src/test/mock-streams.js +0 -3
  53. package/dist/src/test/mock-streams.js.map +1 -1
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/package.json +2 -2
  56. package/src/dev.tsx +59 -107
  57. package/src/hooks/index.ts +3 -0
  58. package/src/hooks/use-mouse.ts +19 -3
  59. package/src/hooks/use-paste.ts +24 -5
  60. package/src/hooks/use-scroll.ts +345 -105
  61. package/src/hooks/use-shortcut.ts +54 -0
  62. package/src/hosts/scroll.ts +21 -2
  63. package/src/index.ts +19 -6
  64. package/src/renderer/input/InputProcessor.ts +8 -1
  65. package/src/renderer/lifecycle/EventBus.ts +14 -4
  66. package/src/renderer-types.ts +9 -1
  67. package/src/renderer.ts +96 -9
  68. package/src/shortcuts.ts +180 -0
  69. package/src/test/mock-streams.ts +0 -3
@@ -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 DevRenderOptions,
33
- type DevRenderResult,
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 { FrameStats, RendererOptions, RenderInstance, Root, TuiRenderer } from "./renderer.js"
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(event)
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<(text: string) => void>()
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: (text: string) => void): () => void {
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
- handler(text)
74
+ if (paste.defaultPrevented) break
75
+ handler(paste)
66
76
  }
67
77
  }
68
78
 
@@ -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: (text: string) => void): () => void
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 { FrameStats, RendererOptions, TuiReadStream, TuiRenderer, TuiWriteStream } from "./renderer-types.js"
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: (text: string) => void) => events.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 function render(element: ReactNode, options?: RendererOptions): RenderInstance {
449
- const renderer = createRenderer(options)
450
- const root = createRoot(renderer)
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
- root.render(element, true)
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
- renderer.stop()
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 - renderer.stop() + process.exit()
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
- renderer.stop()
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
+ }
@@ -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
+ }
@@ -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