@effect-tui/core 0.1.1 → 0.1.4

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 (271) hide show
  1. package/README.md +31 -11
  2. package/dist/ansi.d.ts +127 -32
  3. package/dist/ansi.d.ts.map +1 -1
  4. package/dist/ansi.js +159 -37
  5. package/dist/ansi.js.map +1 -1
  6. package/dist/colors.d.ts +139 -0
  7. package/dist/colors.d.ts.map +1 -0
  8. package/dist/colors.js +339 -0
  9. package/dist/colors.js.map +1 -0
  10. package/dist/index.d.ts +6 -10
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +13 -11
  13. package/dist/index.js.map +1 -1
  14. package/dist/keys.d.ts +21 -0
  15. package/dist/keys.d.ts.map +1 -1
  16. package/dist/keys.js +199 -58
  17. package/dist/keys.js.map +1 -1
  18. package/dist/layout/axis-helpers.d.ts +19 -0
  19. package/dist/layout/axis-helpers.d.ts.map +1 -0
  20. package/dist/layout/axis-helpers.js +19 -0
  21. package/dist/layout/axis-helpers.js.map +1 -0
  22. package/dist/output.d.ts +59 -0
  23. package/dist/output.d.ts.map +1 -0
  24. package/dist/output.js +142 -0
  25. package/dist/output.js.map +1 -0
  26. package/dist/render/buffer.d.ts.map +1 -1
  27. package/dist/render/buffer.js +6 -25
  28. package/dist/render/buffer.js.map +1 -1
  29. package/dist/render/graphemes.d.ts +15 -0
  30. package/dist/render/graphemes.d.ts.map +1 -0
  31. package/dist/render/graphemes.js +28 -0
  32. package/dist/render/graphemes.js.map +1 -0
  33. package/dist/render/measure.d.ts +1 -0
  34. package/dist/render/measure.d.ts.map +1 -1
  35. package/dist/render/measure.js +14 -36
  36. package/dist/render/measure.js.map +1 -1
  37. package/dist/render/palette.d.ts.map +1 -1
  38. package/dist/render/palette.js +26 -1
  39. package/dist/render/palette.js.map +1 -1
  40. package/dist/render/segmenter.d.ts +8 -0
  41. package/dist/render/segmenter.d.ts.map +1 -0
  42. package/dist/render/segmenter.js +23 -0
  43. package/dist/render/segmenter.js.map +1 -0
  44. package/dist/render/surface.d.ts +6 -32
  45. package/dist/render/surface.d.ts.map +1 -1
  46. package/dist/render/surface.js +11 -80
  47. package/dist/render/surface.js.map +1 -1
  48. package/dist/runtime/backend_node.d.ts.map +1 -1
  49. package/dist/runtime/backend_node.js.map +1 -1
  50. package/dist/tailwind-colors.d.ts +291 -0
  51. package/dist/tailwind-colors.d.ts.map +1 -0
  52. package/dist/tailwind-colors.js +291 -0
  53. package/dist/tailwind-colors.js.map +1 -0
  54. package/dist/types.d.ts +15 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +3 -0
  57. package/dist/types.js.map +1 -0
  58. package/package.json +55 -55
  59. package/src/ansi.ts +201 -73
  60. package/src/colors.ts +468 -0
  61. package/src/index.ts +28 -14
  62. package/src/keys.ts +467 -287
  63. package/src/layout/axis-helpers.ts +33 -0
  64. package/src/output.ts +175 -0
  65. package/src/render/buffer.ts +161 -184
  66. package/src/render/graphemes.ts +34 -0
  67. package/src/render/measure.ts +15 -38
  68. package/src/render/palette.ts +98 -77
  69. package/src/render/segmenter.ts +27 -0
  70. package/src/render/surface.ts +139 -225
  71. package/src/runtime/backend_node.ts +71 -71
  72. package/src/tailwind-colors.ts +295 -0
  73. package/src/types.ts +18 -0
  74. package/dist/anim.d.ts +0 -4
  75. package/dist/anim.d.ts.map +0 -1
  76. package/dist/anim.js +0 -5
  77. package/dist/anim.js.map +0 -1
  78. package/dist/layout/linearStack.d.ts +0 -17
  79. package/dist/layout/linearStack.d.ts.map +0 -1
  80. package/dist/layout/linearStack.js +0 -86
  81. package/dist/layout/linearStack.js.map +0 -1
  82. package/dist/motion-value.d.ts +0 -58
  83. package/dist/motion-value.d.ts.map +0 -1
  84. package/dist/motion-value.js +0 -250
  85. package/dist/motion-value.js.map +0 -1
  86. package/dist/present/display.d.ts +0 -58
  87. package/dist/present/display.d.ts.map +0 -1
  88. package/dist/present/display.js +0 -168
  89. package/dist/present/display.js.map +0 -1
  90. package/dist/present/writers/fullscreen.d.ts +0 -19
  91. package/dist/present/writers/fullscreen.d.ts.map +0 -1
  92. package/dist/present/writers/fullscreen.js +0 -55
  93. package/dist/present/writers/fullscreen.js.map +0 -1
  94. package/dist/present/writers/inline.d.ts +0 -20
  95. package/dist/present/writers/inline.d.ts.map +0 -1
  96. package/dist/present/writers/inline.js +0 -92
  97. package/dist/present/writers/inline.js.map +0 -1
  98. package/dist/render/color-utils.d.ts +0 -18
  99. package/dist/render/color-utils.d.ts.map +0 -1
  100. package/dist/render/color-utils.js +0 -58
  101. package/dist/render/color-utils.js.map +0 -1
  102. package/dist/render/diff.d.ts +0 -30
  103. package/dist/render/diff.d.ts.map +0 -1
  104. package/dist/render/diff.js +0 -83
  105. package/dist/render/diff.js.map +0 -1
  106. package/dist/spring-physics.d.ts +0 -36
  107. package/dist/spring-physics.d.ts.map +0 -1
  108. package/dist/spring-physics.js +0 -113
  109. package/dist/spring-physics.js.map +0 -1
  110. package/dist/spring.d.ts +0 -73
  111. package/dist/spring.d.ts.map +0 -1
  112. package/dist/spring.js +0 -136
  113. package/dist/spring.js.map +0 -1
  114. package/dist/ui/containers/canvas.d.ts +0 -13
  115. package/dist/ui/containers/canvas.d.ts.map +0 -1
  116. package/dist/ui/containers/canvas.js +0 -16
  117. package/dist/ui/containers/canvas.js.map +0 -1
  118. package/dist/ui/containers/geometry-reader.d.ts +0 -17
  119. package/dist/ui/containers/geometry-reader.d.ts.map +0 -1
  120. package/dist/ui/containers/geometry-reader.js +0 -24
  121. package/dist/ui/containers/geometry-reader.js.map +0 -1
  122. package/dist/ui/containers/hstack.d.ts +0 -12
  123. package/dist/ui/containers/hstack.d.ts.map +0 -1
  124. package/dist/ui/containers/hstack.js +0 -28
  125. package/dist/ui/containers/hstack.js.map +0 -1
  126. package/dist/ui/containers/scroll.d.ts +0 -28
  127. package/dist/ui/containers/scroll.d.ts.map +0 -1
  128. package/dist/ui/containers/scroll.js +0 -97
  129. package/dist/ui/containers/scroll.js.map +0 -1
  130. package/dist/ui/containers/shared.d.ts +0 -12
  131. package/dist/ui/containers/shared.d.ts.map +0 -1
  132. package/dist/ui/containers/shared.js +0 -19
  133. package/dist/ui/containers/shared.js.map +0 -1
  134. package/dist/ui/containers/vstack.d.ts +0 -12
  135. package/dist/ui/containers/vstack.d.ts.map +0 -1
  136. package/dist/ui/containers/vstack.js +0 -28
  137. package/dist/ui/containers/vstack.js.map +0 -1
  138. package/dist/ui/containers/zstack.d.ts +0 -14
  139. package/dist/ui/containers/zstack.d.ts.map +0 -1
  140. package/dist/ui/containers/zstack.js +0 -36
  141. package/dist/ui/containers/zstack.js.map +0 -1
  142. package/dist/ui/core/geometry-store.d.ts +0 -22
  143. package/dist/ui/core/geometry-store.d.ts.map +0 -1
  144. package/dist/ui/core/geometry-store.js +0 -29
  145. package/dist/ui/core/geometry-store.js.map +0 -1
  146. package/dist/ui/core/geometry.d.ts +0 -34
  147. package/dist/ui/core/geometry.d.ts.map +0 -1
  148. package/dist/ui/core/geometry.js +0 -14
  149. package/dist/ui/core/geometry.js.map +0 -1
  150. package/dist/ui/core/view.d.ts +0 -25
  151. package/dist/ui/core/view.d.ts.map +0 -1
  152. package/dist/ui/core/view.js +0 -34
  153. package/dist/ui/core/view.js.map +0 -1
  154. package/dist/ui/index.d.ts +0 -44
  155. package/dist/ui/index.d.ts.map +0 -1
  156. package/dist/ui/index.js +0 -39
  157. package/dist/ui/index.js.map +0 -1
  158. package/dist/ui/inlinetext.d.ts +0 -24
  159. package/dist/ui/inlinetext.d.ts.map +0 -1
  160. package/dist/ui/inlinetext.js +0 -131
  161. package/dist/ui/inlinetext.js.map +0 -1
  162. package/dist/ui/install.d.ts +0 -22
  163. package/dist/ui/install.d.ts.map +0 -1
  164. package/dist/ui/install.js +0 -66
  165. package/dist/ui/install.js.map +0 -1
  166. package/dist/ui/markdown.d.ts +0 -40
  167. package/dist/ui/markdown.d.ts.map +0 -1
  168. package/dist/ui/markdown.js +0 -351
  169. package/dist/ui/markdown.js.map +0 -1
  170. package/dist/ui/modifiers/border.d.ts +0 -33
  171. package/dist/ui/modifiers/border.d.ts.map +0 -1
  172. package/dist/ui/modifiers/border.js +0 -82
  173. package/dist/ui/modifiers/border.js.map +0 -1
  174. package/dist/ui/modifiers/fill.d.ts +0 -14
  175. package/dist/ui/modifiers/fill.d.ts.map +0 -1
  176. package/dist/ui/modifiers/fill.js +0 -25
  177. package/dist/ui/modifiers/fill.js.map +0 -1
  178. package/dist/ui/modifiers/frame.d.ts +0 -23
  179. package/dist/ui/modifiers/frame.d.ts.map +0 -1
  180. package/dist/ui/modifiers/frame.js +0 -54
  181. package/dist/ui/modifiers/frame.js.map +0 -1
  182. package/dist/ui/modifiers/offset.d.ts +0 -15
  183. package/dist/ui/modifiers/offset.d.ts.map +0 -1
  184. package/dist/ui/modifiers/offset.js +0 -21
  185. package/dist/ui/modifiers/offset.js.map +0 -1
  186. package/dist/ui/modifiers/opacity.d.ts +0 -15
  187. package/dist/ui/modifiers/opacity.d.ts.map +0 -1
  188. package/dist/ui/modifiers/opacity.js +0 -95
  189. package/dist/ui/modifiers/opacity.js.map +0 -1
  190. package/dist/ui/modifiers/padding.d.ts +0 -20
  191. package/dist/ui/modifiers/padding.d.ts.map +0 -1
  192. package/dist/ui/modifiers/padding.js +0 -36
  193. package/dist/ui/modifiers/padding.js.map +0 -1
  194. package/dist/ui/modifiers/styled.d.ts +0 -14
  195. package/dist/ui/modifiers/styled.d.ts.map +0 -1
  196. package/dist/ui/modifiers/styled.js +0 -26
  197. package/dist/ui/modifiers/styled.js.map +0 -1
  198. package/dist/ui/primitives/rectangle.d.ts +0 -15
  199. package/dist/ui/primitives/rectangle.d.ts.map +0 -1
  200. package/dist/ui/primitives/rectangle.js +0 -23
  201. package/dist/ui/primitives/rectangle.js.map +0 -1
  202. package/dist/ui/primitives/spacer.d.ts +0 -13
  203. package/dist/ui/primitives/spacer.d.ts.map +0 -1
  204. package/dist/ui/primitives/spacer.js +0 -16
  205. package/dist/ui/primitives/spacer.js.map +0 -1
  206. package/dist/ui/primitives/text.d.ts +0 -15
  207. package/dist/ui/primitives/text.d.ts.map +0 -1
  208. package/dist/ui/primitives/text.js +0 -79
  209. package/dist/ui/primitives/text.js.map +0 -1
  210. package/dist/ui/primitives/wrapped-text.d.ts +0 -30
  211. package/dist/ui/primitives/wrapped-text.d.ts.map +0 -1
  212. package/dist/ui/primitives/wrapped-text.js +0 -117
  213. package/dist/ui/primitives/wrapped-text.js.map +0 -1
  214. package/dist/ui/shinytext.d.ts +0 -66
  215. package/dist/ui/shinytext.d.ts.map +0 -1
  216. package/dist/ui/shinytext.js +0 -99
  217. package/dist/ui/shinytext.js.map +0 -1
  218. package/dist/ui/text/layout.d.ts +0 -35
  219. package/dist/ui/text/layout.d.ts.map +0 -1
  220. package/dist/ui/text/layout.js +0 -102
  221. package/dist/ui/text/layout.js.map +0 -1
  222. package/dist/ui/textinput.d.ts +0 -140
  223. package/dist/ui/textinput.d.ts.map +0 -1
  224. package/dist/ui/textinput.js +0 -402
  225. package/dist/ui/textinput.js.map +0 -1
  226. package/dist/ui/view-constructors.d.ts +0 -72
  227. package/dist/ui/view-constructors.d.ts.map +0 -1
  228. package/dist/ui/view-constructors.js +0 -74
  229. package/dist/ui/view-constructors.js.map +0 -1
  230. package/src/anim.ts +0 -5
  231. package/src/layout/linearStack.ts +0 -115
  232. package/src/motion-value.ts +0 -335
  233. package/src/present/display.ts +0 -206
  234. package/src/present/writers/fullscreen.ts +0 -58
  235. package/src/present/writers/inline.ts +0 -101
  236. package/src/render/color-utils.ts +0 -60
  237. package/src/render/diff.ts +0 -95
  238. package/src/spring-physics.ts +0 -151
  239. package/src/spring.ts +0 -234
  240. package/src/ui/__snapshots__/wrappedtext.test.ts.snap +0 -57
  241. package/src/ui/containers/canvas.ts +0 -18
  242. package/src/ui/containers/geometry-reader.ts +0 -32
  243. package/src/ui/containers/hstack.ts +0 -33
  244. package/src/ui/containers/scroll.ts +0 -106
  245. package/src/ui/containers/shared.ts +0 -27
  246. package/src/ui/containers/vstack.ts +0 -34
  247. package/src/ui/containers/zstack.ts +0 -37
  248. package/src/ui/core/geometry-store.ts +0 -42
  249. package/src/ui/core/geometry.ts +0 -30
  250. package/src/ui/core/view.ts +0 -49
  251. package/src/ui/index.ts +0 -84
  252. package/src/ui/inlinetext.ts +0 -135
  253. package/src/ui/install.ts +0 -110
  254. package/src/ui/markdown.test.ts +0 -74
  255. package/src/ui/markdown.ts +0 -388
  256. package/src/ui/modifiers/border.ts +0 -100
  257. package/src/ui/modifiers/fill.ts +0 -28
  258. package/src/ui/modifiers/frame.ts +0 -74
  259. package/src/ui/modifiers/offset.ts +0 -23
  260. package/src/ui/modifiers/opacity.ts +0 -93
  261. package/src/ui/modifiers/padding.ts +0 -53
  262. package/src/ui/modifiers/styled.ts +0 -31
  263. package/src/ui/primitives/rectangle.ts +0 -25
  264. package/src/ui/primitives/spacer.ts +0 -18
  265. package/src/ui/primitives/text.ts +0 -85
  266. package/src/ui/primitives/wrapped-text.ts +0 -131
  267. package/src/ui/shinytext.ts +0 -159
  268. package/src/ui/text/layout.ts +0 -119
  269. package/src/ui/textinput.ts +0 -496
  270. package/src/ui/view-constructors.ts +0 -96
  271. package/src/ui/wrappedtext.test.ts +0 -138
