@trendr/core 0.2.11 → 0.4.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/src/text-area.js CHANGED
@@ -1,7 +1,20 @@
1
1
  import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { createSignal } from './signal.js'
3
- import { useInput, useLayout, useCursor } from './hooks.js'
3
+ import { useInput, useLayout, useCursor, useTheme } from './hooks.js'
4
4
  import { registerHook } from './renderer.js'
5
+ import { charWidth, measureText } from './wrap.js'
6
+
7
+ function prevBoundary(s, i) {
8
+ if (i <= 0) return 0
9
+ const c = s.charCodeAt(i - 1)
10
+ return c >= 0xdc00 && c <= 0xdfff && i >= 2 ? i - 2 : i - 1
11
+ }
12
+
13
+ function nextBoundary(s, i) {
14
+ if (i >= s.length) return s.length
15
+ const c = s.charCodeAt(i)
16
+ return c >= 0xd800 && c <= 0xdbff && i + 1 < s.length ? i + 2 : i + 1
17
+ }
5
18
 
6
19
  export function wrapForEditor(text, width) {
7
20
  if (width <= 0) return [{ start: 0, end: 0, hard: true }]
@@ -25,21 +38,32 @@ export function wrapForEditor(text, width) {
25
38
  while (segStart < segment.length) {
26
39
  const remaining = segment.slice(segStart)
27
40
 
28
- if (remaining.length <= width) {
41
+ if (measureText(remaining) <= width) {
29
42
  lines.push({ start: pos + segStart, end: pos + segStart + remaining.length, hard: true })
30
43
  segStart += remaining.length
31
44
  break
32
45
  }
33
46
 
34
- const chunk = remaining.slice(0, width)
47
+ let fit = 0
48
+ let col = 0
49
+ while (fit < remaining.length) {
50
+ const cp = remaining.codePointAt(fit)
51
+ const cw = charWidth(cp)
52
+ if (col + cw > width) break
53
+ col += cw
54
+ fit += cp > 0xffff ? 2 : 1
55
+ }
56
+ if (fit === 0) fit = nextBoundary(remaining, 0)
57
+
58
+ const chunk = remaining.slice(0, fit)
35
59
  const lastSpace = chunk.lastIndexOf(' ')
36
60
 
37
61
  if (lastSpace > 0) {
38
62
  lines.push({ start: pos + segStart, end: pos + segStart + lastSpace, hard: false })
39
63
  segStart += lastSpace + 1
40
64
  } else {
41
- lines.push({ start: pos + segStart, end: pos + segStart + width, hard: false })
42
- segStart += width
65
+ lines.push({ start: pos + segStart, end: pos + segStart + fit, hard: false })
66
+ segStart += fit
43
67
  }
44
68
  }
45
69
 
@@ -57,26 +81,39 @@ export function wrapForEditor(text, width) {
57
81
  return lines
58
82
  }
59
83
 
60
- export function cursorToDisplay(cursor, lineMap) {
84
+ export function cursorToDisplay(cursor, lineMap, text) {
61
85
  for (let row = 0; row < lineMap.length; row++) {
62
86
  const line = lineMap[row]
63
87
  if (cursor >= line.start && cursor <= line.end) {
64
88
  if (cursor === line.end && !line.hard && row + 1 < lineMap.length) {
65
89
  return { row: row + 1, col: 0 }
66
90
  }
67
- return { row, col: cursor - line.start }
91
+ const col = text != null ? measureText(text.slice(line.start, cursor)) : cursor - line.start
92
+ return { row, col }
68
93
  }
69
94
  }
70
95
  const last = lineMap[lineMap.length - 1]
71
- return { row: lineMap.length - 1, col: last.end - last.start }
96
+ const col = text != null ? measureText(text.slice(last.start, last.end)) : last.end - last.start
97
+ return { row: lineMap.length - 1, col }
72
98
  }
73
99
 
74
- export function displayToCursor(row, col, lineMap) {
100
+ export function displayToCursor(row, col, lineMap, text) {
75
101
  const r = Math.max(0, Math.min(row, lineMap.length - 1))
76
102
  const line = lineMap[r]
77
- const maxCol = line.end - line.start
78
- const c = Math.max(0, Math.min(col, maxCol))
79
- return line.start + c
103
+ if (text == null) {
104
+ const maxCol = line.end - line.start
105
+ return line.start + Math.max(0, Math.min(col, maxCol))
106
+ }
107
+ let i = line.start
108
+ let c = 0
109
+ while (i < line.end && c < col) {
110
+ const cp = text.codePointAt(i)
111
+ const w = charWidth(cp)
112
+ if (c + w > col) break
113
+ c += w
114
+ i += cp > 0xffff ? 2 : 1
115
+ }
116
+ return i
80
117
  }
81
118
 
82
119
  function ensureVisible(cursorRow, scroll, height, totalLines) {
@@ -92,9 +129,10 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
92
129
  const [cursor, setCursor] = createSignal(0)
93
130
  if (valueProp !== undefined && valueProp !== value()) {
94
131
  setValue(valueProp)
95
- setCursor(c => Math.min(c, valueProp.length))
132
+ setCursor(valueProp.length)
96
133
  }
97
134
  const ref = registerHook(() => ({ scroll: 0, goalCol: null }))
135
+ const { muted = 'gray' } = useTheme()
98
136
  const layout = useLayout()
99
137
  const { cursorStyle, reset: resetBlink } = useCursor(cursorProp, focused)
100
138
 
@@ -111,6 +149,7 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
111
149
 
112
150
  if (onKeyDown) {
113
151
  event.value = value()
152
+ event.cursor = cursor()
114
153
  if (onKeyDown(event)) {
115
154
  event.stopPropagation()
116
155
  return
@@ -132,9 +171,9 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
132
171
 
133
172
  const isSubmitKey = submitOnEnter
134
173
  ? (key === 'return' && !meta && !shift)
135
- : (meta && key === '\r')
174
+ : (meta && key === 'return')
136
175
  const isNewlineKey = submitOnEnter
137
- ? ((shift && key === 'return') || (meta && key === '\r'))
176
+ ? ((shift && key === 'return') || (meta && key === 'return'))
138
177
  : (key === 'return')
139
178
 
140
179
  if (isSubmitKey) {
@@ -160,26 +199,29 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
160
199
  }
161
200
 
162
201
  if (key === 'backspace') {
163
- if (c > 0) update(v.slice(0, c - 1) + v.slice(c), c - 1)
202
+ if (c > 0) {
203
+ const p = prevBoundary(v, c)
204
+ update(v.slice(0, p) + v.slice(c), p)
205
+ }
164
206
  event.stopPropagation()
165
207
  return
166
208
  }
167
209
 
168
210
  if (key === 'delete') {
169
- if (c < v.length) update(v.slice(0, c) + v.slice(c + 1), c)
211
+ if (c < v.length) update(v.slice(0, c) + v.slice(nextBoundary(v, c)), c)
170
212
  event.stopPropagation()
171
213
  return
172
214
  }
173
215
 
174
216
  if (key === 'left') {
175
- setCursor(Math.max(0, c - 1))
217
+ setCursor(prevBoundary(v, c))
176
218
  ref.goalCol = null
177
219
  event.stopPropagation()
178
220
  return
179
221
  }
180
222
 
181
223
  if (key === 'right') {
182
- setCursor(Math.min(v.length, c + 1))
224
+ setCursor(nextBoundary(v, c))
183
225
  ref.goalCol = null
184
226
  event.stopPropagation()
185
227
  return
@@ -188,48 +230,59 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
188
230
  if (key === 'up' || key === 'down') {
189
231
  const w = layout.width || 80
190
232
  const lineMap = wrapForEditor(v, w)
191
- const pos = cursorToDisplay(c, lineMap)
233
+ const pos = cursorToDisplay(c, lineMap, v)
192
234
  const goal = ref.goalCol !== null ? ref.goalCol : pos.col
193
235
  ref.goalCol = goal
194
236
 
195
237
  const newRow = key === 'up' ? pos.row - 1 : pos.row + 1
196
238
  if (newRow >= 0 && newRow < lineMap.length) {
197
- setCursor(displayToCursor(newRow, goal, lineMap))
239
+ setCursor(displayToCursor(newRow, goal, lineMap, v))
198
240
  }
199
241
  event.stopPropagation()
200
242
  return
201
243
  }
202
244
 
203
- if (key === 'home' || (ctrl && raw === '\x01')) {
245
+ if (key === 'home' || (ctrl && key === 'a')) {
204
246
  const w = layout.width || 80
205
247
  const lineMap = wrapForEditor(v, w)
206
- const pos = cursorToDisplay(c, lineMap)
248
+ const pos = cursorToDisplay(c, lineMap, v)
207
249
  setCursor(lineMap[pos.row].start)
208
250
  ref.goalCol = null
209
251
  event.stopPropagation()
210
252
  return
211
253
  }
212
254
 
213
- if (key === 'end' || (ctrl && raw === '\x05')) {
255
+ if (key === 'end' || (ctrl && key === 'e')) {
214
256
  const w = layout.width || 80
215
257
  const lineMap = wrapForEditor(v, w)
216
- const pos = cursorToDisplay(c, lineMap)
258
+ const pos = cursorToDisplay(c, lineMap, v)
217
259
  setCursor(lineMap[pos.row].end)
218
260
  ref.goalCol = null
219
261
  event.stopPropagation()
220
262
  return
221
263
  }
222
264
 
223
- if (ctrl && raw === '\x15') {
224
- const before = v.slice(0, c)
225
- const nlIdx = before.lastIndexOf('\n')
226
- const lineStart = nlIdx + 1
227
- update(v.slice(0, lineStart) + v.slice(c), lineStart)
265
+ if (ctrl && key === 'u') {
266
+ const lineStart = v.lastIndexOf('\n', c - 1) + 1
267
+ const rest = v.slice(c)
268
+ const lineEmptyAfter = rest.length === 0 || rest[0] === '\n'
269
+ if (c > lineStart && !(lineEmptyAfter && lineStart > 0)) {
270
+ // delete to start of the current line, staying on it
271
+ update(v.slice(0, lineStart) + rest, lineStart)
272
+ } else if (lineStart > 0) {
273
+ // nothing left to delete on this line (or the line is now empty): remove
274
+ // the line break above and land at the end of the previous line, so
275
+ // repeated ctrl+u keeps eating lines upward
276
+ update(v.slice(0, lineStart - 1) + rest, lineStart - 1)
277
+ } else if (c > lineStart) {
278
+ // first line: just clear it
279
+ update(rest, 0)
280
+ }
228
281
  event.stopPropagation()
229
282
  return
230
283
  }
231
284
 
232
- if (ctrl && raw === '\x0b') {
285
+ if (ctrl && key === 'k') {
233
286
  const after = v.slice(c)
234
287
  const nlIdx = after.indexOf('\n')
235
288
  const deleteEnd = nlIdx === -1 ? v.length : c + nlIdx
@@ -238,7 +291,7 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
238
291
  return
239
292
  }
240
293
 
241
- if (ctrl && raw === '\x17') {
294
+ if (ctrl && key === 'w') {
242
295
  const before = v.slice(0, c)
243
296
  const after = v.slice(c)
244
297
  const trimmed = before.replace(/\S+\s*$/, '')
@@ -247,7 +300,7 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
247
300
  return
248
301
  }
249
302
 
250
- if (!ctrl && !meta && raw.length === 1 && raw >= ' ') {
303
+ if (!ctrl && !meta && raw.length >= 1 && raw >= ' ' && !raw.startsWith('\x1b')) {
251
304
  update(v.slice(0, c) + raw + v.slice(c), c + raw.length)
252
305
  event.stopPropagation()
253
306
  }
@@ -259,12 +312,12 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
259
312
  const cs = cursorStyle()
260
313
 
261
314
  if (!v && placeholder && !focused) {
262
- return jsx('text', { style: { color: 'gray', flexGrow: 1 }, children: placeholder })
315
+ return jsx('text', { style: { color: muted, flexGrow: 1 }, children: placeholder })
263
316
  }
264
317
 
265
318
  const effectiveWidth = w || 80
266
319
  const lineMap = wrapForEditor(v, effectiveWidth)
267
- const displayPos = cursorToDisplay(c, lineMap)
320
+ const displayPos = cursorToDisplay(c, lineMap, v)
268
321
 
269
322
  const displayHeight = Math.max(1, Math.min(lineMap.length, maxHeight))
270
323
  ref.scroll = ensureVisible(displayPos.row, ref.scroll, displayHeight, lineMap.length)
@@ -273,13 +326,14 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
273
326
  const visibleLines = lineMap.slice(scroll, scroll + displayHeight)
274
327
 
275
328
  if (!v && placeholder && focused) {
329
+ const first = placeholder.slice(0, nextBoundary(placeholder, 0))
276
330
  return jsx('box', {
277
331
  style: { flexDirection: 'column', height: 1, minHeight: 1, flexGrow: 1 },
278
332
  children: jsxs('box', {
279
333
  style: { flexDirection: 'row', height: 1 },
280
334
  children: [
281
- jsx('text', { style: cs ? { ...cs, color: cs.color ?? 'gray' } : { inverse: true, color: 'gray' }, children: placeholder[0] }),
282
- placeholder.length > 1 && jsx('text', { style: { color: 'gray' }, children: placeholder.slice(1) }),
335
+ jsx('text', { style: cs ? { ...cs, color: cs.color ?? muted } : { inverse: true, color: muted }, children: first }),
336
+ placeholder.length > first.length && jsx('text', { style: { color: muted }, children: placeholder.slice(first.length) }),
283
337
  ],
284
338
  }),
285
339
  })
@@ -294,10 +348,11 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
294
348
  return jsx('text', { key: row, children: content || ' ' })
295
349
  }
296
350
 
297
- const cursorCol = displayPos.col
298
- const before = content.slice(0, cursorCol)
299
- const cursorChar = content[cursorCol] || ' '
300
- const after = content.slice(cursorCol + 1)
351
+ const cursorIdx = Math.max(line.start, Math.min(c, line.end))
352
+ const nb = cursorIdx < line.end ? nextBoundary(v, cursorIdx) : cursorIdx
353
+ const before = v.slice(line.start, cursorIdx)
354
+ const cursorChar = cursorIdx < line.end ? v.slice(cursorIdx, nb) : ' '
355
+ const after = v.slice(nb, line.end)
301
356
 
302
357
  return jsxs('box', {
303
358
  key: row,
package/src/text-input.js CHANGED
@@ -1,13 +1,47 @@
1
1
  import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { createSignal } from './signal.js'
3
- import { useInput, useLayout, useCursor } from './hooks.js'
3
+ import { useInput, useLayout, useCursor, useTheme } from './hooks.js'
4
+ import { charWidth, measureText } from './wrap.js'
4
5
 
5
6
  const BOX = { flexDirection: 'row', height: 1, minHeight: 1, flexGrow: 1 }
6
7
 
8
+ function prevBoundary(s, i) {
9
+ if (i <= 0) return 0
10
+ const c = s.charCodeAt(i - 1)
11
+ return c >= 0xdc00 && c <= 0xdfff && i >= 2 ? i - 2 : i - 1
12
+ }
13
+
14
+ function nextBoundary(s, i) {
15
+ if (i >= s.length) return s.length
16
+ const c = s.charCodeAt(i)
17
+ return c >= 0xd800 && c <= 0xdbff && i + 1 < s.length ? i + 2 : i + 1
18
+ }
19
+
20
+ function sliceByColumns(s, startCol, endCol) {
21
+ let col = 0
22
+ let i = 0
23
+ let out = ''
24
+ while (i < s.length) {
25
+ const cp = s.codePointAt(i)
26
+ const len = cp > 0xffff ? 2 : 1
27
+ const w = charWidth(cp)
28
+ if (col + w > endCol) break
29
+ if (col >= startCol) out += s.slice(i, i + len)
30
+ col += w
31
+ i += len
32
+ }
33
+ return out
34
+ }
35
+
36
+ function firstCodePoint(s) {
37
+ return s.slice(0, nextBoundary(s, 0))
38
+ }
39
+
7
40
  export function TextInput({ onSubmit, onCancel, onChange, placeholder, focused = true, initialValue, clearOnSubmit = false, cursor: cursorProp }) {
8
41
  const init = initialValue ?? ''
9
42
  const [value, setValue] = createSignal(init)
10
43
  const [cursor, setCursor] = createSignal(init.length)
44
+ const { muted = 'gray' } = useTheme()
11
45
  const layout = useLayout()
12
46
  const { cursorStyle, reset: resetBlink } = useCursor(cursorProp, focused)
13
47
 
@@ -21,10 +55,17 @@ export function TextInput({ onSubmit, onCancel, onChange, placeholder, focused =
21
55
  if (!focused) return
22
56
  resetBlink()
23
57
 
24
- const { key, raw, ctrl } = event
58
+ const { key, raw, ctrl, meta } = event
25
59
  const v = value()
26
60
  const c = cursor()
27
61
 
62
+ if (key === 'paste') {
63
+ const pasted = (event.text || '').replace(/^\n+|\n+$/g, '').replace(/\n+/g, ' ')
64
+ if (pasted) update(v.slice(0, c) + pasted + v.slice(c), c + pasted.length)
65
+ event.stopPropagation()
66
+ return
67
+ }
68
+
28
69
  if (key === 'return') {
29
70
  if (onSubmit) {
30
71
  onSubmit(v)
@@ -43,27 +84,30 @@ export function TextInput({ onSubmit, onCancel, onChange, placeholder, focused =
43
84
  }
44
85
 
45
86
  if (key === 'backspace') {
46
- if (c > 0) update(v.slice(0, c - 1) + v.slice(c), c - 1)
87
+ if (c > 0) {
88
+ const p = prevBoundary(v, c)
89
+ update(v.slice(0, p) + v.slice(c), p)
90
+ }
47
91
  event.stopPropagation()
48
92
  return
49
93
  }
50
94
 
51
95
  if (key === 'delete') {
52
- if (c < v.length) update(v.slice(0, c) + v.slice(c + 1), c)
96
+ if (c < v.length) update(v.slice(0, c) + v.slice(nextBoundary(v, c)), c)
53
97
  event.stopPropagation()
54
98
  return
55
99
  }
56
100
 
57
- if (key === 'left') { setCursor(Math.max(0, c - 1)); event.stopPropagation(); return }
58
- if (key === 'right') { setCursor(Math.min(v.length, c + 1)); event.stopPropagation(); return }
101
+ if (key === 'left') { setCursor(prevBoundary(v, c)); event.stopPropagation(); return }
102
+ if (key === 'right') { setCursor(nextBoundary(v, c)); event.stopPropagation(); return }
59
103
 
60
- if (key === 'home' || (ctrl && raw === '\x01')) { setCursor(0); event.stopPropagation(); return }
61
- if (key === 'end' || (ctrl && raw === '\x05')) { setCursor(v.length); event.stopPropagation(); return }
104
+ if (key === 'home' || (ctrl && key === 'a')) { setCursor(0); event.stopPropagation(); return }
105
+ if (key === 'end' || (ctrl && key === 'e')) { setCursor(v.length); event.stopPropagation(); return }
62
106
 
63
- if (ctrl && raw === '\x15') { update(v.slice(c), 0); event.stopPropagation(); return }
64
- if (ctrl && raw === '\x0b') { update(v.slice(0, c), c); event.stopPropagation(); return }
107
+ if (ctrl && key === 'u') { update(v.slice(c), 0); event.stopPropagation(); return }
108
+ if (ctrl && key === 'k') { update(v.slice(0, c), c); event.stopPropagation(); return }
65
109
 
66
- if (ctrl && raw === '\x17') {
110
+ if (ctrl && key === 'w') {
67
111
  const before = v.slice(0, c)
68
112
  const after = v.slice(c)
69
113
  const trimmed = before.replace(/\S+\s*$/, '')
@@ -72,7 +116,7 @@ export function TextInput({ onSubmit, onCancel, onChange, placeholder, focused =
72
116
  return
73
117
  }
74
118
 
75
- if (!ctrl && raw.length === 1 && raw >= ' ') {
119
+ if (!ctrl && !meta && raw.length >= 1 && raw >= ' ' && !raw.startsWith('\x1b')) {
76
120
  update(v.slice(0, c) + raw + v.slice(c), c + raw.length)
77
121
  event.stopPropagation()
78
122
  }
@@ -84,44 +128,42 @@ export function TextInput({ onSubmit, onCancel, onChange, placeholder, focused =
84
128
  const cs = cursorStyle()
85
129
 
86
130
  if (!v && placeholder && !focused) {
87
- return jsx('text', { style: { color: 'gray', flexGrow: 1 }, children: placeholder })
131
+ return jsx('text', { style: { color: muted, flexGrow: 1 }, children: placeholder })
88
132
  }
89
133
 
90
134
  if (!v && placeholder && focused) {
135
+ const first = firstCodePoint(placeholder)
91
136
  return jsxs('box', {
92
137
  style: BOX,
93
138
  children: [
94
- jsx('text', { style: cs ? { ...cs, color: cs.color ?? 'gray' } : { inverse: true, color: 'gray' }, children: placeholder[0] }),
95
- placeholder.length > 1 && jsx('text', { style: { color: 'gray' }, children: placeholder.slice(1) }),
139
+ jsx('text', { style: cs ? { ...cs, color: cs.color ?? muted } : { inverse: true, color: muted }, children: first }),
140
+ placeholder.length > first.length && jsx('text', { style: { color: muted }, children: placeholder.slice(first.length) }),
96
141
  ],
97
142
  })
98
143
  }
99
144
 
100
- const contentWidth = v.length + 1
145
+ const cursorCol = measureText(v.slice(0, c))
146
+ const contentWidth = measureText(v) + 1
101
147
  const needsScroll = w > 0 && contentWidth > w
102
148
 
149
+ const cpAtCursor = c < v.length ? v.slice(c, nextBoundary(v, c)) : ''
150
+ const cursorChar = cpAtCursor || ' '
151
+ const cursorWidth = cpAtCursor ? measureText(cpAtCursor) : 1
152
+
103
153
  if (!needsScroll) {
104
- const cursorChar = v[c] || ' '
105
154
  return jsxs('box', {
106
155
  style: BOX,
107
156
  children: [
108
157
  v.slice(0, c) && jsx('text', { children: v.slice(0, c) }),
109
158
  jsx('text', { style: cs ?? {}, children: cursorChar }),
110
- v.slice(c + 1) && jsx('text', { children: v.slice(c + 1) }),
159
+ v.slice(nextBoundary(v, c)) && jsx('text', { children: v.slice(nextBoundary(v, c)) }),
111
160
  ],
112
161
  })
113
162
  }
114
163
 
115
- let scrollStart = 0
116
- if (c >= w) {
117
- scrollStart = c - w + 1
118
- }
119
-
120
- const visible = v.slice(scrollStart, scrollStart + w)
121
- const cursorInView = c - scrollStart
122
- const before = visible.slice(0, cursorInView)
123
- const cursorChar = visible[cursorInView] || ' '
124
- const after = visible.slice(cursorInView + 1)
164
+ const scrollStart = Math.max(0, cursorCol + cursorWidth - w)
165
+ const before = sliceByColumns(v, scrollStart, cursorCol)
166
+ const after = sliceByColumns(v, cursorCol + cursorWidth, scrollStart + w)
125
167
 
126
168
  return jsxs('box', {
127
169
  style: BOX,