@effect-tui/react 0.16.0 → 2.0.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 (126) hide show
  1. package/README.md +9 -0
  2. package/dist/src/codeblock.d.ts +1 -1
  3. package/dist/src/codeblock.d.ts.map +1 -1
  4. package/dist/src/codeblock.js +2 -2
  5. package/dist/src/codeblock.js.map +1 -1
  6. package/dist/src/components/Markdown.js +3 -3
  7. package/dist/src/components/Markdown.js.map +1 -1
  8. package/dist/src/components/MultilineTextInput.d.ts.map +1 -1
  9. package/dist/src/components/MultilineTextInput.js +133 -305
  10. package/dist/src/components/MultilineTextInput.js.map +1 -1
  11. package/dist/src/components/TextInput.d.ts.map +1 -1
  12. package/dist/src/components/TextInput.js +51 -98
  13. package/dist/src/components/TextInput.js.map +1 -1
  14. package/dist/src/components/text-editing.d.ts +61 -0
  15. package/dist/src/components/text-editing.d.ts.map +1 -1
  16. package/dist/src/components/text-editing.js +131 -0
  17. package/dist/src/components/text-editing.js.map +1 -1
  18. package/dist/src/hosts/base.d.ts +13 -2
  19. package/dist/src/hosts/base.d.ts.map +1 -1
  20. package/dist/src/hosts/base.js +74 -2
  21. package/dist/src/hosts/base.js.map +1 -1
  22. package/dist/src/hosts/box.d.ts +2 -2
  23. package/dist/src/hosts/box.d.ts.map +1 -1
  24. package/dist/src/hosts/box.js +29 -2
  25. package/dist/src/hosts/box.js.map +1 -1
  26. package/dist/src/hosts/canvas.d.ts +22 -2
  27. package/dist/src/hosts/canvas.d.ts.map +1 -1
  28. package/dist/src/hosts/canvas.js +99 -31
  29. package/dist/src/hosts/canvas.js.map +1 -1
  30. package/dist/src/hosts/codeblock.d.ts +8 -10
  31. package/dist/src/hosts/codeblock.d.ts.map +1 -1
  32. package/dist/src/hosts/codeblock.js +36 -33
  33. package/dist/src/hosts/codeblock.js.map +1 -1
  34. package/dist/src/hosts/flex-container.d.ts +2 -2
  35. package/dist/src/hosts/flex-container.d.ts.map +1 -1
  36. package/dist/src/hosts/flex-container.js +17 -2
  37. package/dist/src/hosts/flex-container.js.map +1 -1
  38. package/dist/src/hosts/index.d.ts +1 -1
  39. package/dist/src/hosts/index.d.ts.map +1 -1
  40. package/dist/src/hosts/index.js.map +1 -1
  41. package/dist/src/hosts/overlay-item.d.ts +2 -2
  42. package/dist/src/hosts/overlay-item.d.ts.map +1 -1
  43. package/dist/src/hosts/overlay-item.js +7 -2
  44. package/dist/src/hosts/overlay-item.js.map +1 -1
  45. package/dist/src/hosts/overlay.d.ts +2 -2
  46. package/dist/src/hosts/overlay.d.ts.map +1 -1
  47. package/dist/src/hosts/overlay.js +2 -2
  48. package/dist/src/hosts/overlay.js.map +1 -1
  49. package/dist/src/hosts/scroll.d.ts +7 -2
  50. package/dist/src/hosts/scroll.d.ts.map +1 -1
  51. package/dist/src/hosts/scroll.js +126 -45
  52. package/dist/src/hosts/scroll.js.map +1 -1
  53. package/dist/src/hosts/single-child.d.ts.map +1 -1
  54. package/dist/src/hosts/single-child.js +2 -0
  55. package/dist/src/hosts/single-child.js.map +1 -1
  56. package/dist/src/hosts/spacer.d.ts +1 -1
  57. package/dist/src/hosts/spacer.d.ts.map +1 -1
  58. package/dist/src/hosts/spacer.js +6 -1
  59. package/dist/src/hosts/spacer.js.map +1 -1
  60. package/dist/src/hosts/text.d.ts +20 -15
  61. package/dist/src/hosts/text.d.ts.map +1 -1
  62. package/dist/src/hosts/text.js +104 -71
  63. package/dist/src/hosts/text.js.map +1 -1
  64. package/dist/src/hosts/zstack.d.ts +2 -2
  65. package/dist/src/hosts/zstack.d.ts.map +1 -1
  66. package/dist/src/hosts/zstack.js +7 -2
  67. package/dist/src/hosts/zstack.js.map +1 -1
  68. package/dist/src/index.d.ts +1 -1
  69. package/dist/src/index.d.ts.map +1 -1
  70. package/dist/src/internal/renderer/index.d.ts.map +1 -1
  71. package/dist/src/internal/renderer/index.js +41 -16
  72. package/dist/src/internal/renderer/index.js.map +1 -1
  73. package/dist/src/internal/renderer/types.d.ts +4 -0
  74. package/dist/src/internal/renderer/types.d.ts.map +1 -1
  75. package/dist/src/motion/hooks.d.ts +1 -1
  76. package/dist/src/motion/hooks.js +1 -1
  77. package/dist/src/reconciler/host-config.js +2 -2
  78. package/dist/src/reconciler/host-config.js.map +1 -1
  79. package/dist/src/reconciler/types.d.ts +5 -1
  80. package/dist/src/reconciler/types.d.ts.map +1 -1
  81. package/dist/src/utils/border.d.ts +1 -1
  82. package/dist/src/utils/border.d.ts.map +1 -1
  83. package/dist/src/utils/border.js +2 -0
  84. package/dist/src/utils/border.js.map +1 -1
  85. package/dist/src/utils/index.d.ts +2 -1
  86. package/dist/src/utils/index.d.ts.map +1 -1
  87. package/dist/src/utils/index.js +2 -1
  88. package/dist/src/utils/index.js.map +1 -1
  89. package/dist/src/utils/text-layout.d.ts +22 -0
  90. package/dist/src/utils/text-layout.d.ts.map +1 -0
  91. package/dist/src/utils/text-layout.js +37 -0
  92. package/dist/src/utils/text-layout.js.map +1 -0
  93. package/dist/src/utils/text-wrap.d.ts +26 -1
  94. package/dist/src/utils/text-wrap.d.ts.map +1 -1
  95. package/dist/src/utils/text-wrap.js +106 -11
  96. package/dist/src/utils/text-wrap.js.map +1 -1
  97. package/dist/tsconfig.tsbuildinfo +1 -1
  98. package/package.json +2 -2
  99. package/src/codeblock.tsx +2 -2
  100. package/src/components/Markdown.tsx +3 -3
  101. package/src/components/MultilineTextInput.tsx +138 -344
  102. package/src/components/TextInput.tsx +54 -99
  103. package/src/components/text-editing.ts +180 -0
  104. package/src/hosts/base.ts +86 -3
  105. package/src/hosts/box.ts +37 -2
  106. package/src/hosts/canvas.ts +120 -31
  107. package/src/hosts/codeblock.ts +46 -33
  108. package/src/hosts/flex-container.ts +21 -2
  109. package/src/hosts/index.ts +1 -1
  110. package/src/hosts/overlay-item.ts +8 -2
  111. package/src/hosts/overlay.ts +2 -2
  112. package/src/hosts/scroll.ts +142 -45
  113. package/src/hosts/single-child.ts +2 -0
  114. package/src/hosts/spacer.ts +6 -1
  115. package/src/hosts/text.ts +122 -75
  116. package/src/hosts/zstack.ts +7 -2
  117. package/src/index.ts +1 -1
  118. package/src/internal/renderer/index.ts +53 -20
  119. package/src/internal/renderer/types.ts +4 -0
  120. package/src/motion/hooks.ts +1 -1
  121. package/src/reconciler/host-config.ts +2 -2
  122. package/src/reconciler/types.ts +7 -1
  123. package/src/utils/border.ts +11 -1
  124. package/src/utils/index.ts +15 -1
  125. package/src/utils/text-layout.ts +65 -0
  126. package/src/utils/text-wrap.ts +135 -13