@@ -1,119 +0,0 @@
1
- /* text/layout.ts — Shared text layout utilities for wrapping and caret mapping
2
- *
3
- * Single source of truth for:
4
- * - Computing wrapped visual lines (first-line width vs subsequent)
5
- * - Mapping between grapheme cursor index and visual caret coordinates
6
- * - Mapping from visual cell X back to cursor index (for vertical nav)
7
- *
8
- * These helpers mirror renderer behavior by delegating to render/measure.ts
9
- * (displayWidth, graphemes, sliceByWidth), ensuring edit-time and render-time
10
- * wrapping boundaries are identical.
11
- */
12
-
13
- import { displayWidth, graphemes, sliceByWidth } from "../../render/measure.js"
14
-
15
- export type WrappedLine = { text: string; graphemes: string[]; start: number; width: number }
16
- export type Wrapped = { lines: Array<WrappedLine> }
17
-
18
- export type WrapOptions = {
19
- widthFirst: number
20
- widthOther: number
21
- wordWrap: boolean
22
- breakWords: boolean
23
- }
24
-
25
- /** Compute wrapped lines with widths and grapheme start offsets (soft + hard breaks). */
26
- export function wrapText(value: string, opts: WrapOptions): Wrapped {
27
- const lines: Array<{ graphemes: string[]; start: number; width: number }> = []
28
- const widthFor = (isFirstVisual: boolean) => Math.max(0, isFirstVisual ? opts.widthFirst : opts.widthOther)
29
-
30
- let gOffset = 0 // grapheme index from start of whole value
31
- let isFirstVisual = true
32
-
33
- // Split logical lines by explicit newlines
34
- const logicals = value.split("\n")
35
- for (let li = 0; li < logicals.length; li++) {
36
- const logical = logicals[li]
37
- let rest = logical
38
- // Even for empty logical lines, emit one visual line (empty)
39
- if (rest.length === 0) {
40
- lines.push({ graphemes: [], start: gOffset, width: 0 })
41
- if (li < logicals.length - 1) gOffset += 1 // the "\n" grapheme
42
- isFirstVisual = false
43
- continue
44
- }
45
- while (rest.length > 0) {
46
- const maxW = widthFor(isFirstVisual)
47
- const { text } = sliceByWidth(rest, maxW)
48
- const used = text.length > 0 ? text : rest.slice(0, 1) // ensure progress even when maxW == 0
49
- const usedGs = graphemes(used)
50
- const w = displayWidth(used)
51
- lines.push({ graphemes: usedGs, start: gOffset, width: w })
52
- gOffset += usedGs.length
53
- rest = rest.slice(used.length)
54
- isFirstVisual = false
55
- if (used.length === 0) break
56
- }
57
- if (li < logicals.length - 1) gOffset += 1 // account for newline between logical segments
58
- }
59
- return { lines: lines.map((l) => ({ ...l, text: l.graphemes.join("") })) }
60
- }
61
-
62
- /** Locate the visual line and column within that line for a grapheme cursor. */
63
- export function findVisualPos(wrap: Wrapped, cursor: number): { lineIdx: number; colInLine: number } {
64
- for (let i = 0; i < wrap.lines.length; i++) {
65
- const ln = wrap.lines[i]
66
- const start = ln.start
67
- // Use next line's start as upper bound (accounts for newline gaps)
68
- const nextStart = i + 1 < wrap.lines.length ? wrap.lines[i + 1].start : Infinity
69
-
70
- // Cursor in range [start, nextStart) belongs to this line
71
- if (cursor >= start && cursor < nextStart) {
72
- // For empty lines, cursor must be exactly at start
73
- if (ln.graphemes.length === 0) {
74
- return { lineIdx: i, colInLine: 0 }
75
- }
76
- const col = Math.min(cursor - start, ln.graphemes.length)
77
- return { lineIdx: i, colInLine: col }
78
- }
79
- }
80
- // Fallback: end of last line
81
- const last = wrap.lines[wrap.lines.length - 1]
82
- return { lineIdx: wrap.lines.length - 1, colInLine: last.graphemes.length }
83
- }
84
-
85
- /** Sum display widths of graphemes up to a column to get visual cell X. */
86
- export function cellXWithinLine(wrap: Wrapped, lineIdx: number, colInLine: number): number {
87
- const ln = wrap.lines[lineIdx]
88
- const gs = ln.graphemes
89
- let x = 0
90
- for (let i = 0; i < Math.min(colInLine, gs.length); i++) x += displayWidth(gs[i])
91
- return x
92
- }
93
-
94
- /** Map a visual cell X back to a cursor index on a given line (nearest-left by centers). */
95
- export function cursorFromCellX(wrap: Wrapped, lineIdx: number, x: number): number {
96
- const ln = wrap.lines[lineIdx]
97
- const gs = ln.graphemes
98
- let acc = 0
99
- for (let i = 0; i < gs.length; i++) {
100
- const w = displayWidth(gs[i])
101
- const center = acc + w / 2
102
- if (x < center) return ln.start + i
103
- acc += w
104
- }
105
- return ln.start + gs.length
106
- }
107
-
108
- /** Map cursor index to absolute caret coordinates using provided anchors. */
109
- export function caretXYFromCursor(
110
- wrap: Wrapped,
111
- cursor: number,
112
- anchors: { xFirst: number; xOther: number },
113
- ): { x: number; y: number } {
114
- const { lineIdx, colInLine } = findVisualPos(wrap, cursor)
115
- const baseX = lineIdx === 0 ? anchors.xFirst : anchors.xOther
116
- const x = baseX + cellXWithinLine(wrap, lineIdx, colInLine)
117
- const y = lineIdx
118
- return { x, y }
119
- }
@@ -1,496 +0,0 @@
1
- /* textinput.ts — single-line TextInput component and edit helpers */
2
-
3
- import type { Palette, Surface, StyleSpec } from "../render/surface.js"
4
- import type { Rect } from "./core/geometry.js"
5
- import { View } from "./core/view.js"
6
- import { geometryStore } from "./core/geometry-store.js"
7
- import { Colors } from "../render/surface.js"
8
- import { wrapText, findVisualPos, cellXWithinLine, cursorFromCellX, caretXYFromCursor } from "./text/layout.js"
9
- import { Schema } from "effect"
10
- import type { KeyMsg } from "../keys.js"
11
-
12
- /** Geometry info captured from last render. */
13
- export type TextInputGeom = { firstW: number; wrapW: number }
14
-
15
- /**
16
- * TextInputState — immutable state for text input with built-in editing.
17
- *
18
- * Geometry is captured from render and used automatically in edit().
19
- * This eliminates the need to manually pass wrap widths.
20
- */
21
- export class TextInputState extends Schema.Class<TextInputState>("TextInputState")({
22
- value: Schema.String,
23
- cursor: Schema.Number,
24
- focused: Schema.optionalWith(Schema.Boolean, { default: () => false }),
25
- _vcol: Schema.optionalWith(Schema.UndefinedOr(Schema.Number), { default: () => undefined }),
26
- _geom: Schema.optionalWith(Schema.Unknown, { default: () => undefined }),
27
- }) {
28
- /** Create a new TextInputState with sensible defaults. */
29
- static of(value: string, opts?: { cursor?: number; focused?: boolean }): TextInputState {
30
- return new TextInputState({
31
- value,
32
- cursor: opts?.cursor ?? value.length,
33
- focused: opts?.focused ?? false,
34
- })
35
- }
36
-
37
- /** Create empty focused state. */
38
- static empty(focused = true): TextInputState {
39
- return new TextInputState({ value: "", cursor: 0, focused })
40
- }
41
-
42
- /**
43
- * Edit state in response to a key press.
44
- * Uses geometry captured from last render for accurate multiline navigation.
45
- */
46
- edit(key: KeyMsg, opts?: { multiline?: boolean }): { state: TextInputState; quit?: boolean } {
47
- const geom = this._geom as TextInputGeom | undefined
48
- return editTextInputCore(this, key, {
49
- multiline: opts?.multiline ?? false,
50
- wrapWidth: geom?.wrapW ?? Infinity,
51
- firstLineWidth: geom?.firstW ?? Infinity,
52
- })
53
- }
54
-
55
- // --- Immutable setters ---
56
-
57
- withValue(value: string, cursor?: number): TextInputState {
58
- return new TextInputState({
59
- ...this,
60
- value,
61
- cursor: cursor ?? Math.min(this.cursor, [...value].length),
62
- _vcol: undefined,
63
- })
64
- }
65
-
66
- withCursor(cursor: number): TextInputState {
67
- const max = [...this.value].length
68
- return new TextInputState({
69
- ...this,
70
- cursor: Math.max(0, Math.min(max, cursor)),
71
- _vcol: undefined,
72
- })
73
- }
74
-
75
- focus(): TextInputState {
76
- return this.focused ? this : new TextInputState({ ...this, focused: true })
77
- }
78
-
79
- blur(): TextInputState {
80
- return !this.focused ? this : new TextInputState({ ...this, focused: false })
81
- }
82
-
83
- clear(): TextInputState {
84
- return new TextInputState({ ...this, value: "", cursor: 0, _vcol: undefined })
85
- }
86
-
87
- /** Internal: capture geometry from render. Called by TextInput view. */
88
- _captureGeom(geom: TextInputGeom): void {
89
- ;(this as any)._geom = geom
90
- }
91
- }
92
-
93
- /** Legacy plain-object type for backward compatibility. */
94
- export type TextInputStatePlain = {
95
- value: string
96
- cursor: number
97
- focused?: boolean
98
- _vcol?: number
99
- _geom?: TextInputGeom
100
- }
101
-
102
- export type TextInputOptions = {
103
- placeholder?: string
104
- /** Optional left prompt character(s), e.g. ">" */
105
- prompt?: string
106
- /** Optional styles, merged with inherited styles */
107
- style?: {
108
- base?: StyleSpec
109
- placeholder?: StyleSpec // e.g. { fg: Colors.gray(12) }
110
- prompt?: StyleSpec
111
- caret?: StyleSpec // e.g. { inverse: true }
112
- }
113
- /** Enable multiline input and rendering (supports newlines and wrapping). */
114
- multiline?: boolean
115
- /** Word-wrapping preferences when multiline (defaults: wordWrap=true, breakWords=true). */
116
- wrap?: {
117
- wordWrap?: boolean
118
- breakWords?: boolean
119
- }
120
- }
121
-
122
- /** State that can be passed to TextInput: class instance or plain object. */
123
- export type TextInputStateLike = TextInputState | TextInputStatePlain
124
-
125
- /** A minimal single-line text input view (no border). */
126
- export class TextInput extends View {
127
- constructor(
128
- readonly state: TextInputStateLike,
129
- readonly opts?: TextInputOptions,
130
- ) {
131
- super()
132
- }
133
-
134
- protected measureContent(maxW: number, maxH: number) {
135
- const w = Math.max(0, maxW)
136
- if (!this.opts?.multiline) {
137
- // Greedy single line
138
- return { w, h: 1 }
139
- }
140
- // Multiline: compute wrapped lines to determine natural height (clamped by maxH)
141
- const { lines } = wrapText(this.state.value, {
142
- widthFirst: Math.max(0, w - (this.opts?.prompt ? displayWidth(this.opts?.prompt ?? "") : 0)),
143
- widthOther: w,
144
- wordWrap: this.opts?.wrap?.wordWrap ?? true,
145
- breakWords: this.opts?.wrap?.breakWords ?? true,
146
- })
147
- const h = Math.min(Math.max(1, lines.length), maxH)
148
- return { w, h }
149
- }
150
-
151
- protected renderContent(s: Surface, pal: Palette, rect: Rect) {
152
- const styles = this.opts?.style ?? {}
153
- const idBase = pal.id(styles.base)
154
- const idPlaceholder = pal.id(styles.placeholder ?? { fg: Colors.gray(12) })
155
- const idCaret = pal.id(styles.caret ?? { inverse: true })
156
-
157
- const w = Math.max(0, rect.w)
158
- if (w <= 0 || rect.h <= 0) return
159
-
160
- const promptText = this.opts?.prompt ?? ""
161
- const prefix = promptText ? `${promptText}` : ""
162
- const prefixW = displayWidth(prefix)
163
- if (prefixW > 0) s.drawText(rect.x, rect.y, prefix, pal.id(styles.prompt), w)
164
-
165
- const contentX = rect.x + prefixW
166
- const contentW = Math.max(0, w - prefixW)
167
- if (contentW <= 0) return
168
-
169
- const valueEmpty = this.state.value.length === 0
170
- const placeholderText = this.opts?.placeholder ?? ""
171
-
172
- // Capture geometry into state for accurate edit calculations
173
- const geom: TextInputGeom = {
174
- firstW: contentW,
175
- wrapW: this.opts?.multiline ? w : contentW,
176
- }
177
- if (this.state instanceof TextInputState) {
178
- this.state._captureGeom(geom)
179
- } else {
180
- ;(this.state as TextInputStatePlain)._geom = geom
181
- }
182
-
183
- if (!this.opts?.multiline) {
184
- // Single-line path (back-compat)
185
- const text = valueEmpty ? placeholderText : this.state.value
186
- const isPlaceholder = valueEmpty && text.length > 0
187
- const textStyle = isPlaceholder ? idPlaceholder : idBase
188
- s.drawText(contentX, rect.y, text, textStyle, contentW)
189
-
190
- if (this.state.focused) {
191
- const cx0 = this.state.cursor | 0
192
- const cx = Math.max(0, Math.min(contentW - 1, cx0))
193
- const arr = [...text]
194
- const ch = arr[cx] ?? " "
195
- s.drawText(contentX + cx, rect.y, ch, idCaret, 1)
196
- }
197
- // Publish geometry snapshot if id is attached (legacy)
198
- if ((this as any)._id) {
199
- geometryStore.setInputGeom((this as any)._id, {
200
- firstW: contentW,
201
- wrapW: contentW,
202
- xFirst: contentX,
203
- xOther: contentX,
204
- })
205
- }
206
- return
207
- }
208
-
209
- // Multiline path: wrap and render multiple rows
210
- const wrap = wrapText(this.state.value, {
211
- widthFirst: contentW,
212
- widthOther: w,
213
- wordWrap: this.opts?.wrap?.wordWrap ?? true,
214
- breakWords: this.opts?.wrap?.breakWords ?? true,
215
- })
216
-
217
- // Draw placeholder on first line only when empty
218
- if (valueEmpty && placeholderText) {
219
- s.drawText(contentX, rect.y, placeholderText, idPlaceholder, contentW)
220
- }
221
-
222
- // Determine cursor location in visual coordinates
223
- let caretAbsX = contentX
224
- let caretAbsY = rect.y
225
- if (this.state.focused) {
226
- const { x, y } = caretXYFromCursor(wrap, this.state.cursor, {
227
- xFirst: contentX,
228
- xOther: rect.x,
229
- })
230
- caretAbsX = x
231
- caretAbsY = y + rect.y
232
- }
233
-
234
- // Render lines (clip to rect.h)
235
- const maxLines = Math.min(rect.h, wrap.lines.length)
236
- for (let i = 0; i < maxLines; i++) {
237
- const line = wrap.lines[i]
238
- const baseX = i === 0 ? contentX : rect.x
239
- const maxW = i === 0 ? contentW : w
240
- const text = line.text
241
- if (text.length > 0) s.drawText(baseX, rect.y + i, text, idBase, maxW)
242
- }
243
-
244
- // Publish input geometry for this id (legacy)
245
- if ((this as any)._id) {
246
- geometryStore.setInputGeom((this as any)._id, {
247
- firstW: contentW,
248
- wrapW: w,
249
- xFirst: contentX,
250
- xOther: rect.x,
251
- })
252
- }
253
-
254
- // Caret on top
255
- if (this.state.focused) {
256
- // Determine caret character (either actual char under cursor or space)
257
- const { lineIdx, colInLine } = findVisualPos(wrap, this.state.cursor)
258
- const line = wrap.lines[lineIdx]
259
- const ch = line?.graphemes[colInLine] ?? " "
260
- s.drawText(caretAbsX, caretAbsY, ch, idCaret, 1)
261
- }
262
- }
263
- }
264
- // Builder contribution for the View object
265
- export type ViewTextInputExt = {
266
- textInput(state: TextInputStateLike, opts?: TextInputOptions): View
267
- }
268
- export const viewTextInput: ViewTextInputExt = {
269
- textInput(state: TextInputStateLike, opts?: TextInputOptions): View {
270
- return new TextInput(state, opts)
271
- },
272
- }
273
-
274
- // --- Text editing helpers ---
275
-
276
- export type TextInputEditResult = {
277
- state: TextInputState
278
- quit?: boolean
279
- }
280
-
281
- export type TextInputEditOptions = {
282
- /** Enable multiline editing semantics (enter inserts newline with shift). */
283
- multiline?: boolean
284
- /** Visual wrap width for subsequent lines. If absent, only explicit newlines are considered. */
285
- wrapWidth?: number
286
- /** Visual wrap width for first line (after prompt). Defaults to wrapWidth. */
287
- firstLineWidth?: number
288
- }
289
-
290
- /** Core edit function. Prefer using TextInputState.edit() for automatic geometry. */
291
- function editTextInputCore(state: TextInputStateLike, key: KeyMsg, opts?: TextInputEditOptions): TextInputEditResult {
292
- const { value, cursor, focused, _geom } = state
293
- const vcol = state._vcol as number | undefined
294
- const len = [...value].length
295
-
296
- const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
297
-
298
- // Create new state preserving geometry
299
- const make = (updates: { value?: string; cursor?: number; _vcol?: number }): TextInputState =>
300
- new TextInputState({
301
- value: updates.value ?? value,
302
- cursor: updates.cursor ?? cursor,
303
- focused,
304
- _vcol: updates._vcol,
305
- _geom,
306
- })
307
-
308
- // Unchanged state (preserve as-is, converting to class if needed)
309
- const unchanged = (): TextInputState =>
310
- state instanceof TextInputState ? state : new TextInputState({ value, cursor, focused, _vcol: vcol, _geom })
311
-
312
- const isSpace = (ch: string) => /\s/.test(ch)
313
- const prevWord = (arr: string[], pos: number) => {
314
- let i = clamp(pos, 0, arr.length)
315
- if (i === 0) return 0
316
- while (i > 0 && isSpace(arr[i - 1])) i--
317
- while (i > 0 && !isSpace(arr[i - 1])) i--
318
- return i
319
- }
320
- const nextWord = (arr: string[], pos: number) => {
321
- let i = clamp(pos, 0, arr.length)
322
- if (i === arr.length) return i
323
- while (i < arr.length && !isSpace(arr[i])) i++
324
- while (i < arr.length && isSpace(arr[i])) i++
325
- return i
326
- }
327
-
328
- const multiline = !!opts?.multiline
329
- const wrapWidth = opts?.wrapWidth
330
- const firstWidth = opts?.firstLineWidth ?? wrapWidth
331
-
332
- const getWrap = () =>
333
- wrapText(value, {
334
- widthFirst: firstWidth ?? Infinity,
335
- widthOther: wrapWidth ?? Infinity,
336
- wordWrap: true,
337
- breakWords: true,
338
- })
339
-
340
- switch (key.name) {
341
- case "left":
342
- if (key.meta) {
343
- const arr = [...value]
344
- return { state: make({ cursor: prevWord(arr, cursor) }) }
345
- }
346
- return { state: make({ cursor: clamp(cursor - 1, 0, len) }) }
347
-
348
- case "right":
349
- if (key.meta) {
350
- const arr = [...value]
351
- return { state: make({ cursor: nextWord(arr, cursor) }) }
352
- }
353
- return { state: make({ cursor: clamp(cursor + 1, 0, len) }) }
354
-
355
- case "home":
356
- return { state: make({ cursor: 0 }) }
357
-
358
- case "end":
359
- return { state: make({ cursor: len }) }
360
-
361
- case "up": {
362
- if (!multiline) return { state: unchanged() }
363
- const wrap = getWrap()
364
- const pos = findVisualPos(wrap, cursor)
365
- if (pos.lineIdx <= 0) return { state: unchanged() }
366
- const curX = vcol ?? cellXWithinLine(wrap, pos.lineIdx, pos.colInLine)
367
- const target = cursorFromCellX(wrap, pos.lineIdx - 1, curX)
368
- return { state: make({ cursor: target, _vcol: curX }) }
369
- }
370
-
371
- case "down": {
372
- if (!multiline) return { state: unchanged() }
373
- const wrap = getWrap()
374
- const pos = findVisualPos(wrap, cursor)
375
- if (pos.lineIdx >= wrap.lines.length - 1) return { state: unchanged() }
376
- const curX = vcol ?? cellXWithinLine(wrap, pos.lineIdx, pos.colInLine)
377
- const target = cursorFromCellX(wrap, pos.lineIdx + 1, curX)
378
- return { state: make({ cursor: target, _vcol: curX }) }
379
- }
380
-
381
- case "backspace": {
382
- if (key.meta) {
383
- if (cursor <= 0) return { state: unchanged() }
384
- const arr = [...value]
385
- const start = prevWord(arr, cursor)
386
- arr.splice(start, cursor - start)
387
- return { state: make({ value: arr.join(""), cursor: start }) }
388
- }
389
- if (cursor <= 0) return { state: unchanged() }
390
- const arr = [...value]
391
- arr.splice(cursor - 1, 1)
392
- return { state: make({ value: arr.join(""), cursor: cursor - 1 }) }
393
- }
394
-
395
- case "delete": {
396
- if (key.meta) {
397
- if (cursor <= 0) return { state: unchanged() }
398
- const arr = [...value]
399
- arr.splice(0, cursor)
400
- return { state: make({ value: arr.join(""), cursor: 0 }) }
401
- }
402
- if (cursor >= len) return { state: unchanged() }
403
- const arr = [...value]
404
- arr.splice(cursor, 1)
405
- return { state: make({ value: arr.join(""), cursor }) }
406
- }
407
-
408
- case "enter":
409
- case "return": {
410
- if (multiline) {
411
- const arr = [...value]
412
- arr.splice(cursor, 0, "\n")
413
- return { state: make({ value: arr.join(""), cursor: cursor + 1 }) }
414
- }
415
- return { state: unchanged() }
416
- }
417
-
418
- case "space": {
419
- return editTextInputCore(state, { ...key, name: "char", text: " " }, opts)
420
- }
421
-
422
- case "char": {
423
- if (!key.text) return { state: unchanged() }
424
-
425
- // Ctrl-C: clear or quit
426
- if (key.ctrl && key.text === "c") {
427
- if (value.length > 0) {
428
- return { state: make({ value: "", cursor: 0 }) }
429
- }
430
- return { state: unchanged(), quit: true }
431
- }
432
-
433
- // Ctrl-U: delete to start
434
- if (key.ctrl && key.text === "u") {
435
- if (cursor <= 0) return { state: unchanged() }
436
- const arr = [...value]
437
- arr.splice(0, cursor)
438
- return { state: make({ value: arr.join(""), cursor: 0 }) }
439
- }
440
-
441
- // Ctrl-W: delete previous word
442
- if (key.ctrl && key.text === "w") {
443
- const arr = [...value]
444
- const start = prevWord(arr, cursor)
445
- arr.splice(start, cursor - start)
446
- return { state: make({ value: arr.join(""), cursor: start }) }
447
- }
448
-
449
- // Meta-b/B: previous word
450
- if (key.meta && (key.text === "b" || key.text === "B")) {
451
- const arr = [...value]
452
- return { state: make({ cursor: prevWord(arr, cursor) }) }
453
- }
454
-
455
- // Meta-f/F: next word
456
- if (key.meta && (key.text === "f" || key.text === "F")) {
457
- const arr = [...value]
458
- return { state: make({ cursor: nextWord(arr, cursor) }) }
459
- }
460
-
461
- // Meta-d/D: delete next word
462
- if (key.meta && (key.text === "d" || key.text === "D")) {
463
- const arr = [...value]
464
- const end = nextWord(arr, cursor)
465
- if (end === cursor) return { state: unchanged() }
466
- arr.splice(cursor, end - cursor)
467
- return { state: make({ value: arr.join(""), cursor }) }
468
- }
469
-
470
- if (key.meta) return { state: unchanged() }
471
-
472
- // Insert character
473
- const arr = [...value]
474
- arr.splice(cursor, 0, key.text)
475
- return { state: make({ value: arr.join(""), cursor: cursor + 1 }) }
476
- }
477
-
478
- default:
479
- return { state: unchanged() }
480
- }
481
- }
482
-
483
- /**
484
- * Edit text input state with a key event.
485
- * @deprecated Use TextInputState.edit() instead for automatic geometry handling.
486
- */
487
- export function editTextInput(
488
- state: TextInputStateLike,
489
- key: KeyMsg,
490
- opts?: TextInputEditOptions,
491
- ): TextInputEditResult {
492
- return editTextInputCore(state, key, opts)
493
- }
494
-
495
- // --- Internal helpers for wrapping-aware cursor movement & rendering ---
496
- import { displayWidth } from "../render/measure.js"