@solidrt/components 0.0.11 → 0.0.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/components",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -14,6 +14,6 @@
14
14
  ],
15
15
  "peerDependencies": {
16
16
  "@solidjs/signals": "2.0.0-beta.14",
17
- "@solidrt/core": "0.0.11"
17
+ "@solidrt/core": "0.0.13"
18
18
  }
19
19
  }
package/src/image.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createSignal, createEffect, onCleanup } from "@solidjs/signals"
2
- import { decodeImage, createTexture } from "@solidrt/core/gpu"
2
+ import { createTexture, destroyTexture } from "@solidrt/core/gpu"
3
+ import { decodeImage } from "@solidrt/core/image"
3
4
  import type { LayoutProps, PointerProps } from "@solidrt/core"
4
5
  import type { StyleProps } from "./types"
5
6
 
@@ -10,13 +11,14 @@ export interface ImageProps extends PointerProps {
10
11
  }
11
12
 
12
13
  //TODO onLoad, onError
13
- //TODO release texture in onCleanup
14
14
  export function Image(props: ImageProps) {
15
15
  let [res, setRes] = createSignal<{ id: number; width: number; height: number }>()
16
16
 
17
17
  // Load (and decode) whenever src changes. A url is fetched; bytes are used
18
18
  // directly. The async result is pushed into the signal so the texture shows
19
- // once ready; a stale flag drops results from a superseded src.
19
+ // once ready; a stale flag drops results from a superseded src. The old
20
+ // texture is destroyed before installing the new one; on unmount the current
21
+ // texture is destroyed by onCleanup below.
20
22
  createEffect(
21
23
  () => props.src,
22
24
  (source) => {
@@ -33,7 +35,9 @@ export function Image(props: ImageProps) {
33
35
  if (stale) return
34
36
  let { data, width, height } = decodeImage(bytes)
35
37
  let id = createTexture(data, width, height)
38
+ let old = res()
36
39
  setRes({ id, width, height })
40
+ if (old !== undefined) destroyTexture(old.id)
37
41
  })()
38
42
 
39
43
  return () => {
@@ -52,7 +56,8 @@ export function Image(props: ImageProps) {
52
56
  let texH = () => (typeof props.layout?.height === "number" ? props.layout.height : undefined)
53
57
 
54
58
  onCleanup(() => {
55
- //TODO release texture
59
+ let r = res()
60
+ if (r !== undefined) destroyTexture(r.id)
56
61
  })
57
62
 
58
63
  return (
package/src/safe-area.tsx CHANGED
@@ -1,5 +1,4 @@
1
- import { createSignal } from "@solidjs/signals"
2
- import { onResize } from "@solidrt/core"
1
+ import { safeArea } from "@solidrt/core"
3
2
 
4
3
  export function SafeArea(props: {
5
4
  top?: boolean | number
@@ -9,21 +8,12 @@ export function SafeArea(props: {
9
8
  relative?: boolean
10
9
  children?: any
11
10
  }) {
12
- let [insets, setInsets] = createSignal({ top: 0, bottom: 0, left: 0, right: 0 })
13
- onResize(({ width, height, safeArea }) => {
14
- setInsets({
15
- top: safeArea.top,
16
- left: safeArea.left,
17
- bottom: height - safeArea.bottom,
18
- right: width - safeArea.right,
19
- })
20
- })
21
11
  let pad = (edge: "top" | "bottom" | "left" | "right") => {
22
12
  let defaultOn = edge === "top" || edge === "bottom"
23
13
  let p = props[edge] ?? defaultOn
24
14
  if (p === false) return 0
25
- if (p === true) return insets()[edge]
26
- return Math.max(insets()[edge], p as number)
15
+ if (p === true) return safeArea()[edge]
16
+ return Math.max(safeArea()[edge], p as number)
27
17
  }
28
18
  return (
29
19
  <view
@@ -1,9 +1,14 @@
1
- import { createSignal, flush, onCleanup } from "@solidjs/signals"
2
- import { getBoundingBox, measureText, onLayout, setFocus } from "@solidrt/core"
1
+ import { createEffect, createSignal, onCleanup } from "@solidjs/signals"
2
+ import { setFocus } from "@solidrt/core"
3
+ import { createCaretScroll, createTextBuffer } from "@solidrt/core/text-input"
3
4
  import type { LayoutProps } from "@solidrt/core"
4
5
  import type { StyleProps } from "./types"
5
6
  import { theme } from "./theme"
6
7
 
8
+ // Caret thickness. Shared so the drawn caret and the scroll offset's reserved
9
+ // edge column cannot drift apart.
10
+ const CARET_WIDTH = 1
11
+
7
12
  export interface TextInputProps {
8
13
  value?: string
9
14
  defaultValue?: string
@@ -21,28 +26,38 @@ export interface TextInputProps {
21
26
  style?: StyleProps
22
27
  }
23
28
 
24
- // V1: single-line, caret-at-end only, no selection, no mid-string editing.
25
- // Printable text arrives via onTextInput (post-IME commit). onKeyDown handles
26
- // Backspace, Enter, Escape. Outside-click-to-blur is the caller's job.
29
+ // Single-line. The caret moves through the text (Left/Right/Home/End), edits
30
+ // happen at the caret, and the inner box scrolls to keep it in view. Printable
31
+ // text arrives via onTextInput (post-IME commit). onKeyDown handles caret
32
+ // movement, Backspace/Delete, Enter, Escape. Range selection (shift-movement,
33
+ // highlight, click-to-position) is not wired yet. Outside-click-to-blur is the
34
+ // caller's job.
27
35
  export function TextInput(props: TextInputProps) {
28
- let [internalValue, setInternalValue] = createSignal(props.defaultValue ?? "")
29
36
  let [focused, setFocused] = createSignal(false)
30
37
  let [caretOn, setCaretOn] = createSignal(true)
31
- let [viewportWidth, setViewportWidth] = createSignal(0)
32
38
 
33
39
  let node: { id: number } | undefined
34
40
  let viewport: { id: number } | undefined
35
41
  let blinkId: any = null
36
42
 
37
- let value = () => props.value ?? internalValue()
38
-
39
- let commit = (next: string) => {
40
- if (props.maxLength != null && next.length > props.maxLength) {
41
- next = next.slice(0, props.maxLength)
42
- }
43
- if (props.value == null) setInternalValue(next)
44
- props.onInput?.(next)
45
- }
43
+ let buffer = createTextBuffer({
44
+ value: () => props.value,
45
+ defaultValue: props.defaultValue,
46
+ onInput: (v) => props.onInput?.(v),
47
+ maxLength: () => props.maxLength,
48
+ })
49
+ let value = buffer.value
50
+
51
+ // autoFocus runs in an effect, not the ref: setFocus fires onFocus and reads
52
+ // the node's onTextInput handler to toggle the keyboard, and those handlers
53
+ // are only registered after the element's props are applied. The ref can fire
54
+ // before that, so focusing there would no-op.
55
+ createEffect(
56
+ () => props.autoFocus,
57
+ (autoFocus) => {
58
+ if (autoFocus && node) setFocus(node.id)
59
+ },
60
+ )
46
61
 
47
62
  let handlePointerDown = () => {
48
63
  if (props.disabled) return
@@ -70,11 +85,26 @@ export function TextInput(props: TextInputProps) {
70
85
  let handleKeyDown = (e: any) => {
71
86
  if (props.disabled) return
72
87
  if (e.key === "Backspace") {
73
- let v = value()
74
- if (v.length > 0) commit(v.slice(0, -1))
88
+ buffer.deleteBackward()
89
+ setCaretOn(true)
90
+ } else if (e.key === "Delete") {
91
+ buffer.deleteForward()
92
+ setCaretOn(true)
93
+ } else if (e.key === "Left") {
94
+ buffer.move("left")
95
+ setCaretOn(true)
96
+ } else if (e.key === "Right") {
97
+ buffer.move("right")
98
+ setCaretOn(true)
99
+ } else if (e.key === "Home") {
100
+ buffer.move("start")
101
+ setCaretOn(true)
102
+ } else if (e.key === "End") {
103
+ buffer.move("end")
75
104
  setCaretOn(true)
76
105
  } else if (e.key === "Return" || e.key === "Enter") {
77
106
  props.onSubmit?.(value())
107
+ setFocus(null)
78
108
  } else if (e.key === "Escape") {
79
109
  if (node) setFocus(null)
80
110
  }
@@ -82,7 +112,7 @@ export function TextInput(props: TextInputProps) {
82
112
 
83
113
  let handleTextInput = (e: any) => {
84
114
  if (props.disabled) return
85
- commit(value() + (e.text ?? ""))
115
+ buffer.insertText(e.text ?? "")
86
116
  setCaretOn(true)
87
117
  }
88
118
 
@@ -98,37 +128,44 @@ export function TextInput(props: TextInputProps) {
98
128
  let borderRadius = () => props.style?.borderRadius ?? theme.radius.sm
99
129
 
100
130
  let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
101
- let displayText = () => (showPlaceholder() ? (props.placeholder ?? "") : value())
102
- let displayColor = () => (showPlaceholder() ? theme.color.textMuted : textColor())
103
-
104
- // Viewport width is the inner scroll container's own laid-out width, read
105
- // after layout. This works for numeric, "auto" and "%" widths alike (no need
106
- // to subtract padding: the padding lives on the outer view). getBoundingBox
107
- // is a snapshot, so we push it into a signal from onLayout. The flush drains
108
- // the resulting scrollX update synchronously, before paint, so the scroll
109
- // tracks the width in the same frame instead of trailing it by one.
110
- onLayout(() => {
111
- if (!viewport) return
112
- let w = getBoundingBox(viewport)?.width ?? 0
113
- if (w !== viewportWidth()) setViewportWidth(w)
114
- flush()
115
- })
131
+ let showCaret = () => focused() && caretOn() && !showPlaceholder()
132
+
133
+ // The text is split at the caret into two nodes with a zero-size anchor view
134
+ // between them. Flow places the anchor at the caret x (the before-text width),
135
+ // and the caret is a detached d-rect inside it: detached nodes take no layout
136
+ // slot, so the anchor stays zero-width and the after-text is not shifted. The
137
+ // anchor sits at the row's vertical center (alignItems center, zero height),
138
+ // so the caret is offset up by half its height to straddle it. While the
139
+ // placeholder shows, value() is "" so the slices and the scroll offset are 0
140
+ // with no special case. The viewport node is the inner scroll container;
141
+ // createCaretScroll reads its laid-out width after layout, keeps the caret in
142
+ // view, and flushes the offset before paint.
143
+ let beforeCaret = () => value().slice(0, buffer.caret())
144
+ let afterCaret = () => value().slice(buffer.caret())
145
+ let scrollX = createCaretScroll(
146
+ () => viewport,
147
+ () => ({
148
+ text: value(),
149
+ fontSize: theme.text.body.size,
150
+ caret: buffer.caret(),
151
+ // Constant, not tied to caret visibility: the caret's footprint does not
152
+ // change as it blinks, so reserving the column only when shown would swing
153
+ // the scroll offset every blink and shift text that exactly fills the box.
154
+ caretWidth: CARET_WIDTH,
155
+ }),
156
+ )
116
157
 
117
- let caretWidth = () => (focused() && !showPlaceholder() ? 1 : 0)
118
- let scrollX = () => {
119
- if (showPlaceholder()) return 0
120
- let tw = measureText(value(), { fontSize: theme.text.body.size }).width
121
- let vw = viewportWidth()
122
- if (vw <= 0) return 0
123
- return Math.max(0, tw + caretWidth() - vw)
124
- }
158
+ let textStyle = (color: string) => ({
159
+ fontSize: theme.text.body.size,
160
+ lineHeight: theme.text.body.lineHeight,
161
+ color,
162
+ maxLines: 1,
163
+ flexShrink: 0,
164
+ })
125
165
 
126
166
  return (
127
167
  <view
128
- ref={(n: { id: number }) => {
129
- node = n
130
- if (props.autoFocus) setFocus(n.id)
131
- }}
168
+ ref={(n: { id: number }) => (node = n)}
132
169
  flexDirection="row"
133
170
  alignItems="center"
134
171
  paddingLeft={theme.spacing.md}
@@ -161,18 +198,24 @@ export function TextInput(props: TextInputProps) {
161
198
  overflow="hidden"
162
199
  scrollX={scrollX()}
163
200
  >
164
- <text
165
- fontSize={theme.text.body.size}
166
- lineHeight={theme.text.body.lineHeight}
167
- color={displayColor()}
168
- maxLines={1}
169
- flexShrink={0}
170
- >
171
- {displayText()}
172
- </text>
173
- {focused() && caretOn() && !showPlaceholder() ? (
174
- <rect color={theme.color.text} w={1} h={theme.text.body.size} flexShrink={0} />
175
- ) : null}
201
+ {showPlaceholder() ? (
202
+ <text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</text>
203
+ ) : (
204
+ <view flexDirection="row" alignItems="center" flexShrink={0}>
205
+ <text {...textStyle(textColor())}>{beforeCaret()}</text>
206
+ {showCaret() ? (
207
+ <view>
208
+ <d-rect
209
+ color={textColor()}
210
+ y={-theme.text.body.size / 2}
211
+ w={CARET_WIDTH}
212
+ h={theme.text.body.size}
213
+ />
214
+ </view>
215
+ ) : null}
216
+ <text {...textStyle(textColor())}>{afterCaret()}</text>
217
+ </view>
218
+ )}
176
219
  </view>
177
220
  </view>
178
221
  )
package/src/window.tsx CHANGED
@@ -1,7 +1,7 @@
1
- import type { LayoutProps } from "@solidrt/core"
1
+ import type { LayoutProps, PointerProps } from "@solidrt/core"
2
2
  import type { StyleProps } from "./types"
3
3
 
4
- export interface WindowProps {
4
+ export interface WindowProps extends PointerProps {
5
5
  children?: any
6
6
  title?: string
7
7
  fullscreen?: boolean
@@ -21,6 +21,18 @@ export function Window(props: WindowProps) {
21
21
  fullscreen={props.fullscreen}
22
22
  vsync={props.vsync}
23
23
  fps={props.fps}
24
+ onPointerEnter={props.onPointerEnter}
25
+ onPointerLeave={props.onPointerLeave}
26
+ onPointerDown={props.onPointerDown}
27
+ onPointerUp={props.onPointerUp}
28
+ onPointerMove={props.onPointerMove}
29
+ onWheel={props.onWheel}
30
+ onFocus={props.onFocus}
31
+ onBlur={props.onBlur}
32
+ onKeyDown={props.onKeyDown}
33
+ onKeyUp={props.onKeyUp}
34
+ onTextInput={props.onTextInput}
35
+ pointerEvents={props.pointerEvents}
24
36
  >
25
37
  {props.style?.backgroundColor != null ? (
26
38
  <d-rect color={props.style.backgroundColor} />