@@ -20,5 +20,19 @@ export {
20
20
  styleSpecFromProps,
21
21
  toColorValue,
22
22
  } from "./styles.js"
23
- export { wrapSpans, wrapText } from "./text-wrap.js"
23
+ export {
24
+ buildTextLayout,
25
+ type LineLayout,
26
+ type TextLayout,
27
+ type TextLayoutOptions,
28
+ type VisualLine,
29
+ } from "./text-layout.js"
30
+ export {
31
+ splitSpansByNewline,
32
+ spansDisplayWidth,
33
+ wrapLineWithRanges,
34
+ wrapSpans,
35
+ wrapSpansByLine,
36
+ wrapText,
37
+ } from "./text-wrap.js"
24
38
  export { isWhitespace, matchNextWord, matchPrevWord, splitWords } from "./word-boundaries.js"
@@ -0,0 +1,65 @@
1
+ import { displayWidth, graphemes } from "@effect-tui/core"
2
+ import { wrapLineWithRanges } from "./text-wrap.js"
3
+
4
+ export type VisualLine = {
5
+ logicalRow: number
6
+ startCol: number
7
+ endCol: number
8
+ text: string
9
+ }
10
+
11
+ export type LineLayout = {
12
+ graphemeList: string[]
13
+ widths: number[]
14
+ prefixWidths: number[]
15
+ visualLines: VisualLine[]
16
+ }
17
+
18
+ export type TextLayout = {
19
+ lines: LineLayout[]
20
+ allVisualLines: VisualLine[]
21
+ }
22
+
23
+ export type TextLayoutOptions = {
24
+ wrap?: boolean
25
+ preserveWhitespace?: boolean
26
+ }
27
+
28
+ export function buildTextLayout(text: string, maxWidth: number, options?: TextLayoutOptions): TextLayout {
29
+ const logicalLines = text.split("\n")
30
+ const wrap = options?.wrap ?? true
31
+ const preserveWhitespace = options?.preserveWhitespace ?? false
32
+ const lines: LineLayout[] = []
33
+ const allVisualLines: VisualLine[] = []
34
+
35
+ for (let row = 0; row < logicalLines.length; row++) {
36
+ const line = logicalLines[row]
37
+ const graphemeList = graphemes(line)
38
+ const widths = graphemeList.map((g) => displayWidth(g))
39
+ const prefixWidths: number[] = [0]
40
+ for (let i = 0; i < widths.length; i++) {
41
+ prefixWidths.push(prefixWidths[i] + widths[i])
42
+ }
43
+
44
+ const visualLines: VisualLine[] = wrap
45
+ ? wrapLineWithRanges(line, maxWidth, { preserveWhitespace }).map((range) => ({
46
+ logicalRow: row,
47
+ startCol: range.startCol,
48
+ endCol: range.endCol,
49
+ text: range.text,
50
+ }))
51
+ : [
52
+ {
53
+ logicalRow: row,
54
+ startCol: 0,
55
+ endCol: graphemeList.length,
56
+ text: line,
57
+ },
58
+ ]
59
+
60
+ lines.push({ graphemeList, widths, prefixWidths, visualLines })
61
+ allVisualLines.push(...visualLines)
62
+ }
63
+
64
+ return { lines, allVisualLines }
65
+ }
@@ -1,4 +1,4 @@
1
- import { displayWidth } from "@effect-tui/core"
1
+ import { displayWidth, graphemes } from "@effect-tui/core"
2
2
  import { isWhitespace, splitWords } from "./word-boundaries.js"
