lecodes-web-canvas 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.
- package/package.json +26 -0
- package/src/contract.d.ts +16 -0
- package/src/core/SceneNode.ts +109 -0
- package/src/core/hitTest.ts +30 -0
- package/src/core/text.ts +259 -0
- package/src/core/util/colors.ts +30 -0
- package/src/core/util/genericFamilies.ts +30 -0
- package/src/core/util/geometry.ts +121 -0
- package/src/core/util/svgSrc.ts +141 -0
- package/src/host/BrowserHost.ts +132 -0
- package/src/host/HeadlessHost.ts +64 -0
- package/src/host/Host.ts +85 -0
- package/src/layout/CuiPaint.gen.ts +121 -0
- package/src/layout/LayoutEngine.ts +78 -0
- package/src/layout/creatorUi/loadCreatorUi.ts +87 -0
- package/src/layout/creatorUi/paint-read.ts +131 -0
- package/src/layout/creatorUi/readGradient.ts +62 -0
- package/src/paint/Canvas2DPainter.ts +258 -0
- package/src/paint/Painter.ts +62 -0
- package/src/paint/RecordingPainter.ts +101 -0
- package/src/paint/paintTree.ts +314 -0
- package/src/runtime/TreeUI.ts +822 -0
- package/src/serialize/serializeRenderTree.ts +150 -0
- package/src/types.ts +299 -0
- package/src/vite-env.d.ts +1 -0
- package/src/web/BrowserUI.ts +312 -0
- package/src/web/chrome.ts +12 -0
- package/src/web/renderer.ts +192 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { BorderRadius, Gradient, ImageInfo, Rect, RGBA } from "../types"
|
|
2
|
+
import type { FontSpec } from "../host/Host"
|
|
3
|
+
import type { Borders, DrawImageOpts, Painter } from "./Painter"
|
|
4
|
+
import { rgbaToHex } from "../core/util/colors"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Painter that records primitive ops instead of drawing — Bun-testable and a readable low-level
|
|
8
|
+
* "display list" dump. Proves the paintTree walk without a canvas, and doubles as a golden oracle.
|
|
9
|
+
*/
|
|
10
|
+
export class RecordingPainter implements Painter {
|
|
11
|
+
ops: string[] = []
|
|
12
|
+
private precision: number
|
|
13
|
+
|
|
14
|
+
constructor(precision = 2) {
|
|
15
|
+
this.precision = precision
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private n(v: number): number {
|
|
19
|
+
const f = 10 ** this.precision
|
|
20
|
+
return Math.round(v * f) / f
|
|
21
|
+
}
|
|
22
|
+
private r(rect: Rect): string {
|
|
23
|
+
return `[${this.n(rect.x)}, ${this.n(rect.y)}, ${this.n(rect.width)}, ${this.n(rect.height)}]`
|
|
24
|
+
}
|
|
25
|
+
private rad(radius?: BorderRadius): string {
|
|
26
|
+
if (!radius || (!radius.tl && !radius.tr && !radius.br && !radius.bl)) return ""
|
|
27
|
+
return ` r=${this.n(radius.tl)},${this.n(radius.tr)},${this.n(radius.br)},${this.n(radius.bl)}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beginFrame(width: number, height: number) {
|
|
31
|
+
this.ops.push(`beginFrame ${this.n(width)}x${this.n(height)}`)
|
|
32
|
+
}
|
|
33
|
+
endFrame() {
|
|
34
|
+
this.ops.push("endFrame")
|
|
35
|
+
}
|
|
36
|
+
save() {
|
|
37
|
+
this.ops.push("save")
|
|
38
|
+
}
|
|
39
|
+
restore() {
|
|
40
|
+
this.ops.push("restore")
|
|
41
|
+
}
|
|
42
|
+
setAlpha(multiplier: number) {
|
|
43
|
+
this.ops.push(`setAlpha ${this.n(multiplier)}`)
|
|
44
|
+
}
|
|
45
|
+
beginGroup(alpha: number) {
|
|
46
|
+
this.ops.push(`beginGroup ${this.n(alpha)}`)
|
|
47
|
+
}
|
|
48
|
+
endGroup() {
|
|
49
|
+
this.ops.push("endGroup")
|
|
50
|
+
}
|
|
51
|
+
translate(x: number, y: number) {
|
|
52
|
+
this.ops.push(`translate ${this.n(x)},${this.n(y)}`)
|
|
53
|
+
}
|
|
54
|
+
scale(sx: number, sy: number) {
|
|
55
|
+
this.ops.push(`scale ${this.n(sx)},${this.n(sy)}`)
|
|
56
|
+
}
|
|
57
|
+
transform(a: number, b: number, c: number, d: number, e: number, f: number) {
|
|
58
|
+
this.ops.push(`transform ${this.n(a)},${this.n(b)},${this.n(c)},${this.n(d)},${this.n(e)},${this.n(f)}`)
|
|
59
|
+
}
|
|
60
|
+
clipRoundRect(rect: Rect, radius?: BorderRadius) {
|
|
61
|
+
this.ops.push(`clip ${this.r(rect)}${this.rad(radius)}`)
|
|
62
|
+
}
|
|
63
|
+
fillRoundRect(rect: Rect, radius: BorderRadius | undefined, color: RGBA) {
|
|
64
|
+
this.ops.push(`fillRect ${this.r(rect)}${this.rad(radius)} ${rgbaToHex(color)}`)
|
|
65
|
+
}
|
|
66
|
+
clearRoundRect(rect: Rect, radius: BorderRadius | undefined) {
|
|
67
|
+
this.ops.push(`clearRect ${this.r(rect)}${this.rad(radius)}`)
|
|
68
|
+
}
|
|
69
|
+
fillGradient(rect: Rect, radius: BorderRadius | undefined, gradient: Gradient) {
|
|
70
|
+
const stops = gradient.stops.map((s) => `${rgbaToHex(s.color)}@${this.n(s.position)}`).join(",")
|
|
71
|
+
let shape: string
|
|
72
|
+
if (gradient.type === "radial") {
|
|
73
|
+
const size =
|
|
74
|
+
gradient.extent === "explicit"
|
|
75
|
+
? `r=${this.n(gradient.rx ?? 0)}${gradient.rxUnit === "fraction" ? "f" : ""},${this.n(gradient.ry ?? 0)}${gradient.ryUnit === "fraction" ? "f" : ""}`
|
|
76
|
+
: gradient.extent
|
|
77
|
+
shape = `radial ${gradient.shape} ${size} at=${this.n(gradient.cx)},${this.n(gradient.cy)}`
|
|
78
|
+
} else {
|
|
79
|
+
shape = `angle=${this.n(gradient.angle)}`
|
|
80
|
+
}
|
|
81
|
+
this.ops.push(`gradient ${this.r(rect)}${this.rad(radius)} ${shape} [${stops}]`)
|
|
82
|
+
}
|
|
83
|
+
strokeBorder(rect: Rect, radius: BorderRadius | undefined, sides: Borders) {
|
|
84
|
+
const parts = (["top", "right", "bottom", "left"] as const)
|
|
85
|
+
.filter((s) => sides[s])
|
|
86
|
+
.map((s) => `${s}=${this.n(sides[s]!.width)}/${rgbaToHex(sides[s]!.color)}`)
|
|
87
|
+
this.ops.push(`border ${this.r(rect)}${this.rad(radius)} ${parts.join(" ")}`)
|
|
88
|
+
}
|
|
89
|
+
drawImage(image: ImageInfo, src: Rect | null, dst: Rect, opts: DrawImageOpts) {
|
|
90
|
+
const tint = opts.tint !== undefined ? ` tint=${rgbaToHex(opts.tint)}` : ""
|
|
91
|
+
const crop = src ? ` src=${this.r(src)}` : ""
|
|
92
|
+
this.ops.push(`image "${image.src}" dst=${this.r(dst)}${crop} fit=${opts.fit}${tint}`)
|
|
93
|
+
}
|
|
94
|
+
fillText(text: string, x: number, baselineY: number, font: FontSpec, color: RGBA) {
|
|
95
|
+
this.ops.push(`text ${JSON.stringify(text)} @ ${this.n(x)},${this.n(baselineY)} ${font.size}px ${rgbaToHex(color)}`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
toText(): string {
|
|
99
|
+
return this.ops.join("\n")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type { BorderRadius, Layer, RenderNode, UITransform } from "../types"
|
|
2
|
+
import type { Host, FontSpec } from "../host/Host"
|
|
3
|
+
import type { Borders, Painter } from "./Painter"
|
|
4
|
+
import { clampRadius, hasRadius } from "../core/util/geometry"
|
|
5
|
+
import { isTransparent } from "../core/util/colors"
|
|
6
|
+
import { isSvgLikeSrc } from "../core/util/svgSrc"
|
|
7
|
+
import { wrapText } from "../core/text"
|
|
8
|
+
|
|
9
|
+
/** What the host shows ITSELF, so the painter leaves it out. */
|
|
10
|
+
export type PaintOptions = {
|
|
11
|
+
/** A native view and a video are surfaces UNDER the picture: their box is a window onto them
|
|
12
|
+
* (absent: a video is a placeholder, a native view nothing). */
|
|
13
|
+
surfaces?: boolean
|
|
14
|
+
/** The field being edited, by node id: a live <input> stands over it, with the text and the caret. */
|
|
15
|
+
editing?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Walk the render tree and replay it into a Painter. All paint *logic* (clip-on-radius, border
|
|
20
|
+
* stacking, text alignment + baseline) lives here, so every backend shares it and only the leaf
|
|
21
|
+
* primitives differ. `host` is used for text metrics (deterministic across backends).
|
|
22
|
+
*/
|
|
23
|
+
export function paintTree(tree: RenderNode | null, painter: Painter, host: Host, options: PaintOptions = {}) {
|
|
24
|
+
if (!tree) return
|
|
25
|
+
painter.beginFrame(tree.rect.width, tree.rect.height)
|
|
26
|
+
paintNode(tree, painter, host, options)
|
|
27
|
+
painter.endFrame()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Paint a full frame of compositing layers (main screen + widget overlays) in one begin/end. A
|
|
32
|
+
* widget's `backdrop` is a full-viewport fill painted behind it.
|
|
33
|
+
*/
|
|
34
|
+
export function paintFrame(
|
|
35
|
+
layers: Layer[],
|
|
36
|
+
painter: Painter,
|
|
37
|
+
host: Host,
|
|
38
|
+
width: number,
|
|
39
|
+
height: number,
|
|
40
|
+
transform?: UITransform,
|
|
41
|
+
options: PaintOptions = {},
|
|
42
|
+
) {
|
|
43
|
+
painter.beginFrame(width, height)
|
|
44
|
+
// The simulated keyboard rescales the whole UI into the band above it (the keyboard itself is the
|
|
45
|
+
// host's — DOM on web). The bottom band is left empty for the host to draw over.
|
|
46
|
+
if (transform) {
|
|
47
|
+
painter.save()
|
|
48
|
+
painter.translate(transform.tx, transform.ty)
|
|
49
|
+
painter.scale(transform.scale, transform.scale)
|
|
50
|
+
}
|
|
51
|
+
// z-order (matches the DOM): the base screen(s) first, then ALL widget backdrops, then ALL widget
|
|
52
|
+
// bodies — so with ≥2 overlays a later widget's backdrop never covers an earlier widget.
|
|
53
|
+
const widgets = layers.filter((l) => l.widget)
|
|
54
|
+
for (const l of layers) if (!l.widget) paintNode(l.tree, painter, host, options)
|
|
55
|
+
for (const l of widgets) if (l.backdrop) painter.fillRoundRect({ x: 0, y: 0, width, height }, undefined, l.backdrop.color)
|
|
56
|
+
for (const l of widgets) paintNode(l.tree, painter, host, options)
|
|
57
|
+
if (transform) painter.restore()
|
|
58
|
+
painter.endFrame()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function paintNode(node: RenderNode, painter: Painter, host: Host, options: PaintOptions) {
|
|
62
|
+
const { style, rect } = node
|
|
63
|
+
painter.save()
|
|
64
|
+
// A translucent container fades as a GROUP (composited offscreen, then faded once) so overlapping
|
|
65
|
+
// children blend like the DOM's CSS opacity; a leaf uses cheap per-primitive alpha (identical for a
|
|
66
|
+
// single fill, no offscreen needed).
|
|
67
|
+
const opacity = style.opacity !== undefined && style.opacity < 1 ? style.opacity : undefined
|
|
68
|
+
const grouped = opacity !== undefined && node.children.length > 0
|
|
69
|
+
if (grouped) painter.beginGroup(opacity!)
|
|
70
|
+
else if (opacity !== undefined) painter.setAlpha(opacity)
|
|
71
|
+
|
|
72
|
+
// 2D transform about `transformOrigin` (the node centre by default), affecting the node + its subtree.
|
|
73
|
+
if (style.transform) {
|
|
74
|
+
const [a, b, c, d, e, f] = style.transform
|
|
75
|
+
const o = style.transformOrigin
|
|
76
|
+
const cx = rect.x + (o ? (o.xPercent ? rect.width * o.x / 100 : o.x) : rect.width / 2)
|
|
77
|
+
const cy = rect.y + (o ? (o.yPercent ? rect.height * o.y / 100 : o.y) : rect.height / 2)
|
|
78
|
+
painter.translate(cx, cy)
|
|
79
|
+
painter.transform(a, b, c, d, e, f)
|
|
80
|
+
painter.translate(-cx, -cy)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const radius = clampRadius(style.borderRadius, rect)
|
|
84
|
+
|
|
85
|
+
// background color
|
|
86
|
+
if (!isTransparent(style.backgroundColor)) {
|
|
87
|
+
painter.fillRoundRect(rect, radius, style.backgroundColor!)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// background image (string url, painted behind content)
|
|
91
|
+
if (style.backgroundImage) {
|
|
92
|
+
const bg = host.getImage(style.backgroundImage)
|
|
93
|
+
if (bg && bg.naturalWidth > 0 && bg.naturalHeight > 0) {
|
|
94
|
+
painter.save()
|
|
95
|
+
if (hasRadius(radius)) painter.clipRoundRect(rect, radius)
|
|
96
|
+
painter.drawImage(bg, null, rect, { fit: bgSizeToFit(style.backgroundSize) })
|
|
97
|
+
painter.restore()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// background gradient layers (painted over the background image, like the DOM layer order).
|
|
102
|
+
// CSS paints the first-listed layer on top, so paint the stack back-to-front (last → first).
|
|
103
|
+
if (style.backgroundGradients) {
|
|
104
|
+
for (let i = style.backgroundGradients.length - 1; i >= 0; i--) {
|
|
105
|
+
const g = style.backgroundGradients[i]
|
|
106
|
+
if (g.stops.length > 0) painter.fillGradient(rect, radius, g)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// image (clipped to the radius if any). A source crop overrides object-fit (the frame fills the box).
|
|
111
|
+
if (node.image && node.image.naturalWidth > 0 && node.image.naturalHeight > 0) {
|
|
112
|
+
const crop = node.sourceRect
|
|
113
|
+
painter.save()
|
|
114
|
+
if (hasRadius(radius)) painter.clipRoundRect(rect, radius)
|
|
115
|
+
painter.drawImage(node.image, crop ? { x: crop[0], y: crop[1], width: crop[2], height: crop[3] } : null, rect, {
|
|
116
|
+
// DOM default: `img { object-fit: contain }`. A sourceRect crop always fills its box.
|
|
117
|
+
fit: crop ? "fill" : (style.objectFit ?? "contain"),
|
|
118
|
+
// tintColor recolors a mask-capable (SVG) image; on an opaque raster the DOM no-ops it, so
|
|
119
|
+
// only tint SVG sources — otherwise a photo would become a flat silhouette.
|
|
120
|
+
tint: isSvgLikeSrc(node.image.src) ? style.tintColor : undefined,
|
|
121
|
+
radius,
|
|
122
|
+
})
|
|
123
|
+
painter.restore()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// text
|
|
127
|
+
if (node.type === "text" && node.lines && node.lines.length > 0) {
|
|
128
|
+
paintText(node, painter, host)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// input / textarea: the value (or placeholder), single line, clipped, with a caret when focused
|
|
132
|
+
if ((node.type === "input" || node.type === "textarea") && node.id !== options.editing) {
|
|
133
|
+
paintInput(node, painter, host, radius)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// a surface under the picture: its box is a window onto it. Without one, a video is a sized
|
|
137
|
+
// placeholder
|
|
138
|
+
if (node.type === "video" || node.type === "native") {
|
|
139
|
+
if (options.surfaces) painter.clearRoundRect(rect, radius)
|
|
140
|
+
else if (node.type === "video") paintVideoPlaceholder(node, painter, host, radius)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// clip children to the viewport (scrollable / overflow:hidden)
|
|
144
|
+
if (node.clip) painter.clipRoundRect(rect, radius)
|
|
145
|
+
|
|
146
|
+
// children
|
|
147
|
+
for (const child of node.children) paintNode(child, painter, host, options)
|
|
148
|
+
|
|
149
|
+
// a screen's dim: black over its content and children (a transition's pose)
|
|
150
|
+
if (style.dim !== undefined && style.dim > 0) {
|
|
151
|
+
painter.fillRoundRect(rect, radius, Math.round(Math.min(1, style.dim) * 255) >>> 0)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// border on top
|
|
155
|
+
const sides = visibleBorders(style.border)
|
|
156
|
+
if (sides) painter.strokeBorder(rect, radius, sides)
|
|
157
|
+
|
|
158
|
+
if (grouped) painter.endGroup()
|
|
159
|
+
painter.restore()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function bgSizeToFit(size: "cover" | "contain" | "tile" | undefined): "cover" | "contain" | "fill" {
|
|
163
|
+
// tile isn't supported yet — fall back to cover.
|
|
164
|
+
return size === "contain" ? "contain" : "cover"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function visibleBorders(border: RenderNode["style"]["border"]): Borders | null {
|
|
168
|
+
if (!border) return null
|
|
169
|
+
const out: Borders = {}
|
|
170
|
+
let any = false
|
|
171
|
+
for (const side of ["top", "right", "bottom", "left"] as const) {
|
|
172
|
+
const sd = border[side]
|
|
173
|
+
if (sd && sd.width > 0 && (sd.color >>> 0) % 256 !== 0) {
|
|
174
|
+
out[side] = sd
|
|
175
|
+
any = true
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return any ? out : null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The painter gets a raw ctx (no host), so the host's family mapping (CSS-generic aliases,
|
|
182
|
+
// headless concrete faces) is applied here, when the FontSpec is built.
|
|
183
|
+
const familyOf = (host: Host, family: string | undefined) => {
|
|
184
|
+
const fam = family ?? "Roboto"
|
|
185
|
+
return host.resolveFontFamily?.(fam) ?? fam
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function paintText(node: RenderNode, painter: Painter, host: Host) {
|
|
189
|
+
const s = node.style
|
|
190
|
+
const font: FontSpec = {
|
|
191
|
+
family: familyOf(host, s.fontFamily),
|
|
192
|
+
size: s.fontSize ?? 14,
|
|
193
|
+
weight: s.fontWeight ?? 400,
|
|
194
|
+
italic: s.fontStyle === "italic",
|
|
195
|
+
letterSpacing: s.letterSpacing ?? 0,
|
|
196
|
+
}
|
|
197
|
+
const color = s.color ?? 0x000000ff
|
|
198
|
+
const lineHeight = s.lineHeight
|
|
199
|
+
const align = s.textAlign ?? "start"
|
|
200
|
+
const metrics = host.fontMetrics(font)
|
|
201
|
+
const { rect, lines } = node
|
|
202
|
+
// The rect is the border box; inset by the node's own padding (the lines were already wrapped to
|
|
203
|
+
// this content width in renderTree). Matches the DOM, which applies padding via box-sizing.
|
|
204
|
+
const pl = node.padding?.[0] ?? 0
|
|
205
|
+
const pt = node.padding?.[1] ?? 0
|
|
206
|
+
const pr = node.padding?.[2] ?? 0
|
|
207
|
+
const contentX = rect.x + pl
|
|
208
|
+
const contentW = rect.width - pl - pr
|
|
209
|
+
|
|
210
|
+
for (let i = 0; i < lines!.length; i++) {
|
|
211
|
+
const line = lines![i]
|
|
212
|
+
const lineTop = rect.y + pt + i * lineHeight
|
|
213
|
+
const baselineY = lineTop + (lineHeight - (metrics.ascent + metrics.descent)) / 2 + metrics.ascent
|
|
214
|
+
|
|
215
|
+
const lineWidth = host.measureText(line, font)
|
|
216
|
+
const x =
|
|
217
|
+
align === "center"
|
|
218
|
+
? contentX + (contentW - lineWidth) / 2
|
|
219
|
+
: align === "end"
|
|
220
|
+
? contentX + contentW - lineWidth
|
|
221
|
+
: contentX
|
|
222
|
+
|
|
223
|
+
painter.fillText(line, x, baselineY, font, color)
|
|
224
|
+
|
|
225
|
+
if (s.textDecoration === "underline" || s.textDecoration === "line-through") {
|
|
226
|
+
const thickness = Math.max(1, font.size / 14)
|
|
227
|
+
const y = s.textDecoration === "underline" ? baselineY + thickness : baselineY - font.size * 0.3
|
|
228
|
+
painter.fillRoundRect({ x, y, width: lineWidth, height: thickness }, undefined, color)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function paintVideoPlaceholder(node: RenderNode, painter: Painter, host: Host, radius: BorderRadius | undefined) {
|
|
234
|
+
const { rect } = node
|
|
235
|
+
// Dark placeholder fill (unless the author set a background), so the video reads as a sized box.
|
|
236
|
+
if (isTransparent(node.style.backgroundColor)) painter.fillRoundRect(rect, radius, 0x111317ff)
|
|
237
|
+
// Centred play glyph.
|
|
238
|
+
const size = Math.min(rect.width, rect.height) * 0.3
|
|
239
|
+
if (size >= 6) {
|
|
240
|
+
const font: FontSpec = { family: "Roboto", size, weight: 400, italic: false, letterSpacing: 0 }
|
|
241
|
+
const metrics = host.fontMetrics(font)
|
|
242
|
+
const w = host.measureText("▶", font)
|
|
243
|
+
const x = rect.x + (rect.width - w) / 2
|
|
244
|
+
const baselineY = rect.y + (rect.height - (metrics.ascent + metrics.descent)) / 2 + metrics.ascent
|
|
245
|
+
painter.fillText("▶", x, baselineY, font, 0xffffffcc)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function paintInput(node: RenderNode, painter: Painter, host: Host, radius: BorderRadius | undefined) {
|
|
250
|
+
const s = node.style
|
|
251
|
+
const font: FontSpec = {
|
|
252
|
+
family: familyOf(host, s.fontFamily),
|
|
253
|
+
size: s.fontSize ?? 14,
|
|
254
|
+
weight: s.fontWeight ?? 400,
|
|
255
|
+
italic: s.fontStyle === "italic",
|
|
256
|
+
letterSpacing: s.letterSpacing ?? 0,
|
|
257
|
+
}
|
|
258
|
+
const value = node.value ?? ""
|
|
259
|
+
const empty = value.length === 0
|
|
260
|
+
const masked = s.inputType === "password" && !empty
|
|
261
|
+
const display = empty ? (s.placeholder ?? "") : masked ? "•".repeat(value.length) : value
|
|
262
|
+
const color = empty ? (s.placeholderColor ?? 0x999999ff) : (s.color ?? 0x000000ff)
|
|
263
|
+
const { rect } = node
|
|
264
|
+
// Inset the value/placeholder/caret by the field's own padding (rect is the border box).
|
|
265
|
+
const pl = node.padding?.[0] ?? 0
|
|
266
|
+
const pt = node.padding?.[1] ?? 0
|
|
267
|
+
const pr = node.padding?.[2] ?? 0
|
|
268
|
+
const pb = node.padding?.[3] ?? 0
|
|
269
|
+
const contentX = rect.x + pl
|
|
270
|
+
const contentW = rect.width - pl - pr
|
|
271
|
+
const contentY = rect.y + pt
|
|
272
|
+
const contentH = rect.height - pt - pb
|
|
273
|
+
const metrics = host.fontMetrics(font)
|
|
274
|
+
const lineHeight = s.lineHeight
|
|
275
|
+
const align = s.textAlign ?? "start"
|
|
276
|
+
|
|
277
|
+
// A textarea wraps to the content width and top-aligns its lines; an input is a single line,
|
|
278
|
+
// vertically centred in the field.
|
|
279
|
+
const multiline = node.type === "textarea"
|
|
280
|
+
const lines = multiline ? wrapText(display, contentW, (t) => host.measureText(t, font)).lines : [display]
|
|
281
|
+
const lineX = (w: number) =>
|
|
282
|
+
align === "center" ? contentX + (contentW - w) / 2 : align === "end" ? contentX + contentW - w : contentX
|
|
283
|
+
const lineBaseline = (i: number) =>
|
|
284
|
+
multiline
|
|
285
|
+
? contentY + i * lineHeight + (lineHeight - (metrics.ascent + metrics.descent)) / 2 + metrics.ascent
|
|
286
|
+
: contentY + (contentH - (metrics.ascent + metrics.descent)) / 2 + metrics.ascent
|
|
287
|
+
|
|
288
|
+
painter.save()
|
|
289
|
+
painter.clipRoundRect(rect, radius) // keep content inside the field
|
|
290
|
+
for (let i = 0; i < lines.length; i++) {
|
|
291
|
+
const line = lines[i]
|
|
292
|
+
if (line.length > 0) painter.fillText(line, lineX(host.measureText(line, font)), lineBaseline(i), font, color)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Caret at the end of the real value (or the start when empty) while focused. Static (no blink).
|
|
296
|
+
if (node.state?.focused) {
|
|
297
|
+
const valueLines = empty
|
|
298
|
+
? [""]
|
|
299
|
+
: multiline
|
|
300
|
+
? wrapText(masked ? "•".repeat(value.length) : value, contentW, (t) => host.measureText(t, font)).lines
|
|
301
|
+
: [display]
|
|
302
|
+
const i = valueLines.length - 1
|
|
303
|
+
const lineW = host.measureText(valueLines[i], font)
|
|
304
|
+
const caretX = empty ? lineX(0) : lineX(lineW) + lineW
|
|
305
|
+
const baselineY = lineBaseline(i)
|
|
306
|
+
const caretW = Math.max(1, font.size / 14)
|
|
307
|
+
painter.fillRoundRect(
|
|
308
|
+
{ x: caretX, y: baselineY - metrics.ascent, width: caretW, height: metrics.ascent + metrics.descent },
|
|
309
|
+
undefined,
|
|
310
|
+
s.color ?? 0x000000ff,
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
painter.restore()
|
|
314
|
+
}
|