From 19d76053517c7883dd5a625d29b15971aabdc893 Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Wed, 24 Jun 2026 23:18:03 -0600 Subject: [PATCH 1/2] fix x11 and vulkan issues --- src/figdraw/common/textbackends/common.nim | 22 +- src/figdraw/figrender.nim | 11 +- src/figdraw/windowing/siwinshim.nim | 36 --- tests/opengl_test_utils.nim | 8 +- tests/siwin_test_utils.nim | 10 +- tests/tfigrender_oneframe_screenshot.nim | 94 +++++++ tests/trender_text_invert.nim | 276 +++++++++++++++++++++ tests/tsiwin_rect_mask_vulkan.nim | 61 +++++ tests/tsiwin_scale.nim | 48 ++++ 9 files changed, 510 insertions(+), 56 deletions(-) create mode 100644 tests/tfigrender_oneframe_screenshot.nim create mode 100644 tests/tsiwin_scale.nim diff --git a/src/figdraw/common/textbackends/common.nim b/src/figdraw/common/textbackends/common.nim index 1b44dc98..a58d09ec 100644 --- a/src/figdraw/common/textbackends/common.nim +++ b/src/figdraw/common/textbackends/common.nim @@ -16,7 +16,11 @@ proc calcMinMaxContent*( var curr: Slice[int] var currLen: float var maxWidth: float - var rect: Rect = rect(float32.high, float32.high, 0, 0) + var + minX = float32.high + minY = float32.high + maxX = -float32.high + maxY = -float32.high let glyphCount = if textLayout.arrangedGlyphs.len > 0: @@ -39,10 +43,10 @@ proc calcMinMaxContent*( textLayout.runes[idx] maxWidth += glyphRect.w - rect.x = min(rect.x, glyphRect.x) - rect.y = min(rect.y, glyphRect.y) - rect.w = max(rect.w, glyphRect.x + glyphRect.w) - rect.h = max(rect.h, glyphRect.y + glyphRect.h) + minX = min(minX, glyphRect.x) + minY = min(minY, glyphRect.y) + maxX = max(maxX, glyphRect.x + glyphRect.w) + maxY = max(maxY, glyphRect.y + glyphRect.h) if glyphRune.isWhiteSpace: curr = idx + 1 .. idx @@ -71,9 +75,11 @@ proc calcMinMaxContent*( result.maxSize.x = maxWidth result.maxSize.y = wordsHeight - if glyphCount == 0: - rect = rect(0, 0, 0, 0) - result.bounding = rect + result.bounding = + if glyphCount == 0: + rect(0, 0, 0, 0) + else: + rect(minX, minY, maxX - minX, maxY - minY) proc maxFontSize*(fontSizes: openArray[float]): float32 = for size in fontSizes: diff --git a/src/figdraw/figrender.nim b/src/figdraw/figrender.nim index 7e5e4291..9c79eebd 100644 --- a/src/figdraw/figrender.nim +++ b/src/figdraw/figrender.nim @@ -1,7 +1,7 @@ import std/[hashes, math, os, strutils, tables, unicode] export tables -from pkg/pixie import Image +import pkg/pixie import pkg/chroma import pkg/chronicles @@ -183,6 +183,13 @@ proc takeScreenshot*[BackendState]( ): Image = renderer.ctx.readPixels(frame, readFront = readFront) +proc takeOneFrameScreenshot*[BackendState]( + renderer: FigRenderer[BackendState], frame: Rect = rect(0, 0, 0, 0) +): Image = + ## Captures the frame that was just rendered by renderFrame(). + ## OpenGL draws into the back buffer until the windowing layer swaps. + renderer.takeScreenshot(frame, readFront = renderer.backendKind() != rbOpenGL) + proc logBackend(msg: static string) = info msg, preferredBackend = backendName(PreferredBackendKind) @@ -943,6 +950,6 @@ proc renderFrame*[BackendState]( when defined(testOneFrame) and (UseOpenGlBackend or UseOpenGlFallback): ## This is used for test only ## Take a screen shot of the first frame and exit. - var img = takeScreenshot(renderer) + var img = takeOneFrameScreenshot(renderer) img.writeFile("screenshot.png") quit() diff --git a/src/figdraw/windowing/siwinshim.nim b/src/figdraw/windowing/siwinshim.nim index 3cbfef71..4bd6caae 100644 --- a/src/figdraw/windowing/siwinshim.nim +++ b/src/figdraw/windowing/siwinshim.nim @@ -1,6 +1,4 @@ import std/[os, strutils] -when defined(linux) or defined(bsd): - import std/osproc import vmath import siwin/[window as siWindow, windowOpengl as siWindowOpengl] @@ -39,36 +37,6 @@ proc siwinBackendName*[BackendState](renderer: FigRenderer[BackendState]): strin var siwinGlobalsShared {.threadvar.}: SiwinGlobals -when defined(linux) or defined(bsd): - var cachedXftScale {.threadvar.}: float32 - var cachedXftScaleInitialized {.threadvar.}: bool - - proc xftDpiScale(): float32 = - ## Xft.dpi gives a useful fractional scale for X11 and many XWayland sessions. - ## Cache once per thread to avoid shelling out repeatedly. - if cachedXftScaleInitialized: - return cachedXftScale - cachedXftScaleInitialized = true - cachedXftScale = 0.0 - if getEnv("DISPLAY").len == 0: - return cachedXftScale - try: - let output = execProcess("xrdb -query") - for line in output.splitLines(): - let trimmed = line.strip() - if not trimmed.toLowerAscii().startsWith("xft.dpi:"): - continue - let parts = trimmed.split(":", maxsplit = 1) - if parts.len != 2: - break - let dpi = parts[1].strip().parseFloat() - if dpi > 0: - cachedXftScale = (dpi / 96.0).float32 - break - except CatchableError: - discard - cachedXftScale - proc sharedSiwinGlobals*(): SiwinGlobals = if siwinGlobalsShared.isNil: siwinGlobalsShared = newSiwinGlobals() @@ -283,10 +251,6 @@ proc contentScale*(window: Window): float32 = elif defined(linux) or defined(bsd): let backendScale = window.uiScale() let scale = if backendScale > 0: backendScale else: 1.0 - let xftScale = xftDpiScale() - if xftScale > 0: - if window of siX11Window.WindowX11: - return xftScale scale else: 1.0 diff --git a/tests/opengl_test_utils.nim b/tests/opengl_test_utils.nim index 4993a396..c3cd3859 100644 --- a/tests/opengl_test_utils.nim +++ b/tests/opengl_test_utils.nim @@ -62,11 +62,11 @@ proc renderAndScreenshotOnce*( renderer.renderFrame(renders, sz) if renderer.backendKind() == rbOpenGL: # OpenGL fallback renders into the back buffer; capture before swap. - result = glrenderer.takeScreenshot(renderer, readFront = false) + result = glrenderer.takeOneFrameScreenshot(renderer) renderer.endFrame() else: renderer.endFrame() - result = glrenderer.takeScreenshot(renderer) + result = glrenderer.takeOneFrameScreenshot(renderer) result.writeFile(outputPath) except VulkanError as exc: raise newException(WindyError, "Vulkan device not available: " & exc.msg) @@ -87,7 +87,7 @@ proc renderAndScreenshotOnce*( var renders = makeRenders(sz.x, sz.y) renderer.renderFrame(renders, sz) glFinish() - result = glrenderer.takeScreenshot(renderer, readFront = false) + result = glrenderer.takeOneFrameScreenshot(renderer) window.swapBuffers() result.writeFile(outputPath) finally: @@ -126,7 +126,7 @@ proc renderAndScreenshotOverlayOnce*( renders, vec2(windowW.float32, windowH.float32), clearMain = true ) glFinish() - result = glrenderer.takeScreenshot(renderer, readFront = false) + result = glrenderer.takeOneFrameScreenshot(renderer) window.swapBuffers() result.writeFile(outputPath) finally: diff --git a/tests/siwin_test_utils.nim b/tests/siwin_test_utils.nim index c904a5dd..ff3f6330 100644 --- a/tests/siwin_test_utils.nim +++ b/tests/siwin_test_utils.nim @@ -1,4 +1,4 @@ -import std/[os, strutils] +import std/os import pkg/pixie import figdraw/windowing/siwinshim @@ -55,11 +55,11 @@ proc renderAndScreenshotOnce*( renderer.beginFrame() renderer.renderFrame(renders, sz) if renderer.backendKind() == rbOpenGL: - result = glrenderer.takeScreenshot(renderer, readFront = false) + result = glrenderer.takeOneFrameScreenshot(renderer) renderer.endFrame() else: renderer.endFrame() - result = glrenderer.takeScreenshot(renderer) + result = glrenderer.takeOneFrameScreenshot(renderer) if result.isNil or result.width <= 0 or result.height <= 0 or result.data.len == 0: raise newException( ValueError, "Vulkan screenshot unavailable (no present target or empty frame)" @@ -81,11 +81,9 @@ proc renderAndScreenshotOnce*( let sz = window.logicalSize() var renders = makeRenders(sz.x, sz.y) let renderer = glrenderer.newFigRenderer(atlasSize = atlasSize) - renderer.beginFrame() renderer.renderFrame(renders, sz) glFinish() - result = glrenderer.takeScreenshot(renderer, readFront = false) - renderer.endFrame() + result = glrenderer.takeOneFrameScreenshot(renderer) presentNow(window) result.writeFile(outputPath) finally: diff --git a/tests/tfigrender_oneframe_screenshot.nim b/tests/tfigrender_oneframe_screenshot.nim new file mode 100644 index 00000000..2745ca0e --- /dev/null +++ b/tests/tfigrender_oneframe_screenshot.nim @@ -0,0 +1,94 @@ +import std/unittest + +import figdraw/commons + +when UseOpenGlBackend: + import std/[os, tables] + + import pkg/[chroma, opengl] + import pkg/pixie + + import figdraw/windowing/windyshim + import figdraw/fignodes + import figdraw/figrender as glrenderer + import figdraw/utils/glutils + + proc ensureTestOutputDir(subdir = "output"): string = + result = getCurrentDir() / "tests" / subdir + createDir(result) + + proc makeRenderTree(w, h: float32): Renders = + var list = RenderList() + discard list.addRoot( + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(0, 0, w, h), + fill: rgba(255, 255, 255, 255), + ) + ) + discard list.addRoot( + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(32, 24, 120, 80), + fill: rgba(220, 40, 40, 255), + ) + ) + + result = Renders(layers: initOrderedTable[ZLevel, RenderList]()) + result.layers[0.ZLevel] = list + + proc maxChannelDelta(px: ColorRGBX, r, g, b: uint8): int = + max(abs(px.r.int - r.int), max(abs(px.g.int - g.int), abs(px.b.int - b.int))) + + proc renderOneFrameScreenshot(outputPath: string): Image = + let window = newWindow( + "figdraw test: opengl one-frame screenshot", + ivec2(240'i32, 160'i32), + visible = false, + ) + startOpenGL(openglVersion) + window.makeContextCurrent() + window.visible = true + + try: + if glGetString(GL_VERSION) == nil: + raise newException(WindyError, "OpenGL context unavailable") + + let renderer = glrenderer.newFigRenderer(atlasSize = 512) + pollEvents() + let sz = window.logicalSize() + var renders = makeRenderTree(sz.x, sz.y) + renderer.renderFrame(renders, sz) + glFinish() + result = glrenderer.takeOneFrameScreenshot(renderer) + result.writeFile(outputPath) + finally: + window.close() + +suite "figrender one-frame screenshot": + test "captures OpenGL back buffer instead of black front buffer": + when UseOpenGlBackend: + let outPath = ensureTestOutputDir() / "oneframe_opengl.png" + if fileExists(outPath): + removeFile(outPath) + + block renderOnce: + var img: Image + try: + img = renderOneFrameScreenshot(outPath) + except WindyError: + skip() + break renderOnce + + check fileExists(outPath) + check getFileSize(outPath) > 0 + check img.width == 240 + check img.height == 160 + check img[12, 12].maxChannelDelta(255, 255, 255) <= 12 + check img[64, 48].maxChannelDelta(220, 40, 40) <= 12 + else: + skip() diff --git a/tests/trender_text_invert.nim b/tests/trender_text_invert.nim index c81c3805..159543cc 100644 --- a/tests/trender_text_invert.nim +++ b/tests/trender_text_invert.nim @@ -5,6 +5,7 @@ import pkg/pixie import figdraw/commons import figdraw/fignodes +import figdraw/common/fontutils import figdraw/common/typefaces import ./siwin_test_utils @@ -95,7 +96,282 @@ proc profileDiffFlipped(a, b: seq[int]): int = total += abs(a[i] - b[n - 1 - i]) total +proc testTextLayout( + typefaceId: TypefaceId, + text: string, + width, height: float32, + color: ColorRGBA, + hAlign = Center, +): GlyphArrangement = + let + uiFont = FigFont(typefaceId: typefaceId, size: 13.0'f32) + textStyle = fs(uiFont, fill(color)) + typeset( + rect(0, 0, width, height), + [(textStyle, text)], + hAlign = hAlign, + vAlign = Middle, + minContent = false, + wrap = false, + ) + +proc loadHelloTypeface(): TypefaceId = + let merendaDataDir = getCurrentDir().parentDir().parentDir() / "data" + if fileExists(merendaDataDir / "IBMPlexSans-Regular.ttf"): + setFigDataDir(merendaDataDir) + return loadTypeface("IBMPlexSans-Regular.ttf", ["Ubuntu.ttf"]) + + setFigDataDir(getCurrentDir() / "data") + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + loadTypeface("Ubuntu.ttf", fontData, TTF) + suite "siwin text invert render": + test "left aligned text renders inside small clipped parents": + setFigUiScale(1.0'f32) + setFigDataDir(getCurrentDir() / "data") + + let + fontData = readFile(figDataDir() / "Ubuntu.ttf") + typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + uiFont = FigFont(typefaceId: typefaceId, size: 13.0'f32) + textStyle = fs(uiFont, fill(rgba(18, 28, 44, 255))) + textValue = "Pure Nim responder/action dispatch with plain widget state" + + proc textArrangement(width, height: float32): GlyphArrangement = + typeset( + rect(0, 0, width, height), + [(textStyle, textValue)], + hAlign = Left, + vAlign = Middle, + minContent = false, + wrap = false, + ) + + proc addClippedText( + list: var RenderList, box: Rect, layout: GlyphArrangement, z: ZLevel + ) = + let parentIdx = list.addRoot( + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: z, + screenBox: box, + fill: rgba(242, 245, 250, 255), + flags: {NfClipContent}, + ) + ) + discard list.addChild( + parentIdx, + Fig(kind: nkText, childCount: 0, zlevel: z, screenBox: box, textLayout: layout), + ) + + proc makeRenderTree(w, h: float32): Renders = + var list = RenderList() + discard list.addRoot( + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(0, 0, w, h), + fill: rgba(255, 255, 255, 255), + ) + ) + list.addClippedText(rect(28, 30, 664, 18), textArrangement(664, 18), 1.ZLevel) + list.addClippedText(rect(28, 70, 664, 24), textArrangement(664, 24), 1.ZLevel) + + result = Renders(layers: initOrderedTable[ZLevel, RenderList]()) + result.layers[0.ZLevel] = list + + let outDir = ensureTestOutputDir() + let outPath = outDir / "render_small_clipped_text.png" + if fileExists(outPath): + removeFile(outPath) + + block renderOnce: + var img: Image + try: + img = renderAndScreenshotOnce( + makeRenders = makeRenderTree, + outputPath = outPath, + windowW = 720, + windowH = 130, + title = "figdraw test: small clipped text", + ) + except ValueError: + skip() + break renderOnce + + check fileExists(outPath) + check getFileSize(outPath) > 0 + + let + bodyInk = findInkBounds(img, 24, 24, 420, 32) + statusInk = findInkBounds(img, 24, 64, 420, 38) + check bodyInk.found + check statusInk.found + check bodyInk.x1 - bodyInk.x0 > 120 + check statusInk.x1 - statusInk.x0 > 120 + + test "hello-like clipped label sequence renders every text node": + setFigUiScale(1.0'f32) + setFigDataDir(getCurrentDir() / "data") + + let + typefaceId = loadHelloTypeface() + titleLayout = testTextLayout( + typefaceId, "Hello from KNutella/nimkit", 640, 28, rgba(23, 36, 66, 255), Center + ) + bodyLayout = testTextLayout( + typefaceId, + "Pure Nim responder/action dispatch with plain widget state", + 664, + 18, + rgba(23, 31, 46, 255), + Left, + ) + statusLayout = testTextLayout( + typefaceId, + "Button state: Off (click to cycle)", + 644, + 24, + rgba(23, 69, 46, 255), + Left, + ) + buttonLayout = testTextLayout( + typefaceId, "Cycle State (Off)", 648, 32, rgba(40, 40, 40, 255), Center + ) + + proc addLabel( + list: var RenderList, + parentIdx: FigIdx, + frame, textFrame: Rect, + background: ColorRGBA, + layout: GlyphArrangement, + z: ZLevel, + ) = + let labelIdx = list.addChild( + parentIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: z, + screenBox: frame, + fill: rgba(0, 0, 0, 0), + flags: {NfClipContent}, + ), + ) + discard list.addChild( + labelIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: z, + screenBox: frame, + fill: background, + ), + ) + discard list.addChild( + labelIdx, + Fig( + kind: nkText, + childCount: 0, + zlevel: z, + screenBox: textFrame, + textLayout: layout, + ), + ) + + proc makeRenderTree(w, h: float32): Renders = + var list = RenderList() + let rootIdx = list.addRoot( + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(0, 0, w, h), + fill: rgba(242, 245, 250, 255), + ) + ) + let stackIdx = list.addChild( + rootIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(28, 28, 664, 138), + fill: rgba(0, 0, 0, 0), + ), + ) + list.addLabel( + stackIdx, + rect(28, 28, 664, 28), + rect(40, 28, 640, 28), + rgba(228, 242, 255, 255), + titleLayout, + 0.ZLevel, + ) + list.addLabel( + stackIdx, + rect(28, 68, 664, 18), + rect(28, 68, 664, 18), + rgba(0, 0, 0, 0), + bodyLayout, + 0.ZLevel, + ) + list.addLabel( + stackIdx, + rect(28, 98, 664, 24), + rect(38, 98, 644, 24), + rgba(228, 244, 232, 255), + statusLayout, + 0.ZLevel, + ) + list.addLabel( + stackIdx, + rect(28, 134, 664, 32), + rect(36, 134, 648, 32), + rgba(205, 208, 211, 255), + buttonLayout, + 0.ZLevel, + ) + + result = Renders(layers: initOrderedTable[ZLevel, RenderList]()) + result.layers[0.ZLevel] = list + + let outDir = ensureTestOutputDir() + let outPath = outDir / "render_hello_like_clipped_text.png" + if fileExists(outPath): + removeFile(outPath) + + block renderOnce: + var img: Image + try: + img = renderAndScreenshotOnce( + makeRenders = makeRenderTree, + outputPath = outPath, + windowW = 720, + windowH = 220, + title = "figdraw test: hello-like clipped text", + ) + except ValueError: + skip() + break renderOnce + + check fileExists(outPath) + check getFileSize(outPath) > 0 + + let + titleInk = findInkBounds(img, 260, 28, 200, 28) + bodyInk = findInkBounds(img, 28, 64, 420, 28) + statusInk = findInkBounds(img, 38, 94, 260, 34) + buttonInk = findInkBounds(img, 300, 130, 140, 40) + check titleInk.found + check bodyInk.found + check statusInk.found + check buttonInk.found + check bodyInk.x1 - bodyInk.x0 > 120 + check statusInk.x1 - statusInk.x0 > 100 + test "NfInvertY under mirrored parent stays upright and vertically aligned": setFigUiScale(1.0'f32) setFigDataDir(getCurrentDir() / "data") diff --git a/tests/tsiwin_rect_mask_vulkan.nim b/tests/tsiwin_rect_mask_vulkan.nim index 3003bd93..bc55596f 100644 --- a/tests/tsiwin_rect_mask_vulkan.nim +++ b/tests/tsiwin_rect_mask_vulkan.nim @@ -102,6 +102,32 @@ proc makeMixedRectMaskBatchRenderTree(w, h: float32): Renders = result = Renders(layers: initOrderedTable[ZLevel, RenderList]()) result.layers[0.ZLevel] = list +proc addClippedBand( + list: var RenderList, rectBox: Rect, childColor: ColorRGBA, z: ZLevel +) = + let parentIdx = list.addRoot( + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: z, + screenBox: rectBox, + fill: rgba(0, 0, 0, 0), + flags: {NfClipContent}, + ) + ) + list.addRect(parentIdx, rectBox, childColor, z) + +proc makeSmallClipRenderTree(w, h: float32): Renders = + var list = RenderList() + discard list.addRootRect(rect(0, 0, w, h), rgba(255, 255, 255, 255), 0.ZLevel) + list.addClippedBand(rect(32, 24, 180, 18), rgba(220, 40, 40, 255), 0.ZLevel) + list.addClippedBand(rect(32, 56, 180, 24), rgba(56, 168, 88, 255), 0.ZLevel) + list.addClippedBand(rect(32, 94, 180, 28), rgba(54, 118, 230, 255), 0.ZLevel) + list.addClippedBand(rect(32, 136, 180, 32), rgba(210, 170, 46, 255), 0.ZLevel) + + result = Renders(layers: initOrderedTable[ZLevel, RenderList]()) + result.layers[0.ZLevel] = list + suite "siwin vulkan rect mask": test "keeps mixed masked and unmasked siblings in one batch": when UseVulkanBackend: @@ -138,3 +164,38 @@ suite "siwin vulkan rect mask": assertLogicalColor(rendered, 336, 88, windowW, windowH, 54, 118, 230) else: skip() + + test "NfClipContent renders small clipped rectangle subtrees": + when UseVulkanBackend: + const + windowW = 260 + windowH = 190 + setFigUiScale(1.0) + let outDir = ensureTestOutputDir() + let outPath = outDir / "render_small_clip_vulkan.png" + if fileExists(outPath): + removeFile(outPath) + + block renderOnce: + var rendered: Image + try: + rendered = renderAndScreenshotOnce( + makeRenders = makeSmallClipRenderTree, + outputPath = outPath, + windowW = windowW, + windowH = windowH, + title = "figdraw test: vulkan small clip", + ) + except ValueError: + skip() + break renderOnce + + check fileExists(outPath) + check getFileSize(outPath) > 0 + + assertLogicalColor(rendered, 64, 32, windowW, windowH, 220, 40, 40) + assertLogicalColor(rendered, 64, 68, windowW, windowH, 56, 168, 88) + assertLogicalColor(rendered, 64, 108, windowW, windowH, 54, 118, 230) + assertLogicalColor(rendered, 64, 152, windowW, windowH, 210, 170, 46) + else: + skip() diff --git a/tests/tsiwin_scale.nim b/tests/tsiwin_scale.nim new file mode 100644 index 00000000..a8e82648 --- /dev/null +++ b/tests/tsiwin_scale.nim @@ -0,0 +1,48 @@ +import std/[os, unittest] + +import figdraw/common/shared +import figdraw/windowing/siwinshim + +suite "siwin scale": + test "X11 content scale ignores Xft.dpi because window size is physical pixels": + when defined(linux) or defined(bsd): + block runNativeWindow: + if getEnv("DISPLAY").len == 0: + skip() + break runNativeWindow + + let + oldPath = getEnv("PATH") + fakeBin = getTempDir() / "figdraw-fake-xrdb-" & $getCurrentProcessId() + fakeXrdb = fakeBin / "xrdb" + createDir(fakeBin) + writeFile(fakeXrdb, "#!/bin/sh\nprintf 'Xft.dpi:\\t144\\n'\n") + setFilePermissions( + fakeXrdb, + { + fpUserRead, fpUserWrite, fpUserExec, fpGroupRead, fpGroupExec, fpOthersRead, + fpOthersExec, + }, + ) + putEnv("PATH", fakeBin & PathSep & oldPath) + defer: + putEnv("PATH", oldPath) + removeDir(fakeBin) + setFigUiScale(1.0'f32) + + let window = newSiwinWindow( + size = ivec2(320'i32, 180'i32), + fullscreen = false, + title = "figdraw test: x11 scale", + ) + try: + if window.siwinDisplayServerName() != "x11": + skip() + else: + check window.contentScale() == window.uiScale() + discard window.configureUiScale() + check figUiScale() == window.uiScale() + finally: + window.close() + else: + skip() From a28e7a1b15c2ce30606e6667c22bd7a22b6c8528 Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Thu, 25 Jun 2026 00:08:43 -0600 Subject: [PATCH 2/2] fix vulkan buffer issues --- src/figdraw/vulkan/vulkan_context.nim | 50 ++- tests/siwin_test_utils.nim | 94 ++++++ tests/trender_text_invert.nim | 448 +++++++++++++++++++++++++- 3 files changed, 574 insertions(+), 18 deletions(-) diff --git a/src/figdraw/vulkan/vulkan_context.nim b/src/figdraw/vulkan/vulkan_context.nim index 03667b6a..a9b91620 100644 --- a/src/figdraw/vulkan/vulkan_context.nim +++ b/src/figdraw/vulkan/vulkan_context.nim @@ -1985,22 +1985,40 @@ proc flush(ctx: VulkanContext) = v.sdfPad = 0'u16 v.sdfFactors[0] = ctx.sdfFactors[i * 2 + 0] v.sdfFactors[1] = ctx.sdfFactors[i * 2 + 1] - v.rectMaskParams[0] = ctx.rectMaskParams[i * 4 + 0] - v.rectMaskParams[1] = ctx.rectMaskParams[i * 4 + 1] - v.rectMaskParams[2] = ctx.rectMaskParams[i * 4 + 2] - v.rectMaskParams[3] = ctx.rectMaskParams[i * 4 + 3] - v.rectMaskRadii[0] = ctx.rectMaskRadii[i * 4 + 0] - v.rectMaskRadii[1] = ctx.rectMaskRadii[i * 4 + 1] - v.rectMaskRadii[2] = ctx.rectMaskRadii[i * 4 + 2] - v.rectMaskRadii[3] = ctx.rectMaskRadii[i * 4 + 3] - v.rectMaskMatX[0] = ctx.rectMaskMatX[i * 4 + 0] - v.rectMaskMatX[1] = ctx.rectMaskMatX[i * 4 + 1] - v.rectMaskMatX[2] = ctx.rectMaskMatX[i * 4 + 2] - v.rectMaskMatX[3] = ctx.rectMaskMatX[i * 4 + 3] - v.rectMaskMatY[0] = ctx.rectMaskMatY[i * 4 + 0] - v.rectMaskMatY[1] = ctx.rectMaskMatY[i * 4 + 1] - v.rectMaskMatY[2] = ctx.rectMaskMatY[i * 4 + 2] - v.rectMaskMatY[3] = ctx.rectMaskMatY[i * 4 + 3] + if ctx.batchHasRectMask: + v.rectMaskParams[0] = ctx.rectMaskParams[i * 4 + 0] + v.rectMaskParams[1] = ctx.rectMaskParams[i * 4 + 1] + v.rectMaskParams[2] = ctx.rectMaskParams[i * 4 + 2] + v.rectMaskParams[3] = ctx.rectMaskParams[i * 4 + 3] + v.rectMaskRadii[0] = ctx.rectMaskRadii[i * 4 + 0] + v.rectMaskRadii[1] = ctx.rectMaskRadii[i * 4 + 1] + v.rectMaskRadii[2] = ctx.rectMaskRadii[i * 4 + 2] + v.rectMaskRadii[3] = ctx.rectMaskRadii[i * 4 + 3] + v.rectMaskMatX[0] = ctx.rectMaskMatX[i * 4 + 0] + v.rectMaskMatX[1] = ctx.rectMaskMatX[i * 4 + 1] + v.rectMaskMatX[2] = ctx.rectMaskMatX[i * 4 + 2] + v.rectMaskMatX[3] = ctx.rectMaskMatX[i * 4 + 3] + v.rectMaskMatY[0] = ctx.rectMaskMatY[i * 4 + 0] + v.rectMaskMatY[1] = ctx.rectMaskMatY[i * 4 + 1] + v.rectMaskMatY[2] = ctx.rectMaskMatY[i * 4 + 2] + v.rectMaskMatY[3] = ctx.rectMaskMatY[i * 4 + 3] + else: + v.rectMaskParams[0] = 0.0'f32 + v.rectMaskParams[1] = 0.0'f32 + v.rectMaskParams[2] = -1.0'f32 + v.rectMaskParams[3] = -1.0'f32 + v.rectMaskRadii[0] = 0.0'f32 + v.rectMaskRadii[1] = 0.0'f32 + v.rectMaskRadii[2] = 0.0'f32 + v.rectMaskRadii[3] = 0.0'f32 + v.rectMaskMatX[0] = 0.0'f32 + v.rectMaskMatX[1] = 0.0'f32 + v.rectMaskMatX[2] = 0.0'f32 + v.rectMaskMatX[3] = 0.0'f32 + v.rectMaskMatY[0] = 0.0'f32 + v.rectMaskMatY[1] = 0.0'f32 + v.rectMaskMatY[2] = 0.0'f32 + v.rectMaskMatY[3] = 0.0'f32 let uploadBytes = VkDeviceSize(vertexCount * sizeof(Vertex)) let vertexAlloc = ctx.createBuffer( diff --git a/tests/siwin_test_utils.nim b/tests/siwin_test_utils.nim index ff3f6330..348ff5c6 100644 --- a/tests/siwin_test_utils.nim +++ b/tests/siwin_test_utils.nim @@ -89,3 +89,97 @@ proc renderAndScreenshotOnce*( finally: when not defined(emscripten): window.close() + +proc renderAndScreenshotSequence*( + makeInitialRenders: proc(w, h: float32): Renders {.closure.}, + makeUpdatedRenders: proc(w, h: float32): Renders {.closure.}, + initialPath: string, + updatedPath: string, + windowW = 800, + windowH = 600, + atlasSize = 2048, + title = "figdraw test: siwin screenshot sequence", +): tuple[initial, updated: Image] = + when UseMetalBackend: + try: + let renderer = glrenderer.newFigRenderer(atlasSize = atlasSize) + + var initialRenders = makeInitialRenders(windowW.float32, windowH.float32) + renderer.renderFrame(initialRenders, vec2(windowW.float32, windowH.float32)) + result.initial = glrenderer.takeScreenshot(renderer) + result.initial.writeFile(initialPath) + + var updatedRenders = makeUpdatedRenders(windowW.float32, windowH.float32) + renderer.renderFrame(updatedRenders, vec2(windowW.float32, windowH.float32)) + result.updated = glrenderer.takeScreenshot(renderer) + result.updated.writeFile(updatedPath) + except ValueError: + raise newException(ValueError, "Metal device not available") + elif UseVulkanBackend: + let renderer = glrenderer.newFigRenderer( + atlasSize = atlasSize, backendState = SiwinRenderBackend() + ) + let window = newSiwinWindow( + renderer, + size = ivec2(windowW.int32, windowH.int32), + fullscreen = false, + title = title, + ) + + proc capture( + makeRenders: proc(w, h: float32): Renders {.closure.}, outputPath: string + ): Image = + let sz = window.logicalSize() + var renders = makeRenders(sz.x, sz.y) + renderer.beginFrame() + renderer.renderFrame(renders, sz) + if renderer.backendKind() == rbOpenGL: + result = glrenderer.takeOneFrameScreenshot(renderer) + renderer.endFrame() + else: + renderer.endFrame() + result = glrenderer.takeOneFrameScreenshot(renderer) + if result.isNil or result.width <= 0 or result.height <= 0 or result.data.len == 0: + raise newException( + ValueError, "Vulkan screenshot unavailable (no present target or empty frame)" + ) + result.writeFile(outputPath) + + try: + renderer.setupBackend(window) + window.firstStep() + result.initial = capture(makeInitialRenders, initialPath) + result.updated = capture(makeUpdatedRenders, updatedPath) + except VulkanError as exc: + raise newException(ValueError, "Vulkan device not available: " & exc.msg) + except ValueError: + raise newException(ValueError, "Vulkan device not available") + finally: + when not defined(emscripten): + window.close() + else: + let window = newSiwinWindow( + size = ivec2(windowW.int32, windowH.int32), fullscreen = false, title = title + ) + try: + window.firstStep() + let + sz = window.logicalSize() + renderer = glrenderer.newFigRenderer(atlasSize = atlasSize) + + var initialRenders = makeInitialRenders(sz.x, sz.y) + renderer.renderFrame(initialRenders, sz) + glFinish() + result.initial = glrenderer.takeOneFrameScreenshot(renderer) + presentNow(window) + result.initial.writeFile(initialPath) + + var updatedRenders = makeUpdatedRenders(sz.x, sz.y) + renderer.renderFrame(updatedRenders, sz) + glFinish() + result.updated = glrenderer.takeOneFrameScreenshot(renderer) + presentNow(window) + result.updated.writeFile(updatedPath) + finally: + when not defined(emscripten): + window.close() diff --git a/tests/trender_text_invert.nim b/tests/trender_text_invert.nim index 159543cc..e693437d 100644 --- a/tests/trender_text_invert.nim +++ b/tests/trender_text_invert.nim @@ -23,6 +23,23 @@ proc isInk(px: ColorRGBX): bool = proc isHighlight(px: ColorRGBX): bool = px.a >= 20'u8 and px.r >= 180'u8 and px.g >= 150'u8 and px.b <= 140'u8 +proc isBlueInk(px: ColorRGBX): bool = + px.a >= 20'u8 and px.r <= 80'u8 and px.g <= 140'u8 and px.b >= 180'u8 + +proc countBlueInk(img: Image, x0, y0, w, h: int): int = + let + minX = max(0, x0) + minY = max(0, y0) + maxX = min(img.width - 1, x0 + w - 1) + maxY = min(img.height - 1, y0 + h - 1) + if maxX < minX or maxY < minY: + return 0 + + for y in minY .. maxY: + for x in minX .. maxX: + if isBlueInk(img[x, y]): + inc result + proc findInkBounds(img: Image, x0, y0, w, h: int): InkBounds = let minX = max(0, x0) @@ -70,6 +87,25 @@ proc inkHeight(b: InkBounds): int = return 0 b.y1 - b.y0 + 1 +proc maxInkColumnGap(img: Image, x0, y0, w, h: int): int = + let bounds = findInkBounds(img, x0, y0, w, h) + if not bounds.found: + return high(int) + + var currentGap = 0 + for x in bounds.x0 .. bounds.x1: + var hasInk = false + for y in max(0, y0) .. min(img.height - 1, y0 + h - 1): + if isInk(img[x, y]): + hasInk = true + break + if hasInk: + result = max(result, currentGap) + currentGap = 0 + else: + inc currentGap + max(result, currentGap) + proc rowInkProfile(img: Image, b: InkBounds): seq[int] = if not b.found: return @[] @@ -116,14 +152,20 @@ proc testTextLayout( ) proc loadHelloTypeface(): TypefaceId = + var cached {.global.}: TypefaceId + if Hash(cached) != 0: + return cached + let merendaDataDir = getCurrentDir().parentDir().parentDir() / "data" if fileExists(merendaDataDir / "IBMPlexSans-Regular.ttf"): setFigDataDir(merendaDataDir) - return loadTypeface("IBMPlexSans-Regular.ttf", ["Ubuntu.ttf"]) + cached = loadTypeface("IBMPlexSans-Regular.ttf", ["Ubuntu.ttf"]) + return cached setFigDataDir(getCurrentDir() / "data") let fontData = readFile(figDataDir() / "Ubuntu.ttf") - loadTypeface("Ubuntu.ttf", fontData, TTF) + cached = loadTypeface("Ubuntu.ttf", fontData, TTF) + cached suite "siwin text invert render": test "left aligned text renders inside small clipped parents": @@ -372,6 +414,408 @@ suite "siwin text invert render": check bodyInk.x1 - bodyInk.x0 > 120 check statusInk.x1 - statusInk.x0 > 100 + test "Vulkan preserves glyph atlas text after label content changes": + when UseVulkanBackend: + setFigUiScale(1.0'f32) + setFigDataDir(getCurrentDir() / "data") + + let typefaceId = loadHelloTypeface() + + proc layoutFor( + text: string, width, height: float32, color: ColorRGBA, hAlign: FontHorizontal + ): GlyphArrangement = + testTextLayout(typefaceId, text, width, height, color, hAlign) + + proc measureText(text: string) = + let + uiFont = FigFont(typefaceId: typefaceId, size: 13.0'f32) + textStyle = fs(uiFont, fill(rgba(0, 0, 0, 255))) + discard typeset( + rect(0, 0, 10000, 100), + [(textStyle, text)], + hAlign = Left, + vAlign = Top, + minContent = false, + wrap = false, + ) + + let + titleText = "Hello from KNutella/nimkit" + bodyText = "Pure Nim responder/action dispatch with plain widget state" + initialStatusText = "Button state: Off (click to cycle)" + initialButtonText = "Cycle State (Off)" + updatedStatusText = "Button state: Mixed (click to cycle)" + updatedButtonText = "Cycle State (Mixed)" + + measureText(titleText) + measureText(bodyText) + measureText(initialStatusText) + measureText(initialButtonText) + + let + titleLayout = layoutFor(titleText, 640, 28, rgba(23, 36, 66, 255), Center) + bodyLayout = layoutFor(bodyText, 664, 18, rgba(23, 31, 46, 255), Left) + initialStatusLayout = + layoutFor(initialStatusText, 644, 24, rgba(23, 69, 46, 255), Left) + initialButtonLayout = + layoutFor(initialButtonText, 648, 32, rgba(40, 40, 40, 255), Center) + + proc addLabel( + list: var RenderList, + parentIdx: FigIdx, + frame, textFrame: Rect, + background: ColorRGBA, + layout: GlyphArrangement, + z: ZLevel, + ) = + let labelIdx = list.addChild( + parentIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: z, + screenBox: frame, + fill: rgba(0, 0, 0, 0), + flags: {NfClipContent}, + ), + ) + discard list.addChild( + labelIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: z, + screenBox: frame, + fill: background, + ), + ) + discard list.addChild( + labelIdx, + Fig( + kind: nkText, + childCount: 0, + zlevel: z, + screenBox: textFrame, + textLayout: layout, + ), + ) + + proc makeRenderTree( + w, h: float32, statusLayout, buttonLayout: GlyphArrangement + ): Renders = + var list = RenderList() + let rootIdx = list.addRoot( + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(0, 0, w, h), + fill: rgba(242, 245, 250, 255), + ) + ) + let stackIdx = list.addChild( + rootIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(28, 28, 664, 138), + fill: rgba(0, 0, 0, 0), + ), + ) + list.addLabel( + stackIdx, + rect(28, 28, 664, 28), + rect(40, 28, 640, 28), + rgba(228, 242, 255, 255), + titleLayout, + 0.ZLevel, + ) + list.addLabel( + stackIdx, + rect(28, 68, 664, 18), + rect(28, 68, 664, 18), + rgba(0, 0, 0, 0), + bodyLayout, + 0.ZLevel, + ) + list.addLabel( + stackIdx, + rect(28, 98, 664, 24), + rect(38, 98, 644, 24), + rgba(228, 244, 232, 255), + statusLayout, + 0.ZLevel, + ) + + let buttonIdx = list.addChild( + stackIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(28, 134, 664, 32), + fill: rgba(0, 0, 0, 0), + ), + ) + let buttonClipIdx = list.addChild( + buttonIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(28, 134, 664, 32), + fill: rgba(0, 0, 0, 0), + corners: [14'u16, 14'u16, 14'u16, 14'u16], + flags: {NfClipContent}, + ), + ) + let chromeIdx = list.addChild( + buttonClipIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(30.5, 136.5, 659, 27), + fill: rgba(190, 194, 196, 255), + corners: [12'u16, 12'u16, 12'u16, 12'u16], + flags: {NfRectMaskContent}, + ), + ) + discard list.addChild( + chromeIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(31.5, 137.5, 657, 1), + fill: rgba(250, 250, 250, 255), + ), + ) + discard list.addChild( + chromeIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(30.5, 136.5, 659, 16.74), + fill: rgba(232, 235, 236, 255), + corners: [12'u16, 12'u16, 12'u16, 12'u16], + ), + ) + discard list.addChild( + chromeIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(30.5, 146.22, 659, 17.28), + fill: rgba(190, 194, 196, 255), + corners: [12'u16, 12'u16, 12'u16, 12'u16], + ), + ) + discard list.addChild( + chromeIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(31.5, 150, 657, 1), + fill: rgba(160, 165, 168, 255), + ), + ) + discard list.addChild( + stackIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(26, 132, 668, 36), + fill: rgba(0, 0, 0, 0), + corners: [16'u16, 16'u16, 16'u16, 16'u16], + stroke: RenderStroke(weight: 1.0'f32, fill: fill(rgba(190, 198, 205, 255))), + ), + ) + discard list.addChild( + buttonIdx, + Fig( + kind: nkText, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(36, 135, 648, 32), + textLayout: buttonLayout, + ), + ) + discard list.addChild( + buttonIdx, + Fig( + kind: nkText, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(36, 133.4, 648, 32), + textLayout: buttonLayout, + ), + ) + discard list.addChild( + buttonIdx, + Fig( + kind: nkText, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(36, 134, 648, 32), + textLayout: buttonLayout, + ), + ) + + result = Renders(layers: initOrderedTable[ZLevel, RenderList]()) + result.layers[0.ZLevel] = list + + let outDir = ensureTestOutputDir() + let initialPath = outDir / "render_hello_text_sequence_initial_vulkan.png" + let updatedPath = outDir / "render_hello_text_sequence_updated_vulkan.png" + if fileExists(initialPath): + removeFile(initialPath) + if fileExists(updatedPath): + removeFile(updatedPath) + + block renderSequence: + var images: tuple[initial, updated: Image] + try: + images = renderAndScreenshotSequence( + proc(w, h: float32): Renders = + makeRenderTree(w, h, initialStatusLayout, initialButtonLayout), + proc(w, h: float32): Renders = + let + updatedStatusLayout = + layoutFor(updatedStatusText, 644, 24, rgba(23, 69, 46, 255), Left) + updatedButtonLayout = + layoutFor(updatedButtonText, 648, 32, rgba(40, 40, 40, 255), Center) + makeRenderTree(w, h, updatedStatusLayout, updatedButtonLayout), + initialPath = initialPath, + updatedPath = updatedPath, + windowW = 720, + windowH = 360, + atlasSize = 1024, + title = "figdraw test: vulkan text atlas update", + ) + except ValueError: + skip() + break renderSequence + + check fileExists(initialPath) + check getFileSize(initialPath) > 0 + check fileExists(updatedPath) + check getFileSize(updatedPath) > 0 + + check maxInkColumnGap(images.initial, 220, 24, 280, 36) < 36 + check maxInkColumnGap(images.initial, 24, 64, 440, 28) < 36 + check maxInkColumnGap(images.initial, 34, 94, 300, 36) < 36 + + check maxInkColumnGap(images.updated, 220, 24, 280, 36) < 36 + check maxInkColumnGap(images.updated, 24, 64, 440, 28) < 36 + check maxInkColumnGap(images.updated, 34, 94, 300, 36) < 36 + else: + skip() + + test "Vulkan clears stale rect-mask attributes before unmasked batches": + when UseVulkanBackend: + setFigUiScale(1.0'f32) + + proc makeMaskedFrame(w, h: float32): Renders = + var list = RenderList() + let rootIdx = list.addRoot( + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(0, 0, w, h), + fill: rgba(255, 255, 255, 255), + ) + ) + let maskIdx = list.addChild( + rootIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(520, 180, 32, 32), + fill: rgba(0, 0, 0, 0), + corners: [8'u16, 8'u16, 8'u16, 8'u16], + flags: {NfRectMaskContent}, + ), + ) + for i in 0 ..< 20: + discard list.addChild( + maskIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(20 + i.float32 * 18, 28, 12, 84), + fill: rgba(20, 110, 220, 255), + ), + ) + + result = Renders(layers: initOrderedTable[ZLevel, RenderList]()) + result.layers[0.ZLevel] = list + + proc makeUnmaskedFrame(w, h: float32): Renders = + var list = RenderList() + let rootIdx = list.addRoot( + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(0, 0, w, h), + fill: rgba(255, 255, 255, 255), + ) + ) + for i in 0 ..< 16: + discard list.addChild( + rootIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: 0.ZLevel, + screenBox: rect(20 + i.float32 * 18, 28, 12, 84), + fill: rgba(20, 110, 220, 255), + ), + ) + + result = Renders(layers: initOrderedTable[ZLevel, RenderList]()) + result.layers[0.ZLevel] = list + + let outDir = ensureTestOutputDir() + let initialPath = outDir / "render_stale_rect_mask_initial_vulkan.png" + let updatedPath = outDir / "render_stale_rect_mask_unmasked_vulkan.png" + if fileExists(initialPath): + removeFile(initialPath) + if fileExists(updatedPath): + removeFile(updatedPath) + + block renderSequence: + var images: tuple[initial, updated: Image] + try: + images = renderAndScreenshotSequence( + makeMaskedFrame, + makeUnmaskedFrame, + initialPath = initialPath, + updatedPath = updatedPath, + windowW = 420, + windowH = 140, + atlasSize = 1024, + title = "figdraw test: vulkan stale rect mask", + ) + except ValueError: + skip() + break renderSequence + + check fileExists(updatedPath) + check getFileSize(updatedPath) > 0 + check countBlueInk(images.updated, 16, 24, 310, 94) > 10000 + else: + skip() + test "NfInvertY under mirrored parent stays upright and vertically aligned": setFigUiScale(1.0'f32) setFigDataDir(getCurrentDir() / "data")