3
3
 
4
4
  type SpanLike = { text: string }
@@ -6,8 +6,11 @@ type SpanLike = { text: string }
6
6
  type TokenSpec<T> = {
7
7
  text: string
8
8
  width: number
9
+ graphemeCount: number
9
10
  isWhitespace: boolean
10
- make: (text: string) => T
11
+ startCol?: number
12
+ segments?: string[]
13
+ make: (text: string, segmentStart: number, segmentEnd: number) => T
11
14
  }
12
15
 
13
16
  type LineToken<T> = {
@@ -16,8 +19,13 @@ type LineToken<T> = {
16
19
  isWhitespace: boolean
17
20
  }
18
21
 
19
- type WrapOptions = {
22
+ export type WrapOptions = {
20
23
  trimTrailingWhitespace?: boolean
24
+ preserveWhitespace?: boolean
25
+ }
26
+
27
+ export function spansDisplayWidth<T extends { text: string }>(spans: T[]): number {
28
+ return spans.reduce((sum, span) => sum + displayWidth(span.text), 0)
21
29
  }
22
30
 
23
31
  function wrapTokens<T>(tokens: TokenSpec<T>[], maxWidth: number, options?: WrapOptions): T[][] {
@@ -40,8 +48,15 @@ function wrapTokens<T>(tokens: TokenSpec<T>[], maxWidth: number, options?: WrapO
40
48
  lineWidth = 0
41
49
  }
42
50
 
43
- const addToken = (token: TokenSpec<T>, text: string, width: number, isWhitespace: boolean) => {
44
- lines[lines.length - 1].push({ value: token.make(text), width, isWhitespace })
51
+ const addToken = (
52
+ token: TokenSpec<T>,
53
+ text: string,
54
+ width: number,
55
+ isWhitespace: boolean,
56
+ segmentStart: number,
57
+ segmentEnd: number,
58
+ ) => {
59
+ lines[lines.length - 1].push({ value: token.make(text, segmentStart, segmentEnd), width, isWhitespace })
45
60
  lineWidth += width
46
61
  }
47
62
 
@@ -51,11 +66,12 @@ function wrapTokens<T>(tokens: TokenSpec<T>[], maxWidth: number, options?: WrapO
51
66
  const isWs = token.isWhitespace
52
67
 
53
68
  if (lineWidth + tokenWidth <= maxWidth) {
54
- addToken(token, token.text, tokenWidth, isWs)
69
+ const start = token.startCol ?? 0
70
+ addToken(token, token.text, tokenWidth, isWs, start, start + token.graphemeCount)
55
71
  continue
56
72
  }
57
73
 
58
- if (isWs) {
74
+ if (isWs && !options?.preserveWhitespace) {
59
75
  // Skip whitespace at line break
60
76
  continue
61
77
  }
@@ -65,7 +81,8 @@ function wrapTokens<T>(tokens: TokenSpec<T>[], maxWidth: number, options?: WrapO
65
81
  if (lines[lines.length - 1].length > 0) {
66
82
  startNewLine()
67
83
  }
68
- addToken(token, token.text, tokenWidth, isWs)
84
+ const start = token.startCol ?? 0
85
+ addToken(token, token.text, tokenWidth, isWs, start, start + token.graphemeCount)
69
86
  continue
70
87
  }
71
88
 
@@ -75,26 +92,32 @@ function wrapTokens<T>(tokens: TokenSpec<T>[], maxWidth: number, options?: WrapO
75
92
  startNewLine()
76
93
  }
77
94
 
95
+ const segments = token.segments ?? Array.from(token.text)
96
+ const tokenStart = token.startCol ?? 0
78
97
  let segment = ""
79
98
  let segmentWidth = 0
99
+ let segmentStartOffset = 0
100
+ let segmentOffset = 0
80
101
 
81
- for (const ch of token.text) {
102
+ for (const ch of segments) {
82
103
  const chWidth = displayWidth(ch)
83
104
  if (lineWidth + segmentWidth + chWidth > maxWidth && (segment || lineWidth > 0)) {
84
105
  if (segment) {
85
- addToken(token, segment, segmentWidth, false)
106
+ addToken(token, segment, segmentWidth, false, tokenStart + segmentStartOffset, tokenStart + segmentOffset)
86
107
  }
87
108
  startNewLine()
88
109
  segment = ch
89
110
  segmentWidth = chWidth
111
+ segmentStartOffset = segmentOffset
90
112
  } else {
91
113
  segment += ch
92
114
  segmentWidth += chWidth
93
115
  }
116
+ segmentOffset += 1
94
117
  }
95
118
 
96
119
  if (segment) {
97
- addToken(token, segment, segmentWidth, false)
120
+ addToken(token, segment, segmentWidth, false, tokenStart + segmentStartOffset, tokenStart + segmentOffset)
98
121
  }
99
122
  }
100
123
 
@@ -106,25 +129,121 @@ function wrapTokens<T>(tokens: TokenSpec<T>[], maxWidth: number, options?: WrapO
106
129
  return lines.length > 0 ? lines.map((line) => line.map((token) => token.value)) : [[]]
107
130
  }
108
131
 
132
+ export type WrappedLineRange = {
133
+ text: string
134
+ startCol: number
135
+ endCol: number
136
+ }
137
+
138
+ /**
139
+ * Wrap a single line into visual lines with grapheme ranges.
140
+ */
141
+ export function wrapLineWithRanges(line: string, maxWidth: number, options?: WrapOptions): WrappedLineRange[] {
142
+ if (line === "") {
143
+ return [{ text: "", startCol: 0, endCol: 0 }]
144
+ }
145
+
146
+ const tokens: Array<TokenSpec<WrappedLineRange>> = []
147
+ let col = 0
148
+ for (const token of splitWords(line)) {
149
+ if (!token) continue
150
+ const segments = graphemes(token)
151
+ const graphemeCount = segments.length
152
+ const startCol = col
153
+ tokens.push({
154
+ text: token,
155
+ width: displayWidth(token),
156
+ graphemeCount,
157
+ startCol,
158
+ segments,
159
+ isWhitespace: isWhitespace(token),
160
+ make: (text, segmentStart, segmentEnd) => ({ text, startCol: segmentStart, endCol: segmentEnd }),
161
+ })
162
+ col += graphemeCount
163
+ }
164
+
165
+ const wrapped = wrapTokens(tokens, maxWidth, options)
166
+ const lines: WrappedLineRange[] = []
167
+
168
+ for (const lineTokens of wrapped) {
169
+ if (lineTokens.length === 0) {
170
+ lines.push({ text: "", startCol: 0, endCol: 0 })
171
+ continue
172
+ }
173
+ lines.push({
174
+ text: lineTokens.map((token) => token.text).join(""),
175
+ startCol: lineTokens[0].startCol,
176
+ endCol: lineTokens[lineTokens.length - 1].endCol,
177
+ })
178
+ }
179
+
180
+ return lines.length > 0 ? lines : [{ text: "", startCol: 0, endCol: 0 }]
181
+ }
182
+
109
183
  /**
110
184
  * Wrap spans into lines, breaking at word boundaries.
111
185
  * Preserves span styling by cloning span objects with updated text.
112
186
  */
113
- export function wrapSpans<T extends SpanLike>(spans: T[], maxWidth: number): T[][] {
187
+ export function wrapSpans<T extends SpanLike>(spans: T[], maxWidth: number, options?: WrapOptions): T[][] {
114
188
  const tokens: Array<TokenSpec<T>> = []
115
189
  for (const span of spans) {
116
190
  for (const token of splitWords(span.text)) {
117
191
  if (!token) continue
192
+ const segments = graphemes(token)
118
193
  tokens.push({
119
194
  text: token,
120
195
  width: displayWidth(token),
196
+ graphemeCount: segments.length,
197
+ segments,
121
198
  isWhitespace: isWhitespace(token),
122
199
  make: (text) => ({ ...span, text }),
123
200
  })
124
201
  }
125
202
  }
126
203
 
127
- return wrapTokens(tokens, maxWidth)
204
+ return wrapTokens(tokens, maxWidth, options)
205
+ }
206
+
207
+ /**
208
+ * Split spans into logical lines at newline boundaries.
209
+ * Preserves empty lines.
210
+ */
211
+ export function splitSpansByNewline<T extends SpanLike>(spans: T[]): T[][] {
212
+ const lines: T[][] = [[]]
213
+
214
+ for (const span of spans) {
215
+ if (!span.text) continue
216
+ const parts = span.text.split("\n")
217
+ for (let i = 0; i < parts.length; i++) {
218
+ if (i > 0) {
219
+ lines.push([])
220
+ }
221
+ const part = parts[i]
222
+ if (part) {
223
+ lines[lines.length - 1].push({ ...span, text: part })
224
+ }
225
+ }
226
+ }
227
+
228
+ return lines.length > 0 ? lines : [[]]
229
+ }
230
+
231
+ /**
232
+ * Wrap spans per logical line (newline-aware).
233
+ */
234
+ export function wrapSpansByLine<T extends SpanLike>(spans: T[], maxWidth: number, options?: WrapOptions): T[][] {
235
+ const logicalLines = splitSpansByNewline(spans)
236
+ const lines: T[][] = []
237
+
238
+ for (const line of logicalLines) {
239
+ if (line.length === 0) {
240
+ lines.push([])
241
+ continue
242
+ }
243
+ lines.push(...wrapSpans(line, maxWidth, options))
244
+ }
245
+
246
+ return lines.length > 0 ? lines : [[]]
128
247
  }
129
248
 
130
249
  /**
@@ -141,9 +260,12 @@ export function wrapText(text: string, maxWidth: number): string[] {
141
260
  const tokens: Array<TokenSpec<string>> = []
142
261
  for (const token of splitWords(rawLine)) {
143
262
  if (!token) continue
263
+ const segments = graphemes(token)
144
264
  tokens.push({
145
265
  text: token,
146
266
  width: displayWidth(token),
267
+ graphemeCount: segments.length,
268
+ segments,
147
269
  isWhitespace: isWhitespace(token),
148
270
  make: (segment) => segment,
149
271
  })