From 614e84bc22415239e0a0a3cf98d407c156ccde73 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 10 Nov 2026 16:42:15 -0800 Subject: [PATCH 1/3] add native chat text and input --- .github/workflows/build.yml | 7 + nimby.lock | 2 +- src/silky/atlas.nim | 112 ++++-- src/silky/clips.nim | 1 + src/silky/contexts.nim | 124 ++++-- src/silky/drawers/metal4.nim | 14 +- src/silky/drawers/shader.nim | 18 +- src/silky/dsl.nim | 32 ++ src/silky/internal/inputs.nim | 103 ++++- src/silky/semantics.nim | 21 +- src/silky/testwindow.nim | 29 +- src/silky/textboxes.nim | 696 +++++++++++++++++++++++++++++++-- src/silky/widgets.nim | 69 +++- tests/test_button_vertices.nim | 85 ++++ tests/test_buttons.nim | 128 ++++++ tests/test_decorations.nim | 118 ++++++ tests/test_density.nim | 315 +++++++++++++++ tests/test_inputs.nim | 641 +++++++++++++++++++++++++++++- tests/test_quotes.nim | 225 +++++++++++ tests/test_rules.nim | 141 +++++++ tests/test_shader_codegen.nim | 33 ++ tests/test_textboxes.nim | 189 +++++++++ 22 files changed, 2969 insertions(+), 134 deletions(-) create mode 100644 tests/test_button_vertices.nim create mode 100644 tests/test_buttons.nim create mode 100644 tests/test_decorations.nim create mode 100644 tests/test_density.nim create mode 100644 tests/test_quotes.nim create mode 100644 tests/test_rules.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 605a63c..b29fa86 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,8 +59,15 @@ jobs: run: | nim r tests/test_textboxes.nim nim r -d:silkyTesting -d:nimTypeNames tests/test_inputs.nim + nim r -d:silkyTesting tests/test_buttons.nim nim r -d:silkyTesting tests/test_subwindow.nim nim r tests/test_atlas_png.nim + nim r tests/test_shader_codegen.nim + nim r ${{ matrix.build_flags }} tests/test_density.nim + nim r ${{ matrix.build_flags }} tests/test_decorations.nim + nim r ${{ matrix.build_flags }} tests/test_rules.nim + nim r ${{ matrix.build_flags }} tests/test_quotes.nim + nim r ${{ matrix.build_flags }} tests/test_button_vertices.nim nim r -d:silkyTesting tests/test_layout.nim nim r -d:silkyTesting tests/test_layout_regressions.nim nim r ${{ matrix.build_flags }} tests/test_layout_clips.nim diff --git a/nimby.lock b/nimby.lock index cec4b91..22999c5 100644 --- a/nimby.lock +++ b/nimby.lock @@ -8,7 +8,7 @@ nimsimd 1.3.2 https://github.com/guzba/nimsimd 3f6b2668ffb0867d0bf786a658b817763 bumpy 1.1.3 https://github.com/treeform/bumpy dc9a3d6d15680ec7f603959d65ba8ee4f1ddbc72 crunchy 0.1.11 https://github.com/guzba/crunchy 98eb6526982bb8aae8eec6e8781f4539fa19e049 bitty 0.1.4 https://github.com/treeform/bitty/ c40e81277c04befa37234cba5052f58648bef895 -windy 0.5.0 https://github.com/treeform/windy d3d92f5decf22f98c61d198d6285427c83f67b60 +windy 0.5.0 https://github.com/treeform/windy 8e1e2499a050bcbb1546f239b8c3a8b1b16748c0 opengl 1.2.9 https://github.com/nim-lang/opengl 8e2e098f82dc5eefd874488c37b5830233cd18f4 urlly 1.1.1 https://github.com/treeform/urlly 99784779f05649df25fd9c33003d8ef6de027345 ws 0.5.0 https://github.com/treeform/ws cbb8f763b436669392d10baec2a45778395395cc diff --git a/src/silky/atlas.nim b/src/silky/atlas.nim index 75fd9f2..cfe83e6 100644 --- a/src/silky/atlas.nim +++ b/src/silky/atlas.nim @@ -24,6 +24,8 @@ type y*: int width*: int height*: int + pixelScale*: int = 1 + ## The number of texture pixels per logical pixel. LetterEntry* = object ## The position and size of a letter in the font atlas. @@ -53,6 +55,8 @@ type ascent*: float32 lineGap*: float32 subpixelSteps*: int + pixelScale*: float32 = 1 + ## The glyph raster density; font metrics remain logical. entries*: Table[string, seq[LetterEntry]] lookup*: GlyphLookup @@ -291,9 +295,12 @@ proc writePng*(path, json: string, image: Image) = proc addImage*( builder: AtlasBuilder, name: string, - image: Image + image: Image, + pixelScale: int = 1 ): bool = ## Pack one image into the atlas. False when the atlas is full. + if pixelScale < 1: + raise newException(SilkyAtlasError, "Image pixel scale must be positive") let allocation = builder.allocator.allocate(image.width, image.height) if not allocation.success: return false @@ -306,7 +313,8 @@ proc addImage*( x: allocation.x, y: allocation.y, width: image.width, - height: image.height + height: image.height, + pixelScale: pixelScale ) true @@ -340,8 +348,9 @@ proc newAtlasBuilderFromAtlas*( for variants in fontAtlas.entries.values: for letter in variants: let - w = letter.boundsWidth.ceil.int - h = letter.boundsHeight.ceil.int + density = max(fontAtlas.pixelScale, 0.001'f) + w = (letter.boundsWidth * density).ceil.int + h = (letter.boundsHeight * density).ceil.int if w > 0 and h > 0: result.markOccupiedEntry(letter.x, letter.y, w, h) @@ -360,7 +369,12 @@ proc growAtlas*(builder: AtlasBuilder, newSize: int) = builder.allocator = newSkylineAllocator(newSize, builder.margin) builder.allocator.markRegion(0, 0, oldSize, oldSize) -proc addDir*(builder: AtlasBuilder, path: string, removePrefix: string = "") = +proc addDir*( + builder: AtlasBuilder, + path: string, + removePrefix: string = "", + pixelScale: int = 1 +) = ## Add all images in the given directory to the atlas. for file in walkDir(path): if file.path.endsWith(".png"): @@ -369,19 +383,24 @@ proc addDir*(builder: AtlasBuilder, path: string, removePrefix: string = "") = if removePrefix.len > 0: key.removePrefix(removePrefix) key.removeSuffix(".png") - if not builder.addImage(key, image): + if not builder.addImage(key, image, pixelScale): raise newException( ValueError, "Failed to allocate space for " & file.path & "\n" & "You need to increase the size of the atlas" ) -proc addDirRecursive*(builder: AtlasBuilder, path: string, removePrefix: string = "") = +proc addDirRecursive*( + builder: AtlasBuilder, + path: string, + removePrefix: string = "", + pixelScale: int = 1 +) = ## Add all images in the given directory and its subdirectories to the atlas. - builder.addDir(path, removePrefix) + builder.addDir(path, removePrefix, pixelScale) for entry in walkDir(path): if entry.kind == pcDir: - builder.addDirRecursive(entry.path, removePrefix) + builder.addDirRecursive(entry.path, removePrefix, pixelScale) proc finiteBounds(bounds: Rect): Rect = ## Glyphs with no outline (such as space) compute to infinite/NaN bounds, @@ -393,18 +412,33 @@ proc finiteBounds(bounds: Rect): Rect = else: rect(0, 0, 0, 0) -proc addFont*(builder: AtlasBuilder, path: string, name: string, size: float32, chars: seq[string] = AsciiGlyphs, subpixelSteps: int = 0) = - ## Add a font to the atlas. - let fontAtlas = FontAtlas() +proc addFont*( + builder: AtlasBuilder, + typeface: Typeface, + name: string, + size: float32, + chars: seq[string] = AsciiGlyphs, + subpixelSteps: int = 0, + pixelScale: float32 = 1 +) = + ## Rasterizes a font at pixelScale with logical layout metrics. + if pixelScale <= 0 or + pixelScale.classify notin {fcNormal, fcSubnormal}: + raise newException(SilkyAtlasError, "Font pixel scale must be positive") + let + density = pixelScale.float32 + fontAtlas = FontAtlas() fontAtlas.size = size fontAtlas.subpixelSteps = subpixelSteps - let typeface = readTypeface(path) - var fontObj = newFont(typeface) + fontAtlas.pixelScale = pixelScale + let fontObj = newFont(typeface) fontObj.size = size - fontAtlas.lineHeight = (typeface.ascent - typeface.descent + typeface.lineGap) * fontObj.scale - fontAtlas.ascent = typeface.ascent * fontObj.scale - fontAtlas.descent = typeface.descent * fontObj.scale - fontAtlas.lineGap = typeface.lineGap * fontObj.scale + let logicalScale = fontObj.scale + fontAtlas.lineHeight = + (typeface.ascent - typeface.descent + typeface.lineGap) * logicalScale + fontAtlas.ascent = typeface.ascent * logicalScale + fontAtlas.descent = typeface.descent * logicalScale + fontAtlas.lineGap = typeface.lineGap * logicalScale # If subpixelSteps > 0, generates multiple versions of each glyph shifted by 1/subpixelSteps pixels. # For example, subpixelSteps=10 generates 10 versions per glyph at 0.0, 0.1, 0.2, ... 0.9 pixel offsets. let numVariants = if subpixelSteps > 0: subpixelSteps else: 1 @@ -413,10 +447,10 @@ proc addFont*(builder: AtlasBuilder, path: string, name: string, size: float32, let rune = glyphStr.runeAt(0) glyphPath = typeface.getGlyphPath(rune) - scale = fontObj.scale + scale = logicalScale * density scaleMat = scale(vec2(scale)) baseBounds = glyphPath.computeBounds(scaleMat).snapToPixels().finiteBounds() - advance = typeface.getAdvance(rune) * scale + advance = typeface.getAdvance(rune) * logicalScale fontAtlas.entries[glyphStr] = @[] for variant in 0 ..< numVariants: let @@ -441,7 +475,7 @@ proc addFont*(builder: AtlasBuilder, path: string, name: string, size: float32, let allocation = builder.allocator.allocate(glyphImage.width, glyphImage.height) if not allocation.success: raise newException( - ValueError, + SilkyAtlasError, "Failed to allocate space for glyph: " & glyphStr & " variant " & $variant & "\n" & "You need to increase the size of the atlas" ) @@ -453,20 +487,20 @@ proc addFont*(builder: AtlasBuilder, path: string, name: string, size: float32, fontAtlas.entries[glyphStr].add(LetterEntry( x: allocation.x, y: allocation.y, - boundsX: baseBounds.x + subpixelOffset.floor, - boundsY: baseBounds.y, - boundsWidth: offsetBounds.w.ceil, - boundsHeight: offsetBounds.h.ceil, + boundsX: (baseBounds.x + subpixelOffset.floor) / density, + boundsY: baseBounds.y / density, + boundsWidth: offsetBounds.w.ceil / density, + boundsHeight: offsetBounds.h.ceil / density, advance: advance )) else: fontAtlas.entries[glyphStr].add(LetterEntry( x: 0, y: 0, - boundsX: baseBounds.x, - boundsY: baseBounds.y, - boundsWidth: baseBounds.w, - boundsHeight: baseBounds.h, + boundsX: baseBounds.x / density, + boundsY: baseBounds.y / density, + boundsWidth: baseBounds.w / density, + boundsHeight: baseBounds.h / density, advance: advance )) # Kerning only needs to be stored once per base glyph (variant 0). @@ -474,10 +508,28 @@ proc addFont*(builder: AtlasBuilder, path: string, name: string, size: float32, let rune2 = glyphStr2.runeAt(0) let kerning = typeface.getKerningAdjustment(rune, rune2) if kerning != 0: - fontAtlas.entries[glyphStr][0].kerning[glyphStr2] = kerning * scale + fontAtlas.entries[glyphStr][0].kerning[glyphStr2] = + kerning * logicalScale fontAtlas.rebuildGlyphLookups() builder.atlas.fonts[name] = fontAtlas +proc addFont*( + builder: AtlasBuilder, + path: string, + name: string, + size: float32, + chars: seq[string] = AsciiGlyphs, + subpixelSteps: int = 0, + pixelScale: float32 = 1 +) = + ## Loads a font file and rasterizes it at the requested pixel density. + try: + builder.addFont( + readTypeface(path), name, size, chars, subpixelSteps, pixelScale + ) + except PixieError as error: + raise newException(SilkyAtlasError, "Could not load font: " & error.msg) + proc write*(builder: AtlasBuilder, outputPngPath: string) = ## Write atlas image and JSON metadata into a single PNG. writePng(outputPngPath, builder.atlas.toJson(), builder.atlasImage) diff --git a/src/silky/clips.nim b/src/silky/clips.nim index 14ae5cf..5653d4f 100644 --- a/src/silky/clips.nim +++ b/src/silky/clips.nim @@ -18,6 +18,7 @@ type ## Vertex and clip boundaries for a deferred layout on both layers. marks*: array[2, int] clipMark*: int + textMark*: int proc intersectClips(a, b: Rect): Rect = ## Intersects clip bounds while keeping empty intersections empty. diff --git a/src/silky/contexts.nim b/src/silky/contexts.nim index 0f79620..0532a67 100644 --- a/src/silky/contexts.nim +++ b/src/silky/contexts.nim @@ -236,6 +236,7 @@ proc vertexMark*(sk: Silky): int = proc beginVertexSpan*(sk: Silky): VertexSpan = ## Captures vertices and original clips until a layout is placed. result.clipMark = sk.clipStack.regions.len + result.textMark = sk.inputs.textPlacementMark() for layer in 0 ..< result.marks.len: result.marks[layer] = sk.drawer.layers[layer].len sk.vertexClips[layer].setLen(result.marks[layer]) @@ -243,11 +244,20 @@ proc beginVertexSpan*(sk: Silky): VertexSpan = proc endVertexSpan*(sk: Silky, span: VertexSpan, offset: Vec2) = ## Places a deferred span and resolves clips against fixed ancestors. + let + scale = max(sk.uiScale, 0.001'f) + paintOffset = vec2( + round(offset.x * scale) / scale, + round(offset.y * scale) / scale + ) + # Deferred placement must preserve the pixel grid used by text and images. + # Layout and clipping retain their exact logical positions. sk.clipStack.translateClips(span.clipMark, offset) + sk.inputs.translateTextInput(span.textMark, paintOffset * scale) for layer in 0 ..< span.marks.len: for i in span.marks[layer] ..< sk.drawer.layers[layer].len: let clip = sk.clipStack.regions[sk.vertexClips[layer][i]].visible - sk.drawer.layers[layer][i].pos += offset + sk.drawer.layers[layer][i].pos += paintOffset sk.drawer.layers[layer][i].clipPos = clip.xy sk.drawer.layers[layer][i].clipSize = clip.wh dec sk.clipStack.captures @@ -257,6 +267,7 @@ proc moveVerticesBehind*(sk: Silky, layer, spanStart, chromeStart: int) = ## span so late-drawn parent chrome renders behind its children (T2). let total = sk.drawer.layers[layer].len if chromeStart > spanStart and chromeStart < total: + sk.inputs.moveTextInputBehind(ord(layer), spanStart, chromeStart, total) sk.drawer.layers[layer].rotateLeft( spanStart ..< total, chromeStart - spanStart ) @@ -270,16 +281,23 @@ proc translateVertices*(sk: Silky, layer, spanStart: int, offset: Vec2) = ## whose position is only known at scope close (T6). if offset == vec2(0, 0): return + let + scale = max(sk.uiScale, 0.001'f) + paintOffset = vec2( + round(offset.x * scale) / scale, + round(offset.y * scale) / scale + ) + sk.inputs.translateTextInputVertices(ord(layer), spanStart, paintOffset * scale) for i in spanStart ..< sk.drawer.layers[layer].len: - sk.drawer.layers[layer][i].pos += offset + sk.drawer.layers[layer][i].pos += paintOffset proc getImageSize*(sk: Silky, image: string): Vec2 = - ## Returns the size of an atlas image in pixels. + ## Returns the logical size of an atlas image. if image notin sk.atlas.entries: echo "[Warning] Image not found in atlas: " & image return vec2(0, 0) let uv = sk.atlas.entries[image] - vec2(uv.width.float32, uv.height.float32) + vec2(uv.width.float32, uv.height.float32) / max(uv.pixelScale, 1).float32 proc nextRune(text: string, i: var int): Rune {.inline.} = ## Reads one UTF-8 rune and advances the byte index. @@ -305,6 +323,7 @@ proc resetInteractions*(sk: Silky) = proc beginUiShared*(sk: Silky, window: Window, size: IVec2) = ## Starts a frame and updates the shared UI state. + sk.cursor = Cursor(kind: ArrowCursor) sk.inputs.beginInputFrame(window) beginProfileFrame() @@ -348,6 +367,12 @@ proc endInteractions(interactor: var Interactor) = proc endUiShared*(sk: Silky) = ## Ends a frame after the backend has finished drawing. + let previousCursor = sk.window.cursor + if previousCursor.kind != sk.cursor.kind or + (sk.cursor.kind == CustomCursor and + (previousCursor.image != sk.cursor.image or + previousCursor.hotspot != sk.cursor.hotspot)): + sk.window.cursor = sk.cursor if sk.window.buttonReleased[MouseLeft]: sk.resetInteractions() sk.interactor.endInteractions() @@ -527,6 +552,8 @@ proc drawText*( let fontData = sk.atlas.fonts[font] + density = max(fontData.pixelScale, 0.001'f) + displayScale = max(sk.uiScale, 0.001'f) maxPos = pos + vec2(maxWidth, maxHeight) hasSubpixel = fontData.subpixelSteps > 0 layer = sk.drawer.currentLayer @@ -551,9 +578,9 @@ proc drawText*( of LeftAlign: 0.0'f of CenterAlign: - floor((maxWidth - lineWidth) * 0.5) + floor((maxWidth - lineWidth) * 0.5 * displayScale) / displayScale of RightAlign: - floor(maxWidth - lineWidth) + floor((maxWidth - lineWidth) * displayScale) / displayScale if dx != 0: for j in lineStartIdx ..< sk.drawer.layers[layer].len: sk.drawer.layers[layer][j].pos.x += dx @@ -597,7 +624,9 @@ proc drawText*( let variant = if hasSubpixel: - let frac = currentPos.x - currentPos.x.floor + let + rasterX = currentPos.x * density + frac = rasterX - rasterX.floor (frac * fontData.subpixelSteps.float32).int mod fontData.subpixelSteps else: @@ -626,15 +655,24 @@ proc drawText*( break if entry.boundsWidth > 0 and entry.boundsHeight > 0: - let glyphPos = vec2( - floor(currentPos.x) + entry.boundsX, - round(currentPos.y + entry.boundsY) - ) + let + glyphX = + if hasSubpixel: + floor(currentPos.x * density) / density + entry.boundsX + else: + floor((currentPos.x + entry.boundsX) * displayScale) / + displayScale + # Keep every glyph on one baseline, including mismatched atlas scales. + glyphPos = vec2( + glyphX, + round(currentPos.y * displayScale) / displayScale + entry.boundsY + ) + glyphSize = vec2(entry.boundsWidth, entry.boundsHeight) sk.drawQuad( glyphPos, - vec2(entry.boundsWidth, entry.boundsHeight), + glyphSize, vec2(entry.x.float32, entry.y.float32), - vec2(entry.boundsWidth, entry.boundsHeight), + glyphSize * density, color ) @@ -657,9 +695,9 @@ proc drawText*( of TopAlign: 0.0'f of MiddleAlign: - floor((maxHeight - textHeight) * 0.5) + floor((maxHeight - textHeight) * 0.5 * displayScale) / displayScale of BottomAlign: - floor(maxHeight - textHeight) + floor((maxHeight - textHeight) * displayScale) / displayScale if dy != 0: for j in textStartIdx ..< sk.drawer.layers[layer].len: sk.drawer.layers[layer][j].pos.y += dy @@ -744,6 +782,10 @@ proc addAtlasImage*(sk: Silky, name: string, image: Image) = sk.atlas = sk.builder.atlas sk.uploadAtlas() +proc alignPixels(pos: Vec2, scale: float32): Vec2 = + ## Aligns a logical point to the framebuffer's pixel grid. + vec2(round(pos.x * scale), round(pos.y * scale)) / scale + proc drawImage*( sk: Silky, name: string, @@ -755,7 +797,15 @@ proc drawImage*( if name notin sk.atlas.entries: echo "[Warning] Sprite not found in atlas: " & name return - let uv = sk.atlas.entries[name] + let + uv = sk.atlas.entries[name] + density = max(uv.pixelScale, 1).float32 + displayScale = max(sk.uiScale, 0.001'f) + alignedPos = + if density > 1: + alignPixels(pos, displayScale) + else: + pos var mPos = vec2(-1, -1) mSize = vec2(0, 0) @@ -764,8 +814,8 @@ proc drawImage*( mPos = vec2(m.x.float32, m.y.float32) mSize = vec2(m.width.float32, m.height.float32) sk.drawQuad( - pos, - vec2(uv.width.float32, uv.height.float32), + alignedPos, + vec2(uv.width.float32, uv.height.float32) / density, vec2(uv.x.float32, uv.y.float32), vec2(uv.width.float32, uv.height.float32), color, @@ -796,23 +846,35 @@ proc draw9Patch*( return let scale = max(sk.sliceScale, 1) - topS = top * scale - rightS = right * scale - bottomS = bottom * scale - leftS = left * scale uv = sk.atlas.entries[name] - l = leftS.float32 - r = rightS.float32 - u = topS.float32 - d = bottomS.float32 + density = max(uv.pixelScale, 1) + topS = top * scale * density + rightS = right * scale * density + bottomS = bottom * scale * density + leftS = left * scale * density + l = (left * scale).float32 + r = (right * scale).float32 + u = (top * scale).float32 + d = (bottom * scale).float32 + displayScale = max(sk.uiScale, 0.001'f) + alignedPos = + if density > 1: + alignPixels(pos, displayScale) + else: + pos + alignedSize = + if density > 1: + alignPixels(pos + size, displayScale) - alignedPos + else: + size srcXOffsets = [0.int, leftS, uv.width - rightS] srcWidths = [leftS, uv.width - leftS - rightS, rightS] srcYOffsets = [0.int, topS, uv.height - bottomS] srcHeights = [topS, uv.height - topS - bottomS, bottomS] - dstXOffsets = [0.0'f, l, size.x - r] - dstWidths = [l, size.x - l - r, r] - dstYOffsets = [0.0'f, u, size.y - d] - dstHeights = [u, size.y - u - d, d] + dstXOffsets = [0.0'f, l, alignedSize.x - r] + dstWidths = [l, alignedSize.x - l - r, r] + dstYOffsets = [0.0'f, u, alignedSize.y - d] + dstHeights = [u, alignedSize.y - u - d, d] order = [ (0, 0), (2, 0), (0, 2), (2, 2), (1, 0), (0, 1), (2, 1), (1, 2), @@ -828,7 +890,7 @@ proc draw9Patch*( if dw <= 0.001 or dh <= 0.001 or sw <= 0 or sh <= 0: continue sk.drawQuad( - vec2(pos.x + dstXOffsets[x], pos.y + dstYOffsets[y]), + alignedPos + vec2(dstXOffsets[x], dstYOffsets[y]), vec2(dw, dh), vec2( (uv.x + srcXOffsets[x]).float32, diff --git a/src/silky/drawers/metal4.nim b/src/silky/drawers/metal4.nim index 4fa8630..dbd8d12 100644 --- a/src/silky/drawers/metal4.nim +++ b/src/silky/drawers/metal4.nim @@ -144,18 +144,20 @@ fragment float4 texturedFragmentMain( texture2d tex [[texture(0)]], sampler texSampler [[sampler(0)]] ) { + // Fetch before clipping so discarded neighbors keep valid derivatives. + float4 base = tex.sample(texSampler, in.uv); + float4 shaded = base * in.color; + if (in.maskUv.x >= 0.0) { + float maskR = tex.sample(texSampler, in.maskUv).r; + shaded = float4(base.rgb * mix(float3(1.0), in.color.rgb, maskR), base.a * in.color.a); + } if (in.position.x < in.clipPos.x || in.position.y < in.clipPos.y || in.position.x > in.clipPos.x + in.clipSize.x || in.position.y > in.clipPos.y + in.clipSize.y) { discard_fragment(); } - float4 base = tex.sample(texSampler, in.uv); - if (in.maskUv.x >= 0.0) { - float maskR = tex.sample(texSampler, in.maskUv).r; - return float4(base.rgb * mix(float3(1.0), in.color.rgb, maskR), base.a * in.color.a); - } - return base * in.color; + return shaded; } """ diff --git a/src/silky/drawers/shader.nim b/src/silky/drawers/shader.nim index 4dd4a58..7e975f4 100644 --- a/src/silky/drawers/shader.nim +++ b/src/silky/drawers/shader.nim @@ -41,17 +41,19 @@ proc silkyFrag*( fragmentMaskUv: Vec2, fragColor: var Vec4 ) = + # Sample before clipping so neighboring fragments provide valid derivatives. + let base = texture(atlasSampler, fragmentUv) + var shaded = base * fragmentColor + if fragmentMaskUv.x >= 0.0: + let maskR = texture(atlasSampler, fragmentMaskUv).r + shaded = vec4( + base.rgb * mix(vec3(1.0, 1.0, 1.0), fragmentColor.rgb, maskR), + base.a * fragmentColor.a + ) if fragmentPos.x < fragmentClipPos.x or fragmentPos.y < fragmentClipPos.y or fragmentPos.x > fragmentClipPos.x + fragmentClipSize.x or fragmentPos.y > fragmentClipPos.y + fragmentClipSize.y: discardFragment() - elif fragmentMaskUv.x >= 0.0: - let base = texture(atlasSampler, fragmentUv) - let maskR = texture(atlasSampler, fragmentMaskUv).r - fragColor = vec4( - base.rgb * mix(vec3(1.0, 1.0, 1.0), fragmentColor.rgb, maskR), - base.a * fragmentColor.a - ) else: - fragColor = texture(atlasSampler, fragmentUv) * fragmentColor + fragColor = shaded diff --git a/src/silky/dsl.nim b/src/silky/dsl.nim index 0b74d27..632854c 100644 --- a/src/silky/dsl.nim +++ b/src/silky/dsl.nim @@ -936,6 +936,38 @@ template button*(label: string, isEnabled: bool, body: untyped) = baseWidgets.button(label, isEnabled, false): body +template button*( + label: string, + minimumSize: Vec2, + isEnabled: bool, + isError: bool, + body: untyped +) = + ## Creates a native minimum-size button inside the current DSL layout. + profileBlock "button": + sk.startCurrentChildren() + baseWidgets.button(label, minimumSize, isEnabled, isError): + body + +template button*( + label: string, + minimumSize: Vec2, + isEnabled: bool, + body: untyped +) = + ## Creates a minimum-size button with an enabled state. + profileBlock "button": + sk.startCurrentChildren() + baseWidgets.button(label, minimumSize, isEnabled, false): + body + +template button*(label: string, minimumSize: Vec2, body: untyped) = + ## Creates an enabled minimum-size button. + profileBlock "button": + sk.startCurrentChildren() + baseWidgets.button(label, minimumSize, true, false): + body + template icon*(imageName: string) = profileBlock "icon": sk.startCurrentChildren() diff --git a/src/silky/internal/inputs.nim b/src/silky/internal/inputs.nim index 63c3c92..e77e8f2 100644 --- a/src/silky/internal/inputs.nim +++ b/src/silky/internal/inputs.nim @@ -1,5 +1,6 @@ import std/unicode, + vmath, silky/common when defined(silkyTesting): @@ -11,12 +12,14 @@ const MaxPendingEvents = 65_536 type InputKind* = enum - KeyDown, KeyUp, TextInput + KeyDown, KeyUp, TextInput, TextCommit, NativeEdit InputEvent* = object kind*: InputKind button*: Button rune*: Rune + text*: string + edit*: TextInputEdit control*, shift*, alt*, super*: bool CallbackIdentity = tuple[code, environment: pointer] @@ -29,10 +32,12 @@ type epoch: uint64 submitted: bool consumed: bool - callbacks: array[4, CallbackIdentity] + callbacks: array[6, CallbackIdentity] forwardedKeys: set[Button] consumedKeys: set[Button] releaseCallback: proc(button: Button) + pendingText: TextInputState + placementSerial, placementLayer, placementVertex: int proc identity[T](callback: T): CallbackIdentity = ## Identifies a callback without retaining its closure environment. @@ -67,6 +72,8 @@ proc stopInput*(inputs: TextInputs, window: Window) = inputs.editable = false inputs.id.setLen(0) inputs.events.setLen(0) + inputs.pendingText = TextInputState() + window.setTextInputState(TextInputState()) if window.runeInputEnabled: window.runeInputEnabled = false @@ -99,6 +106,13 @@ proc installPress(inputs: TextInputs, window: Window) = event.kind = KeyDown event.button = button inputs.consumedKeys.incl(button) + if inputs.editable and button == KeyV and + (event.super or (event.control and not event.alt)): + event.kind = TextCommit + event.text = getClipboardString() + if event.text.len > 0: + inputs.addEvent(event) + return inputs.addEvent(event) return if button >= Key0: @@ -161,6 +175,38 @@ proc installFocus(inputs: TextInputs, window: Window) = previousFocus() inputs.callbacks[3] = identity(window.onFocusChange) +proc installText(inputs: TextInputs, window: Window) = + ## Captures complete native commits before a dictation tool restores state. + let + windowPointer = cast[pointer](window) + previousText = window.onTextInput + window.onTextInput = proc(text: string) = + ## Preserves one native insertion as one ordered editing operation. + let window = cast[Window](windowPointer) + if inputs.active: + if inputs.editable and text.len > 0: + inputs.addEvent(InputEvent(kind: TextCommit, text: text)) + return + if previousText != nil: + previousText(text) + elif window.onRune != nil: + for rune in text.runes: + window.onRune(rune) + inputs.callbacks[4] = identity(window.onTextInput) + +proc installEdit(inputs: TextInputs, window: Window) = + ## Queues native accessibility edits in the same order as keyboard input. + let previousEdit = window.onTextEdit + window.onTextEdit = proc(edit: TextInputEdit) = + ## Applies edits only to the field that currently owns text input. + if inputs.active: + if inputs.editable: + inputs.addEvent(InputEvent(kind: NativeEdit, edit: edit)) + return + if previousEdit != nil: + previousEdit(edit) + inputs.callbacks[5] = identity(window.onTextEdit) + proc overrideCallbacks( inputs: TextInputs, window: Window, initialize = false ) = @@ -173,6 +219,10 @@ proc overrideCallbacks( inputs.installRune(window) if initialize or identity(window.onFocusChange) != inputs.callbacks[3]: inputs.installFocus(window) + if initialize or identity(window.onTextInput) != inputs.callbacks[4]: + inputs.installText(window) + if initialize or identity(window.onTextEdit) != inputs.callbacks[5]: + inputs.installEdit(window) proc newTextInputs*(window: Window): TextInputs = ## Sets up all text input handling directly from the window. @@ -187,6 +237,51 @@ proc beginInputFrame*(inputs: TextInputs, window: Window) = inputs.overrideCallbacks(window) inputs.submitted = false inputs.consumed = false + inputs.pendingText = TextInputState() + inputs.placementSerial = 0 + +proc textPlacementMark*(inputs: TextInputs): int = + ## Marks the start of a deferred layout containing a focused editor. + if inputs != nil: + result = inputs.placementSerial + +proc publishTextInput*( + inputs: TextInputs, state: TextInputState, layer, firstVertex: int +) = + ## Stages native editor state until layout placement is complete. + inputs.pendingText = state + inputs.placementLayer = layer + inputs.placementVertex = firstVertex + inc inputs.placementSerial + +proc translateTextInput*(inputs: TextInputs, first: int, offset: Vec2) = + ## Moves an editor published inside a deferred layout span. + if inputs != nil and inputs.placementSerial > first: + inputs.pendingText.bounds.x += offset.x + inputs.pendingText.bounds.y += offset.y + +proc translateTextInputVertices*( + inputs: TextInputs, layer, first: int, offset: Vec2 +) = + ## Moves editor bounds along with their rendered vertices. + if inputs != nil and inputs.placementSerial > 0 and + inputs.placementLayer == layer and inputs.placementVertex >= first: + inputs.pendingText.bounds.x += offset.x + inputs.pendingText.bounds.y += offset.y + +proc moveTextInputBehind*( + inputs: TextInputs, layer, spanStart, chromeStart, total: int +) = + ## Preserves the editor's vertex position when parent chrome is reordered. + if inputs == nil or inputs.placementSerial == 0: + return + if inputs.placementLayer != layer or + inputs.placementVertex < spanStart or inputs.placementVertex >= total: + return + if inputs.placementVertex < chromeStart: + inputs.placementVertex += total - chromeStart + else: + inputs.placementVertex -= chromeStart - spanStart proc submitInput*( inputs: TextInputs, @@ -226,3 +321,7 @@ proc endInputFrame*(inputs: TextInputs, window: Window) = ## Ends capture when the active field was not submitted this frame. if not inputs.submitted: inputs.stopInput(window) + elif inputs.editable and inputs.pendingText.id == inputs.id: + window.setTextInputState(inputs.pendingText) + else: + window.setTextInputState(TextInputState()) diff --git a/src/silky/semantics.nim b/src/silky/semantics.nim index f455937..013e7e8 100644 --- a/src/silky/semantics.nim +++ b/src/silky/semantics.nim @@ -393,11 +393,16 @@ proc vertexMark*(sk: Silky): int = proc beginVertexSpan*(sk: Silky): VertexSpan = ## Captures clip bounds while a semantic layout awaits placement. result.clipMark = sk.clipStack.regions.len + result.textMark = sk.inputs.textPlacementMark() inc sk.clipStack.captures proc endVertexSpan*(sk: Silky, span: VertexSpan, offset: Vec2) = ## Places captured clip bounds without emitting GPU vertices. + let + scale = max(sk.uiScale, 0.001'f) + paintOffset = vec2(round(offset.x * scale), round(offset.y * scale)) sk.clipStack.translateClips(span.clipMark, offset) + sk.inputs.translateTextInput(span.textMark, paintOffset) dec sk.clipStack.captures proc moveVerticesBehind*(sk: Silky, layer, spanStart, chromeStart: int) = @@ -405,6 +410,7 @@ proc moveVerticesBehind*(sk: Silky, layer, spanStart, chromeStart: int) = ## span so late-drawn parent chrome renders behind its children (T2). let total = sk.layers[layer].len if chromeStart > spanStart and chromeStart < total: + sk.inputs.moveTextInputBehind(ord(layer), spanStart, chromeStart, total) sk.layers[layer].rotateLeft( spanStart ..< total, chromeStart - spanStart ) @@ -414,6 +420,10 @@ proc translateVertices*(sk: Silky, layer, spanStart: int, offset: Vec2) = ## whose position is only known at scope close (T6). if offset == vec2(0, 0): return + let + scale = max(sk.uiScale, 0.001'f) + paintOffset = vec2(round(offset.x * scale), round(offset.y * scale)) + sk.inputs.translateTextInputVertices(ord(layer), spanStart, paintOffset) for i in spanStart ..< sk.layers[layer].len: sk.layers[layer][i].pos += offset @@ -452,11 +462,11 @@ proc place*(sk: Silky, size: Vec2): Vec2 = sk.advance(size) proc getImageSize*(sk: Silky, image: string): Vec2 = - ## Returns the size of an image from the atlas. + ## Returns the logical size of an image from the atlas. if image notin sk.atlas.entries: return vec2(0, 0) let uv = sk.atlas.entries[image] - vec2(uv.width.float32, uv.height.float32) + vec2(uv.width.float32, uv.height.float32) / max(uv.pixelScale, 1).float32 proc nextRune(text: string, i: var int): Rune {.inline.} = ## Reads one UTF-8 rune and advances the byte index. @@ -609,6 +619,7 @@ proc newSilky*(window: Window, atlasPngPath: string): Silky = proc beginUi*(sk: Silky, window: auto, size: IVec2) = ## Begins a new UI frame. + sk.cursor = Cursor(kind: ArrowCursor) sk.inputs.beginInputFrame(window) sk.tooltipActive = sk.showTooltip sk.showTooltip = false @@ -635,6 +646,12 @@ proc resetInteractions*(sk: Silky) = proc endUi*(sk: Silky) = ## Ends the current UI frame. + let previousCursor = sk.window.cursor + if previousCursor.kind != sk.cursor.kind or + (sk.cursor.kind == CustomCursor and + (previousCursor.image != sk.cursor.image or + previousCursor.hotspot != sk.cursor.hotspot)): + sk.window.cursor = sk.cursor sk.interactor.endInteractions() sk.clear() sk.popLayout() diff --git a/src/silky/testwindow.nim b/src/silky/testwindow.nim index 8ae0b79..980bace 100644 --- a/src/silky/testwindow.nim +++ b/src/silky/testwindow.nim @@ -3,8 +3,12 @@ import std/unicode, vmath -from windy/common import Button, CursorKind, Cursor -export Button, CursorKind, Cursor, unicode +from windy/common import + Button, CursorKind, Cursor, TextInputState, TextInputEdit, + TextInputEditKind, TextInputEditCallback +export + Button, CursorKind, Cursor, unicode, TextInputState, TextInputEdit, + TextInputEditKind, TextInputEditCallback type Window* = ref object ## Test window that simulates a windy Window. @@ -21,6 +25,9 @@ type Window* = ref object cursor*: Cursor = Cursor(kind: ArrowCursor) runeInputEnabled*: bool onRune*: proc(rune: Rune) + onTextInput*: proc(text: string) + onTextEdit*: TextInputEditCallback + textInput*: TextInputState onButtonPress*: proc(button: Button) onButtonRelease*: proc(button: Button) onFocusChange*: proc() @@ -28,6 +35,10 @@ type Window* = ref object var clipboard: string +proc setTextInputState*(window: Window, state: TextInputState) = + ## Records the editor exposed to native text services. + window.textInput = state + proc newWindow*(width = 800, height = 600): Window = ## Creates a new test window with the given dimensions. Window( @@ -98,9 +109,21 @@ proc typeRune*(w: Window, rune: Rune) = return if rune.int32 < 32 or rune.int32 in 127 .. 159: return - if w.onRune != nil: + if w.onTextInput != nil: + w.onTextInput($rune) + elif w.onRune != nil: w.onRune(rune) +proc typeText*(w: Window, text: string) = + ## Delivers one committed text edit without synthesizing Enter key presses. + if not w.runeInputEnabled or text.len == 0: + return + if w.onTextInput != nil: + w.onTextInput(text) + else: + for rune in text.runes: + w.typeRune(rune) + proc changeFocus*(w: Window) = ## Releases held buttons and delivers a window focus change. for button in Button: diff --git a/src/silky/textboxes.nim b/src/silky/textboxes.nim index e2aca6b..c520cb3 100644 --- a/src/silky/textboxes.nim +++ b/src/silky/textboxes.nim @@ -38,6 +38,7 @@ const LF = Rune(10) CR = Rune(13) Space = Rune(32) + Tab = Rune(9) CursorWidth* = 2.0f DoubleClickTime = 0.4 SelectionColor = rgbx(30, 60, 130, 128) @@ -48,6 +49,50 @@ else: const TextBoxScrollSpeed = -10.0f type + TextSpan* = object + ## Read-only formatting over a half-open range of rune offsets. + first*, last*: int + font*: string + color*: ColorRGBX + background*: ColorRGBX + strikethrough*: bool + linkId*: int + ## Matching positive IDs group the styled parts of one hoverable link. + + TextBlock* = object + ## A styled block over a half-open range of shared rune offsets. + first*, last*: int + font*: string + padding*: Vec2 + spacing*: float32 + patch*: string + patchBorder*: int + label*, labelFont*: string + labelColor*: ColorRGBX + labelSpacing*: float32 + ruleColor*: ColorRGBX + ruleThickness*: float32 + ## Positive thickness turns an empty block into a horizontal rule. + quoteDepth*, quoteContinue*: int + ## Continued quote levels also draw their borders through spacing. + quoteIndent*: float32 = 16 + quoteColor*: ColorRGBX + quoteThickness*: float32 = 2 + + BlockLayout = object + bounds: Rect + ruleBounds: Rect + quoteBounds: Rect + labelPosition: Vec2 + labelFont: string + + TextRun = object + text: string + font: string + position: Vec2 + bounds: Rect + span: int + TextBoxState* = ref object ## State for a multi-line text box widget. runes*: seq[Rune] @@ -73,10 +118,25 @@ type scrollingX*: bool scrollDragOffset*: Vec2 singleLine*: bool + submitOnEnter*: bool + submitted*: bool enabled*: bool = true password*: bool passwordChar*: Rune = Rune('*') allowedChars*: seq[Rune] + spans*: seq[TextSpan] + blocks*: seq[TextBlock] + activatedSpan*: int = -1 + hoveredSpan*: int = -1 + runeSpans: seq[int] + runs: seq[TextRun] + blockLayouts: seq[BlockLayout] + blockHeight: float32 + blockCursor: Rect + lastFont: string + pressedSpan: int = -1 + pressPosition: Vec2 + spanDragged: bool textCache: string textCacheValid: bool displayCache: string @@ -156,8 +216,12 @@ proc sameText*(state: TextBoxState, text: string): bool = inc runeIdx result = i == text.len and runeIdx == state.runes.len -proc setText*(state: TextBoxState, text: string) = - ## Sets the text content and resets cursor to end. +proc setText*( + state: TextBoxState, + text: string, + preserveSelection = false +) = + ## Sets text, optionally retaining the selection while content streams. ## In single-line mode, newlines are converted to spaces. ## Disallowed characters are removed. state.runes = text.toRunes @@ -171,8 +235,12 @@ proc setText*(state: TextBoxState, text: string) = if r in state.allowedChars: filtered.add(r) state.runes = filtered - state.cursor = state.runes.len - state.selector = state.cursor + if preserveSelection: + state.cursor = clamp(state.cursor, 0, state.runes.len) + state.selector = clamp(state.selector, 0, state.runes.len) + else: + state.cursor = state.runes.len + state.selector = state.cursor state.dirty = true state.invalidateTextCaches() @@ -243,6 +311,399 @@ proc computeLayout*(state: TextBoxState, fontData: FontAtlas, maxWidth: float32) inc i state.dirty = false +proc computeLayout*( + state: TextBoxState, + fonts: Table[string, FontAtlas], + baseFont: string, + maxWidth: float32 +) + +proc computeBlocks( + state: TextBoxState, + fonts: Table[string, FontAtlas], + baseFont: string, + maxWidth: float32 +) = + ## Combines block layouts into one selectable document and native run list. + state.lineHeight = fonts[baseFont].lineHeight + state.lastMaxWidth = maxWidth + state.layout.setLen(state.runes.len) + state.runeSpans.setLen(state.runes.len) + state.runs.setLen(0) + state.blockLayouts.setLen(0) + state.blockHeight = 0 + for index in state.runeSpans.mitems: + index = -1 + var + covered = 0 + previousEnd = rect(0, 0, 0, state.lineHeight) + for item in state.blocks: + let + first = clamp(item.first, covered, state.runes.len) + last = clamp(item.last, first, state.runes.len) + width = max(0.0'f, maxWidth) + depth = max(0, item.quoteDepth) + padding = vec2( + if depth > 0: + min(max(0.0'f, item.padding.x), width / 2) + else: + max(0.0'f, item.padding.x), + max(0.0'f, item.padding.y) + ) + quoteInset = min( + max(0.0'f, width - padding.x * 2), + depth.float32 * max(0.0'f, item.quoteIndent) + ) + blockWidth = width - quoteInset + for i in covered ..< first: + state.layout[i] = previousEnd + if first == last and item.ruleThickness > 0: + let + origin = vec2(quoteInset + padding.x, state.blockHeight + padding.y) + height = item.ruleThickness + padding.y * 2 + state.blockLayouts.add(BlockLayout( + bounds: rect(quoteInset, state.blockHeight, blockWidth, height), + ruleBounds: rect( + origin, + vec2(max(0.0'f, blockWidth - padding.x * 2), item.ruleThickness) + ), + quoteBounds: rect( + padding.x, state.blockHeight, max(0.0'f, width - padding.x * 2), + height + ) + )) + state.blockCursor = rect(origin, vec2(0)) + state.blockHeight += height + max(0.0'f, item.spacing) + previousEnd = state.blockCursor + covered = last + continue + let + font = + if item.font in fonts: + item.font + else: + baseFont + labelFont = + if item.labelFont in fonts: + item.labelFont + else: + baseFont + labelHeight = + if item.label.len > 0: + fonts[labelFont].lineHeight + max(0.0'f, item.labelSpacing) + else: + 0.0'f + origin = vec2( + quoteInset + padding.x, state.blockHeight + padding.y + labelHeight + ) + local = TextBoxState( + runes: state.runes[first ..< last], + wordWrap: state.wordWrap, + spans: @[TextSpan()] + ) + var spanIndices = @[-1] + for index, span in state.spans: + if span.first < last and span.last > first: + var relative = span + relative.first = max(first, span.first) - first + relative.last = min(last, span.last) - first + local.spans.add(relative) + spanIndices.add(index) + local.computeLayout(fonts, font, max(0.0'f, blockWidth - padding.x * 2)) + var contentHeight = local.lineHeight + for i, bounds in local.layout: + state.layout[first + i] = rect(origin + bounds.xy, bounds.wh) + let span = local.runeSpans[i] + if span >= 0: + state.runeSpans[first + i] = spanIndices[span] + for run in local.runs.mitems: + run.position += origin + run.bounds.x += origin.x + run.bounds.y += origin.y + if run.span >= 0: + run.span = spanIndices[run.span] + state.runs.add(run) + state.blockCursor = rect(origin.x, origin.y, 0, local.lineHeight) + if local.layout.len > 0: + let bounds = local.layout[^1] + contentHeight = bounds.y + bounds.h + state.blockCursor = rect( + origin.x + bounds.x + bounds.w, + origin.y + bounds.y, + 0, + bounds.h + ) + if local.runes[^1] == LF: + contentHeight += local.lineHeight + state.blockCursor.x = origin.x + state.blockCursor.y += local.lineHeight + let height = contentHeight + padding.y * 2 + labelHeight + state.blockLayouts.add(BlockLayout( + bounds: rect(quoteInset, state.blockHeight, blockWidth, height), + quoteBounds: rect( + padding.x, state.blockHeight, max(0.0'f, width - padding.x * 2), + height + ), + labelPosition: vec2( + quoteInset + padding.x, state.blockHeight + padding.y + ), + labelFont: labelFont + )) + state.blockHeight += height + max(0.0'f, item.spacing) + previousEnd = state.blockCursor + covered = last + for i in covered ..< state.runes.len: + state.layout[i] = previousEnd + state.dirty = false + +proc computeLayout*( + state: TextBoxState, + fonts: Table[string, FontAtlas], + baseFont: string, + maxWidth: float32 +) = + ## Lays out styled text with the same glyph positions used for selection. + state.lastFont = baseFont + if state.blocks.len > 0: + state.computeBlocks(fonts, baseFont, maxWidth) + return + state.blockLayouts.setLen(0) + state.blockHeight = 0 + if state.spans.len == 0: + state.runeSpans.setLen(0) + state.runs.setLen(0) + state.computeLayout(fonts[baseFont], maxWidth) + return + let base = fonts[baseFont] + var + resolvedFonts = @[base] + fontNames = @[baseFont] + baseline = base.ascent + descent = base.descent + lineGap = base.lineGap + state.lineHeight = base.lineHeight + state.lastMaxWidth = maxWidth + state.layout.setLen(0) + state.runs.setLen(0) + state.runeSpans.setLen(state.runes.len) + for index in state.runeSpans.mitems: + index = -1 + for index, span in state.spans: + let name = + if span.font.len > 0 and span.font in fonts: + span.font + else: + baseFont + let font = fonts[name] + resolvedFonts.add(font) + fontNames.add(name) + if span.first < state.runes.len and span.last > max(0, span.first): + state.lineHeight = max(state.lineHeight, font.lineHeight) + baseline = max(baseline, font.ascent) + descent = min(descent, font.descent) + lineGap = max(lineGap, font.lineGap) + for i in max(0, span.first) ..< min(span.last, state.runes.len): + state.runeSpans[i] = index + state.lineHeight = max(state.lineHeight, baseline - descent + lineGap) + var + advances = newSeq[float32](state.runes.len) + kernings = newSeq[float32](state.runes.len) + for i, rune in state.runes: + if rune == LF: + continue + let font = resolvedFonts[state.runeSpans[i] + 1] + var entry: ptr LetterEntry + if rune == Tab: + if font.lookupLetter(Space, 0, entry): + advances[i] = entry.advance * 4 + continue + if font.lookupLetter(rune, 0, entry): + advances[i] = entry.advance + if i + 1 < state.runes.len and state.runes[i + 1] != LF and + font == resolvedFonts[state.runeSpans[i + 1] + 1]: + kernings[i] = font.lookupKerning(rune, state.runes[i + 1]) + var position = vec2(0, 0) + for i, rune in state.runes: + if rune == LF: + state.layout.add(rect(position.x, position.y, 0, state.lineHeight)) + position.x = 0 + position.y += state.lineHeight + continue + if state.wordWrap and maxWidth < float32.high: + let wordStart = i == 0 or state.runes[i - 1] in [Space, Tab, LF] + if wordStart and rune notin [Space, Tab] and position.x > 0: + var + wordWidth = 0.0'f + j = i + while j < state.runes.len and state.runes[j] notin [Space, Tab, LF]: + wordWidth += advances[j] + if j + 1 < state.runes.len and + state.runes[j + 1] notin [Space, Tab, LF]: + wordWidth += kernings[j] + inc j + if position.x + wordWidth > maxWidth: + position.x = 0 + position.y += state.lineHeight + if position.x > 0 and position.x + advances[i] > maxWidth: + position.x = 0 + position.y += state.lineHeight + state.layout.add(rect( + position.x, position.y, advances[i], state.lineHeight + )) + position.x += advances[i] + kernings[i] + var i = 0 + while i < state.runes.len: + if state.runes[i] in [LF, Tab]: + inc i + continue + let + first = i + span = state.runeSpans[i] + font = resolvedFonts[span + 1] + bounds = state.layout[i] + var text = "" + while i < state.runes.len and state.runes[i] notin [LF, Tab] and + state.runeSpans[i] == span and state.layout[i].y == bounds.y: + text.add($state.runes[i]) + inc i + let last = state.layout[i - 1] + state.runs.add(TextRun( + text: text, + font: fontNames[span + 1], + position: vec2(bounds.x, bounds.y + baseline - font.ascent), + bounds: rect( + bounds.x, bounds.y, last.x + last.w - bounds.x, bounds.h + ), + span: state.runeSpans[first] + )) + state.dirty = false + +proc spanAt(state: TextBoxState, position: Vec2): int = + ## Finds a formatted span only when the pointer touches its glyph bounds. + for i, bounds in state.layout: + if position.overlaps(bounds) and i < state.runeSpans.len: + return state.runeSpans[i] + -1 + +proc drawSpanBackgrounds(sk: Silky, state: TextBoxState, origin: Vec2) = + ## Draws inline backgrounds before the native selection highlight. + for run in state.runs: + if run.span >= 0: + let color = state.spans[run.span].background + if color.a > 0: + sk.drawRect(origin + run.bounds.xy, run.bounds.wh, color) + +proc drawBlocks( + sk: Silky, + state: TextBoxState, + origin: Vec2, + defaultColor: ColorRGBX +) = + ## Draws native block surfaces, borders, rules, and captions. + for i, bounds in state.blockLayouts: + let item = state.blocks[i] + if item.patch.len > 0: + sk.draw9Patch( + item.patch, + item.patchBorder, + origin + bounds.bounds.xy, + bounds.bounds.wh + ) + if item.quoteDepth > 0 and item.quoteThickness > 0 and + item.quoteColor.a > 0 and bounds.quoteBounds.w > 0: + let + scale = max(sk.uiScale, 0.001'f) + indent = max(0.0'f, item.quoteIndent) + position = origin + bounds.quoteBounds.xy + limit = floor((position.x + bounds.quoteBounds.w) * scale) / scale + top = round(position.y * scale) / scale + thickness = max(1.0'f, round(item.quoteThickness * scale)) / scale + continuation = clamp(item.quoteContinue, 0, item.quoteDepth) + for level in 0 ..< item.quoteDepth: + let + left = round((position.x + level.float32 * indent) * scale) / + scale + width = min(thickness, limit - left) + if width <= 0: + break + let + spacing = + if level < continuation: + max(0.0'f, item.spacing) + else: + 0.0'f + bottom = round( + (position.y + bounds.quoteBounds.h + spacing) * scale + ) / scale + sk.drawRect( + vec2(left, top), vec2(width, bottom - top), item.quoteColor + ) + if indent == 0: + break + if bounds.ruleBounds.h > 0: + if bounds.ruleBounds.w > 0 and item.ruleColor.a > 0: + let + scale = max(sk.uiScale, 0.001'f) + position = origin + bounds.ruleBounds.xy + left = round(position.x * scale) / scale + right = round((position.x + bounds.ruleBounds.w) * scale) / scale + top = round(position.y * scale) / scale + thickness = max(1.0'f, round(bounds.ruleBounds.h * scale)) / scale + sk.drawRect( + vec2(left, top), vec2(right - left, thickness), item.ruleColor + ) + continue + if item.label.len > 0: + let color = + if item.labelColor.a > 0: + item.labelColor + else: + defaultColor + discard sk.drawText( + bounds.labelFont, + item.label, + origin + bounds.labelPosition, + color, + maxWidth = max(0.0'f, bounds.bounds.w - item.padding.x * 2) + ) + +proc drawSpans( + sk: Silky, + state: TextBoxState, + origin: Vec2, + defaultColor: ColorRGBX +) = + ## Draws cached runs at their measured positions without wrapping again. + for run in state.runs: + let color = + if run.span >= 0 and state.spans[run.span].color.a > 0: + state.spans[run.span].color + else: + defaultColor + discard sk.drawText( + run.font, run.text, origin + run.position, color, clip = false + ) + if run.span >= 0: + let + span = state.spans[run.span] + underline = state.hoveredSpan >= 0 and span.linkId > 0 and + span.linkId == state.spans[state.hoveredSpan].linkId + if span.strikethrough or underline: + let + font = sk.atlas.fonts[run.font] + scale = max(sk.uiScale, 0.001'f) + left = round((origin.x + run.bounds.x) * scale) / scale + right = round((origin.x + run.bounds.x + run.bounds.w) * scale) / + scale + baseline = origin.y + run.position.y + font.ascent + thickness = max(1.0'f, round(scale)) / scale + if span.strikethrough: + let top = round((baseline - font.size * 0.3'f) * scale) / scale + sk.drawRect(vec2(left, top), vec2(right - left, thickness), color) + if underline: + let top = round((baseline + 1) * scale) / scale + sk.drawRect(vec2(left, top), vec2(right - left, thickness), color) + proc pickGlyphAt*(state: TextBoxState, pos: Vec2): int = ## Finds the character index closest to the given position. ## Returns -1 if no character found on that line. @@ -254,6 +715,17 @@ proc pickGlyphAt*(state: TextBoxState, pos: Vec2): int = if minDistance < 0 or dist < minDistance: minDistance = dist minGlyph = i + if minGlyph < 0 and state.blocks.len > 0: + var nearestY = float32.high + for i, bounds in state.layout: + let + distanceY = max(bounds.y - pos.y, pos.y - bounds.y - bounds.h) + distanceX = abs(pos.x - bounds.x) + if distanceY < nearestY or + (distanceY == nearestY and distanceX < minDistance): + nearestY = distanceY + minDistance = distanceX + minGlyph = i return minGlyph proc getSelectionRects*(layout: seq[Rect], start, stop: int): seq[Rect] = @@ -273,6 +745,8 @@ proc getSelectionRects*(layout: seq[Rect], start, stop: int): seq[Rect] = proc innerHeight*(state: TextBoxState): float32 = ## Returns the total content height. + if state.blocks.len > 0: + return state.blockHeight if state.layout.len > 0: let lastPos = state.layout[^1] return lastPos.y + lastPos.h @@ -287,6 +761,13 @@ proc innerWidth*(state: TextBoxState): float32 = proc locationRect*(state: TextBoxState, loc: int): Rect = ## Returns the rectangle where the cursor should be drawn. + if state.blocks.len > 0: + if loc >= state.layout.len: + result = state.blockCursor + elif loc >= 0: + result = state.layout[loc] + result.w = CursorWidth + return if state.layout.len > 0: if loc >= state.layout.len: let selectRect = state.layout[^1] @@ -527,12 +1008,39 @@ proc right*(state: TextBoxState, shift = false) = state.scrollToCursor() state.resetBlink() +proc adjacentLine(state: TextBoxState, forward: bool): int = + ## Finds a neighboring visual line when document blocks vary in height. + let currentY = state.cursorPos.y + var + nearest = float32.high + targetY = currentY + for bounds in state.layout: + let distance = + if forward: + bounds.y - currentY + else: + currentY - bounds.y + if distance > 0 and distance < nearest: + nearest = distance + targetY = bounds.y + bounds.h * 0.5'f + if nearest == float32.high: + if forward: + return state.runes.len + return 0 + state.pickGlyphAt(vec2(state.savedX, targetY)) + proc down*(state: TextBoxState, shift = false) = ## Moves the cursor down one line. if state.layout.len > 0: - let curPos = state.cursorPos - let index = state.pickGlyphAt( - vec2(state.savedX, curPos.y + state.lineHeight * 1.5)) + let + curPos = state.cursorPos + index = + if state.blocks.len > 0: + state.adjacentLine(true) + else: + state.pickGlyphAt( + vec2(state.savedX, curPos.y + state.lineHeight * 1.5) + ) if index != -1: state.cursor = index if not shift: @@ -547,9 +1055,15 @@ proc down*(state: TextBoxState, shift = false) = proc up*(state: TextBoxState, shift = false) = ## Moves the cursor up one line. if state.layout.len > 0: - let curPos = state.cursorPos - let index = state.pickGlyphAt( - vec2(state.savedX, curPos.y - state.lineHeight * 0.5)) + let + curPos = state.cursorPos + index = + if state.blocks.len > 0: + state.adjacentLine(false) + else: + state.pickGlyphAt( + vec2(state.savedX, curPos.y - state.lineHeight * 0.5) + ) if index != -1: state.cursor = index if not shift: @@ -769,7 +1283,9 @@ proc handleKey(state: TextBoxState, event: InputEvent) = of KeyPageDown: state.pageDown(shift) of KeyEnter: - if not state.singleLine: + if state.submitOnEnter and not shift: + state.submitted = state.enabled + elif not state.singleLine: state.typeCharacter(LF) of KeyA: if ctrl: @@ -804,7 +1320,8 @@ proc handleKey(state: TextBoxState, event: InputEvent) = proc handleInput( state: TextBoxState, inputs: TextInputs, - fontData: FontAtlas, + fonts: Table[string, FontAtlas], + baseFont: string, maxWidth: float32 ) = ## Drains ordered input and refreshes layout before cursor navigation. @@ -817,18 +1334,32 @@ proc handleInput( case event.kind of TextInput: state.typeCharacter(event.rune) + of TextCommit: + state.pasteText(event.text) + of NativeEdit: + if state.enabled: + let first = clamp(event.edit.start, 0, state.runes.len) + state.selector = first + state.cursor = first + clamp( + event.edit.length, 0, state.runes.len - first + ) + case event.edit.kind + of ReplaceText: + state.pasteText(event.edit.text) + of SelectText: + state.resetBlink() of InputKind.KeyDown: if state.dirty and event.button in { KeyLeft, KeyRight, KeyUp, KeyDown, KeyHome, KeyEnd, KeyPageUp, KeyPageDown }: - state.computeLayout(fontData, maxWidth) + state.computeLayout(fonts, baseFont, maxWidth) state.handleKey(event) of InputKind.KeyUp: discard if events.len > 0: if state.dirty: - state.computeLayout(fontData, maxWidth) + state.computeLayout(fonts, baseFont, maxWidth) state.scrollToCursor() proc drawScrollbars*(sk: Silky, state: TextBoxState, window: Window, @@ -914,7 +1445,10 @@ proc textBox*( error: bool = false, password: bool = false, passwordChar: Rune = Rune('*'), - allowedChars: seq[Rune] = default(seq[Rune]) + allowedChars: seq[Rune] = default(seq[Rune]), + submitOnEnter: bool = false, + spans: seq[TextSpan] = default(seq[TextSpan]), + blocks: seq[TextBlock] = default(seq[TextBlock]) ) {.measure.} = ## Text box widget with editing, selection, and scroll. ## When disabled, text can be selected and copied but not modified. @@ -931,12 +1465,35 @@ proc textBox*( newState.setText(t) textBoxStates[id] = newState state = textBoxStates[id] + state.submitOnEnter = submitOnEnter + state.submitted = false + state.activatedSpan = -1 + state.hoveredSpan = -1 let inputs = sk.textInputs if state.focused and state.focusEpoch != inputs.focusEpoch: state.focused = false state.dragging = false + if not enabled: + state.selector = state.cursor state.enabled = enabled state.allowedChars = allowedChars + let + activeSpans = + if not enabled and not password: + spans + else: + default(seq[TextSpan]) + activeBlocks = + if not enabled and not password and not singleLine: + blocks + else: + default(seq[TextBlock]) + if state.spans != activeSpans: + state.spans = activeSpans + state.dirty = true + if state.blocks != activeBlocks: + state.blocks = activeBlocks + state.dirty = true if state.password != password: state.password = password state.dirty = true @@ -951,10 +1508,11 @@ proc textBox*( if state.wordWrap != effectiveWrap: state.wordWrap = effectiveWrap state.dirty = true - if not state.focused and not state.sameText(t): - state.setText(t) + if (not state.focused or not enabled) and not state.sameText(t): + state.setText(t, preserveSelection = not enabled) let - fontData = sk.atlas.fonts[sk.textStyle] + firstVertex = sk.vertexMark() + textLayer = sk.currentDrawLayer() padding = sk.theme.padding.float32 outerRect = rect(sk.at, vec2(boxWidth, boxHeight)) innerRect = rect( @@ -963,8 +1521,8 @@ proc textBox*( ) state.boxSize = vec2(innerRect.w, innerRect.h) if state.dirty or state.layout.len == 0 or - state.lastMaxWidth != innerRect.w: - state.computeLayout(fontData, innerRect.w) + state.lastMaxWidth != innerRect.w or state.lastFont != sk.textStyle: + state.computeLayout(sk.atlas.fonts, sk.textStyle, innerRect.w) let shift = window.buttonDown[KeyLeftShift] or window.buttonDown[KeyRightShift] let mouseVec = sk.mousePos @@ -972,6 +1530,7 @@ proc textBox*( mouseVec.overlaps(sk.clipRect) let onScrollbar = mouseInside and not mouseVec.overlaps(innerRect) if window.buttonPressed[MouseLeft]: + state.pressedSpan = -1 if mouseInside and not onScrollbar: state.focused = true let localMouse = mouseVec - innerRect.xy + state.scrollPos @@ -985,11 +1544,31 @@ proc textBox*( of 1: state.mouseAction(localMouse, click = true, shift = shift) state.dragging = true + if not enabled and not shift: + state.pressedSpan = state.spanAt(localMouse) + state.pressPosition = mouseVec + state.spanDragged = false of 2: state.selectWord(localMouse) of 3: state.selectParagraph(localMouse) else: state.selectAll() elif not mouseInside: state.focused = false + state.dragging = false + if not enabled: + state.selector = state.cursor + if state.pressedSpan >= 0: + let distance = mouseVec - state.pressPosition + if distance.x * distance.x + distance.y * distance.y > 9: + state.spanDragged = true + if window.buttonReleased[MouseLeft]: + let localMouse = mouseVec - innerRect.xy + state.scrollPos + if mouseInside and not state.spanDragged and + state.cursor == state.selector and + state.spanAt(localMouse) == state.pressedSpan: + state.activatedSpan = state.pressedSpan + state.pressedSpan = -1 + elif not window.buttonDown[MouseLeft]: + state.pressedSpan = -1 if state.dragging and not state.scrollingX and not state.scrollingY: if window.buttonDown[MouseLeft] and not window.buttonPressed[MouseLeft]: let localMouse = mouseVec - innerRect.xy + state.scrollPos @@ -999,16 +1578,29 @@ proc textBox*( inputs.submitInput(window, id, state.focused, enabled) if state.focused: state.focusEpoch = inputs.focusEpoch - state.handleInput(inputs, fontData, innerRect.w) + state.handleInput(inputs, sk.atlas.fonts, sk.textStyle, innerRect.w) t = state.getText() if state.dirty: - state.computeLayout(fontData, innerRect.w) + state.computeLayout(sk.atlas.fonts, sk.textStyle, innerRect.w) let maxScrollY = max(0.0f, state.innerHeight - innerRect.h) state.scrollPos.y = clamp(state.scrollPos.y, 0.0f, maxScrollY) if state.wordWrap: state.scrollPos.x = 0 else: state.scrollPos.x = max(0.0f, state.scrollPos.x) + let + overVerticalBar = state.innerHeight > innerRect.h and + mouseVec.x >= outerRect.x + outerRect.w - 10 + overHorizontalBar = not state.wordWrap and + state.innerWidth > innerRect.w and + mouseVec.y >= outerRect.y + outerRect.h - 10 + if mouseInside and mouseVec.overlaps(innerRect) and + not state.scrollingX and not state.scrollingY and + not overVerticalBar and not overHorizontalBar: + let index = state.spanAt(mouseVec - innerRect.xy + state.scrollPos) + if index >= 0 and state.spans[index].linkId > 0: + state.hoveredSpan = index + sk.cursor = Cursor(kind: PointerCursor) let patch = if error: "textbox.error.9patch" elif not enabled: "textbox.disabled.9patch" @@ -1024,6 +1616,10 @@ proc textBox*( sk.draw9Patch(patch, sk.theme.textboxPatch, outerRect.xy, outerRect.wh) sk.pushClipRect(innerRect) let textOrigin = innerRect.xy - state.scrollPos + if state.blocks.len > 0: + sk.drawBlocks(state, textOrigin, textColor) + if state.spans.len > 0: + sk.drawSpanBackgrounds(state, textOrigin) if state.cursor != state.selector: let sel = state.selection let selRects = getSelectionRects(state.layout, sel.a, sel.b) @@ -1031,15 +1627,18 @@ proc textBox*( sk.drawRect( vec2(textOrigin.x + r.x, textOrigin.y + r.y), vec2(r.w, r.h), SelectionColor) - discard sk.drawText( - sk.textStyle, - state.displayText, - textOrigin, - textColor, - maxWidth = innerRect.w, - wordWrap = state.wordWrap, - clip = false - ) + if state.spans.len > 0 or state.blocks.len > 0: + sk.drawSpans(state, textOrigin, textColor) + else: + discard sk.drawText( + sk.textStyle, + state.displayText, + textOrigin, + textColor, + maxWidth = innerRect.w, + wordWrap = state.wordWrap, + clip = false + ) if state.focused and state.cursorVisible: let cr = state.locationRect(state.cursor) sk.drawRect( @@ -1053,6 +1652,31 @@ proc textBox*( window.scrollDelta.y != 0: state.scrollBy( window.scrollDelta.y * TextBoxScrollSpeed, innerRect.h) + if state.focused and enabled and not password: + let selection = state.selection + var + visibleStart = state.runes.len + visibleEnd = 0 + for i, bounds in state.layout: + if bounds.y + bounds.h > state.scrollPos.y and + bounds.y < state.scrollPos.y + innerRect.h and + bounds.x + bounds.w >= state.scrollPos.x and + bounds.x < state.scrollPos.x + innerRect.w: + visibleStart = min(visibleStart, i) + visibleEnd = i + 1 + if visibleEnd == 0: + visibleStart = min(state.cursor, state.runes.len) + visibleEnd = visibleStart + inputs.publishTextInput(TextInputState( + id: id, + text: t, + selectionStart: selection.a, + selectionEnd: selection.b, + visibleStart: visibleStart, + visibleEnd: visibleEnd, + bounds: rect(innerRect.xy * sk.uiScale, innerRect.wh * sk.uiScale), + multiline: not singleLine + ), textLayer, firstVertex) sk.advance(vec2(boxWidth, boxHeight)) template textBox*( @@ -1065,11 +1689,17 @@ template textBox*( isError = false, isPassword = false, passChar: Rune = Rune('*'), - chars: seq[Rune] = default(seq[Rune]) + chars: seq[Rune] = default(seq[Rune]), + submitOnEnter = false, + spans: seq[TextSpan] = default(seq[TextSpan]), + blocks: seq[TextBlock] = default(seq[TextBlock]) ) = ## Text box widget. Set singleLine for a single-line input. + ## submitOnEnter reports Enter in state.submitted and preserves Shift+Enter. + ## Read-only spans preserve selection and report clicks in activatedSpan. sk.textBox(window, id, t, boxWidth, boxHeight, wrapWords, singleLine, - isEnabled, isError, isPassword, passChar, chars + isEnabled, isError, isPassword, passChar, chars, submitOnEnter, spans, + blocks ) template textInput*( diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index 53423ee..d1a4f92 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -449,26 +449,40 @@ template frame*(id: string, framePos, frameSize: Vec2, body: untyped) = sk.frameEnd(window, frameCtx.state, frameCtx.originPos) sk.endWidget() -template button*(label: string, isEnabled: bool, isError: bool, body: untyped) = - ## Create a button with enabled and error states. +template sizedButton( + label: string, + minimumSize: Vec2, + isEnabled: bool, + isError: bool, + body: untyped +) = + ## Creates a native button with a minimum size and a left-aligned label. let - textSize = sk.getTextSize(sk.textStyle, label) - buttonSize = textSize + vec2(sk.theme.padding) * 2 + buttonLabel = label + minimum = minimumSize + enabled = isEnabled + error = isError + padding = vec2(sk.theme.padding) + textSize = sk.getTextSize(sk.textStyle, buttonLabel) + intrinsicSize = textSize + padding * 2 + buttonSize = vec2( + max(intrinsicSize.x, minimum.x), max(intrinsicSize.y, minimum.y) + ) buttonOrigin = sk.placedAt(buttonSize) buttonRect = rect(buttonOrigin, buttonSize) when defined(silkyTesting): - sk.beginWidget("Button", text = label, rect = buttonRect) + sk.beginWidget("Button", text = buttonLabel, rect = buttonRect) let textColor = - if not isEnabled: + if not enabled: sk.theme.disabledTextColor - elif isError: + elif error: sk.theme.errorTextColor else: sk.theme.defaultTextColor - interaction = sk.interact(buttonRect, isEnabled, isError) + interaction = sk.interact(buttonRect, enabled, error) let patch = case interaction of Error: @@ -491,21 +505,48 @@ template button*(label: string, isEnabled: bool, isError: bool, body: untyped) = let pressed = interaction == Pressed or interaction == Held hovered = pressed or interaction == Hovered - sk.setWidgetState(enabled = isEnabled, pressed = pressed, hovered = hovered) + sk.setWidgetState(enabled = enabled, pressed = pressed, hovered = hovered) - let at = buttonOrigin + vec2(sk.theme.padding) - discard sk.drawText(sk.textStyle, label, at, textColor) + let at = buttonOrigin + vec2(padding.x, (buttonSize.y - textSize.y) / 2) + discard sk.drawText(sk.textStyle, buttonLabel, at, textColor) when defined(silkyTesting): sk.endWidget() - sk.advance(buttonSize + vec2(sk.theme.padding)) + sk.advance(buttonSize + padding) + +template button*( + label: string, + minimumSize: Vec2, + isEnabled: bool, + isError: bool, + body: untyped +) = + ## Creates a minimum-size button with enabled and error states. + sizedButton(label, minimumSize, isEnabled, isError, body) + +template button*( + label: string, + minimumSize: Vec2, + isEnabled: bool, + body: untyped +) = + ## Creates a native button with a minimum size and an enabled state. + sizedButton(label, minimumSize, isEnabled, false, body) + +template button*(label: string, minimumSize: Vec2, body: untyped) = + ## Creates a native button with a minimum size. + sizedButton(label, minimumSize, true, false, body) + +template button*(label: string, isEnabled: bool, isError: bool, body: untyped) = + ## Creates an intrinsic-size button with enabled and error states. + sizedButton(label, vec2(0), isEnabled, isError, body) template button*(label: string, body: untyped) = ## Create a button. - button(label, true, false, body) + sizedButton(label, vec2(0), true, false, body) template button*(label: string, isEnabled: bool, body: untyped) = ## Create a button. - button(label, isEnabled, false, body) + sizedButton(label, vec2(0), isEnabled, false, body) template icon*(image: string) = ## Draw an icon. diff --git a/tests/test_button_vertices.nim b/tests/test_button_vertices.nim new file mode 100644 index 0000000..fcce423 --- /dev/null +++ b/tests/test_button_vertices.nim @@ -0,0 +1,85 @@ +import + std/[importutils, tables], + bumpy, pixie, vmath, windy, + silky, silky/internal/inputs + +const FontPath = "tests/data/IBMPlexSans-Regular.ttf" + +proc close(a, b: float32): bool = + ## Compares font and button positions with float tolerance. + abs(a - b) < 0.001 + +proc fixture(builder: AtlasBuilder, density: float32): Silky = + ## Queues native button vertices without a platform window or GPU. + privateAccess(Silky) + result = Silky( + atlas: builder.atlas, + image: builder.atlasImage, + uiScale: density, + inFrame: true, + inputs: TextInputs(), + window: Window() + ) + new(result.drawer) + result.mousePos = vec2(-1) + result.pushLayout(vec2(0), vec2(400)) + result.pushClipRect(rect(0, 0, 400, 400)) + +proc glyphPosition(sk: Silky): Vec2 = + ## Finds the W glyph quad by its atlas entry. + let + glyph = sk.atlas.fonts["Default"].entries["W"][0] + uv = vec2(glyph.x.float32, glyph.y.float32) + for vertex in sk.drawer.layers[NormalLayer]: + if vertex.uv == uv: + return vertex.pos + doAssert false, "The button label must use the native glyph atlas." + +block: + echo "Testing minimum-size button text stays left and vertically centered" + for density in [1.0'f, 1.25'f, 1.5'f, 2.0'f]: + let builder = newAtlasBuilder(256, 2) + builder.addFont( + FontPath, "Default", 18, @["W"], pixelScale = density + ) + doAssert builder.addImage("button.9patch", newImage(24, 24)) + let + sk = fixture(builder, density) + reference = fixture(builder, density) + origin = vec2(20.17'f, 30.29'f) + textSize = sk.getTextSize("Default", "W") + padding = sk.theme.padding.float32 + spacing = sk.theme.spacing.float32 + expected = origin + vec2(padding, (64 - textSize.y) / 2) + sk.at = origin + button("W", vec2(180, 64)): + doAssert false, "A button outside the pointer must not activate." + discard reference.drawText( + "Default", "W", expected, sk.theme.defaultTextColor + ) + let + actualGlyph = sk.glyphPosition() + expectedGlyph = reference.glyphPosition() + doAssert close(actualGlyph.x, expectedGlyph.x) + doAssert close(actualGlyph.y, expectedGlyph.y) + doAssert actualGlyph.x < origin.x + 30, + "The label must not center horizontally across the wide button." + doAssert actualGlyph.y > origin.y + padding, + "The taller button must center the label vertically." + doAssert close(sk.at.y, origin.y + 64 + padding + spacing), + "Native layout must advance by the actual button height." + + sk.drawer.layers[NormalLayer].setLen(0) + reference.drawer.layers[NormalLayer].setLen(0) + sk.at = origin + button("W"): + discard + discard reference.drawText( + "Default", "W", origin + vec2(padding), sk.theme.defaultTextColor + ) + let legacy = sk.glyphPosition() + doAssert close(legacy.x, reference.glyphPosition().x) + doAssert close(legacy.y, reference.glyphPosition().y) + doAssert close(sk.at.y, origin.y + textSize.y + padding * 3 + spacing) + +echo "All native button vertex tests passed." diff --git a/tests/test_buttons.nim b/tests/test_buttons.nim new file mode 100644 index 0000000..20a9860 --- /dev/null +++ b/tests/test_buttons.nim @@ -0,0 +1,128 @@ +import + bumpy, vmath, + silky + +when not defined(silkyTesting): + {.error: "Compile with -d:silkyTesting".} + +var + clicks: int + enabled = true + clipped = false + evaluations: array[4, int] + +proc drawButtons(sk: Silky, window: Window) = + ## Draws a full-width model row and a legacy intrinsic button. + sk.pushLayout(vec2(40, 30), vec2(260, 220)) + if clipped: + sk.pushClipRect(rect(40, 30, 120, 80)) + button("Model", vec2(240, 64), enabled): + inc clicks + if clipped: + sk.popClipRect() + button("Legacy"): + discard + sk.popLayout() + +proc click(harness: var TestHarness, position: Vec2) = + ## Clicks a UI coordinate after warming the native hover state. + let pixels = (position * harness.sk.uiScale).ivec2 + harness.window.moveMouse(pixels.x, pixels.y) + discard harness.pumpFrame(drawButtons, 2) + harness.window.pressButton(MouseLeft) + discard harness.pumpFrame(drawButtons) + harness.window.releaseButton(MouseLeft) + discard harness.pumpFrame(drawButtons) + +proc countedLabel(): string = + ## Counts how many times a template evaluates its label expression. + inc evaluations[0] + "Counted" + +proc countedSize(): Vec2 = + ## Counts how many times a template evaluates its minimum size. + inc evaluations[1] + vec2(180, 64) + +proc countedEnabled(): bool = + ## Counts how many times a template evaluates its enabled expression. + inc evaluations[2] + true + +proc countedError(): bool = + ## Counts how many times a template evaluates its error expression. + inc evaluations[3] + false + +proc drawCounted(sk: Silky, window: Window) = + ## Exercises the full overload inside an actual deferred DSL group. + ui: + group "menu": + box 40, 30, 260, 300 + button( + countedLabel(), countedSize(), countedEnabled(), countedError() + ): + discard + button("Minimum", vec2(180, 32)): + discard + button("Enabled", true): + discard + button("Error", true, false): + discard + button("Too small", vec2(1)): + discard + +block: + echo "Testing minimum button size preserves native hit testing" + let builder = newAtlasBuilder(1024, 4) + builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18) + for scale in [1.0'f, 1.25'f, 1.5'f, 2.0'f]: + var harness = newTestHarness(builder.atlas, 1200, 1000) + harness.sk.uiScale = scale + clicks = 0 + enabled = true + clipped = false + discard harness.pumpFrame(drawButtons) + let + model = harness.findByText("Model", "Button") + legacy = harness.findByText("Legacy", "Button") + intrinsic = harness.sk.getTextSize("Default", "Legacy") + + vec2(harness.sk.theme.padding.float32 * 2) + rightEdge = model.rect.xy + vec2(model.rect.w - 2, model.rect.h / 2) + doAssert model.rect.wh == vec2(240, 64) + doAssert legacy.rect.wh == intrinsic + let pixels = (rightEdge * scale).ivec2 + harness.window.moveMouse(pixels.x, pixels.y) + discard harness.pumpFrame(drawButtons, 2) + doAssert harness.findByText("Model", "Button").state.hovered + harness.click(rightEdge) + doAssert clicks == 1 + + enabled = false + discard harness.pumpFrame(drawButtons) + doAssert not harness.findByText("Model", "Button").state.enabled + harness.click(rightEdge) + doAssert clicks == 1 + doAssert not harness.findByText("Model", "Button").state.hovered + + enabled = true + clipped = true + harness.click(rightEdge) + doAssert clicks == 1 + doAssert not harness.findByText("Model", "Button").state.hovered + harness.click(model.rect.xy + vec2(16, model.rect.h / 2)) + doAssert clicks == 2 + + echo "Testing sized button arguments are evaluated once per frame" + var harness = newTestHarness(builder.atlas) + evaluations = [0, 0, 0, 0] + for frame in 1 .. 3: + discard harness.pumpFrame(drawCounted) + doAssert evaluations == [frame, frame, frame, frame] + doAssert harness.findByText("Counted", "Button").rect.wh == vec2(180, 64) + doAssert harness.findByText("Minimum", "Button").rect.w == 180 + doAssert harness.findByText("Too small", "Button").rect.wh == + harness.sk.getTextSize("Default", "Too small") + + vec2(harness.sk.theme.padding.float32 * 2) + +echo "All native button interaction tests passed." diff --git a/tests/test_decorations.nim b/tests/test_decorations.nim new file mode 100644 index 0000000..8da10ae --- /dev/null +++ b/tests/test_decorations.nim @@ -0,0 +1,118 @@ +import + std/[importutils, math, tables, unicode], + bumpy, pixie, vmath, windy, + silky, silky/internal/inputs + +const + FontPath = "tests/data/IBMPlexSans-Regular.ttf" + TextColor = rgbx(90, 150, 220, 255) + +proc close(a, b: float32): bool = + ## Compares physical and logical coordinates with float tolerance. + abs(a - b) < 0.001 + +proc fixture(builder: AtlasBuilder, density: float32): Silky = + ## Queues native widget vertices without a platform window or GPU. + privateAccess(Silky) + result = Silky( + atlas: builder.atlas, + image: builder.atlasImage, + uiScale: density, + inFrame: true, + inputs: TextInputs() + ) + new(result.drawer) + result.theme.padding = 0 + result.pushLayout(vec2(0), vec2(300, 300)) + result.pushClipRect(rect(0, 0, 300, 300)) + +proc rules(sk: Silky): seq[Rect] = + ## Extracts solid decoration quads and checks their color and clipping. + let vertices = sk.drawer.layers[NormalLayer] + for i in countup(0, vertices.high, 6): + if vertices[i].uv != vertices[i + 2].uv: + continue + doAssert vertices[i].color == TextColor + doAssert vertices[i].clipPos == vec2(10.17'f, 20.29'f) + doAssert vertices[i].clipSize == vec2(64, 220) + for j in i ..< i + 6: + let pixel = vertices[j].pos * sk.uiScale + doAssert close(pixel.x, round(pixel.x)) + doAssert close(pixel.y, round(pixel.y)) + result.add(rect( + vertices[i].pos, + vertices[i + 2].pos - vertices[i].pos + )) + +block: + echo "Testing native strikethrough wrapping, hover, and pixel alignment" + let window = Window() + for density in [1.0'f, 1.25'f, 1.5'f, 2.0'f]: + let + builder = newAtlasBuilder(256, 2) + origin = vec2(10.17'f, 20.29'f) + builder.addFont( + FontPath, + "Default", + 18, + @[" ", "W"], + pixelScale = density + ) + doAssert builder.addImage("textbox.disabled.9patch", newImage(16, 16)) + let sk = fixture(builder, density) + var + text = "WW WW WW" + spans = @[ + TextSpan( + first: 0, + last: text.runeLen, + strikethrough: true, + color: TextColor, + linkId: 1 + ) + ] + sk.at = origin + sk.mousePos = vec2(-1) + sk.textBox( + window, "decorations", text, 64, 220, + wrapWords = true, enabled = false, spans = spans + ) + let + state = textBoxStates["decorations"] + strikeRules = sk.rules() + doAssert strikeRules.len == 3 + for i, rule in strikeRules: + let line = origin.y + state.layout[i * 3].y + doAssert rule.y > line + doAssert rule.y + rule.h < line + state.lineHeight + doAssert rule.w <= 64 + doAssert close(rule.h * density, max(1.0'f, round(density))) + state.selectAll() + doAssert state.copyText() == text + state.selector = state.cursor + + sk.drawer.layers[NormalLayer].setLen(0) + sk.at = origin + sk.mousePos = origin + state.layout[0].xy + state.layout[0].wh / 2 + sk.textBox( + window, "decorations", text, 64, 220, + wrapWords = true, enabled = false, spans = spans + ) + let hoveredRules = sk.rules() + doAssert hoveredRules.len == strikeRules.len * 2 + for i, rule in strikeRules: + doAssert hoveredRules[i * 2] == rule + doAssert hoveredRules[i * 2 + 1].y > rule.y + + sk.drawer.layers[NormalLayer].setLen(0) + sk.at = origin + sk.mousePos = vec2(-1) + spans[0].strikethrough = false + sk.textBox( + window, "decorations", text, 64, 220, + wrapWords = true, enabled = false, spans = spans + ) + doAssert sk.rules().len == 0 + textBoxStates.del("decorations") + +echo "All text decoration tests passed." diff --git a/tests/test_density.nim b/tests/test_density.nim new file mode 100644 index 0000000..cff3d65 --- /dev/null +++ b/tests/test_density.nim @@ -0,0 +1,315 @@ +import + std/[importutils, math, os, tables], + bumpy, pixie, vmath, + silky + +const + FontPath = "tests/data/IBMPlexSans-Regular.ttf" + Glyphs = @[" ", "A", "V", "W", "g", "j", "@"] + +proc close(a, b: float32): bool = + ## Compares font metrics with a small floating point tolerance. + abs(a - b) < 0.001 + +proc overlaps(a, b: Entry): bool = + ## Returns whether two physical atlas rectangles share pixels. + a.x < b.x + b.width and b.x < a.x + a.width and + a.y < b.y + b.height and b.y < a.y + a.height + +proc newTextFixture(builder: AtlasBuilder, density: float32): Silky = + ## Queues native glyph vertices without creating a window or GPU. + privateAccess(Silky) + result = Silky( + atlas: builder.atlas, + image: builder.atlasImage, + uiScale: density, + inFrame: true + ) + new(result.drawer) + result.pushLayout(vec2(0, 0), vec2(300, 200)) + result.pushClipRect(rect(0, 0, 300, 200)) + +proc entryAt(font: FontAtlas, uv: Vec2): LetterEntry = + ## Finds the exact atlas variant emitted by the native renderer. + for variants in font.entries.values: + for entry in variants: + if uv == vec2(entry.x.float32, entry.y.float32): + return entry + doAssert false, "No glyph at texture coordinate " & $uv + +proc assertPixelQuads(sk: Silky) = + ## Requires each glyph texel to cover exactly one framebuffer pixel. + let vertices = sk.drawer.layers[NormalLayer] + doAssert vertices.len > 0 + doAssert vertices.len mod 6 == 0 + for vertex in vertices: + let pixel = vertex.pos * sk.uiScale + doAssert close(pixel.x, round(pixel.x)), $pixel + doAssert close(pixel.y, round(pixel.y)), $pixel + doAssert close(vertex.uv.x, round(vertex.uv.x)) + doAssert close(vertex.uv.y, round(vertex.uv.y)) + for i in countup(0, vertices.high, 6): + let + physical = (vertices[i + 2].pos - vertices[i].pos) * sk.uiScale + texture = vertices[i + 2].uv - vertices[i].uv + doAssert close(physical.x, texture.x), $physical & " != " & $texture + doAssert close(physical.y, texture.y), $physical & " != " & $texture + +block: + echo "Testing exact-scale font rasterization and native text vertices" + let regular = newAtlasBuilder(256, 2) + regular.addFont(FontPath, "Default", 17, Glyphs) + let reference = regular.atlas.fonts["Default"] + for density in [0.75'f, 1.0'f, 1.25'f, 1.5'f, 2.0'f]: + for steps in [0, 10]: + let builder = newAtlasBuilder(512, 2) + builder.addFont( + FontPath, + "Default", + 17, + Glyphs, + subpixelSteps = steps, + pixelScale = density + ) + let + font = builder.atlas.fonts["Default"] + sk = newTextFixture(builder, density) + origin = vec2(10.17, 20.29) + height = 100.0'f + doAssert font.pixelScale == density + doAssert close(font.size, reference.size) + doAssert close(font.ascent, reference.ascent) + doAssert close(font.descent, reference.descent) + doAssert close(font.lineHeight, reference.lineHeight) + for glyph in Glyphs: + let + entry = font.entries[glyph][0] + expected = reference.entries[glyph][0] + doAssert close(entry.advance, expected.advance), glyph + for next, adjustment in expected.kerning: + doAssert close(entry.kerning[next], adjustment), glyph & next + for horizontal in [LeftAlign, CenterAlign, RightAlign]: + for vertical in [TopAlign, MiddleAlign, BottomAlign]: + sk.drawer.layers[NormalLayer].setLen(0) + discard sk.drawText( + "Default", + "AVWgj@\n@jgWVA", + origin, + rgbx(255, 255, 255, 255), + maxWidth = 240, + maxHeight = height, + hAlign = horizontal, + vAlign = vertical + ) + sk.assertPixelQuads() + let + vertices = sk.drawer.layers[NormalLayer] + extra = height - font.lineHeight * 2 + alignment = + case vertical + of TopAlign: + 0.0'f + of MiddleAlign: + floor(extra * 0.5 * density) + of BottomAlign: + floor(extra * density) + doAssert vertices.len == 12 * 6 + for i in 0 ..< 12: + let + vertex = vertices[i * 6] + entry = font.entryAt(vertex.uv) + baseline = (vertex.pos.y - entry.boundsY) * density + expected = round( + (origin.y + font.ascent + (i div 6).float32 * + font.lineHeight) * density + ) + alignment + doAssert close(baseline, expected), + "Glyphs must share the line baseline: " & $baseline & + " != " & $expected + + echo "Testing deferred text placement at scale ", density + sk.drawer.layers[NormalLayer].setLen(0) + let span = sk.beginVertexSpan() + discard sk.drawText( + "Default", "AVWgj@", origin, rgbx(255, 255, 255, 255) + ) + sk.endVertexSpan(span, vec2(3.17, 4.29)) + sk.assertPixelQuads() + sk.translateVertices(NormalLayer, 0, vec2(1.39, -2.13)) + sk.assertPixelQuads() + var baseline = 0.0'f + for i in 0 ..< 6: + let + vertex = sk.drawer.layers[NormalLayer][i * 6] + entry = font.entryAt(vertex.uv) + current = (vertex.pos.y - entry.boundsY) * density + if i == 0: + baseline = current + doAssert close(current, baseline) + sk.popClipRect() + sk.popLayout() + + if steps == 0: + let path = "tests/dist/fractional_density.png" + builder.write(path) + let loaded = readAtlas(path).atlas.fonts["Default"] + doAssert loaded.pixelScale == density + doAssert loaded.entries == font.entries + +block: + echo "Testing dense fonts preserve logical layout metrics" + let + regular = newAtlasBuilder(256, 2) + dense = newAtlasBuilder(256, 2) + regular.addFont(FontPath, "Default", 17, Glyphs) + dense.addFont(FontPath, "Default", 17, Glyphs, pixelScale = 2) + let + regularFont = regular.atlas.fonts["Default"] + denseFont = dense.atlas.fonts["Default"] + doAssert regularFont.pixelScale == 1 + doAssert denseFont.pixelScale == 2 + doAssert close(regularFont.size, denseFont.size) + doAssert close(regularFont.ascent, denseFont.ascent) + doAssert close(regularFont.descent, denseFont.descent) + doAssert close(regularFont.lineHeight, denseFont.lineHeight) + doAssert close(regularFont.lineGap, denseFont.lineGap) + for glyph in Glyphs: + let + regularEntry = regularFont.entries[glyph][0] + denseEntry = denseFont.entries[glyph][0] + doAssert close(regularEntry.advance, denseEntry.advance), glyph + doAssert abs(regularEntry.boundsX - denseEntry.boundsX) <= 0.5 + doAssert abs(regularEntry.boundsY - denseEntry.boundsY) <= 0.5 + doAssert abs(regularEntry.boundsWidth - denseEntry.boundsWidth) <= 1 + doAssert abs(regularEntry.boundsHeight - denseEntry.boundsHeight) <= 1 + doAssert close( + denseEntry.boundsWidth * 2, + round(denseEntry.boundsWidth * 2) + ) + doAssert close( + denseEntry.boundsHeight * 2, + round(denseEntry.boundsHeight * 2) + ) + for next, adjustment in regularEntry.kerning: + doAssert close(adjustment, denseEntry.kerning[next]) + doAssert denseFont.entries["A"][0].boundsHeight * 2 > + regularFont.entries["A"][0].boundsHeight + + echo "Testing density survives embedded PNG metadata" + let path = "tests/dist/density.png" + doAssert dense.addImage("icon", newImage(48, 24), pixelScale = 2) + dense.write(path) + let loaded = readAtlas(path) + doAssert loaded.atlas.entries["icon"].pixelScale == 2 + doAssert loaded.atlas.entries["icon"].width == 48 + doAssert loaded.atlas.entries["icon"].height == 24 + doAssert loaded.atlas.fonts["Default"].pixelScale == 2 + doAssert close( + loaded.atlas.fonts["Default"].lineHeight, + regularFont.lineHeight + ) + doAssert loaded.atlas.fonts["Default"].entries["A"][0] == + denseFont.entries["A"][0] + +block: + echo "Testing directory images retain their density" + let + directory = "tests/dist/density_images/" + builder = newAtlasBuilder(128, 2) + createDir(directory) + newImage(40, 24).writeFile(directory & "icon.png") + builder.addDir(directory, directory, pixelScale = 2) + let entry = builder.atlas.entries["icon"] + doAssert entry.pixelScale == 2 + doAssert entry.width == 40 and entry.height == 24 + +block: + echo "Testing live packing reserves full dense glyph rectangles" + let builder = newAtlasBuilder(128, 1) + builder.addFont(FontPath, "Default", 24, Glyphs, pixelScale = 2) + let + live = newAtlasBuilderFromAtlas(builder.atlas, builder.atlasImage) + tile = newImage(5, 5) + var added = 0 + for i in 0 ..< 1024: + let name = "tile" & $i + if not live.addImage(name, tile): + break + inc added + let entry = live.atlas.entries[name] + for font in live.atlas.fonts.values: + for variants in font.entries.values: + for glyph in variants: + let bounds = Entry( + x: glyph.x, + y: glyph.y, + width: round(glyph.boundsWidth * font.pixelScale.float32).int, + height: round(glyph.boundsHeight * font.pixelScale.float32).int + ) + if bounds.width > 0 and bounds.height > 0: + doAssert not overlaps(entry, bounds), name & " overlaps a glyph" + doAssert added > 0 + doAssert added < 1024 + +block: + echo "Testing native image and patch coordinates at two densities" + for density in 1 .. 2: + let + builder = newAtlasBuilder(128, 2) + sk = Silky(atlas: builder.atlas, image: builder.atlasImage) + position = vec2(12, 20) + doAssert builder.addImage( + "patch", + newImage(32 * density, 32 * density), + pixelScale = density + ) + new(sk.drawer) + sk.uiScale = density.float32 + sk.pushLayout(vec2(0, 0), vec2(300, 200)) + sk.pushClipRect(rect(0, 0, 300, 200)) + let + entry = builder.atlas.entries["patch"] + uv = vec2(entry.x.float32, entry.y.float32) + doAssert sk.getImageSize("patch") == vec2(32, 32) + sk.drawImage("patch", position) + doAssert sk.drawer.layers[NormalLayer].len == 6 + doAssert sk.drawer.layers[NormalLayer][0].pos == position + doAssert sk.drawer.layers[NormalLayer][2].pos == position + vec2(32) + doAssert sk.drawer.layers[NormalLayer][0].uv == uv + doAssert sk.drawer.layers[NormalLayer][2].uv == + uv + vec2((32 * density).float32) + + sk.drawer.layers[NormalLayer].setLen(0) + sk.draw9Patch("patch", 4, position, vec2(100, 60)) + doAssert sk.drawer.layers[NormalLayer].len == 54 + for i in countup(0, 48, 6): + let + first = sk.drawer.layers[NormalLayer][i] + last = sk.drawer.layers[NormalLayer][i + 2] + destination = last.pos - first.pos + source = last.uv - first.uv + doAssert first.pos.x >= position.x + doAssert first.pos.y >= position.y + doAssert last.pos.x <= position.x + 100 + doAssert last.pos.y <= position.y + 60 + if i < 24: + doAssert destination == vec2(4) + doAssert source == vec2((4 * density).float32) + if density == 2: + for displayScale in [1.0'f, 1.5'f, 2.0'f]: + sk.uiScale = displayScale + sk.drawer.layers[NormalLayer].setLen(0) + sk.drawImage("patch", vec2(12.2, 20.3)) + let origin = sk.drawer.layers[NormalLayer][0].pos * displayScale + doAssert close(origin.x, round(origin.x)) + doAssert close(origin.y, round(origin.y)) + sk.drawer.layers[NormalLayer].setLen(0) + sk.draw9Patch("patch", 4, vec2(12.2, 20.3), vec2(100.3, 60.2)) + for vertex in sk.drawer.layers[NormalLayer]: + let pixel = vertex.pos * displayScale + doAssert close(pixel.x, round(pixel.x)) + doAssert close(pixel.y, round(pixel.y)) + sk.popClipRect() + sk.popLayout() + +echo "All density tests passed." diff --git a/tests/test_inputs.nim b/tests/test_inputs.nim index 2ad63d4..e0571fd 100644 --- a/tests/test_inputs.nim +++ b/tests/test_inputs.nim @@ -1,6 +1,6 @@ import std/unicode, - vmath, + bumpy, vmath, silky, silky/internal/inputs when not defined(silkyTesting): @@ -11,9 +11,14 @@ type Fixture = object id: string text: string singleLine: bool + submitOnEnter: bool enabled: bool password: bool allowedChars: seq[Rune] + spans: seq[TextSpan] + blocks: seq[TextBlock] + height: float32 + clip: Rect var atlas: SilkyAtlas @@ -23,6 +28,8 @@ block: let builder = newAtlasBuilder(1024, 4) builder.addDir("tests/data/", "tests/data/") builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18.0) + builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Wide", 30.0) + builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Small", 12.0) atlas = builder.atlas proc fixture( @@ -30,7 +37,9 @@ proc fixture( onPress: proc(button: Button) = nil, onRelease: proc(button: Button) = nil, onRune: proc(rune: Rune) = nil, - onFocus: proc() = nil + onFocus: proc() = nil, + onTextInput: proc(text: string) = nil, + onTextEdit: TextInputEditCallback = nil ): Fixture = ## Creates a real Silky test context with optional application callbacks. inc nextId @@ -38,12 +47,15 @@ proc fixture( result.text = text result.singleLine = true result.enabled = true + result.height = 120 result.harness.window = newWindow() let window = result.harness.window window.onButtonPress = onPress window.onButtonRelease = onRelease window.onRune = onRune window.onFocusChange = onFocus + window.onTextInput = onTextInput + window.onTextEdit = onTextEdit result.harness.sk = newSilky(window, atlas) proc frame(f: var Fixture, visible = true, duplicate = false) = @@ -53,6 +65,8 @@ proc frame(f: var Fixture, visible = true, duplicate = false) = let sk = f.harness.sk window = f.harness.window + if f.clip.w > 0: + sk.pushClipRect(f.clip) for i in 0 .. ord(duplicate): sk.at = vec2(20, 20) sk.textBox( @@ -60,13 +74,18 @@ proc frame(f: var Fixture, visible = true, duplicate = false) = f.id, f.text, 240, - 120, + f.height, wrapWords = true, singleLine = f.singleLine, enabled = f.enabled, password = f.password, - allowedChars = f.allowedChars + allowedChars = f.allowedChars, + submitOnEnter = f.submitOnEnter, + spans = f.spans, + blocks = f.blocks ) + if f.clip.w > 0: + sk.popClipRect() f.harness.endFrame() f.harness.window.resetInputState() @@ -74,6 +93,16 @@ proc state(f: Fixture): TextBoxState = ## Returns the text state belonging to a fixture. textBoxStates[f.id] +proc hoverGlyph(f: var Fixture, index: int) = + ## Moves the pointer over a visible laid-out glyph in the current view. + let + bounds = f.state.layout[index] + padding = f.harness.sk.theme.padding.float32 + point = vec2(20 + padding) + bounds.xy + bounds.wh / 2 - + f.state.scrollPos + f.harness.window.moveMouse(point.x.int, point.y.int) + f.frame() + proc focus(f: var Fixture) = ## Clicks the field and positions the caret at the end of its text. f.frame() @@ -319,6 +348,297 @@ block: f.frame() doAssert f.text == "123" +block: + echo "Testing Enter submission and captured Shift modifiers" + var f = fixture("First") + f.singleLine = false + f.submitOnEnter = true + f.focus() + let window = f.harness.window + window.tap(KeyEnter) + f.frame() + doAssert f.state.submitted and f.text == "First" + f.frame() + doAssert not f.state.submitted + window.pressButton(KeyLeftShift) + window.tap(KeyEnter) + window.releaseButton(KeyLeftShift) + f.frame() + doAssert not f.state.submitted and f.text == "First\n" + setClipboardString("Second\nThird") + window.shortcut(KeyV) + window.tap(KeyEnter) + f.frame() + doAssert f.state.submitted and f.text == "First\nSecond\nThird" + f.enabled = false + window.tap(KeyEnter) + f.frame() + doAssert not f.state.submitted + +block: + echo "Testing clipboard restoration before the paste frame" + for modifier in [KeyLeftSuper, KeyLeftControl]: + var f = fixture("Original") + f.singleLine = false + f.submitOnEnter = true + f.focus() + let + window = f.harness.window + dictated = "First\tline\n\n第二段 👩‍💻" + window.shortcut(KeyA, modifier) + setClipboardString(dictated) + window.shortcut(KeyV, modifier) + setClipboardString("Restored clipboard") + doAssert f.text == "Original" + f.frame(duplicate = true) + doAssert f.text == dictated and not f.state.submitted + doAssert getClipboardString() == "Restored clipboard" + f.frame() + doAssert f.text == dictated + window.shortcut(KeyZ, modifier) + f.frame() + doAssert f.text == "Original" + window.shortcut(KeyY, modifier) + f.frame() + doAssert f.text == dictated + +block: + echo "Testing native text commits replace a Unicode selection atomically" + var f = fixture("Aé🙂Z") + f.singleLine = false + f.submitOnEnter = true + f.focus() + let + window = f.harness.window + committed = "First\tline\nSecond 👩‍💻" + expected = "A" & committed & "Z" + f.state.cursor = 1 + f.state.selector = 3 + window.onTextInput(committed) + doAssert f.text == "Aé🙂Z" + f.frame(duplicate = true) + doAssert f.text == expected and not f.state.submitted + doAssert f.state.cursor == 1 + committed.runeLen + doAssert f.state.selector == f.state.cursor + window.shortcut(KeyZ) + f.frame() + doAssert f.text == "Aé🙂Z" + window.shortcut(KeyY) + f.frame() + doAssert f.text == expected + +block: + echo "Testing disabled fields reject native commits and captured paste" + var f = fixture("Read only") + f.enabled = false + f.focus() + let window = f.harness.window + window.shortcut(KeyA) + window.onTextInput("Ignored\n👩‍💻") + setClipboardString("Ignored paste") + window.shortcut(KeyV) + f.frame() + doAssert f.text == "Read only" and not f.state.submitted + doAssert f.state.selection == (0 .. 9) + window.shortcut(KeyC) + f.frame() + doAssert getClipboardString() == "Read only" + +block: + echo "Testing native editor publication uses rune ranges and pixel bounds" + var f = fixture("Aé🙂Z\nNext") + f.singleLine = false + f.focus() + let + window = f.harness.window + padding = f.harness.sk.theme.padding.float32 + scale = 1.5'f + f.state.cursor = 3 + f.state.selector = 1 + f.harness.sk.uiScale = scale + f.frame() + doAssert window.textInput.id == f.id + doAssert window.textInput.text == f.text + doAssert window.textInput.selectionStart == 1 + doAssert window.textInput.selectionEnd == 3 + doAssert window.textInput.visibleStart == 0 + doAssert window.textInput.visibleEnd == f.text.runeLen + doAssert window.textInput.multiline and not window.textInput.password + doAssert window.textInput.bounds == rect( + (20 + padding) * scale, + (20 + padding) * scale, + (240 - padding * 2) * scale, + (f.height - padding * 2) * scale + ) + +block: + echo "Testing native editor bounds follow nested deferred layout spans" + for scale in [1.0'f, 1.5'f, 2.0'f]: + var f = fixture("Nested 🙂") + f.focus() + let + sk = f.harness.sk + window = f.harness.window + padding = sk.theme.padding.float32 + sk.uiScale = scale + f.harness.beginFrame() + let + outer = sk.beginVertexSpan() + inner = sk.beginVertexSpan() + sk.at = vec2(20, 20) + sk.textBox(window, f.id, f.text, 120, 60, wrapWords = true) + sk.endVertexSpan(inner, vec2(8, 16)) + sk.endVertexSpan(outer, vec2(40, -24)) + let unrelated = sk.beginVertexSpan() + sk.endVertexSpan(unrelated, vec2(200, 300)) + f.harness.endFrame() + doAssert window.textInput.id == f.id + let expected = rect( + (vec2(68, 12) + vec2(padding)) * scale, + (vec2(120, 60) - vec2(padding * 2)) * scale + ) + doAssert window.textInput.bounds == expected, + $scale & ": " & $window.textInput.bounds & " != " & $expected + +block: + echo "Testing native editor visibility follows the scrolled text" + var f = fixture("") + for i in 0 ..< 20: + if i > 0: + f.text.add("\n") + f.text.add("Line " & $i) + f.singleLine = false + f.height = 54 + f.focus() + let window = f.harness.window + window.onTextEdit(TextInputEdit(kind: SelectText, start: high(int))) + f.frame() + doAssert window.textInput.visibleStart > 0 + doAssert window.textInput.visibleEnd > window.textInput.visibleStart + doAssert window.textInput.visibleEnd <= f.text.runeLen + doAssert window.textInput.selectionStart == f.text.runeLen + +block: + echo "Testing native editor publication clears for unavailable fields" + for mode in 0 .. 3: + var f = fixture("Private text 🙂") + let window = f.harness.window + doAssert window.textInput == TextInputState() + f.focus() + f.frame() + doAssert window.textInput.text == f.text + case mode + of 0: + f.frame(visible = false) + of 1: + f.enabled = false + f.frame() + of 2: + f.password = true + f.frame() + else: + window.changeFocus() + doAssert window.textInput == TextInputState() + f.frame() + doAssert window.textInput == TextInputState() + +block: + echo "Testing native range replacement and deletion are single edits" + var f = fixture("Aé🙂Z") + f.singleLine = false + f.submitOnEnter = true + f.focus() + let + window = f.harness.window + replacement = "First\tline\n第二段 👩‍💻" + expected = "A" & replacement & "Z" + window.onTextEdit(TextInputEdit( + kind: ReplaceText, + text: replacement, + start: 1, + length: 2 + )) + doAssert f.text == "Aé🙂Z" + f.frame(duplicate = true) + doAssert f.text == expected and not f.state.submitted + doAssert f.state.undoStack.len == 1 + window.shortcut(KeyZ) + f.frame() + doAssert f.text == "Aé🙂Z" + window.shortcut(KeyY) + f.frame() + doAssert f.text == expected + window.onTextEdit(TextInputEdit( + kind: ReplaceText, + start: 1, + length: replacement.runeLen + )) + f.frame() + doAssert f.text == "AZ" and not f.state.submitted + window.shortcut(KeyZ) + f.frame() + doAssert f.text == expected + +block: + echo "Testing native edits clamp invalid and oversized rune ranges" + var f = fixture("Aé🙂Z") + f.focus() + let window = f.harness.window + window.onTextEdit(TextInputEdit( + kind: ReplaceText, + text: "!", + start: high(int), + length: high(int) + )) + f.frame() + doAssert f.text == "Aé🙂Z!" + window.onTextEdit(TextInputEdit( + kind: ReplaceText, + start: low(int), + length: 2 + )) + f.frame() + doAssert f.text == "🙂Z!" + window.onTextEdit(TextInputEdit( + kind: SelectText, + start: 1, + length: low(int) + )) + f.frame() + doAssert f.state.selection == (1 .. 1) + window.onTextEdit(TextInputEdit( + kind: ReplaceText, + start: 1, + length: high(int) + )) + f.frame() + doAssert f.text == "🙂" + window.shortcut(KeyZ) + f.frame() + doAssert f.text == "🙂Z!" + +block: + echo "Testing native selection preserves edit order and undo history" + var f = fixture("Aé🙂Z") + f.focus() + let window = f.harness.window + window.onTextInput("!") + window.onTextEdit(TextInputEdit(kind: SelectText, start: 1, length: 2)) + f.frame() + doAssert f.text == "Aé🙂Z!" and f.state.selection == (1 .. 3) + doAssert f.state.undoStack.len == 1 + window.shortcut(KeyZ) + f.frame() + doAssert f.text == "Aé🙂Z" + window.onTextEdit(TextInputEdit(kind: SelectText, start: 0, length: 1)) + f.frame() + doAssert f.state.undoStack.len == 0 and f.state.redoStack.len == 1 + window.shortcut(KeyY) + window.onTextEdit(TextInputEdit(kind: SelectText, start: 1, length: 2)) + window.onTextInput("R") + f.frame() + doAssert f.text == "ARZ!" + block: echo "Testing disabled fields retain selection and copy without editing" var f = fixture() @@ -341,6 +661,133 @@ block: doAssert f.text == "Hello!" and getClipboardString() == "Hello!" doAssert f.state.selection == (0 .. 6) +block: + echo "Testing read-only styled text retains selection and copy" + var f = fixture("é code 世界") + f.enabled = false + f.singleLine = false + f.spans = @[TextSpan(first: 2, last: 6, font: "Wide")] + f.focus() + let + window = f.harness.window + styledWidth = f.state.layout[2].w + window.shortcut(KeyA) + window.shortcut(KeyC) + window.tap(KeyBackspace) + window.typeText("ignored") + f.frame() + doAssert f.text == "é code 世界" + doAssert getClipboardString() == f.text + doAssert f.state.selection == (0 .. f.text.runeLen) + doAssert f.state.layout[2].w == styledWidth + f.enabled = true + f.frame() + doAssert f.state.layout[2].w < styledWidth + window.typeText("Editable") + f.frame() + doAssert f.text == "Editable" + +block: + echo "Testing styled span clicks activate only without dragging" + var f = fixture("Click here") + f.enabled = false + f.spans = @[TextSpan(first: 0, last: f.text.runeLen, font: "Wide")] + f.frame() + let + window = f.harness.window + bounds = f.state.layout[0] + padding = f.harness.sk.theme.padding.float32 + x = (20 + padding + bounds.x + bounds.w / 2).int + y = (20 + padding + bounds.y + bounds.h / 2).int + window.moveMouse(x, y) + window.pressButton(MouseLeft) + f.frame() + doAssert f.state.activatedSpan == -1 + window.releaseButton(MouseLeft) + f.frame() + doAssert f.state.activatedSpan == 0 + f.frame() + doAssert f.state.activatedSpan == -1 + f.state.lastClickTime = 0 + window.pressButton(MouseLeft) + f.frame() + window.moveMouse(x + 20, y) + f.frame() + window.moveMouse(x, y) + window.releaseButton(MouseLeft) + f.frame() + doAssert f.state.activatedSpan == -1 + +block: + echo "Testing link hover cursor and non-link text boundaries" + var f = fixture("Link bold text") + f.enabled = false + f.spans = @[ + TextSpan(first: 0, last: 4, linkId: 1), + TextSpan(first: 5, last: 9, font: "Wide") + ] + f.frame() + f.hoverGlyph(1) + doAssert f.state.hoveredSpan == 0 + doAssert f.harness.window.cursor.kind == PointerCursor + f.hoverGlyph(6) + doAssert f.state.hoveredSpan == -1 + doAssert f.harness.window.cursor.kind == ArrowCursor + f.hoverGlyph(1) + f.harness.window.moveMouse(21, 21) + f.frame() + doAssert f.state.hoveredSpan == -1 + doAssert f.harness.window.cursor.kind == ArrowCursor + f.hoverGlyph(1) + f.harness.window.moveMouse(600, 400) + f.frame() + doAssert f.state.hoveredSpan == -1 + doAssert f.harness.window.cursor.kind == ArrowCursor + f.hoverGlyph(1) + f.frame(visible = false) + doAssert f.harness.window.cursor.kind == ArrowCursor + +block: + echo "Testing wrapped links follow scrolling and ancestor clipping" + var f = fixture( + "A long linked sentence that wraps onto several lines in this text box." + ) + f.enabled = false + f.singleLine = false + f.spans = @[ + TextSpan(first: 0, last: f.text.runeLen, font: "Wide", linkId: 1) + ] + f.frame() + var secondLine = -1 + for i, bounds in f.state.layout: + if bounds.y > 0 and bounds.w > 0: + secondLine = i + break + doAssert secondLine >= 0 + f.hoverGlyph(secondLine) + doAssert f.state.hoveredSpan == 0 + doAssert f.harness.window.cursor.kind == PointerCursor + f.clip = rect(20, 20, 240, 20) + f.frame() + doAssert f.state.hoveredSpan == -1 + doAssert f.harness.window.cursor.kind == ArrowCursor + f.clip = rect(0, 0, 0, 0) + f.state.scrollPos.y = f.state.lineHeight + f.hoverGlyph(secondLine) + doAssert f.state.hoveredSpan == 0 + doAssert f.harness.window.cursor.kind == PointerCursor + f.spans[0].linkId = 0 + f.frame() + doAssert f.state.hoveredSpan == -1 + doAssert f.harness.window.cursor.kind == ArrowCursor + f.spans[0].linkId = 1 + f.frame() + doAssert f.state.hoveredSpan == 0 + f.spans.setLen(0) + f.frame() + doAssert f.state.hoveredSpan == -1 + doAssert f.harness.window.cursor.kind == ArrowCursor + block: echo "Testing focus loss, disappearing fields, and field switching" var f = fixture() @@ -369,6 +816,100 @@ block: f.frame() doAssert f.text == "Other!" +block: + echo "Testing one block widget selects and copies across all sections" + const Sample = "Title\nBody link\ncode()" + var f = fixture(Sample) + f.enabled = false + f.singleLine = false + f.height = 240 + f.blocks = @[ + TextBlock( + first: 0, last: 5, font: "Wide", + padding: vec2(4, 3), spacing: 12 + ), + TextBlock(first: 6, last: 15, padding: vec2(4, 3), spacing: 12), + TextBlock( + first: 16, last: Sample.runeLen, font: "Small", + padding: vec2(4, 3) + ) + ] + f.spans = @[TextSpan(first: 11, last: 15, linkId: 1)] + f.frame() + let + window = f.harness.window + origin = vec2(20 + f.harness.sk.theme.padding.float32) + first = f.state.layout[0] + last = f.state.layout[^1] + start = origin + first.xy + vec2(0, first.h / 2) + finish = origin + last.xy + vec2(last.w + 1, last.h / 2) + window.moveMouse(start.x.int, start.y.int) + window.pressButton(MouseLeft) + f.frame() + window.moveMouse(finish.x.int, finish.y.int) + f.frame() + window.releaseButton(MouseLeft) + f.frame() + doAssert f.state.selection == (0 .. Sample.runeLen) + window.shortcut(KeyC) + f.frame() + doAssert getClipboardString() == Sample + + echo "Testing block links retain hover, activation, and drag selection" + f.hoverGlyph(12) + doAssert f.state.hoveredSpan == 0 + doAssert window.cursor.kind == PointerCursor + f.state.lastClickTime = 0 + window.tap(MouseLeft) + f.frame() + doAssert f.state.activatedSpan == 0 + f.state.lastClickTime = 0 + window.pressButton(MouseLeft) + f.frame() + window.moveMouse(start.x.int, start.y.int) + f.frame() + window.releaseButton(MouseLeft) + f.frame() + doAssert f.state.activatedSpan == -1 + doAssert f.state.selection.a == 0 + doAssert f.state.selection.b >= 11 + + echo "Testing streaming text preserves one existing block selection" + f.state.cursor = 2 + f.state.selector = 13 + let copied = f.state.copyText() + f.text.add("\necho 1") + f.blocks[^1].last = f.text.runeLen + f.frame() + doAssert f.state.selection == (2 .. 13) + doAssert f.state.copyText() == copied + window.shortcut(KeyA) + window.shortcut(KeyC) + f.frame() + doAssert getClipboardString() == f.text + f.text = "Title\nBody" + f.blocks.setLen(2) + f.blocks[^1].last = f.text.runeLen + f.spans.setLen(0) + f.frame() + doAssert f.state.selection == (0 .. f.text.runeLen) + doAssert f.state.copyText() == f.text + + echo "Testing read-only selection clears on blur and focus epoch changes" + window.moveMouse(600, 400) + window.tap(MouseLeft) + f.frame() + doAssert not f.state.focused + doAssert f.state.cursor == f.state.selector + f.focus() + window.shortcut(KeyA) + f.frame() + doAssert f.state.cursor != f.state.selector + window.changeFocus() + f.frame() + doAssert not f.state.focused + doAssert f.state.cursor == f.state.selector + block: echo "Testing one-batch editing matches separate frames and layout" var fixtures = [fixture(""), fixture("")] @@ -459,6 +1000,98 @@ block: f.frame() doAssert f.text == "R" +block: + echo "Testing native commit callbacks forward only while inactive" + var + commits: seq[string] + f = fixture( + onTextInput = (proc(text: string) = commits.add(text)) + ) + let window = f.harness.window + window.onTextInput("Inactive\n🙂") + doAssert commits == @["Inactive\n🙂"] + f.focus() + doAssert f.text == "Hello" + window.onTextInput(" active") + f.frame() + doAssert f.text == "Hello active" + doAssert commits == @["Inactive\n🙂"] + window.onTextInput(" discarded") + window.changeFocus() + f.frame() + doAssert f.text == "Hello active" + window.onTextInput("After blur") + doAssert commits == @["Inactive\n🙂", "After blur"] + f.focus() + doAssert f.text == "Hello active" + +block: + echo "Testing replaced native commit callbacks are reinstalled once" + var + oldCommits, newCommits: seq[string] + f = fixture( + onTextInput = (proc(text: string) = oldCommits.add(text)) + ) + let window = f.harness.window + f.focus() + window.onTextInput(" pending") + window.onTextInput = proc(text: string) = newCommits.add(text) + f.frame() + doAssert f.text == "Hello pending" + for i in 0 ..< 5: + window.shortcut(KeyA) + window.onTextInput("Replacement") + f.frame() + doAssert f.text == "Replacement" + doAssert oldCommits.len == 0 and newCommits.len == 0 + window.changeFocus() + f.frame() + window.onTextInput("Forward once") + doAssert oldCommits.len == 0 and newCommits == @["Forward once"] + f.focus() + window.onTextInput = nil + f.frame() + window.onTextInput(" restored") + f.frame() + doAssert f.text == "Replacement restored" + doAssert oldCommits.len == 0 and newCommits == @["Forward once"] + +block: + echo "Testing native edit callbacks forward and reinstall without repeats" + var + oldEdits, newEdits: seq[TextInputEdit] + f = fixture(onTextEdit = (proc(edit: TextInputEdit) = oldEdits.add(edit))) + let + window = f.harness.window + edit = TextInputEdit(kind: ReplaceText, text: "R", start: 0, length: 5) + window.onTextEdit(edit) + doAssert oldEdits == @[edit] + f.focus() + window.onTextEdit(edit) + window.onTextEdit = proc(edit: TextInputEdit) = newEdits.add(edit) + f.frame() + doAssert f.text == "R" and oldEdits == @[edit] and newEdits.len == 0 + window.onTextEdit = nil + f.frame() + window.onTextEdit(TextInputEdit(kind: ReplaceText, text: "!", start: 1)) + f.frame() + doAssert f.text == "R!" + window.onTextEdit = proc(edit: TextInputEdit) = newEdits.add(edit) + f.frame() + f.enabled = false + f.frame() + window.onTextEdit(edit) + f.frame() + doAssert f.text == "R!" and newEdits.len == 0 + f.enabled = true + f.frame() + window.onTextEdit(edit) + window.changeFocus() + f.frame() + doAssert f.text == "R!" + window.onTextEdit(edit) + doAssert oldEdits == @[edit] and newEdits == @[edit] + block: echo "Testing application callbacks assigned after construction" var diff --git a/tests/test_quotes.nim b/tests/test_quotes.nim new file mode 100644 index 0000000..ede10f6 --- /dev/null +++ b/tests/test_quotes.nim @@ -0,0 +1,225 @@ +import + std/[importutils, math, tables], + bumpy, pixie, vmath, windy, + silky, silky/internal/inputs + +const + FontPath = "tests/data/IBMPlexSans-Regular.ttf" + QuoteColor = rgbx(90, 150, 220, 255) + RuleColor = rgbx(170, 120, 80, 255) + CaptionColor = rgbx(80, 190, 130, 255) + +proc close(a, b: float32): bool = + ## Compares logical and physical coordinates with float tolerance. + abs(a - b) < 0.001 + +proc fixture(builder: AtlasBuilder, density: float32): Silky = + ## Queues native widget vertices without a platform window or GPU. + privateAccess(Silky) + result = Silky( + atlas: builder.atlas, + image: builder.atlasImage, + uiScale: density, + inFrame: true, + inputs: TextInputs() + ) + new(result.drawer) + result.theme.padding = 0 + result.mousePos = vec2(-1) + result.pushLayout(vec2(0), vec2(300, 300)) + result.pushClipRect(rect(0, 0, 300, 300)) + +proc render( + sk: Silky, + source: string, + blocks: seq[TextBlock], + width = 180.0'f +): TextBoxState = + ## Draws one read-only quote document through the native textbox. + var text = source + textBoxStates.del("quotes") + sk.at = vec2(10.17'f, 20.29'f) + sk.textBox( + Window(), "quotes", text, width, 220, + wrapWords = true, enabled = false, blocks = blocks + ) + result = textBoxStates["quotes"] + +proc bars(sk: Silky, color: ColorRGBX, width = 180.0'f): seq[Rect] = + ## Finds solid rules and checks their physical alignment and clipping. + let vertices = sk.drawer.layers[NormalLayer] + for i in countup(0, vertices.high, 6): + if vertices[i].color != color: + continue + doAssert vertices[i].uv == vertices[i + 2].uv + doAssert vertices[i].clipPos == vec2(10.17'f, 20.29'f) + doAssert vertices[i].clipSize == vec2(width, 220) + for j in i ..< i + 6: + let pixel = vertices[j].pos * sk.uiScale + doAssert close(pixel.x, round(pixel.x)) + doAssert close(pixel.y, round(pixel.y)) + result.add(rect( + vertices[i].pos, + vertices[i + 2].pos - vertices[i].pos + )) + +proc patchBounds(sk: Silky, name: string): Rect = + ## Finds the outer bounds of one native code surface by atlas region. + let + sprite = sk.atlas.entries[name] + vertices = sk.drawer.layers[NormalLayer] + var + first = true + right, bottom: float32 + for i in countup(0, vertices.high, 6): + let uv = vertices[i].uv + if uv.x < sprite.x.float32 or uv.x >= (sprite.x + sprite.width).float32 or + uv.y < sprite.y.float32 or uv.y >= (sprite.y + sprite.height).float32: + continue + if first: + result.x = vertices[i].pos.x + result.y = vertices[i].pos.y + first = false + else: + result.x = min(result.x, vertices[i].pos.x) + result.y = min(result.y, vertices[i].pos.y) + right = max(right, vertices[i + 2].pos.x) + bottom = max(bottom, vertices[i + 2].pos.y) + doAssert not first + result.w = right - result.x + result.h = bottom - result.y + +proc caption(sk: Silky): Vec2 = + ## Finds the first caption glyph emitted by the native font renderer. + for vertex in sk.drawer.layers[NormalLayer]: + if vertex.color == CaptionColor: + return vertex.pos + doAssert false, "Expected a native code caption glyph." + +block: + echo "Testing nested quote bars and continued container spacing" + let builder = newAtlasBuilder(2048, 2) + builder.addFont(FontPath, "Default", 18) + builder.addFont(FontPath, "Heading", 26) + builder.addFont(FontPath, "Small", 12) + doAssert builder.addImage("textbox.disabled.9patch", newImage(16, 16)) + doAssert builder.addImage("code.9patch", newImage(16, 16)) + for density in [1.0'f, 1.25'f, 1.5'f, 2.0'f]: + for continuing in 0 .. 2: + let + sk = fixture(builder, density) + state = sk.render("Head\nBody", @[ + TextBlock( + first: 0, last: 4, font: "Heading", + padding: vec2(12, 6), spacing: 8, + quoteDepth: 2, quoteContinue: continuing, quoteColor: QuoteColor + ), + TextBlock( + first: 5, last: 9, padding: vec2(12, 6), + quoteDepth: 2, quoteColor: QuoteColor + ) + ]) + quads = sk.bars(QuoteColor) + firstHeight = builder.atlas.fonts["Heading"].lineHeight + 12 + doAssert quads.len == 4 + doAssert state.layout[0].x == 44 + doAssert state.layout[5].x == 44 + for level in 0 .. 1: + let extension = if level < continuing: 8.0'f else: 0.0'f + doAssert close( + quads[level].x * density, + round((10.17'f + 12 + level.float32 * 16) * density) + ) + doAssert close(quads[level].w * density, max(1.0'f, round(2 * density))) + doAssert close( + (quads[level].y + quads[level].h) * density, + round((20.29'f + firstHeight + extension) * density) + ) + doAssert close( + quads[level + 2].y * density, + round((20.29'f + firstHeight + 8) * density) + ) + state.selectAll() + doAssert state.copyText() == "Head\nBody" + doAssert state.layout.len == 9 + + echo "Testing quoted code surfaces, captions, and rules at ", density + let + plain = fixture(builder, density) + quoted = fixture(builder, density) + var code = TextBlock( + first: 0, last: 2, padding: vec2(12, 6), + patch: "code.9patch", patchBorder: 4, + label: "nim", labelFont: "Small", labelColor: CaptionColor, + labelSpacing: 4 + ) + let plainState = plain.render("WW", @[code]) + code.quoteDepth = 2 + code.quoteColor = QuoteColor + let + quoteState = quoted.render("WW", @[code]) + plainPatch = plain.patchBounds("code.9patch") + quotePatch = quoted.patchBounds("code.9patch") + doAssert close(quotePatch.x - plainPatch.x, 32) + doAssert close(plainPatch.w - quotePatch.w, 32) + doAssert close(quoteState.layout[0].x - plainState.layout[0].x, 32) + doAssert close(quoted.caption().x - plain.caption().x, 32) + doAssert quoteState.innerHeight == plainState.innerHeight + let + ruled = fixture(builder, density) + ruleState = ruled.render("", @[ + TextBlock( + first: 0, last: 0, padding: vec2(12, 6), + ruleColor: RuleColor, ruleThickness: 1, + quoteDepth: 2, quoteColor: QuoteColor + ) + ]) + horizontal = ruled.bars(RuleColor) + doAssert ruleState.innerHeight == 13 + doAssert ruleState.runes.len == 0 + doAssert horizontal.len == 1 + doAssert close(horizontal[0].x * density, round((10.17'f + 44) * density)) + + echo "Testing quote wrapping, empty paragraphs, and bounded indentation" + let + quoted = fixture(builder, 2) + wrapped = quoted.render("WW WW WW", @[ + TextBlock( + first: 0, last: 8, padding: vec2(12, 6), + quoteDepth: 1, quoteColor: QuoteColor + ) + ], 80) + doAssert wrapped.layout[3].y > wrapped.layout[0].y + wrapped.selectAll() + doAssert wrapped.copyText() == "WW WW WW" + let + empty = fixture(builder, 2) + emptyState = empty.render("", @[ + TextBlock( + first: 0, last: 0, padding: vec2(12, 6), + quoteDepth: 1, quoteColor: QuoteColor + ) + ]) + doAssert close( + emptyState.innerHeight, + builder.atlas.fonts["Default"].lineHeight + 12 + ) + doAssert emptyState.layout.len == 0 + doAssert empty.bars(QuoteColor).len == 1 + for width in [0.0'f, 10.0'f, 20.0'f, 80.0'f]: + let + narrow = fixture(builder, 2) + narrowState = narrow.render("", @[ + TextBlock( + first: 0, last: 0, padding: vec2(12, 6), + quoteDepth: 1000, quoteColor: QuoteColor + ) + ], width) + doAssert narrowState.innerHeight > 0 + for quad in narrow.bars(QuoteColor, width): + doAssert quad.w >= 0 and quad.h >= 0 + doAssert quad.x >= 10 + doAssert quad.x + quad.w <= 10.17'f + width + 0.5'f + textBoxStates.del("quotes") + +echo "All native quote tests passed." diff --git a/tests/test_rules.nim b/tests/test_rules.nim new file mode 100644 index 0000000..e1f4cba --- /dev/null +++ b/tests/test_rules.nim @@ -0,0 +1,141 @@ +import + std/[importutils, math, tables, unicode], + bumpy, pixie, vmath, windy, + silky, silky/internal/inputs + +const + FontPath = "tests/data/IBMPlexSans-Regular.ttf" + RuleColor = rgbx(90, 150, 220, 255) + +proc close(a, b: float32): bool = + ## Compares logical and physical coordinates with float tolerance. + abs(a - b) < 0.001 + +proc fixture(builder: AtlasBuilder, density: float32): Silky = + ## Queues native widget vertices without a platform window or GPU. + privateAccess(Silky) + result = Silky( + atlas: builder.atlas, + image: builder.atlasImage, + uiScale: density, + inFrame: true, + inputs: TextInputs() + ) + new(result.drawer) + result.theme.padding = 0 + result.mousePos = vec2(-1) + result.pushLayout(vec2(0), vec2(300, 300)) + result.pushClipRect(rect(0, 0, 300, 300)) + +proc rules(sk: Silky, clip: Rect): seq[Rect] = + ## Finds rule quads and checks their pixel alignment and native clip. + let vertices = sk.drawer.layers[NormalLayer] + for i in countup(0, vertices.high, 6): + if vertices[i].color != RuleColor: + continue + doAssert vertices[i].uv == vertices[i + 2].uv + doAssert vertices[i].clipPos == clip.xy + doAssert vertices[i].clipSize == clip.wh + for j in i ..< i + 6: + let pixel = vertices[j].pos * sk.uiScale + doAssert close(pixel.x, round(pixel.x)) + doAssert close(pixel.y, round(pixel.y)) + result.add(rect( + vertices[i].pos, + vertices[i + 2].pos - vertices[i].pos + )) + +block: + echo "Testing rule-only blocks have no text or implicit font line" + privateAccess(TextBoxState) + let + builder = newAtlasBuilder(1024, 2) + window = Window() + builder.addFont(FontPath, "Default", 18) + doAssert builder.addImage("textbox.disabled.9patch", newImage(16, 16)) + for density in [1.0'f, 1.25'f, 1.5'f, 2.0'f]: + let + sk = fixture(builder, density) + origin = vec2(10.17'f, 20.29'f) + rule = TextBlock( + first: 0, + last: 0, + ruleColor: RuleColor, + ruleThickness: 1, + padding: vec2(12, 6) + ) + var text = "" + sk.at = origin + sk.textBox( + window, "rule", text, 80, 100, + wrapWords = true, enabled = false, blocks = @[rule] + ) + let + state = textBoxStates["rule"] + quads = sk.rules(rect(origin, vec2(80, 100))) + doAssert state.runes.len == 0 + doAssert state.layout.len == 0 + doAssert state.runs.len == 0 + doAssert state.innerHeight == 13 + doAssert state.locationRect(0).h == 0 + state.selectAll() + doAssert state.copyText() == "" + doAssert state.cursor == 0 and state.selector == 0 + doAssert quads.len == 1 + doAssert close(quads[0].x * density, round((origin.x + 12) * density)) + doAssert close(quads[0].y * density, round((origin.y + 6) * density)) + doAssert close( + (quads[0].x + quads[0].w) * density, + round((origin.x + 80 - 12) * density) + ) + doAssert close(quads[0].h * density, max(1.0'f, round(density))) + + sk.drawer.layers[NormalLayer].setLen(0) + sk.at = origin + sk.textBox( + window, "rule", text, 20, 100, + wrapWords = true, enabled = false, blocks = @[rule] + ) + doAssert sk.rules(rect(origin, vec2(20, 100))).len == 0 + doAssert state.innerHeight == 13 + textBoxStates.del("rule") + + echo "Testing rules preserve shared selection and surrounding wrapping" + const Source = "WW WW WW\nAfter" + let + after = Source.find('\n') + 1 + before = TextBlock(first: 0, last: after - 1, padding: vec2(12, 6)) + rule = TextBlock( + first: after - 1, + last: after - 1, + ruleColor: RuleColor, + ruleThickness: 1, + padding: vec2(12, 6), + spacing: 8 + ) + following = TextBlock( + first: after, last: Source.runeLen, padding: vec2(12, 6) + ) + plain = TextBoxState(wordWrap: true, blocks: @[before, following]) + ruled = TextBoxState(wordWrap: true, blocks: @[before, rule, following]) + plain.setText(Source) + ruled.setText(Source) + plain.computeLayout(builder.atlas.fonts, "Default", 64) + ruled.computeLayout(builder.atlas.fonts, "Default", 64) + doAssert ruled.layout.len == plain.layout.len + doAssert ruled.runes == plain.runes + doAssert ruled.runs.len == plain.runs.len + doAssert ruled.layout[3].y > ruled.layout[0].y + for i in 0 ..< after - 1: + doAssert ruled.layout[i] == plain.layout[i] + for i in after ..< Source.runeLen: + doAssert ruled.layout[i].x == plain.layout[i].x + doAssert ruled.layout[i].wh == plain.layout[i].wh + doAssert close(ruled.layout[i].y - plain.layout[i].y, 21) + doAssert close(ruled.innerHeight - plain.innerHeight, 21) + ruled.selectAll() + doAssert ruled.copyText() == Source + doAssert ruled.locationRect(Source.runeLen).h == + plain.locationRect(Source.runeLen).h + +echo "All native rule tests passed." diff --git a/tests/test_shader_codegen.nim b/tests/test_shader_codegen.nim index 8f1a9a1..2d7b7ed 100644 --- a/tests/test_shader_codegen.nim +++ b/tests/test_shader_codegen.nim @@ -1,5 +1,19 @@ import strutils, shady, silky/drawers/shader +proc checkSampleOrder(source, base, mask: string) = + ## Keeps texture derivatives valid at fragment clipping boundaries. + let + baseIndex = source.find(base) + maskIndex = source.find(mask) + clipIndex = source.find("fragmentPos.x < fragmentClipPos.x") + discardIndex = source.find("discard") + doAssert baseIndex >= 0 and maskIndex >= 0 + doAssert clipIndex >= 0 and discardIndex > clipIndex + doAssert baseIndex < clipIndex, + "The atlas must be sampled before nonuniform clipping." + doAssert maskIndex < clipIndex, + "The mask must be sampled before nonuniform clipping." + block: let vertex = toShader(silkyVert, glsl3Desktop, shaderVertex) doAssert "uniform vec2 viewportSize;" in vertex @@ -15,6 +29,10 @@ block: doAssert "texture(atlasSampler, fragmentUv)" in fragment doAssert "(texture(atlasSampler, fragmentMaskUv)).r" in fragment doAssert "mix(vec3(1.0, 1.0, 1.0), fragmentColor.rgb, maskR)" in fragment + fragment.checkSampleOrder( + "texture(atlasSampler, fragmentUv)", + "texture(atlasSampler, fragmentMaskUv)" + ) block: let vertex = toShader(silkyVert, vulkanGlsl450, shaderVertex) @@ -32,6 +50,10 @@ block: doAssert "layout(location = 5) in vec2 fragmentMaskUv;" in fragment doAssert "layout(location = 0) out vec4 fragColor;" in fragment doAssert "(texture(atlasSampler, fragmentMaskUv)).r" in fragment + fragment.checkSampleOrder( + "texture(atlasSampler, fragmentUv)", + "texture(atlasSampler, fragmentMaskUv)" + ) block: let vertex = toHLSL(silkyVert, shaderVertex) @@ -51,5 +73,16 @@ block: doAssert "atlasSampler.Sample(atlasSamplerSampler, fragmentUv)" in fragment doAssert "(atlasSampler.Sample(atlasSamplerSampler, fragmentMaskUv)).r" in fragment doAssert "lerp(float3(1.0, 1.0, 1.0), fragmentColor.rgb, maskR)" in fragment + fragment.checkSampleOrder( + "atlasSampler.Sample(atlasSamplerSampler, fragmentUv)", + "atlasSampler.Sample(atlasSamplerSampler, fragmentMaskUv)" + ) + +block: + let fragment = toShader(silkyFrag, metalMSL, shaderFragment) + fragment.checkSampleOrder( + "atlasSampler.sample(atlasSamplerSampler, fragmentUv)", + "atlasSampler.sample(atlasSamplerSampler, fragmentMaskUv)" + ) echo "Silky Shady shader codegen tests passed" diff --git a/tests/test_textboxes.nim b/tests/test_textboxes.nim index 7683633..1fb7ddb 100644 --- a/tests/test_textboxes.nim +++ b/tests/test_textboxes.nim @@ -8,6 +8,8 @@ import # Build a real atlas with font metrics. let builder = newAtlasBuilder(1024, 4) builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18.0) +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Wide", 30.0) +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Small", 12.0) builder.write("tests/dist/atlas.png") let silkyAtlas = readAtlasFromPng("tests/dist/atlas.png") let fontData = silkyAtlas.fonts["Default"] @@ -31,6 +33,193 @@ proc newStateSingleLine(text: string, maxWidth = 500.0f): TextBoxState = result.setText(text) result.computeLayout(fontData, maxWidth) +proc advance(font: FontAtlas, rune: Rune): float32 = + ## Returns the actual atlas advance used by span layout assertions. + var entry: ptr LetterEntry + doAssert font.lookupLetter(rune, 0, entry) + entry.advance + +block: + echo "Testing styled text uses each span's font metrics" + let + s = newState("W a") + wide = silkyAtlas.fonts["Wide"] + small = silkyAtlas.fonts["Small"] + s.enabled = false + s.spans = @[ + TextSpan(first: 0, last: 1, font: "Wide"), + TextSpan(first: 2, last: 3, font: "Small") + ] + s.computeLayout(silkyAtlas.fonts, "Default", 500) + doAssert s.layout.len == 3 + doAssert abs(s.layout[0].w - wide.advance(Rune('W'))) < 0.01 + doAssert abs(s.layout[1].w - fontData.advance(Rune(' '))) < 0.01 + doAssert abs(s.layout[2].w - small.advance(Rune('a'))) < 0.01 + doAssert abs(s.layout[1].x - s.layout[0].w) < 0.01 + doAssert s.lineHeight >= wide.lineHeight + for bounds in s.layout: + doAssert bounds.h == s.lineHeight + +block: + echo "Testing formatting and links preserve native pair kerning" + const Sample = "AVATAR To WAVY" + let + plain = newStateNoWrap(Sample) + styled = newStateNoWrap(Sample) + doAssert fontData.lookupKerning(Rune('A'), Rune('V')) != 0 + doAssert fontData.lookupKerning(Rune('T'), Rune('o')) != 0 + doAssert plain.layout[1].x < plain.layout[0].w + styled.enabled = false + for i in 0 ..< styled.runes.len: + styled.spans.add(TextSpan( + first: i, + last: i + 1, + font: + if i mod 2 == 0: + "Default" + else: + "", + linkId: + if i in 1 .. 8: + 1 + else: + 0 + )) + styled.computeLayout(silkyAtlas.fonts, "Default", 500) + doAssert styled.layout.len == plain.layout.len + for i in 0 ..< plain.layout.len: + let + expected = plain.layout[i] + actual = styled.layout[i] + doAssert abs(actual.x - expected.x) < 0.001 + doAssert abs(actual.y - expected.y) < 0.001 + doAssert abs(actual.w - expected.w) < 0.001 + doAssert abs(actual.h - expected.h) < 0.001 + +block: + echo "Testing styled long words wrap before crossing the width" + let + s = newState("WWWWWW") + wide = silkyAtlas.fonts["Wide"] + width = wide.advance(Rune('W')) * 2.5'f + s.enabled = false + s.spans = @[TextSpan(first: 0, last: 6, font: "Wide")] + s.computeLayout(silkyAtlas.fonts, "Default", width) + doAssert s.layout[0].y == s.layout[1].y + doAssert s.layout[2].y > s.layout[1].y + doAssert s.layout[4].y > s.layout[3].y + for bounds in s.layout: + doAssert bounds.x + bounds.w <= width + 0.01 + +block: + echo "Testing styled Unicode selection preserves the original text" + let s = newState("é code 世界") + s.enabled = false + s.spans = @[TextSpan(first: 2, last: 6, font: "Small")] + s.computeLayout(silkyAtlas.fonts, "Default", 500) + doAssert s.layout.len == s.runes.len + s.cursor = 2 + s.selector = 6 + doAssert s.copyText() == "code" + doAssert getSelectionRects(s.layout, 2, 6).len == 1 + s.selectAll() + doAssert s.copyText() == "é code 世界" + +block: + echo "Testing styled code preserves tabs when copying" + let + s = newState("a\tb") + small = silkyAtlas.fonts["Small"] + s.enabled = false + s.spans = @[TextSpan(first: 0, last: 3, font: "Small")] + s.computeLayout(silkyAtlas.fonts, "Default", 500) + doAssert abs(s.layout[1].w - small.advance(Rune(' ')) * 4) < 0.01 + doAssert abs(s.layout[2].x - s.layout[1].x - s.layout[1].w) < 0.01 + s.selectAll() + doAssert s.copyText() == "a\tb" + +block: + echo "Testing the styled layout overload preserves unstyled layout" + let + s = newState("A plain paragraph that wraps across lines.", 100) + expected = s.layout + s.computeLayout(silkyAtlas.fonts, "Default", 100) + doAssert s.layout == expected + +block: + echo "Testing one selection spans headings, wrapped prose, and code" + const + Heading = "Heading" + Body = "é body wraps over several lines for selection." + Code = "let x = 1\n echo x" + Sample = Heading & "\n" & Body & "\n" & Code + let + bodyStart = Heading.runeLen + 1 + codeStart = bodyStart + Body.runeLen + 1 + s = newState(Sample, 180) + s.enabled = false + s.blocks = @[ + TextBlock( + first: 0, last: Heading.runeLen, font: "Wide", + padding: vec2(12, 6), spacing: 8 + ), + TextBlock( + first: bodyStart, last: codeStart - 1, + padding: vec2(12, 6), spacing: 8 + ), + TextBlock( + first: codeStart, last: Sample.runeLen, font: "Small", + padding: vec2(12, 12), label: "nim", labelFont: "Small", + labelSpacing: 8 + ) + ] + s.computeLayout(silkyAtlas.fonts, "Default", 180) + doAssert s.layout.len == Sample.runeLen + doAssert s.layout[0].h > s.layout[bodyStart].h + doAssert s.layout[bodyStart].h > s.layout[codeStart].h + doAssert s.layout[codeStart - 2].y > s.layout[bodyStart].y + doAssert s.layout[bodyStart].x == 12 + doAssert s.layout[codeStart].x == 12 + doAssert abs(s.innerHeight - s.layout[^1].y - s.layout[^1].h - 12) < 0.001 + for index in [0, bodyStart, codeStart]: + doAssert s.locationRect(index).h == s.layout[index].h + s.selector = 2 + s.cursor = codeStart + 5 + doAssert s.copyText() == "ading\n" & Body & "\nlet x" + let selected = getSelectionRects(s.layout, s.selector, s.cursor) + doAssert selected.len >= 4 + doAssert selected[0].h > selected[^1].h + s.selectAll() + doAssert s.copyText() == Sample + + echo "Testing dragging through block spacing has no selection dead zone" + let + first = s.layout[0] + body = s.layout[bodyStart] + s.mouseAction(first.xy + vec2(0, first.h / 2)) + s.mouseAction( + vec2(body.x, body.y - 1), click = false, shift = true + ) + doAssert s.selector == 0 + doAssert s.cursor == bodyStart + doAssert s.copyText() == Heading & "\n" + + echo "Testing keyboard selection follows rows with different heights" + s.cursor = 0 + s.selector = 0 + s.savedX = first.x + s.down(shift = true) + doAssert s.cursor == bodyStart + doAssert s.selector == 0 + s.up(shift = true) + doAssert s.cursor == 0 + s.cursor = codeStart + s.selector = codeStart + s.savedX = s.layout[codeStart].x + s.up(shift = true) + doAssert s.cursor >= bodyStart and s.cursor < codeStart + doAssert s.selector == codeStart + block: echo "Testing getText / setText" let s = newState("") From 4d0f4bf9247762c6e5650faa2bd8a8e53dc70228 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 10 Nov 2026 18:36:33 -0800 Subject: [PATCH 2/3] separate rich text from plain textboxes --- .github/workflows/build.yml | 1 + src/silky.nim | 11 +- src/silky/richtextboxes.nim | 715 +++++++++++++++++++++++++++++++++++ src/silky/textboxes.nim | 595 +++-------------------------- tests/test_decorations.nim | 117 ++++-- tests/test_inputs.nim | 195 ++++++++-- tests/test_quotes.nim | 41 +- tests/test_richtextboxes.nim | 224 +++++++++++ tests/test_rules.nim | 58 +-- tests/test_textboxes.nim | 192 +--------- 10 files changed, 1301 insertions(+), 848 deletions(-) create mode 100644 src/silky/richtextboxes.nim create mode 100644 tests/test_richtextboxes.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b29fa86..e9e5653 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,6 +58,7 @@ jobs: if: ${{ !matrix.emscripten }} run: | nim r tests/test_textboxes.nim + nim r ${{ matrix.build_flags }} tests/test_richtextboxes.nim nim r -d:silkyTesting -d:nimTypeNames tests/test_inputs.nim nim r -d:silkyTesting tests/test_buttons.nim nim r -d:silkyTesting tests/test_subwindow.nim diff --git a/src/silky.nim b/src/silky.nim index 9cf0160..af4dbfe 100644 --- a/src/silky.nim +++ b/src/silky.nim @@ -3,9 +3,12 @@ from silky/common import SilkyError export SilkyError when defined(silkyTesting): - import silky/[semantics, atlas, widgets, textboxes, testing, dsl, menus, profiles] + import silky/[ + semantics, atlas, widgets, textboxes, richtextboxes, testing, dsl, menus, + profiles + ] export semantics except textInputs - export atlas, tables, textboxes, testing, dsl, menus, profiles + export atlas, tables, textboxes, richtextboxes, testing, dsl, menus, profiles export widgets except button, checkBox, clickableIcon, dropDown, frame, group, h1text, icon, iconButton, image, listBox, progressBar, radioButton, ribbon, scrubber, text @@ -16,14 +19,14 @@ else: not defined(useMetal4) and not defined(useCpu): import opengl - import silky/[contexts, atlas, widgets, textboxes, dsl, menus] + import silky/[contexts, atlas, widgets, textboxes, richtextboxes, dsl, menus] when not defined(useDirectX) and not defined(useVulkan) and not defined(useMetal4) and not defined(useCpu): export opengl export contexts except textInputs - export windy, atlas, tables, textboxes, dsl, menus + export windy, atlas, tables, textboxes, richtextboxes, dsl, menus export widgets except button, checkBox, clickableIcon, dropDown, frame, group, h1text, icon, iconButton, image, listBox, progressBar, radioButton, ribbon, scrubber, text diff --git a/src/silky/richtextboxes.nim b/src/silky/richtextboxes.nim new file mode 100644 index 0000000..994de3e --- /dev/null +++ b/src/silky/richtextboxes.nim @@ -0,0 +1,715 @@ +import + std/[tables, unicode, times], + vmath, bumpy, chroma, + silky/atlas, silky/internal/inputs, silky/textboxes + +when defined(silkyTesting): + import silky/[semantics, testing, profiles] +else: + import silky/[contexts, profiles], windy + +const + LF = Rune(10) + Space = Rune(32) + Tab = Rune(9) + DoubleClickTime = 0.4 + SelectionColor = rgbx(30, 60, 130, 128) + +when defined(macos): + const TextBoxScrollSpeed = 10.0f +else: + const TextBoxScrollSpeed = -10.0f + +type + TextSpan* = object + ## Read-only formatting over a half-open range of rune offsets. + first*, last*: int + font*: string + color*: ColorRGBX + background*: ColorRGBX + strikethrough*: bool + linkId*: int + ## Matching positive IDs group the styled parts of one hoverable link. + + TextBlock* = object + ## A styled block over a half-open range of shared rune offsets. + first*, last*: int + font*: string + padding*: Vec2 + spacing*: float32 + patch*: string + patchBorder*: int + label*, labelFont*: string + labelColor*: ColorRGBX + labelSpacing*: float32 + ruleColor*: ColorRGBX + ruleThickness*: float32 + ## Positive thickness turns an empty block into a horizontal rule. + quoteDepth*, quoteContinue*: int + ## Continued quote levels also draw their borders through spacing. + quoteIndent*: float32 = 16 + quoteColor*: ColorRGBX + quoteThickness*: float32 = 2 + + BlockLayout = object + bounds: Rect + ruleBounds: Rect + quoteBounds: Rect + labelPosition: Vec2 + labelFont: string + + TextRun = object + text: string + font: string + position: Vec2 + bounds: Rect + span: int + + RichTextBoxState* = ref object + ## Read-only styled content with one shared plain-text selection state. + editor*: TextBoxState + spans*: seq[TextSpan] + blocks*: seq[TextBlock] + activatedSpan*: int = -1 + hoveredSpan*: int = -1 + runeSpans: seq[int] + runs: seq[TextRun] + blockLayouts: seq[BlockLayout] + lastFont: string + focusEpoch: uint64 + pressedSpan: int = -1 + pressPosition: Vec2 + spanDragged: bool + +var + richTextBoxStates*: Table[string, RichTextBoxState] + +proc newRichTextBoxState*(text = "", wordWrap = true): RichTextBoxState = + ## Creates a read-only document with its own selection and layout cache. + result = RichTextBoxState( + editor: TextBoxState(wordWrap: wordWrap, enabled: false) + ) + result.editor.setText(text) + +proc getText*(state: RichTextBoxState): string = + ## Returns the underlying selectable text without formatting markers. + state.editor.getText() + +proc sameText*(state: RichTextBoxState, text: string): bool = + ## Compares the selectable text without allocating. + state.editor.sameText(text) + +proc setText*( + state: RichTextBoxState, + text: string, + preserveSelection = false +) = + ## Replaces content while optionally retaining a streamed selection. + state.editor.setText(text, preserveSelection) + +proc innerHeight*(state: RichTextBoxState): float32 = + ## Returns the measured height of the complete styled document. + state.editor.innerHeight + +proc computeLayout*( + state: RichTextBoxState, + fonts: Table[string, FontAtlas], + baseFont: string, + maxWidth: float32 +) + +proc computeBlocks( + state: RichTextBoxState, + fonts: Table[string, FontAtlas], + baseFont: string, + maxWidth: float32 +) = + ## Combines block layouts into one selectable document and native run list. + state.editor.positionedLayout = true + state.editor.lineHeight = fonts[baseFont].lineHeight + state.editor.lastMaxWidth = maxWidth + state.editor.layout.setLen(state.editor.runes.len) + state.runeSpans.setLen(state.editor.runes.len) + state.runs.setLen(0) + state.blockLayouts.setLen(0) + state.editor.layoutHeight = 0 + for index in state.runeSpans.mitems: + index = -1 + var + covered = 0 + previousEnd = rect(0, 0, 0, state.editor.lineHeight) + for item in state.blocks: + let + first = clamp(item.first, covered, state.editor.runes.len) + last = clamp(item.last, first, state.editor.runes.len) + width = max(0.0'f, maxWidth) + depth = max(0, item.quoteDepth) + padding = vec2( + if depth > 0: + min(max(0.0'f, item.padding.x), width / 2) + else: + max(0.0'f, item.padding.x), + max(0.0'f, item.padding.y) + ) + quoteInset = min( + max(0.0'f, width - padding.x * 2), + depth.float32 * max(0.0'f, item.quoteIndent) + ) + blockWidth = width - quoteInset + for i in covered ..< first: + state.editor.layout[i] = previousEnd + if first == last and item.ruleThickness > 0: + let + origin = vec2( + quoteInset + padding.x, + state.editor.layoutHeight + padding.y + ) + height = item.ruleThickness + padding.y * 2 + state.blockLayouts.add(BlockLayout( + bounds: rect(quoteInset, state.editor.layoutHeight, blockWidth, height), + ruleBounds: rect( + origin, + vec2(max(0.0'f, blockWidth - padding.x * 2), item.ruleThickness) + ), + quoteBounds: rect( + padding.x, + state.editor.layoutHeight, + max(0.0'f, width - padding.x * 2), + height + ) + )) + state.editor.layoutCursor = rect(origin, vec2(0)) + state.editor.layoutHeight += height + max(0.0'f, item.spacing) + previousEnd = state.editor.layoutCursor + covered = last + continue + let + font = + if item.font in fonts: + item.font + else: + baseFont + labelFont = + if item.labelFont in fonts: + item.labelFont + else: + baseFont + labelHeight = + if item.label.len > 0: + fonts[labelFont].lineHeight + max(0.0'f, item.labelSpacing) + else: + 0.0'f + origin = vec2( + quoteInset + padding.x, + state.editor.layoutHeight + padding.y + labelHeight + ) + local = RichTextBoxState( + editor: TextBoxState( + runes: state.editor.runes[first ..< last], + wordWrap: state.editor.wordWrap, + enabled: false + ), + spans: @[TextSpan()] + ) + var spanIndices = @[-1] + for index, span in state.spans: + if span.first < last and span.last > first: + var relative = span + relative.first = max(first, span.first) - first + relative.last = min(last, span.last) - first + local.spans.add(relative) + spanIndices.add(index) + local.computeLayout(fonts, font, max(0.0'f, blockWidth - padding.x * 2)) + var contentHeight = local.editor.lineHeight + for i, bounds in local.editor.layout: + state.editor.layout[first + i] = rect(origin + bounds.xy, bounds.wh) + let span = local.runeSpans[i] + if span >= 0: + state.runeSpans[first + i] = spanIndices[span] + for run in local.runs.mitems: + run.position += origin + run.bounds.x += origin.x + run.bounds.y += origin.y + if run.span >= 0: + run.span = spanIndices[run.span] + state.runs.add(run) + state.editor.layoutCursor = rect( + origin.x, + origin.y, + 0, + local.editor.lineHeight + ) + if local.editor.layout.len > 0: + let bounds = local.editor.layout[^1] + contentHeight = bounds.y + bounds.h + state.editor.layoutCursor = rect( + origin.x + bounds.x + bounds.w, + origin.y + bounds.y, + 0, + bounds.h + ) + if local.editor.runes[^1] == LF: + contentHeight += local.editor.lineHeight + state.editor.layoutCursor.x = origin.x + state.editor.layoutCursor.y += local.editor.lineHeight + let height = contentHeight + padding.y * 2 + labelHeight + state.blockLayouts.add(BlockLayout( + bounds: rect(quoteInset, state.editor.layoutHeight, blockWidth, height), + quoteBounds: rect( + padding.x, state.editor.layoutHeight, max(0.0'f, width - padding.x * 2), + height + ), + labelPosition: vec2( + quoteInset + padding.x, state.editor.layoutHeight + padding.y + ), + labelFont: labelFont + )) + state.editor.layoutHeight += height + max(0.0'f, item.spacing) + previousEnd = state.editor.layoutCursor + covered = last + for i in covered ..< state.editor.runes.len: + state.editor.layout[i] = previousEnd + state.editor.dirty = false + +proc computeLayout*( + state: RichTextBoxState, + fonts: Table[string, FontAtlas], + baseFont: string, + maxWidth: float32 +) = + ## Lays out styled text with the same glyph positions used for selection. + state.lastFont = baseFont + if state.blocks.len > 0: + state.computeBlocks(fonts, baseFont, maxWidth) + return + state.editor.positionedLayout = false + state.editor.layoutCursor = rect(0, 0, 0, 0) + state.blockLayouts.setLen(0) + state.editor.layoutHeight = 0 + if state.spans.len == 0: + state.runeSpans.setLen(0) + state.runs.setLen(0) + state.editor.computeLayout(fonts[baseFont], maxWidth) + return + let base = fonts[baseFont] + var + resolvedFonts = @[base] + fontNames = @[baseFont] + baseline = base.ascent + descent = base.descent + lineGap = base.lineGap + state.editor.lineHeight = base.lineHeight + state.editor.lastMaxWidth = maxWidth + state.editor.layout.setLen(0) + state.runs.setLen(0) + state.runeSpans.setLen(state.editor.runes.len) + for index in state.runeSpans.mitems: + index = -1 + for index, span in state.spans: + let name = + if span.font.len > 0 and span.font in fonts: + span.font + else: + baseFont + let font = fonts[name] + resolvedFonts.add(font) + fontNames.add(name) + if span.first < state.editor.runes.len and span.last > max(0, span.first): + state.editor.lineHeight = max(state.editor.lineHeight, font.lineHeight) + baseline = max(baseline, font.ascent) + descent = min(descent, font.descent) + lineGap = max(lineGap, font.lineGap) + for i in max(0, span.first) ..< min(span.last, state.editor.runes.len): + state.runeSpans[i] = index + state.editor.lineHeight = max( + state.editor.lineHeight, + baseline - descent + lineGap + ) + var + advances = newSeq[float32](state.editor.runes.len) + kernings = newSeq[float32](state.editor.runes.len) + for i, rune in state.editor.runes: + if rune == LF: + continue + let font = resolvedFonts[state.runeSpans[i] + 1] + var entry: ptr LetterEntry + if rune == Tab: + if font.lookupLetter(Space, 0, entry): + advances[i] = entry.advance * 4 + continue + if font.lookupLetter(rune, 0, entry): + advances[i] = entry.advance + if i + 1 < state.editor.runes.len and state.editor.runes[i + 1] != LF and + font == resolvedFonts[state.runeSpans[i + 1] + 1]: + kernings[i] = font.lookupKerning(rune, state.editor.runes[i + 1]) + var position = vec2(0, 0) + for i, rune in state.editor.runes: + if rune == LF: + state.editor.layout.add(rect( + position.x, + position.y, + 0, + state.editor.lineHeight + )) + position.x = 0 + position.y += state.editor.lineHeight + continue + if state.editor.wordWrap and maxWidth < float32.high: + let wordStart = i == 0 or state.editor.runes[i - 1] in [Space, Tab, LF] + if wordStart and rune notin [Space, Tab] and position.x > 0: + var + wordWidth = 0.0'f + j = i + while j < state.editor.runes.len and + state.editor.runes[j] notin [Space, Tab, LF]: + wordWidth += advances[j] + if j + 1 < state.editor.runes.len and + state.editor.runes[j + 1] notin [Space, Tab, LF]: + wordWidth += kernings[j] + inc j + if position.x + wordWidth > maxWidth: + position.x = 0 + position.y += state.editor.lineHeight + if position.x > 0 and position.x + advances[i] > maxWidth: + position.x = 0 + position.y += state.editor.lineHeight + state.editor.layout.add(rect( + position.x, position.y, advances[i], state.editor.lineHeight + )) + position.x += advances[i] + kernings[i] + var i = 0 + while i < state.editor.runes.len: + if state.editor.runes[i] in [LF, Tab]: + inc i + continue + let + first = i + span = state.runeSpans[i] + font = resolvedFonts[span + 1] + bounds = state.editor.layout[i] + var text = "" + while i < state.editor.runes.len and + state.editor.runes[i] notin [LF, Tab] and + state.runeSpans[i] == span and state.editor.layout[i].y == bounds.y: + text.add($state.editor.runes[i]) + inc i + let last = state.editor.layout[i - 1] + state.runs.add(TextRun( + text: text, + font: fontNames[span + 1], + position: vec2(bounds.x, bounds.y + baseline - font.ascent), + bounds: rect( + bounds.x, bounds.y, last.x + last.w - bounds.x, bounds.h + ), + span: state.runeSpans[first] + )) + state.editor.dirty = false + +proc spanAt(state: RichTextBoxState, position: Vec2): int = + ## Finds a formatted span only when the pointer touches its glyph bounds. + for i, bounds in state.editor.layout: + if position.overlaps(bounds) and i < state.runeSpans.len: + return state.runeSpans[i] + -1 + +proc drawSpanBackgrounds(sk: Silky, state: RichTextBoxState, origin: Vec2) = + ## Draws inline backgrounds before the native selection highlight. + for run in state.runs: + if run.span >= 0: + let color = state.spans[run.span].background + if color.a > 0: + sk.drawRect(origin + run.bounds.xy, run.bounds.wh, color) + +proc drawBlocks( + sk: Silky, + state: RichTextBoxState, + origin: Vec2, + defaultColor: ColorRGBX +) = + ## Draws native block surfaces, borders, rules, and captions. + for i, bounds in state.blockLayouts: + let item = state.blocks[i] + if item.patch.len > 0: + sk.draw9Patch( + item.patch, + item.patchBorder, + origin + bounds.bounds.xy, + bounds.bounds.wh + ) + if item.quoteDepth > 0 and item.quoteThickness > 0 and + item.quoteColor.a > 0 and bounds.quoteBounds.w > 0: + let + scale = max(sk.uiScale, 0.001'f) + indent = max(0.0'f, item.quoteIndent) + position = origin + bounds.quoteBounds.xy + limit = floor((position.x + bounds.quoteBounds.w) * scale) / scale + top = round(position.y * scale) / scale + thickness = max(1.0'f, round(item.quoteThickness * scale)) / scale + continuation = clamp(item.quoteContinue, 0, item.quoteDepth) + for level in 0 ..< item.quoteDepth: + let + left = round((position.x + level.float32 * indent) * scale) / + scale + width = min(thickness, limit - left) + if width <= 0: + break + let + spacing = + if level < continuation: + max(0.0'f, item.spacing) + else: + 0.0'f + bottom = round( + (position.y + bounds.quoteBounds.h + spacing) * scale + ) / scale + sk.drawRect( + vec2(left, top), vec2(width, bottom - top), item.quoteColor + ) + if indent == 0: + break + if bounds.ruleBounds.h > 0: + if bounds.ruleBounds.w > 0 and item.ruleColor.a > 0: + let + scale = max(sk.uiScale, 0.001'f) + position = origin + bounds.ruleBounds.xy + left = round(position.x * scale) / scale + right = round((position.x + bounds.ruleBounds.w) * scale) / scale + top = round(position.y * scale) / scale + thickness = max(1.0'f, round(bounds.ruleBounds.h * scale)) / scale + sk.drawRect( + vec2(left, top), vec2(right - left, thickness), item.ruleColor + ) + continue + if item.label.len > 0: + let color = + if item.labelColor.a > 0: + item.labelColor + else: + defaultColor + discard sk.drawText( + bounds.labelFont, + item.label, + origin + bounds.labelPosition, + color, + maxWidth = max(0.0'f, bounds.bounds.w - item.padding.x * 2) + ) + +proc drawSpans( + sk: Silky, + state: RichTextBoxState, + origin: Vec2, + defaultColor: ColorRGBX +) = + ## Draws cached runs at their measured positions without wrapping again. + for run in state.runs: + let color = + if run.span >= 0 and state.spans[run.span].color.a > 0: + state.spans[run.span].color + else: + defaultColor + discard sk.drawText( + run.font, run.text, origin + run.position, color, clip = false + ) + if run.span >= 0: + let + span = state.spans[run.span] + underline = state.hoveredSpan >= 0 and span.linkId > 0 and + span.linkId == state.spans[state.hoveredSpan].linkId + if span.strikethrough or underline: + let + font = sk.atlas.fonts[run.font] + scale = max(sk.uiScale, 0.001'f) + left = round((origin.x + run.bounds.x) * scale) / scale + right = round((origin.x + run.bounds.x + run.bounds.w) * scale) / + scale + baseline = origin.y + run.position.y + font.ascent + thickness = max(1.0'f, round(scale)) / scale + if span.strikethrough: + let top = round((baseline - font.size * 0.3'f) * scale) / scale + sk.drawRect(vec2(left, top), vec2(right - left, thickness), color) + if underline: + let top = round((baseline + 1) * scale) / scale + sk.drawRect(vec2(left, top), vec2(right - left, thickness), color) + +proc richTextBox*( + sk: Silky, + window: Window, + id, text: string, + boxWidth, boxHeight: float32, + wrapWords = true, + spans: seq[TextSpan] = default(seq[TextSpan]), + blocks: seq[TextBlock] = default(seq[TextBlock]) +) {.measure.} = + ## Draws a read-only styled document with one selection and link handling. + if id notin richTextBoxStates: + richTextBoxStates[id] = newRichTextBoxState(text, wrapWords) + let + state = richTextBoxStates[id] + editor = state.editor + inputs = sk.textInputs + editor.enabled = false + state.activatedSpan = -1 + state.hoveredSpan = -1 + if editor.focused and state.focusEpoch != inputs.focusEpoch: + editor.focused = false + editor.dragging = false + editor.selector = editor.cursor + if state.spans != spans: + state.spans = spans + editor.dirty = true + if state.blocks != blocks: + state.blocks = blocks + editor.dirty = true + if editor.wordWrap != wrapWords: + editor.wordWrap = wrapWords + editor.dirty = true + if not state.sameText(text): + state.setText(text, preserveSelection = true) + let + padding = sk.theme.padding.float32 + outerRect = rect(sk.at, vec2(boxWidth, boxHeight)) + innerRect = rect( + sk.at.x + padding, sk.at.y + padding, + boxWidth - padding * 2, boxHeight - padding * 2 + ) + editor.boxSize = innerRect.wh + if editor.dirty or editor.lastMaxWidth != innerRect.w or + state.lastFont != sk.textStyle: + state.computeLayout(sk.atlas.fonts, sk.textStyle, innerRect.w) + let + shift = window.buttonDown[KeyLeftShift] or + window.buttonDown[KeyRightShift] + mouseVec = sk.mousePos + mouseInside = mouseVec.overlaps(outerRect) and + mouseVec.overlaps(sk.clipRect) + onScrollbar = mouseInside and not mouseVec.overlaps(innerRect) + if window.buttonPressed[MouseLeft]: + state.pressedSpan = -1 + if mouseInside and not onScrollbar: + editor.focused = true + let + localMouse = mouseVec - innerRect.xy + editor.scrollPos + now = epochTime() + if now - editor.lastClickTime < DoubleClickTime: + inc editor.clickCount + else: + editor.clickCount = 1 + editor.lastClickTime = now + case editor.clickCount + of 1: + editor.mouseAction(localMouse, click = true, shift = shift) + editor.dragging = true + if not shift: + state.pressedSpan = state.spanAt(localMouse) + state.pressPosition = mouseVec + state.spanDragged = false + of 2: + editor.selectWord(localMouse) + of 3: + editor.selectParagraph(localMouse) + else: + editor.selectAll() + elif not mouseInside: + editor.focused = false + editor.dragging = false + editor.selector = editor.cursor + if state.pressedSpan >= 0: + let distance = mouseVec - state.pressPosition + if distance.x * distance.x + distance.y * distance.y > 9: + state.spanDragged = true + if window.buttonReleased[MouseLeft]: + let localMouse = mouseVec - innerRect.xy + editor.scrollPos + if mouseInside and not state.spanDragged and + editor.cursor == editor.selector and + state.spanAt(localMouse) == state.pressedSpan: + state.activatedSpan = state.pressedSpan + state.pressedSpan = -1 + elif not window.buttonDown[MouseLeft]: + state.pressedSpan = -1 + if editor.dragging and not editor.scrollingX and not editor.scrollingY: + if window.buttonDown[MouseLeft] and not window.buttonPressed[MouseLeft]: + let localMouse = mouseVec - innerRect.xy + editor.scrollPos + editor.mouseAction(localMouse, click = false, shift = true) + if window.buttonReleased[MouseLeft] or not window.buttonDown[MouseLeft]: + editor.dragging = false + inputs.submitInput(window, "rich-textbox:" & id, editor.focused, false) + if editor.focused: + state.focusEpoch = inputs.focusEpoch + editor.handleSelectionInput(inputs) + let maxScrollY = max(0.0'f, editor.innerHeight - innerRect.h) + editor.scrollPos.y = clamp(editor.scrollPos.y, 0.0'f, maxScrollY) + if editor.wordWrap: + editor.scrollPos.x = 0 + else: + editor.scrollPos.x = max(0.0'f, editor.scrollPos.x) + let + overVerticalBar = editor.innerHeight > innerRect.h and + mouseVec.x >= outerRect.x + outerRect.w - 10 + overHorizontalBar = not editor.wordWrap and + editor.innerWidth > innerRect.w and + mouseVec.y >= outerRect.y + outerRect.h - 10 + if mouseInside and mouseVec.overlaps(innerRect) and + not editor.scrollingX and not editor.scrollingY and + not overVerticalBar and not overHorizontalBar: + let index = state.spanAt(mouseVec - innerRect.xy + editor.scrollPos) + if index >= 0 and state.spans[index].linkId > 0: + state.hoveredSpan = index + sk.cursor = Cursor(kind: PointerCursor) + let textColor = sk.theme.disabledTextColor + sk.draw9Patch( + "textbox.disabled.9patch", sk.theme.textboxPatch, outerRect.xy, outerRect.wh + ) + sk.pushClipRect(innerRect) + let textOrigin = innerRect.xy - editor.scrollPos + if state.blocks.len > 0: + sk.drawBlocks(state, textOrigin, textColor) + if state.spans.len > 0: + sk.drawSpanBackgrounds(state, textOrigin) + if editor.cursor != editor.selector: + let selection = editor.selection + for bounds in getSelectionRects(editor.layout, selection.a, selection.b): + sk.drawRect( + textOrigin + bounds.xy, bounds.wh, SelectionColor + ) + if state.spans.len > 0 or state.blocks.len > 0: + sk.drawSpans(state, textOrigin, textColor) + else: + discard sk.drawText( + sk.textStyle, + editor.displayText, + textOrigin, + textColor, + maxWidth = innerRect.w, + wordWrap = editor.wordWrap, + clip = false + ) + if editor.focused and editor.cursorVisible: + let cursor = editor.locationRect(editor.cursor) + sk.drawRect(textOrigin + cursor.xy, cursor.wh, textColor) + sk.popClipRect() + sk.drawScrollbars( + editor, window, outerRect, innerRect, sk.mousePos, sk.clipRect + ) + if mouseInside and window.scrollDelta.y != 0: + editor.scrollBy(window.scrollDelta.y * TextBoxScrollSpeed, innerRect.h) + sk.advance(vec2(boxWidth, boxHeight)) + +template richTextBox*( + id: string, + text: string, + boxWidth, boxHeight: float32, + wrapWords = true, + spans: seq[TextSpan] = default(seq[TextSpan]), + blocks: seq[TextBlock] = default(seq[TextBlock]) +) = + ## Draws a selectable read-only rich document using the current context. + sk.richTextBox( + window, + id, + text, + boxWidth, + boxHeight, + wrapWords, + spans, + blocks + ) diff --git a/src/silky/textboxes.nim b/src/silky/textboxes.nim index c520cb3..4e17f19 100644 --- a/src/silky/textboxes.nim +++ b/src/silky/textboxes.nim @@ -38,7 +38,6 @@ const LF = Rune(10) CR = Rune(13) Space = Rune(32) - Tab = Rune(9) CursorWidth* = 2.0f DoubleClickTime = 0.4 SelectionColor = rgbx(30, 60, 130, 128) @@ -49,50 +48,6 @@ else: const TextBoxScrollSpeed = -10.0f type - TextSpan* = object - ## Read-only formatting over a half-open range of rune offsets. - first*, last*: int - font*: string - color*: ColorRGBX - background*: ColorRGBX - strikethrough*: bool - linkId*: int - ## Matching positive IDs group the styled parts of one hoverable link. - - TextBlock* = object - ## A styled block over a half-open range of shared rune offsets. - first*, last*: int - font*: string - padding*: Vec2 - spacing*: float32 - patch*: string - patchBorder*: int - label*, labelFont*: string - labelColor*: ColorRGBX - labelSpacing*: float32 - ruleColor*: ColorRGBX - ruleThickness*: float32 - ## Positive thickness turns an empty block into a horizontal rule. - quoteDepth*, quoteContinue*: int - ## Continued quote levels also draw their borders through spacing. - quoteIndent*: float32 = 16 - quoteColor*: ColorRGBX - quoteThickness*: float32 = 2 - - BlockLayout = object - bounds: Rect - ruleBounds: Rect - quoteBounds: Rect - labelPosition: Vec2 - labelFont: string - - TextRun = object - text: string - font: string - position: Vec2 - bounds: Rect - span: int - TextBoxState* = ref object ## State for a multi-line text box widget. runes*: seq[Rune] @@ -103,6 +58,10 @@ type focused*: bool focusEpoch: uint64 layout*: seq[Rect] + positionedLayout*: bool + ## Selection geometry may have variable line heights and gaps. + layoutHeight*: float32 + layoutCursor*: Rect dirty*: bool undoStack*: seq[(seq[Rune], int)] redoStack*: seq[(seq[Rune], int)] @@ -124,19 +83,7 @@ type password*: bool passwordChar*: Rune = Rune('*') allowedChars*: seq[Rune] - spans*: seq[TextSpan] - blocks*: seq[TextBlock] - activatedSpan*: int = -1 - hoveredSpan*: int = -1 - runeSpans: seq[int] - runs: seq[TextRun] - blockLayouts: seq[BlockLayout] - blockHeight: float32 - blockCursor: Rect - lastFont: string - pressedSpan: int = -1 - pressPosition: Vec2 - spanDragged: bool + lastFont: FontAtlas textCache: string textCacheValid: bool displayCache: string @@ -252,6 +199,10 @@ proc computeLayout*(state: TextBoxState, fontData: FontAtlas, maxWidth: float32) ## Computes per-character selection rectangles in local coordinates. ## Mirrors the glyph-walking logic from drawText but stores positions ## instead of emitting GPU vertices. + state.positionedLayout = false + state.layoutHeight = 0 + state.layoutCursor = rect(0, 0, 0, 0) + state.lastFont = fontData state.layout.setLen(0) state.lineHeight = fontData.lineHeight state.lastMaxWidth = maxWidth @@ -311,399 +262,6 @@ proc computeLayout*(state: TextBoxState, fontData: FontAtlas, maxWidth: float32) inc i state.dirty = false -proc computeLayout*( - state: TextBoxState, - fonts: Table[string, FontAtlas], - baseFont: string, - maxWidth: float32 -) - -proc computeBlocks( - state: TextBoxState, - fonts: Table[string, FontAtlas], - baseFont: string, - maxWidth: float32 -) = - ## Combines block layouts into one selectable document and native run list. - state.lineHeight = fonts[baseFont].lineHeight - state.lastMaxWidth = maxWidth - state.layout.setLen(state.runes.len) - state.runeSpans.setLen(state.runes.len) - state.runs.setLen(0) - state.blockLayouts.setLen(0) - state.blockHeight = 0 - for index in state.runeSpans.mitems: - index = -1 - var - covered = 0 - previousEnd = rect(0, 0, 0, state.lineHeight) - for item in state.blocks: - let - first = clamp(item.first, covered, state.runes.len) - last = clamp(item.last, first, state.runes.len) - width = max(0.0'f, maxWidth) - depth = max(0, item.quoteDepth) - padding = vec2( - if depth > 0: - min(max(0.0'f, item.padding.x), width / 2) - else: - max(0.0'f, item.padding.x), - max(0.0'f, item.padding.y) - ) - quoteInset = min( - max(0.0'f, width - padding.x * 2), - depth.float32 * max(0.0'f, item.quoteIndent) - ) - blockWidth = width - quoteInset - for i in covered ..< first: - state.layout[i] = previousEnd - if first == last and item.ruleThickness > 0: - let - origin = vec2(quoteInset + padding.x, state.blockHeight + padding.y) - height = item.ruleThickness + padding.y * 2 - state.blockLayouts.add(BlockLayout( - bounds: rect(quoteInset, state.blockHeight, blockWidth, height), - ruleBounds: rect( - origin, - vec2(max(0.0'f, blockWidth - padding.x * 2), item.ruleThickness) - ), - quoteBounds: rect( - padding.x, state.blockHeight, max(0.0'f, width - padding.x * 2), - height - ) - )) - state.blockCursor = rect(origin, vec2(0)) - state.blockHeight += height + max(0.0'f, item.spacing) - previousEnd = state.blockCursor - covered = last - continue - let - font = - if item.font in fonts: - item.font - else: - baseFont - labelFont = - if item.labelFont in fonts: - item.labelFont - else: - baseFont - labelHeight = - if item.label.len > 0: - fonts[labelFont].lineHeight + max(0.0'f, item.labelSpacing) - else: - 0.0'f - origin = vec2( - quoteInset + padding.x, state.blockHeight + padding.y + labelHeight - ) - local = TextBoxState( - runes: state.runes[first ..< last], - wordWrap: state.wordWrap, - spans: @[TextSpan()] - ) - var spanIndices = @[-1] - for index, span in state.spans: - if span.first < last and span.last > first: - var relative = span - relative.first = max(first, span.first) - first - relative.last = min(last, span.last) - first - local.spans.add(relative) - spanIndices.add(index) - local.computeLayout(fonts, font, max(0.0'f, blockWidth - padding.x * 2)) - var contentHeight = local.lineHeight - for i, bounds in local.layout: - state.layout[first + i] = rect(origin + bounds.xy, bounds.wh) - let span = local.runeSpans[i] - if span >= 0: - state.runeSpans[first + i] = spanIndices[span] - for run in local.runs.mitems: - run.position += origin - run.bounds.x += origin.x - run.bounds.y += origin.y - if run.span >= 0: - run.span = spanIndices[run.span] - state.runs.add(run) - state.blockCursor = rect(origin.x, origin.y, 0, local.lineHeight) - if local.layout.len > 0: - let bounds = local.layout[^1] - contentHeight = bounds.y + bounds.h - state.blockCursor = rect( - origin.x + bounds.x + bounds.w, - origin.y + bounds.y, - 0, - bounds.h - ) - if local.runes[^1] == LF: - contentHeight += local.lineHeight - state.blockCursor.x = origin.x - state.blockCursor.y += local.lineHeight - let height = contentHeight + padding.y * 2 + labelHeight - state.blockLayouts.add(BlockLayout( - bounds: rect(quoteInset, state.blockHeight, blockWidth, height), - quoteBounds: rect( - padding.x, state.blockHeight, max(0.0'f, width - padding.x * 2), - height - ), - labelPosition: vec2( - quoteInset + padding.x, state.blockHeight + padding.y - ), - labelFont: labelFont - )) - state.blockHeight += height + max(0.0'f, item.spacing) - previousEnd = state.blockCursor - covered = last - for i in covered ..< state.runes.len: - state.layout[i] = previousEnd - state.dirty = false - -proc computeLayout*( - state: TextBoxState, - fonts: Table[string, FontAtlas], - baseFont: string, - maxWidth: float32 -) = - ## Lays out styled text with the same glyph positions used for selection. - state.lastFont = baseFont - if state.blocks.len > 0: - state.computeBlocks(fonts, baseFont, maxWidth) - return - state.blockLayouts.setLen(0) - state.blockHeight = 0 - if state.spans.len == 0: - state.runeSpans.setLen(0) - state.runs.setLen(0) - state.computeLayout(fonts[baseFont], maxWidth) - return - let base = fonts[baseFont] - var - resolvedFonts = @[base] - fontNames = @[baseFont] - baseline = base.ascent - descent = base.descent - lineGap = base.lineGap - state.lineHeight = base.lineHeight - state.lastMaxWidth = maxWidth - state.layout.setLen(0) - state.runs.setLen(0) - state.runeSpans.setLen(state.runes.len) - for index in state.runeSpans.mitems: - index = -1 - for index, span in state.spans: - let name = - if span.font.len > 0 and span.font in fonts: - span.font - else: - baseFont - let font = fonts[name] - resolvedFonts.add(font) - fontNames.add(name) - if span.first < state.runes.len and span.last > max(0, span.first): - state.lineHeight = max(state.lineHeight, font.lineHeight) - baseline = max(baseline, font.ascent) - descent = min(descent, font.descent) - lineGap = max(lineGap, font.lineGap) - for i in max(0, span.first) ..< min(span.last, state.runes.len): - state.runeSpans[i] = index - state.lineHeight = max(state.lineHeight, baseline - descent + lineGap) - var - advances = newSeq[float32](state.runes.len) - kernings = newSeq[float32](state.runes.len) - for i, rune in state.runes: - if rune == LF: - continue - let font = resolvedFonts[state.runeSpans[i] + 1] - var entry: ptr LetterEntry - if rune == Tab: - if font.lookupLetter(Space, 0, entry): - advances[i] = entry.advance * 4 - continue - if font.lookupLetter(rune, 0, entry): - advances[i] = entry.advance - if i + 1 < state.runes.len and state.runes[i + 1] != LF and - font == resolvedFonts[state.runeSpans[i + 1] + 1]: - kernings[i] = font.lookupKerning(rune, state.runes[i + 1]) - var position = vec2(0, 0) - for i, rune in state.runes: - if rune == LF: - state.layout.add(rect(position.x, position.y, 0, state.lineHeight)) - position.x = 0 - position.y += state.lineHeight - continue - if state.wordWrap and maxWidth < float32.high: - let wordStart = i == 0 or state.runes[i - 1] in [Space, Tab, LF] - if wordStart and rune notin [Space, Tab] and position.x > 0: - var - wordWidth = 0.0'f - j = i - while j < state.runes.len and state.runes[j] notin [Space, Tab, LF]: - wordWidth += advances[j] - if j + 1 < state.runes.len and - state.runes[j + 1] notin [Space, Tab, LF]: - wordWidth += kernings[j] - inc j - if position.x + wordWidth > maxWidth: - position.x = 0 - position.y += state.lineHeight - if position.x > 0 and position.x + advances[i] > maxWidth: - position.x = 0 - position.y += state.lineHeight - state.layout.add(rect( - position.x, position.y, advances[i], state.lineHeight - )) - position.x += advances[i] + kernings[i] - var i = 0 - while i < state.runes.len: - if state.runes[i] in [LF, Tab]: - inc i - continue - let - first = i - span = state.runeSpans[i] - font = resolvedFonts[span + 1] - bounds = state.layout[i] - var text = "" - while i < state.runes.len and state.runes[i] notin [LF, Tab] and - state.runeSpans[i] == span and state.layout[i].y == bounds.y: - text.add($state.runes[i]) - inc i - let last = state.layout[i - 1] - state.runs.add(TextRun( - text: text, - font: fontNames[span + 1], - position: vec2(bounds.x, bounds.y + baseline - font.ascent), - bounds: rect( - bounds.x, bounds.y, last.x + last.w - bounds.x, bounds.h - ), - span: state.runeSpans[first] - )) - state.dirty = false - -proc spanAt(state: TextBoxState, position: Vec2): int = - ## Finds a formatted span only when the pointer touches its glyph bounds. - for i, bounds in state.layout: - if position.overlaps(bounds) and i < state.runeSpans.len: - return state.runeSpans[i] - -1 - -proc drawSpanBackgrounds(sk: Silky, state: TextBoxState, origin: Vec2) = - ## Draws inline backgrounds before the native selection highlight. - for run in state.runs: - if run.span >= 0: - let color = state.spans[run.span].background - if color.a > 0: - sk.drawRect(origin + run.bounds.xy, run.bounds.wh, color) - -proc drawBlocks( - sk: Silky, - state: TextBoxState, - origin: Vec2, - defaultColor: ColorRGBX -) = - ## Draws native block surfaces, borders, rules, and captions. - for i, bounds in state.blockLayouts: - let item = state.blocks[i] - if item.patch.len > 0: - sk.draw9Patch( - item.patch, - item.patchBorder, - origin + bounds.bounds.xy, - bounds.bounds.wh - ) - if item.quoteDepth > 0 and item.quoteThickness > 0 and - item.quoteColor.a > 0 and bounds.quoteBounds.w > 0: - let - scale = max(sk.uiScale, 0.001'f) - indent = max(0.0'f, item.quoteIndent) - position = origin + bounds.quoteBounds.xy - limit = floor((position.x + bounds.quoteBounds.w) * scale) / scale - top = round(position.y * scale) / scale - thickness = max(1.0'f, round(item.quoteThickness * scale)) / scale - continuation = clamp(item.quoteContinue, 0, item.quoteDepth) - for level in 0 ..< item.quoteDepth: - let - left = round((position.x + level.float32 * indent) * scale) / - scale - width = min(thickness, limit - left) - if width <= 0: - break - let - spacing = - if level < continuation: - max(0.0'f, item.spacing) - else: - 0.0'f - bottom = round( - (position.y + bounds.quoteBounds.h + spacing) * scale - ) / scale - sk.drawRect( - vec2(left, top), vec2(width, bottom - top), item.quoteColor - ) - if indent == 0: - break - if bounds.ruleBounds.h > 0: - if bounds.ruleBounds.w > 0 and item.ruleColor.a > 0: - let - scale = max(sk.uiScale, 0.001'f) - position = origin + bounds.ruleBounds.xy - left = round(position.x * scale) / scale - right = round((position.x + bounds.ruleBounds.w) * scale) / scale - top = round(position.y * scale) / scale - thickness = max(1.0'f, round(bounds.ruleBounds.h * scale)) / scale - sk.drawRect( - vec2(left, top), vec2(right - left, thickness), item.ruleColor - ) - continue - if item.label.len > 0: - let color = - if item.labelColor.a > 0: - item.labelColor - else: - defaultColor - discard sk.drawText( - bounds.labelFont, - item.label, - origin + bounds.labelPosition, - color, - maxWidth = max(0.0'f, bounds.bounds.w - item.padding.x * 2) - ) - -proc drawSpans( - sk: Silky, - state: TextBoxState, - origin: Vec2, - defaultColor: ColorRGBX -) = - ## Draws cached runs at their measured positions without wrapping again. - for run in state.runs: - let color = - if run.span >= 0 and state.spans[run.span].color.a > 0: - state.spans[run.span].color - else: - defaultColor - discard sk.drawText( - run.font, run.text, origin + run.position, color, clip = false - ) - if run.span >= 0: - let - span = state.spans[run.span] - underline = state.hoveredSpan >= 0 and span.linkId > 0 and - span.linkId == state.spans[state.hoveredSpan].linkId - if span.strikethrough or underline: - let - font = sk.atlas.fonts[run.font] - scale = max(sk.uiScale, 0.001'f) - left = round((origin.x + run.bounds.x) * scale) / scale - right = round((origin.x + run.bounds.x + run.bounds.w) * scale) / - scale - baseline = origin.y + run.position.y + font.ascent - thickness = max(1.0'f, round(scale)) / scale - if span.strikethrough: - let top = round((baseline - font.size * 0.3'f) * scale) / scale - sk.drawRect(vec2(left, top), vec2(right - left, thickness), color) - if underline: - let top = round((baseline + 1) * scale) / scale - sk.drawRect(vec2(left, top), vec2(right - left, thickness), color) - proc pickGlyphAt*(state: TextBoxState, pos: Vec2): int = ## Finds the character index closest to the given position. ## Returns -1 if no character found on that line. @@ -715,7 +273,7 @@ proc pickGlyphAt*(state: TextBoxState, pos: Vec2): int = if minDistance < 0 or dist < minDistance: minDistance = dist minGlyph = i - if minGlyph < 0 and state.blocks.len > 0: + if minGlyph < 0 and state.positionedLayout: var nearestY = float32.high for i, bounds in state.layout: let @@ -745,8 +303,8 @@ proc getSelectionRects*(layout: seq[Rect], start, stop: int): seq[Rect] = proc innerHeight*(state: TextBoxState): float32 = ## Returns the total content height. - if state.blocks.len > 0: - return state.blockHeight + if state.positionedLayout: + return state.layoutHeight if state.layout.len > 0: let lastPos = state.layout[^1] return lastPos.y + lastPos.h @@ -761,9 +319,9 @@ proc innerWidth*(state: TextBoxState): float32 = proc locationRect*(state: TextBoxState, loc: int): Rect = ## Returns the rectangle where the cursor should be drawn. - if state.blocks.len > 0: + if state.positionedLayout: if loc >= state.layout.len: - result = state.blockCursor + result = state.layoutCursor elif loc >= 0: result = state.layout[loc] result.w = CursorWidth @@ -1009,7 +567,7 @@ proc right*(state: TextBoxState, shift = false) = state.resetBlink() proc adjacentLine(state: TextBoxState, forward: bool): int = - ## Finds a neighboring visual line when document blocks vary in height. + ## Finds a neighboring visual line when cached lines vary in height. let currentY = state.cursorPos.y var nearest = float32.high @@ -1035,7 +593,7 @@ proc down*(state: TextBoxState, shift = false) = let curPos = state.cursorPos index = - if state.blocks.len > 0: + if state.positionedLayout: state.adjacentLine(true) else: state.pickGlyphAt( @@ -1058,7 +616,7 @@ proc up*(state: TextBoxState, shift = false) = let curPos = state.cursorPos index = - if state.blocks.len > 0: + if state.positionedLayout: state.adjacentLine(false) else: state.pickGlyphAt( @@ -1317,14 +875,30 @@ proc handleKey(state: TextBoxState, event: InputEvent) = else: discard +proc handleSelectionInput*(state: TextBoxState, inputs: TextInputs) = + ## Drains read-only navigation and copy events without accepting edits. + assert not state.enabled + let + epoch = inputs.focusEpoch + events = inputs.takeInput() + for event in events: + if inputs.focusEpoch != epoch: + break + if event.kind == InputKind.KeyDown: + state.handleKey(event) + if events.len > 0: + state.scrollToCursor() + proc handleInput( state: TextBoxState, inputs: TextInputs, - fonts: Table[string, FontAtlas], - baseFont: string, + fontData: FontAtlas, maxWidth: float32 ) = ## Drains ordered input and refreshes layout before cursor navigation. + if not state.enabled: + state.handleSelectionInput(inputs) + return let epoch = inputs.focusEpoch events = inputs.takeInput() @@ -1353,13 +927,13 @@ proc handleInput( KeyLeft, KeyRight, KeyUp, KeyDown, KeyHome, KeyEnd, KeyPageUp, KeyPageDown }: - state.computeLayout(fonts, baseFont, maxWidth) + state.computeLayout(fontData, maxWidth) state.handleKey(event) of InputKind.KeyUp: discard if events.len > 0: if state.dirty: - state.computeLayout(fonts, baseFont, maxWidth) + state.computeLayout(fontData, maxWidth) state.scrollToCursor() proc drawScrollbars*(sk: Silky, state: TextBoxState, window: Window, @@ -1446,9 +1020,7 @@ proc textBox*( password: bool = false, passwordChar: Rune = Rune('*'), allowedChars: seq[Rune] = default(seq[Rune]), - submitOnEnter: bool = false, - spans: seq[TextSpan] = default(seq[TextSpan]), - blocks: seq[TextBlock] = default(seq[TextBlock]) + submitOnEnter: bool = false ) {.measure.} = ## Text box widget with editing, selection, and scroll. ## When disabled, text can be selected and copied but not modified. @@ -1467,8 +1039,6 @@ proc textBox*( state = textBoxStates[id] state.submitOnEnter = submitOnEnter state.submitted = false - state.activatedSpan = -1 - state.hoveredSpan = -1 let inputs = sk.textInputs if state.focused and state.focusEpoch != inputs.focusEpoch: state.focused = false @@ -1477,23 +1047,6 @@ proc textBox*( state.selector = state.cursor state.enabled = enabled state.allowedChars = allowedChars - let - activeSpans = - if not enabled and not password: - spans - else: - default(seq[TextSpan]) - activeBlocks = - if not enabled and not password and not singleLine: - blocks - else: - default(seq[TextBlock]) - if state.spans != activeSpans: - state.spans = activeSpans - state.dirty = true - if state.blocks != activeBlocks: - state.blocks = activeBlocks - state.dirty = true if state.password != password: state.password = password state.dirty = true @@ -1521,8 +1074,8 @@ proc textBox*( ) state.boxSize = vec2(innerRect.w, innerRect.h) if state.dirty or state.layout.len == 0 or - state.lastMaxWidth != innerRect.w or state.lastFont != sk.textStyle: - state.computeLayout(sk.atlas.fonts, sk.textStyle, innerRect.w) + state.lastMaxWidth != innerRect.w or state.lastFont != sk.atlas.fonts[sk.textStyle]: + state.computeLayout(sk.atlas.fonts[sk.textStyle], innerRect.w) let shift = window.buttonDown[KeyLeftShift] or window.buttonDown[KeyRightShift] let mouseVec = sk.mousePos @@ -1530,7 +1083,6 @@ proc textBox*( mouseVec.overlaps(sk.clipRect) let onScrollbar = mouseInside and not mouseVec.overlaps(innerRect) if window.buttonPressed[MouseLeft]: - state.pressedSpan = -1 if mouseInside and not onScrollbar: state.focused = true let localMouse = mouseVec - innerRect.xy + state.scrollPos @@ -1544,10 +1096,6 @@ proc textBox*( of 1: state.mouseAction(localMouse, click = true, shift = shift) state.dragging = true - if not enabled and not shift: - state.pressedSpan = state.spanAt(localMouse) - state.pressPosition = mouseVec - state.spanDragged = false of 2: state.selectWord(localMouse) of 3: state.selectParagraph(localMouse) else: state.selectAll() @@ -1556,19 +1104,6 @@ proc textBox*( state.dragging = false if not enabled: state.selector = state.cursor - if state.pressedSpan >= 0: - let distance = mouseVec - state.pressPosition - if distance.x * distance.x + distance.y * distance.y > 9: - state.spanDragged = true - if window.buttonReleased[MouseLeft]: - let localMouse = mouseVec - innerRect.xy + state.scrollPos - if mouseInside and not state.spanDragged and - state.cursor == state.selector and - state.spanAt(localMouse) == state.pressedSpan: - state.activatedSpan = state.pressedSpan - state.pressedSpan = -1 - elif not window.buttonDown[MouseLeft]: - state.pressedSpan = -1 if state.dragging and not state.scrollingX and not state.scrollingY: if window.buttonDown[MouseLeft] and not window.buttonPressed[MouseLeft]: let localMouse = mouseVec - innerRect.xy + state.scrollPos @@ -1578,29 +1113,16 @@ proc textBox*( inputs.submitInput(window, id, state.focused, enabled) if state.focused: state.focusEpoch = inputs.focusEpoch - state.handleInput(inputs, sk.atlas.fonts, sk.textStyle, innerRect.w) + state.handleInput(inputs, sk.atlas.fonts[sk.textStyle], innerRect.w) t = state.getText() if state.dirty: - state.computeLayout(sk.atlas.fonts, sk.textStyle, innerRect.w) + state.computeLayout(sk.atlas.fonts[sk.textStyle], innerRect.w) let maxScrollY = max(0.0f, state.innerHeight - innerRect.h) state.scrollPos.y = clamp(state.scrollPos.y, 0.0f, maxScrollY) if state.wordWrap: state.scrollPos.x = 0 else: state.scrollPos.x = max(0.0f, state.scrollPos.x) - let - overVerticalBar = state.innerHeight > innerRect.h and - mouseVec.x >= outerRect.x + outerRect.w - 10 - overHorizontalBar = not state.wordWrap and - state.innerWidth > innerRect.w and - mouseVec.y >= outerRect.y + outerRect.h - 10 - if mouseInside and mouseVec.overlaps(innerRect) and - not state.scrollingX and not state.scrollingY and - not overVerticalBar and not overHorizontalBar: - let index = state.spanAt(mouseVec - innerRect.xy + state.scrollPos) - if index >= 0 and state.spans[index].linkId > 0: - state.hoveredSpan = index - sk.cursor = Cursor(kind: PointerCursor) let patch = if error: "textbox.error.9patch" elif not enabled: "textbox.disabled.9patch" @@ -1616,10 +1138,6 @@ proc textBox*( sk.draw9Patch(patch, sk.theme.textboxPatch, outerRect.xy, outerRect.wh) sk.pushClipRect(innerRect) let textOrigin = innerRect.xy - state.scrollPos - if state.blocks.len > 0: - sk.drawBlocks(state, textOrigin, textColor) - if state.spans.len > 0: - sk.drawSpanBackgrounds(state, textOrigin) if state.cursor != state.selector: let sel = state.selection let selRects = getSelectionRects(state.layout, sel.a, sel.b) @@ -1627,18 +1145,15 @@ proc textBox*( sk.drawRect( vec2(textOrigin.x + r.x, textOrigin.y + r.y), vec2(r.w, r.h), SelectionColor) - if state.spans.len > 0 or state.blocks.len > 0: - sk.drawSpans(state, textOrigin, textColor) - else: - discard sk.drawText( - sk.textStyle, - state.displayText, - textOrigin, - textColor, - maxWidth = innerRect.w, - wordWrap = state.wordWrap, - clip = false - ) + discard sk.drawText( + sk.textStyle, + state.displayText, + textOrigin, + textColor, + maxWidth = innerRect.w, + wordWrap = state.wordWrap, + clip = false + ) if state.focused and state.cursorVisible: let cr = state.locationRect(state.cursor) sk.drawRect( @@ -1690,16 +1205,12 @@ template textBox*( isPassword = false, passChar: Rune = Rune('*'), chars: seq[Rune] = default(seq[Rune]), - submitOnEnter = false, - spans: seq[TextSpan] = default(seq[TextSpan]), - blocks: seq[TextBlock] = default(seq[TextBlock]) + submitOnEnter = false ) = ## Text box widget. Set singleLine for a single-line input. ## submitOnEnter reports Enter in state.submitted and preserves Shift+Enter. - ## Read-only spans preserve selection and report clicks in activatedSpan. sk.textBox(window, id, t, boxWidth, boxHeight, wrapWords, singleLine, - isEnabled, isError, isPassword, passChar, chars, submitOnEnter, spans, - blocks + isEnabled, isError, isPassword, passChar, chars, submitOnEnter ) template textInput*( diff --git a/tests/test_decorations.nim b/tests/test_decorations.nim index 8da10ae..b287c8f 100644 --- a/tests/test_decorations.nim +++ b/tests/test_decorations.nim @@ -44,6 +44,30 @@ proc rules(sk: Silky): seq[Rect] = vertices[i + 2].pos - vertices[i].pos )) +proc glyphCount(sk: Silky, glyph: string): int = + ## Counts native glyph quads without depending on block decorations. + let + entry = sk.atlas.fonts["Default"].entries[glyph][0] + uv = vec2(entry.x.float32, entry.y.float32) + vertices = sk.drawer.layers[NormalLayer] + for i in countup(0, vertices.high, 6): + if vertices[i].uv == uv: + inc result + +proc transition( + sk: Silky, + text: string, + blocks: seq[TextBlock] = @[] +): RichTextBoxState = + ## Redraws one rich widget as its source and formatting change. + sk.drawer.layers[NormalLayer].setLen(0) + sk.at = vec2(10.17'f, 20.29'f) + sk.richTextBox( + Window(), "transitions", text, 180, 160, + wrapWords = true, blocks = blocks + ) + result = richTextBoxStates["transitions"] + block: echo "Testing native strikethrough wrapping, hover, and pixel alignment" let window = Window() @@ -59,44 +83,45 @@ block: pixelScale = density ) doAssert builder.addImage("textbox.disabled.9patch", newImage(16, 16)) - let sk = fixture(builder, density) - var + let + sk = fixture(builder, density) text = "WW WW WW" - spans = @[ - TextSpan( - first: 0, - last: text.runeLen, - strikethrough: true, - color: TextColor, - linkId: 1 - ) - ] + var spans = @[ + TextSpan( + first: 0, + last: text.runeLen, + strikethrough: true, + color: TextColor, + linkId: 1 + ) + ] sk.at = origin sk.mousePos = vec2(-1) - sk.textBox( + sk.richTextBox( window, "decorations", text, 64, 220, - wrapWords = true, enabled = false, spans = spans + wrapWords = true, spans = spans ) let - state = textBoxStates["decorations"] + state = richTextBoxStates["decorations"] strikeRules = sk.rules() doAssert strikeRules.len == 3 for i, rule in strikeRules: - let line = origin.y + state.layout[i * 3].y + let line = origin.y + state.editor.layout[i * 3].y doAssert rule.y > line - doAssert rule.y + rule.h < line + state.lineHeight + doAssert rule.y + rule.h < line + state.editor.lineHeight doAssert rule.w <= 64 doAssert close(rule.h * density, max(1.0'f, round(density))) - state.selectAll() - doAssert state.copyText() == text - state.selector = state.cursor + state.editor.selectAll() + doAssert state.editor.copyText() == text + state.editor.selector = state.editor.cursor sk.drawer.layers[NormalLayer].setLen(0) sk.at = origin - sk.mousePos = origin + state.layout[0].xy + state.layout[0].wh / 2 - sk.textBox( + sk.mousePos = origin + state.editor.layout[0].xy + + state.editor.layout[0].wh / 2 + sk.richTextBox( window, "decorations", text, 64, 220, - wrapWords = true, enabled = false, spans = spans + wrapWords = true, spans = spans ) let hoveredRules = sk.rules() doAssert hoveredRules.len == strikeRules.len * 2 @@ -108,11 +133,53 @@ block: sk.at = origin sk.mousePos = vec2(-1) spans[0].strikethrough = false - sk.textBox( + sk.richTextBox( window, "decorations", text, 64, 220, - wrapWords = true, enabled = false, spans = spans + wrapWords = true, spans = spans ) doAssert sk.rules().len == 0 - textBoxStates.del("decorations") + richTextBoxStates.del("decorations") + +block: + echo "Testing rich formatting removal retains native text rendering" + privateAccess(RichTextBoxState) + let builder = newAtlasBuilder(256, 2) + builder.addFont( + FontPath, "Default", 18, @["W"], pixelScale = 2 + ) + doAssert builder.addImage("textbox.disabled.9patch", newImage(16, 16)) + let + sk = fixture(builder, 2) + font = builder.atlas.fonts["Default"] + state = sk.transition("WW", @[ + TextBlock( + first: 0, last: 2, padding: vec2(12, 6), spacing: 8, + quoteDepth: 1, quoteColor: TextColor + ), + TextBlock( + first: 2, last: 2, padding: vec2(12, 6), + ruleColor: TextColor, ruleThickness: 1 + ) + ]) + doAssert sk.glyphCount("W") == 2 + doAssert state.innerHeight > font.lineHeight + doAssert state.editor.layout[0].x > 0 + doAssert state.editor.locationRect(2).h == 0 + + doAssert sk.transition("WW") == state + doAssert sk.glyphCount("W") == 2, + "A rich widget with no spans or blocks must still draw its text." + doAssert state.editor.layout[0].xy == vec2(0) + doAssert close(state.innerHeight, font.lineHeight) + doAssert close(state.editor.locationRect(2).h, font.lineHeight) + + doAssert sk.transition("") == state + doAssert sk.glyphCount("W") == 0 + doAssert state.runs.len == 0 + doAssert state.editor.layout.len == 0 + doAssert state.editor.cursor == 0 and state.editor.selector == 0 + doAssert close(state.innerHeight, font.lineHeight) + doAssert close(state.editor.locationRect(0).h, font.lineHeight) + richTextBoxStates.del("transitions") echo "All text decoration tests passed." diff --git a/tests/test_inputs.nim b/tests/test_inputs.nim index e0571fd..0bdc274 100644 --- a/tests/test_inputs.nim +++ b/tests/test_inputs.nim @@ -13,6 +13,7 @@ type Fixture = object singleLine: bool submitOnEnter: bool enabled: bool + rich: bool password: bool allowedChars: seq[Rune] spans: seq[TextSpan] @@ -69,29 +70,40 @@ proc frame(f: var Fixture, visible = true, duplicate = false) = sk.pushClipRect(f.clip) for i in 0 .. ord(duplicate): sk.at = vec2(20, 20) - sk.textBox( - window, - f.id, - f.text, - 240, - f.height, - wrapWords = true, - singleLine = f.singleLine, - enabled = f.enabled, - password = f.password, - allowedChars = f.allowedChars, - submitOnEnter = f.submitOnEnter, - spans = f.spans, - blocks = f.blocks - ) + if f.rich: + sk.richTextBox( + window, f.id, f.text, 240, f.height, + wrapWords = true, spans = f.spans, blocks = f.blocks + ) + else: + sk.textBox( + window, + f.id, + f.text, + 240, + f.height, + wrapWords = true, + singleLine = f.singleLine, + enabled = f.enabled, + password = f.password, + allowedChars = f.allowedChars, + submitOnEnter = f.submitOnEnter + ) if f.clip.w > 0: sk.popClipRect() f.harness.endFrame() f.harness.window.resetInputState() proc state(f: Fixture): TextBoxState = - ## Returns the text state belonging to a fixture. - textBoxStates[f.id] + ## Returns the selection editor belonging to either widget kind. + if f.rich: + richTextBoxStates[f.id].editor + else: + textBoxStates[f.id] + +proc richState(f: Fixture): RichTextBoxState = + ## Returns formatting and link state owned only by the rich widget. + richTextBoxStates[f.id] proc hoverGlyph(f: var Fixture, index: int) = ## Moves the pointer over a visible laid-out glyph in the current view. @@ -664,7 +676,7 @@ block: block: echo "Testing read-only styled text retains selection and copy" var f = fixture("é code 世界") - f.enabled = false + f.rich = true f.singleLine = false f.spans = @[TextSpan(first: 2, last: 6, font: "Wide")] f.focus() @@ -680,17 +692,30 @@ block: doAssert getClipboardString() == f.text doAssert f.state.selection == (0 .. f.text.runeLen) doAssert f.state.layout[2].w == styledWidth - f.enabled = true + window.onTextInput("Ignored native commit") + window.onTextEdit(TextInputEdit( + kind: ReplaceText, start: 0, length: f.text.runeLen, text: "Ignored edit" + )) + setClipboardString("Ignored paste") + window.shortcut(KeyV) f.frame() + doAssert f.richState.getText() == "é code 世界" + doAssert f.state.undoStack.len == 0 + let rich = f.richState + f.rich = false + f.focus() + doAssert f.state != rich.editor doAssert f.state.layout[2].w < styledWidth + window.shortcut(KeyA) window.typeText("Editable") f.frame() doAssert f.text == "Editable" + doAssert rich.getText() == "é code 世界" block: echo "Testing styled span clicks activate only without dragging" var f = fixture("Click here") - f.enabled = false + f.rich = true f.spans = @[TextSpan(first: 0, last: f.text.runeLen, font: "Wide")] f.frame() let @@ -702,12 +727,12 @@ block: window.moveMouse(x, y) window.pressButton(MouseLeft) f.frame() - doAssert f.state.activatedSpan == -1 + doAssert f.richState.activatedSpan == -1 window.releaseButton(MouseLeft) f.frame() - doAssert f.state.activatedSpan == 0 + doAssert f.richState.activatedSpan == 0 f.frame() - doAssert f.state.activatedSpan == -1 + doAssert f.richState.activatedSpan == -1 f.state.lastClickTime = 0 window.pressButton(MouseLeft) f.frame() @@ -716,32 +741,32 @@ block: window.moveMouse(x, y) window.releaseButton(MouseLeft) f.frame() - doAssert f.state.activatedSpan == -1 + doAssert f.richState.activatedSpan == -1 block: echo "Testing link hover cursor and non-link text boundaries" var f = fixture("Link bold text") - f.enabled = false + f.rich = true f.spans = @[ TextSpan(first: 0, last: 4, linkId: 1), TextSpan(first: 5, last: 9, font: "Wide") ] f.frame() f.hoverGlyph(1) - doAssert f.state.hoveredSpan == 0 + doAssert f.richState.hoveredSpan == 0 doAssert f.harness.window.cursor.kind == PointerCursor f.hoverGlyph(6) - doAssert f.state.hoveredSpan == -1 + doAssert f.richState.hoveredSpan == -1 doAssert f.harness.window.cursor.kind == ArrowCursor f.hoverGlyph(1) f.harness.window.moveMouse(21, 21) f.frame() - doAssert f.state.hoveredSpan == -1 + doAssert f.richState.hoveredSpan == -1 doAssert f.harness.window.cursor.kind == ArrowCursor f.hoverGlyph(1) f.harness.window.moveMouse(600, 400) f.frame() - doAssert f.state.hoveredSpan == -1 + doAssert f.richState.hoveredSpan == -1 doAssert f.harness.window.cursor.kind == ArrowCursor f.hoverGlyph(1) f.frame(visible = false) @@ -752,7 +777,7 @@ block: var f = fixture( "A long linked sentence that wraps onto several lines in this text box." ) - f.enabled = false + f.rich = true f.singleLine = false f.spans = @[ TextSpan(first: 0, last: f.text.runeLen, font: "Wide", linkId: 1) @@ -765,27 +790,27 @@ block: break doAssert secondLine >= 0 f.hoverGlyph(secondLine) - doAssert f.state.hoveredSpan == 0 + doAssert f.richState.hoveredSpan == 0 doAssert f.harness.window.cursor.kind == PointerCursor f.clip = rect(20, 20, 240, 20) f.frame() - doAssert f.state.hoveredSpan == -1 + doAssert f.richState.hoveredSpan == -1 doAssert f.harness.window.cursor.kind == ArrowCursor f.clip = rect(0, 0, 0, 0) f.state.scrollPos.y = f.state.lineHeight f.hoverGlyph(secondLine) - doAssert f.state.hoveredSpan == 0 + doAssert f.richState.hoveredSpan == 0 doAssert f.harness.window.cursor.kind == PointerCursor f.spans[0].linkId = 0 f.frame() - doAssert f.state.hoveredSpan == -1 + doAssert f.richState.hoveredSpan == -1 doAssert f.harness.window.cursor.kind == ArrowCursor f.spans[0].linkId = 1 f.frame() - doAssert f.state.hoveredSpan == 0 + doAssert f.richState.hoveredSpan == 0 f.spans.setLen(0) f.frame() - doAssert f.state.hoveredSpan == -1 + doAssert f.richState.hoveredSpan == -1 doAssert f.harness.window.cursor.kind == ArrowCursor block: @@ -820,7 +845,7 @@ block: echo "Testing one block widget selects and copies across all sections" const Sample = "Title\nBody link\ncode()" var f = fixture(Sample) - f.enabled = false + f.rich = true f.singleLine = false f.height = 240 f.blocks = @[ @@ -857,12 +882,12 @@ block: echo "Testing block links retain hover, activation, and drag selection" f.hoverGlyph(12) - doAssert f.state.hoveredSpan == 0 + doAssert f.richState.hoveredSpan == 0 doAssert window.cursor.kind == PointerCursor f.state.lastClickTime = 0 window.tap(MouseLeft) f.frame() - doAssert f.state.activatedSpan == 0 + doAssert f.richState.activatedSpan == 0 f.state.lastClickTime = 0 window.pressButton(MouseLeft) f.frame() @@ -870,7 +895,7 @@ block: f.frame() window.releaseButton(MouseLeft) f.frame() - doAssert f.state.activatedSpan == -1 + doAssert f.richState.activatedSpan == -1 doAssert f.state.selection.a == 0 doAssert f.state.selection.b >= 11 @@ -910,6 +935,96 @@ block: doAssert not f.state.focused doAssert f.state.cursor == f.state.selector +block: + echo "Testing same-id plain and rich widgets isolate state and focus" + const RichSource = "Read only reply" + for reverse in [false, true]: + let id = "shared-widget-" & $reverse + var + harness = newTestHarness(atlas) + plain = "Editable" + + proc renderBoth(sk: Silky, window: Window) = + ## Draws independent widgets with the same public identifier. + template plainWidget() = + ## Draws the editable surface below the rich reply. + sk.at = vec2(20, 150) + sk.textBox(window, id, plain, 240, 80, wrapWords = true) + + template richWidget() = + ## Draws a read-only surface without borrowing plain widget state. + sk.at = vec2(20, 20) + sk.richTextBox( + window, id, RichSource, 240, 80, + spans = @[TextSpan(first: 0, last: RichSource.runeLen, font: "Wide")] + ) + + if reverse: + plainWidget() + richWidget() + else: + richWidget() + plainWidget() + + discard harness.pumpFrame(renderBoth) + let + window = harness.window + editor = textBoxStates[id] + rich = richTextBoxStates[id] + doAssert editor != rich.editor + doAssert editor.getText() == "Editable" + doAssert rich.getText() == RichSource + window.moveMouse(35, 165) + window.tap(MouseLeft) + discard harness.pumpFrame(renderBoth) + doAssert editor.focused and not rich.editor.focused + editor.cursor = plain.runeLen + editor.selector = editor.cursor + window.typeText("!") + discard harness.pumpFrame(renderBoth) + doAssert plain == "Editable!" + doAssert rich.getText() == RichSource + doAssert window.runeInputEnabled + + window.moveMouse(35, 35) + window.tap(MouseLeft) + discard harness.pumpFrame(renderBoth) + doAssert rich.editor.focused and not editor.focused + doAssert not window.runeInputEnabled + window.shortcut(KeyA) + window.typeText("Ignored typed text") + window.onTextInput("Ignored native commit") + window.onTextEdit(TextInputEdit( + kind: ReplaceText, + start: 0, + length: RichSource.runeLen, + text: "Ignored native edit" + )) + setClipboardString("Ignored paste") + window.shortcut(KeyV) + window.shortcut(KeyX) + window.tap(KeyBackspace) + discard harness.pumpFrame(renderBoth) + doAssert plain == "Editable!" + doAssert rich.getText() == RichSource + doAssert rich.editor.selection == (0 .. RichSource.runeLen) + doAssert rich.editor.undoStack.len == 0 + window.shortcut(KeyC) + discard harness.pumpFrame(renderBoth) + doAssert getClipboardString() == RichSource + + window.moveMouse(35, 165) + window.tap(MouseLeft) + discard harness.pumpFrame(renderBoth) + editor.cursor = plain.runeLen + editor.selector = editor.cursor + window.onTextInput("2") + discard harness.pumpFrame(renderBoth) + doAssert plain == "Editable!2" + doAssert rich.getText() == RichSource + textBoxStates.del(id) + richTextBoxStates.del(id) + block: echo "Testing one-batch editing matches separate frames and layout" var fixtures = [fixture(""), fixture("")] diff --git a/tests/test_quotes.nim b/tests/test_quotes.nim index ede10f6..1409f5e 100644 --- a/tests/test_quotes.nim +++ b/tests/test_quotes.nim @@ -34,16 +34,15 @@ proc render( source: string, blocks: seq[TextBlock], width = 180.0'f -): TextBoxState = - ## Draws one read-only quote document through the native textbox. - var text = source - textBoxStates.del("quotes") +): RichTextBoxState = + ## Draws one selectable quote document through the native rich textbox. + richTextBoxStates.del("quotes") sk.at = vec2(10.17'f, 20.29'f) - sk.textBox( - Window(), "quotes", text, width, 220, - wrapWords = true, enabled = false, blocks = blocks + sk.richTextBox( + Window(), "quotes", source, width, 220, + wrapWords = true, blocks = blocks ) - result = textBoxStates["quotes"] + result = richTextBoxStates["quotes"] proc bars(sk: Silky, color: ColorRGBX, width = 180.0'f): seq[Rect] = ## Finds solid rules and checks their physical alignment and clipping. @@ -122,8 +121,8 @@ block: quads = sk.bars(QuoteColor) firstHeight = builder.atlas.fonts["Heading"].lineHeight + 12 doAssert quads.len == 4 - doAssert state.layout[0].x == 44 - doAssert state.layout[5].x == 44 + doAssert state.editor.layout[0].x == 44 + doAssert state.editor.layout[5].x == 44 for level in 0 .. 1: let extension = if level < continuing: 8.0'f else: 0.0'f doAssert close( @@ -139,9 +138,9 @@ block: quads[level + 2].y * density, round((20.29'f + firstHeight + 8) * density) ) - state.selectAll() - doAssert state.copyText() == "Head\nBody" - doAssert state.layout.len == 9 + state.editor.selectAll() + doAssert state.editor.copyText() == "Head\nBody" + doAssert state.editor.layout.len == 9 echo "Testing quoted code surfaces, captions, and rules at ", density let @@ -162,7 +161,9 @@ block: quotePatch = quoted.patchBounds("code.9patch") doAssert close(quotePatch.x - plainPatch.x, 32) doAssert close(plainPatch.w - quotePatch.w, 32) - doAssert close(quoteState.layout[0].x - plainState.layout[0].x, 32) + doAssert close( + quoteState.editor.layout[0].x - plainState.editor.layout[0].x, 32 + ) doAssert close(quoted.caption().x - plain.caption().x, 32) doAssert quoteState.innerHeight == plainState.innerHeight let @@ -176,7 +177,7 @@ block: ]) horizontal = ruled.bars(RuleColor) doAssert ruleState.innerHeight == 13 - doAssert ruleState.runes.len == 0 + doAssert ruleState.editor.runes.len == 0 doAssert horizontal.len == 1 doAssert close(horizontal[0].x * density, round((10.17'f + 44) * density)) @@ -189,9 +190,9 @@ block: quoteDepth: 1, quoteColor: QuoteColor ) ], 80) - doAssert wrapped.layout[3].y > wrapped.layout[0].y - wrapped.selectAll() - doAssert wrapped.copyText() == "WW WW WW" + doAssert wrapped.editor.layout[3].y > wrapped.editor.layout[0].y + wrapped.editor.selectAll() + doAssert wrapped.editor.copyText() == "WW WW WW" let empty = fixture(builder, 2) emptyState = empty.render("", @[ @@ -204,7 +205,7 @@ block: emptyState.innerHeight, builder.atlas.fonts["Default"].lineHeight + 12 ) - doAssert emptyState.layout.len == 0 + doAssert emptyState.editor.layout.len == 0 doAssert empty.bars(QuoteColor).len == 1 for width in [0.0'f, 10.0'f, 20.0'f, 80.0'f]: let @@ -220,6 +221,6 @@ block: doAssert quad.w >= 0 and quad.h >= 0 doAssert quad.x >= 10 doAssert quad.x + quad.w <= 10.17'f + width + 0.5'f - textBoxStates.del("quotes") + richTextBoxStates.del("quotes") echo "All native quote tests passed." diff --git a/tests/test_richtextboxes.nim b/tests/test_richtextboxes.nim new file mode 100644 index 0000000..68260df --- /dev/null +++ b/tests/test_richtextboxes.nim @@ -0,0 +1,224 @@ +import + std/[tables, unicode], + vmath, bumpy, + silky/atlas, silky/textboxes, silky/richtextboxes + +var silkyAtlas: SilkyAtlas + +block: + let builder = newAtlasBuilder(1024, 4) + builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18) + builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Wide", 30) + builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Small", 12) + silkyAtlas = builder.atlas + +proc newState(text: string, maxWidth = 500.0'f): RichTextBoxState = + ## Creates a wrapped read-only document and computes its rich layout. + result = newRichTextBoxState(text) + result.editor.boxSize = vec2(maxWidth, 300) + result.computeLayout(silkyAtlas.fonts, "Default", maxWidth) + +proc newStateNoWrap(text: string): RichTextBoxState = + ## Creates an unwrapped rich document for exact font comparisons. + result = newRichTextBoxState(text, wordWrap = false) + result.computeLayout(silkyAtlas.fonts, "Default", 500) + +proc newPlainState( + text: string, + maxWidth = 500.0'f, + wordWrap = false +): TextBoxState = + ## Creates an independent single-font reference layout. + result = TextBoxState(wordWrap: wordWrap) + result.setText(text) + result.computeLayout(silkyAtlas.fonts["Default"], maxWidth) + +proc advance(font: FontAtlas, rune: Rune): float32 = + ## Returns the actual atlas advance used by span layout assertions. + var entry: ptr LetterEntry + doAssert font.lookupLetter(rune, 0, entry) + entry.advance + +block: + echo "Testing styled text uses each span's font metrics" + let + s = newState("W a") + wide = silkyAtlas.fonts["Wide"] + small = silkyAtlas.fonts["Small"] + regular = silkyAtlas.fonts["Default"] + s.spans = @[ + TextSpan(first: 0, last: 1, font: "Wide"), + TextSpan(first: 2, last: 3, font: "Small") + ] + s.computeLayout(silkyAtlas.fonts, "Default", 500) + doAssert s.editor.layout.len == 3 + doAssert abs(s.editor.layout[0].w - wide.advance(Rune('W'))) < 0.01 + doAssert abs(s.editor.layout[1].w - regular.advance(Rune(' '))) < 0.01 + doAssert abs(s.editor.layout[2].w - small.advance(Rune('a'))) < 0.01 + doAssert abs(s.editor.layout[1].x - s.editor.layout[0].w) < 0.01 + doAssert s.editor.lineHeight >= wide.lineHeight + for bounds in s.editor.layout: + doAssert bounds.h == s.editor.lineHeight + +block: + echo "Testing formatting and links preserve native pair kerning" + const Sample = "AVATAR To WAVY" + let + plain = newPlainState(Sample) + styled = newStateNoWrap(Sample) + doAssert silkyAtlas.fonts["Default"].lookupKerning(Rune('A'), Rune('V')) != 0 + doAssert silkyAtlas.fonts["Default"].lookupKerning(Rune('T'), Rune('o')) != 0 + doAssert plain.layout[1].x < plain.layout[0].w + for i in 0 ..< styled.editor.runes.len: + styled.spans.add(TextSpan( + first: i, + last: i + 1, + font: + if i mod 2 == 0: + "Default" + else: + "", + linkId: + if i in 1 .. 8: + 1 + else: + 0 + )) + styled.computeLayout(silkyAtlas.fonts, "Default", 500) + doAssert styled.editor.layout.len == plain.layout.len + for i in 0 ..< plain.layout.len: + let + expected = plain.layout[i] + actual = styled.editor.layout[i] + doAssert abs(actual.x - expected.x) < 0.001 + doAssert abs(actual.y - expected.y) < 0.001 + doAssert abs(actual.w - expected.w) < 0.001 + doAssert abs(actual.h - expected.h) < 0.001 + +block: + echo "Testing styled long words wrap before crossing the width" + let + s = newState("WWWWWW") + wide = silkyAtlas.fonts["Wide"] + width = wide.advance(Rune('W')) * 2.5'f + s.spans = @[TextSpan(first: 0, last: 6, font: "Wide")] + s.computeLayout(silkyAtlas.fonts, "Default", width) + doAssert s.editor.layout[0].y == s.editor.layout[1].y + doAssert s.editor.layout[2].y > s.editor.layout[1].y + doAssert s.editor.layout[4].y > s.editor.layout[3].y + for bounds in s.editor.layout: + doAssert bounds.x + bounds.w <= width + 0.01 + +block: + echo "Testing styled Unicode selection preserves the original text" + let s = newState("é code 世界") + s.spans = @[TextSpan(first: 2, last: 6, font: "Small")] + s.computeLayout(silkyAtlas.fonts, "Default", 500) + doAssert s.editor.layout.len == s.editor.runes.len + s.editor.cursor = 2 + s.editor.selector = 6 + doAssert s.editor.copyText() == "code" + doAssert getSelectionRects(s.editor.layout, 2, 6).len == 1 + s.editor.selectAll() + doAssert s.editor.copyText() == "é code 世界" + +block: + echo "Testing styled code preserves tabs when copying" + let + s = newState("a\tb") + small = silkyAtlas.fonts["Small"] + s.spans = @[TextSpan(first: 0, last: 3, font: "Small")] + s.computeLayout(silkyAtlas.fonts, "Default", 500) + doAssert abs(s.editor.layout[1].w - small.advance(Rune(' ')) * 4) < 0.01 + doAssert abs( + s.editor.layout[2].x - s.editor.layout[1].x - s.editor.layout[1].w + ) < 0.01 + s.editor.selectAll() + doAssert s.editor.copyText() == "a\tb" + +block: + echo "Testing rich text without styles preserves plain layout" + const Source = "A plain paragraph that wraps across lines." + let + plain = newPlainState(Source, 100, wordWrap = true) + rich = newState(Source, 100) + doAssert rich.editor.layout == plain.layout + +block: + echo "Testing one selection spans headings, wrapped prose, and code" + const + Heading = "Heading" + Body = "é body wraps over several lines for selection." + Code = "let x = 1\n echo x" + Sample = Heading & "\n" & Body & "\n" & Code + let + bodyStart = Heading.runeLen + 1 + codeStart = bodyStart + Body.runeLen + 1 + s = newState(Sample, 180) + s.blocks = @[ + TextBlock( + first: 0, last: Heading.runeLen, font: "Wide", + padding: vec2(12, 6), spacing: 8 + ), + TextBlock( + first: bodyStart, last: codeStart - 1, + padding: vec2(12, 6), spacing: 8 + ), + TextBlock( + first: codeStart, last: Sample.runeLen, font: "Small", + padding: vec2(12, 12), label: "nim", labelFont: "Small", + labelSpacing: 8 + ) + ] + s.computeLayout(silkyAtlas.fonts, "Default", 180) + doAssert s.editor.layout.len == Sample.runeLen + doAssert s.editor.layout[0].h > s.editor.layout[bodyStart].h + doAssert s.editor.layout[bodyStart].h > s.editor.layout[codeStart].h + doAssert s.editor.layout[codeStart - 2].y > s.editor.layout[bodyStart].y + doAssert s.editor.layout[bodyStart].x == 12 + doAssert s.editor.layout[codeStart].x == 12 + doAssert abs( + s.innerHeight - s.editor.layout[^1].y - s.editor.layout[^1].h - 12 + ) < 0.001 + for index in [0, bodyStart, codeStart]: + doAssert s.editor.locationRect(index).h == s.editor.layout[index].h + s.editor.selector = 2 + s.editor.cursor = codeStart + 5 + doAssert s.editor.copyText() == "ading\n" & Body & "\nlet x" + let selected = getSelectionRects( + s.editor.layout, s.editor.selector, s.editor.cursor + ) + doAssert selected.len >= 4 + doAssert selected[0].h > selected[^1].h + s.editor.selectAll() + doAssert s.editor.copyText() == Sample + + echo "Testing dragging through block spacing has no selection dead zone" + let + first = s.editor.layout[0] + body = s.editor.layout[bodyStart] + s.editor.mouseAction(first.xy + vec2(0, first.h / 2)) + s.editor.mouseAction( + vec2(body.x, body.y - 1), click = false, shift = true + ) + doAssert s.editor.selector == 0 + doAssert s.editor.cursor == bodyStart + doAssert s.editor.copyText() == Heading & "\n" + + echo "Testing keyboard selection follows rows with different heights" + s.editor.cursor = 0 + s.editor.selector = 0 + s.editor.savedX = first.x + s.editor.down(shift = true) + doAssert s.editor.cursor == bodyStart + doAssert s.editor.selector == 0 + s.editor.up(shift = true) + doAssert s.editor.cursor == 0 + s.editor.cursor = codeStart + s.editor.selector = codeStart + s.editor.savedX = s.editor.layout[codeStart].x + s.editor.up(shift = true) + doAssert s.editor.cursor >= bodyStart and s.editor.cursor < codeStart + doAssert s.editor.selector == codeStart + +echo "All rich textbox layout tests passed." diff --git a/tests/test_rules.nim b/tests/test_rules.nim index e1f4cba..bc7789e 100644 --- a/tests/test_rules.nim +++ b/tests/test_rules.nim @@ -47,7 +47,7 @@ proc rules(sk: Silky, clip: Rect): seq[Rect] = block: echo "Testing rule-only blocks have no text or implicit font line" - privateAccess(TextBoxState) + privateAccess(RichTextBoxState) let builder = newAtlasBuilder(1024, 2) window = Window() @@ -64,23 +64,23 @@ block: ruleThickness: 1, padding: vec2(12, 6) ) - var text = "" + let text = "" sk.at = origin - sk.textBox( + sk.richTextBox( window, "rule", text, 80, 100, - wrapWords = true, enabled = false, blocks = @[rule] + wrapWords = true, blocks = @[rule] ) let - state = textBoxStates["rule"] + state = richTextBoxStates["rule"] quads = sk.rules(rect(origin, vec2(80, 100))) - doAssert state.runes.len == 0 - doAssert state.layout.len == 0 + doAssert state.editor.runes.len == 0 + doAssert state.editor.layout.len == 0 doAssert state.runs.len == 0 doAssert state.innerHeight == 13 - doAssert state.locationRect(0).h == 0 - state.selectAll() - doAssert state.copyText() == "" - doAssert state.cursor == 0 and state.selector == 0 + doAssert state.editor.locationRect(0).h == 0 + state.editor.selectAll() + doAssert state.editor.copyText() == "" + doAssert state.editor.cursor == 0 and state.editor.selector == 0 doAssert quads.len == 1 doAssert close(quads[0].x * density, round((origin.x + 12) * density)) doAssert close(quads[0].y * density, round((origin.y + 6) * density)) @@ -92,13 +92,13 @@ block: sk.drawer.layers[NormalLayer].setLen(0) sk.at = origin - sk.textBox( + sk.richTextBox( window, "rule", text, 20, 100, - wrapWords = true, enabled = false, blocks = @[rule] + wrapWords = true, blocks = @[rule] ) doAssert sk.rules(rect(origin, vec2(20, 100))).len == 0 doAssert state.innerHeight == 13 - textBoxStates.del("rule") + richTextBoxStates.del("rule") echo "Testing rules preserve shared selection and surrounding wrapping" const Source = "WW WW WW\nAfter" @@ -116,26 +116,26 @@ block: following = TextBlock( first: after, last: Source.runeLen, padding: vec2(12, 6) ) - plain = TextBoxState(wordWrap: true, blocks: @[before, following]) - ruled = TextBoxState(wordWrap: true, blocks: @[before, rule, following]) - plain.setText(Source) - ruled.setText(Source) + plain = newRichTextBoxState(Source) + ruled = newRichTextBoxState(Source) + plain.blocks = @[before, following] + ruled.blocks = @[before, rule, following] plain.computeLayout(builder.atlas.fonts, "Default", 64) ruled.computeLayout(builder.atlas.fonts, "Default", 64) - doAssert ruled.layout.len == plain.layout.len - doAssert ruled.runes == plain.runes + doAssert ruled.editor.layout.len == plain.editor.layout.len + doAssert ruled.editor.runes == plain.editor.runes doAssert ruled.runs.len == plain.runs.len - doAssert ruled.layout[3].y > ruled.layout[0].y + doAssert ruled.editor.layout[3].y > ruled.editor.layout[0].y for i in 0 ..< after - 1: - doAssert ruled.layout[i] == plain.layout[i] + doAssert ruled.editor.layout[i] == plain.editor.layout[i] for i in after ..< Source.runeLen: - doAssert ruled.layout[i].x == plain.layout[i].x - doAssert ruled.layout[i].wh == plain.layout[i].wh - doAssert close(ruled.layout[i].y - plain.layout[i].y, 21) + doAssert ruled.editor.layout[i].x == plain.editor.layout[i].x + doAssert ruled.editor.layout[i].wh == plain.editor.layout[i].wh + doAssert close(ruled.editor.layout[i].y - plain.editor.layout[i].y, 21) doAssert close(ruled.innerHeight - plain.innerHeight, 21) - ruled.selectAll() - doAssert ruled.copyText() == Source - doAssert ruled.locationRect(Source.runeLen).h == - plain.locationRect(Source.runeLen).h + ruled.editor.selectAll() + doAssert ruled.editor.copyText() == Source + doAssert ruled.editor.locationRect(Source.runeLen).h == + plain.editor.locationRect(Source.runeLen).h echo "All native rule tests passed." diff --git a/tests/test_textboxes.nim b/tests/test_textboxes.nim index 1fb7ddb..8a37426 100644 --- a/tests/test_textboxes.nim +++ b/tests/test_textboxes.nim @@ -8,8 +8,6 @@ import # Build a real atlas with font metrics. let builder = newAtlasBuilder(1024, 4) builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18.0) -builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Wide", 30.0) -builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Small", 12.0) builder.write("tests/dist/atlas.png") let silkyAtlas = readAtlasFromPng("tests/dist/atlas.png") let fontData = silkyAtlas.fonts["Default"] @@ -33,192 +31,10 @@ proc newStateSingleLine(text: string, maxWidth = 500.0f): TextBoxState = result.setText(text) result.computeLayout(fontData, maxWidth) -proc advance(font: FontAtlas, rune: Rune): float32 = - ## Returns the actual atlas advance used by span layout assertions. - var entry: ptr LetterEntry - doAssert font.lookupLetter(rune, 0, entry) - entry.advance - -block: - echo "Testing styled text uses each span's font metrics" - let - s = newState("W a") - wide = silkyAtlas.fonts["Wide"] - small = silkyAtlas.fonts["Small"] - s.enabled = false - s.spans = @[ - TextSpan(first: 0, last: 1, font: "Wide"), - TextSpan(first: 2, last: 3, font: "Small") - ] - s.computeLayout(silkyAtlas.fonts, "Default", 500) - doAssert s.layout.len == 3 - doAssert abs(s.layout[0].w - wide.advance(Rune('W'))) < 0.01 - doAssert abs(s.layout[1].w - fontData.advance(Rune(' '))) < 0.01 - doAssert abs(s.layout[2].w - small.advance(Rune('a'))) < 0.01 - doAssert abs(s.layout[1].x - s.layout[0].w) < 0.01 - doAssert s.lineHeight >= wide.lineHeight - for bounds in s.layout: - doAssert bounds.h == s.lineHeight - -block: - echo "Testing formatting and links preserve native pair kerning" - const Sample = "AVATAR To WAVY" - let - plain = newStateNoWrap(Sample) - styled = newStateNoWrap(Sample) - doAssert fontData.lookupKerning(Rune('A'), Rune('V')) != 0 - doAssert fontData.lookupKerning(Rune('T'), Rune('o')) != 0 - doAssert plain.layout[1].x < plain.layout[0].w - styled.enabled = false - for i in 0 ..< styled.runes.len: - styled.spans.add(TextSpan( - first: i, - last: i + 1, - font: - if i mod 2 == 0: - "Default" - else: - "", - linkId: - if i in 1 .. 8: - 1 - else: - 0 - )) - styled.computeLayout(silkyAtlas.fonts, "Default", 500) - doAssert styled.layout.len == plain.layout.len - for i in 0 ..< plain.layout.len: - let - expected = plain.layout[i] - actual = styled.layout[i] - doAssert abs(actual.x - expected.x) < 0.001 - doAssert abs(actual.y - expected.y) < 0.001 - doAssert abs(actual.w - expected.w) < 0.001 - doAssert abs(actual.h - expected.h) < 0.001 - -block: - echo "Testing styled long words wrap before crossing the width" - let - s = newState("WWWWWW") - wide = silkyAtlas.fonts["Wide"] - width = wide.advance(Rune('W')) * 2.5'f - s.enabled = false - s.spans = @[TextSpan(first: 0, last: 6, font: "Wide")] - s.computeLayout(silkyAtlas.fonts, "Default", width) - doAssert s.layout[0].y == s.layout[1].y - doAssert s.layout[2].y > s.layout[1].y - doAssert s.layout[4].y > s.layout[3].y - for bounds in s.layout: - doAssert bounds.x + bounds.w <= width + 0.01 - -block: - echo "Testing styled Unicode selection preserves the original text" - let s = newState("é code 世界") - s.enabled = false - s.spans = @[TextSpan(first: 2, last: 6, font: "Small")] - s.computeLayout(silkyAtlas.fonts, "Default", 500) - doAssert s.layout.len == s.runes.len - s.cursor = 2 - s.selector = 6 - doAssert s.copyText() == "code" - doAssert getSelectionRects(s.layout, 2, 6).len == 1 - s.selectAll() - doAssert s.copyText() == "é code 世界" - -block: - echo "Testing styled code preserves tabs when copying" - let - s = newState("a\tb") - small = silkyAtlas.fonts["Small"] - s.enabled = false - s.spans = @[TextSpan(first: 0, last: 3, font: "Small")] - s.computeLayout(silkyAtlas.fonts, "Default", 500) - doAssert abs(s.layout[1].w - small.advance(Rune(' ')) * 4) < 0.01 - doAssert abs(s.layout[2].x - s.layout[1].x - s.layout[1].w) < 0.01 - s.selectAll() - doAssert s.copyText() == "a\tb" - -block: - echo "Testing the styled layout overload preserves unstyled layout" - let - s = newState("A plain paragraph that wraps across lines.", 100) - expected = s.layout - s.computeLayout(silkyAtlas.fonts, "Default", 100) - doAssert s.layout == expected - -block: - echo "Testing one selection spans headings, wrapped prose, and code" - const - Heading = "Heading" - Body = "é body wraps over several lines for selection." - Code = "let x = 1\n echo x" - Sample = Heading & "\n" & Body & "\n" & Code - let - bodyStart = Heading.runeLen + 1 - codeStart = bodyStart + Body.runeLen + 1 - s = newState(Sample, 180) - s.enabled = false - s.blocks = @[ - TextBlock( - first: 0, last: Heading.runeLen, font: "Wide", - padding: vec2(12, 6), spacing: 8 - ), - TextBlock( - first: bodyStart, last: codeStart - 1, - padding: vec2(12, 6), spacing: 8 - ), - TextBlock( - first: codeStart, last: Sample.runeLen, font: "Small", - padding: vec2(12, 12), label: "nim", labelFont: "Small", - labelSpacing: 8 - ) - ] - s.computeLayout(silkyAtlas.fonts, "Default", 180) - doAssert s.layout.len == Sample.runeLen - doAssert s.layout[0].h > s.layout[bodyStart].h - doAssert s.layout[bodyStart].h > s.layout[codeStart].h - doAssert s.layout[codeStart - 2].y > s.layout[bodyStart].y - doAssert s.layout[bodyStart].x == 12 - doAssert s.layout[codeStart].x == 12 - doAssert abs(s.innerHeight - s.layout[^1].y - s.layout[^1].h - 12) < 0.001 - for index in [0, bodyStart, codeStart]: - doAssert s.locationRect(index).h == s.layout[index].h - s.selector = 2 - s.cursor = codeStart + 5 - doAssert s.copyText() == "ading\n" & Body & "\nlet x" - let selected = getSelectionRects(s.layout, s.selector, s.cursor) - doAssert selected.len >= 4 - doAssert selected[0].h > selected[^1].h - s.selectAll() - doAssert s.copyText() == Sample - - echo "Testing dragging through block spacing has no selection dead zone" - let - first = s.layout[0] - body = s.layout[bodyStart] - s.mouseAction(first.xy + vec2(0, first.h / 2)) - s.mouseAction( - vec2(body.x, body.y - 1), click = false, shift = true - ) - doAssert s.selector == 0 - doAssert s.cursor == bodyStart - doAssert s.copyText() == Heading & "\n" - - echo "Testing keyboard selection follows rows with different heights" - s.cursor = 0 - s.selector = 0 - s.savedX = first.x - s.down(shift = true) - doAssert s.cursor == bodyStart - doAssert s.selector == 0 - s.up(shift = true) - doAssert s.cursor == 0 - s.cursor = codeStart - s.selector = codeStart - s.savedX = s.layout[codeStart].x - s.up(shift = true) - doAssert s.cursor >= bodyStart and s.cursor < codeStart - doAssert s.selector == codeStart +static: + doAssert not declared(TextSpan) + doAssert not declared(RichTextBoxState) + doAssert not compiles(TextBoxState(spans: @[])) block: echo "Testing getText / setText" From 574d0fea07554216a9aced7695c409463d09cc38 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 10 Nov 2026 19:11:21 -0800 Subject: [PATCH 3/3] capture textbox input on programmatic focus --- .github/workflows/build.yml | 1 + src/silky/textboxes.nim | 9 +++ tests/test_focus.nim | 119 ++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 tests/test_focus.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9e5653..5070bed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,6 +61,7 @@ jobs: nim r ${{ matrix.build_flags }} tests/test_richtextboxes.nim nim r -d:silkyTesting -d:nimTypeNames tests/test_inputs.nim nim r -d:silkyTesting tests/test_buttons.nim + nim r -d:silkyTesting tests/test_focus.nim nim r -d:silkyTesting tests/test_subwindow.nim nim r tests/test_atlas_png.nim nim r tests/test_shader_codegen.nim diff --git a/src/silky/textboxes.nim b/src/silky/textboxes.nim index 4e17f19..aaf5dff 100644 --- a/src/silky/textboxes.nim +++ b/src/silky/textboxes.nim @@ -111,6 +111,15 @@ proc resetBlink*(state: TextBoxState) = ## Resets the cursor blink timer so the cursor is immediately visible. state.blinkTime = epochTime() +proc focus*(state: TextBoxState, sk: Silky, window: Window, id: string) = + ## Focuses a registered field and captures input before its next draw. + let inputs = sk.textInputs + inputs.submitInput(window, id, true, state.enabled) + state.focused = true + state.focusEpoch = inputs.focusEpoch + state.dragging = false + state.resetBlink() + proc cursorVisible*(state: TextBoxState): bool = ## Returns true when the blinking cursor should be drawn. ## Blinks 0.5s on, 0.5s off, always starting visible after a reset. diff --git a/tests/test_focus.nim b/tests/test_focus.nim new file mode 100644 index 0000000..701529f --- /dev/null +++ b/tests/test_focus.nim @@ -0,0 +1,119 @@ +import + std/unicode, + vmath, + silky, silky/semantics, silky/internal/inputs + +when not defined(silkyTesting): + {.error: "Compile with -d:silkyTesting".} + +type InputMode = enum + NoText, RuneInput, CommittedInput + +proc tap(window: Window, button: Button) = + ## Delivers a complete key or mouse event before the next draw. + window.pressButton(button) + window.releaseButton(button) + +block: + echo "Testing programmatic focus captures input before the first draw" + let builder = newAtlasBuilder(1024, 4) + builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18) + for oldFirst in [false, true]: + for mode in InputMode: + let + suffix = $oldFirst & $mode + oldId = "composer-" & suffix + newId = "rename-" & suffix + var + harness = newTestHarness(builder.atlas) + composer = "Composer draft" + title = "Old title" + showRename = false + + proc draw(sk: Silky, window: Window) = + ## Draws the old owner before or after the newly focused popup. + template oldField() = + ## Draws the original composer without changing its draft. + sk.at = vec2(20, 20) + sk.textBox(window, oldId, composer, 240, 64, wrapWords = true) + + template renameField() = + ## Draws the rename input once its popup has opened. + if showRename: + sk.at = vec2(20, 120) + sk.textBox( + window, newId, title, 240, 64, + wrapWords = false, singleLine = true, submitOnEnter = true + ) + + if oldFirst: + oldField() + renameField() + else: + renameField() + oldField() + + discard harness.pumpFrame(draw) + let window = harness.window + window.moveMouse(35, 35) + window.tap(MouseLeft) + discard harness.pumpFrame(draw) + let oldState = textBoxStates[oldId] + doAssert oldState.focused and window.runeInputEnabled + + harness.sk.textInputs.stopInput(window) + doAssert not window.runeInputEnabled + let state = TextBoxState(singleLine: true, dragging: true) + state.setText(title) + textBoxStates[newId] = state + state.focus(harness.sk, window, newId) + state.selectAll() + showRename = true + doAssert state.focused and not state.dragging + doAssert state.cursorVisible + doAssert window.runeInputEnabled + doAssert state.selection == (0 .. title.runeLen) + + window.moveMouse(600, 400) + window.releaseButton(MouseLeft) + let expected = + case mode + of NoText: + title + of RuneInput: + "R" + of CommittedInput: + "Renamed café" + case mode + of NoText: + discard + of RuneInput: + window.typeRune(Rune('R')) + of CommittedInput: + window.onTextInput(expected) + if mode != NoText: + window.tap(KeyEnter) + discard harness.pumpFrame(draw) + doAssert state.focused and not oldState.focused + doAssert title == expected + doAssert composer == "Composer draft" + doAssert state.submitted == (mode != NoText) + doAssert window.textInput.id == newId + doAssert window.textInput.text == expected + doAssert not window.textInput.multiline + if mode == NoText: + doAssert state.selection == (0 .. title.runeLen), + "The menu's stale mouse release must retain select-all." + else: + doAssert state.selection == (expected.runeLen .. expected.runeLen) + doAssert state.undoStack.len == 1 + window.pressButton(KeyLeftSuper) + window.tap(KeyZ) + window.releaseButton(KeyLeftSuper) + discard harness.pumpFrame(draw) + doAssert title == "Old title" + doAssert composer == "Composer draft" + textBoxStates.del(oldId) + textBoxStates.del(newId) + +echo "All programmatic focus tests passed."