diff --git a/README.md b/README.md index a6125fa..0e9826c 100644 --- a/README.md +++ b/README.md @@ -303,14 +303,20 @@ Add the `harfbuzz` feature when using FigDraw from another project: requires "https://github.com/elcritch/figdraw[windy,harfbuzz]" ``` -Note: Windy is the default example. Harfbuzz support works with Siwin or other WMs. +Note: Windy is the default example. Harfbuzz support works with Siwin, Surfer or other windowing libraries. Then compile with the Harfbuzzy text backend: +### windy example ```sh nim r -d:figdrawTextBackend=harfbuzzy examples/windy_text_shaping_demo.nim ``` +### surfer example +```sh +nim r -d:figdrawTextBackend=harfbuzzy examples/surfer_text_shaping_demo.nim +``` + For an example or app that should always use Harfbuzzy, put the backend switch in a sibling `.nims` file: diff --git a/config.nims b/config.nims index ea445b8..094c236 100644 --- a/config.nims +++ b/config.nims @@ -1,9 +1,13 @@ ---nimcache:".nimcache/" ---passc:"-Wno-incompatible-function-pointer-types" ---define:useMalloc ---define:release +--nimcache: + ".nimcache/" +--passc: + "-Wno-incompatible-function-pointer-types" +--define: + useMalloc +--define: + release -import std/strutils +import std/[strformat, strutils] import std/os when defined(macosx) and defined(figdraw.moltenvkBrew): @@ -12,6 +16,38 @@ when defined(macosx) and defined(figdraw.moltenvkBrew): quit "figdraw.moltenvkBrew requires Homebrew molten-vk" switch("passL", "-Wl,-rpath," & moltenVkPrefix & "/lib") +when defined(linux): + # Optional deps + when defined(figdraw.vulkan): + switch("passC", gorgeEx("pkg-config --cflags vulkan").output.strip()) + switch("passL", gorgeEx("pkg-config --libs vulkan").output.strip()) + + when defined(figdraw.harfbuzz): + switch("passC", gorgeEx("pkg-config --cflags harfbuzz fribidi").output.strip()) + switch("passL", gorgeEx("pkg-config --libs harfbuzz fribidi").output.strip()) + + # Deps that figdraw absolutely needs to even compile + # source: painful amounts of trial and error + const + XorgDependencies = "x11-xcb xcb xcursor xkbcommon xrender" + WaylandDependencies = "wayland-client wayland-egl" + AuxDependencies = "gl glesv2 egl" + + switch( + "passC", + gorgeEx( + &"pkg-config --cflags {XorgDependencies} {WaylandDependencies} {AuxDependencies}" + ).output + .strip(), + ) + switch( + "passL", + gorgeEx( + &"pkg-config --libs {XorgDependencies} {WaylandDependencies} {AuxDependencies}" + ).output + .strip(), + ) + proc nimExec(subcmd, file: string, extraFlags = "", platform = "") = let nimFlags = getEnv("NIMFLAGS").strip() var cmd: string @@ -42,7 +78,8 @@ task test, "run unit test": getEnv("FIGDRAW_TEST_SDL2").strip().toLowerAscii() in ["1", "true", "yes", "on"] for platformArg in platforms(): - if platformArg != "": echo "Running platform args: ", platformArg + if platformArg != "": + echo "Running platform args: ", platformArg for file in listFiles("tests"): if file.startsWith("tests/t") and file.endsWith(".nim"): nimExec("r", file, platform = platformArg) @@ -70,8 +107,7 @@ task test_emscripten, "build emscripten examples": task bindings, "Generate bindings": proc compile(libName: string, flags = "") = - exec "nim c -f " & flags & - " --path:src -d:release " & + exec "nim c -f " & flags & " --path:src -d:release " & " -d:gennyNim -d:gennyC -d:gennyPython " & " --app:lib --gc:arc --tlsEmulation:off --out:" & libName & " --outdir:src/figdraw/bindings/generated src/figdraw/bindings/bindings.nim" diff --git a/examples/surfer_text.nim b/examples/surfer_text.nim new file mode 100644 index 0000000..4fc16a5 --- /dev/null +++ b/examples/surfer_text.nim @@ -0,0 +1,460 @@ +when defined(emscripten): + {.error: "Surfer does not support WASM.".} + +import std/[os, options, times, unicode, strutils] +import chroma +import pkg/pixie/fonts + +import figdraw/windowing/surfershim +import figdraw/commons +import figdraw/fignodes +import figdraw/figrender + +import pkg/surfer/app + +when not UseVulkanBackend: + {.error: "Surfer onlysupports the Vulkan backend for now.".} + +const FontName {.strdefine: "figdraw.defaultfont".}: string = "Ubuntu.ttf" +const RunOnce {.booldefine: "figdraw.runOnce".}: bool = false +const MonoFontSize = 12.0'f32 + +type TextSubpixelMode = enum + tsmOff + tsmUvShift + tsmGlyphVariants + +proc subpixelModeDescription(mode: TextSubpixelMode): string = + case mode + of tsmOff: "off" + of tsmUvShift: "uv shift" + of tsmGlyphVariants: "glyph variants" + +proc textStatusLine(mode: TextSubpixelMode, lcdFilteringEnabled: bool): string = + let lcdMode = if lcdFilteringEnabled: "on" else: "off" + "LCD: " & lcdMode & ", subpixel: " & subpixelModeDescription(mode) + +proc applyTextSampling[BackendState]( + renderer: FigRenderer[BackendState], + mode: TextSubpixelMode, + lcdFilteringEnabled: bool, +) = + renderer.setTextLcdFiltering(lcdFilteringEnabled) + case mode + of tsmOff: + renderer.setTextSubpixelPositioning(false) + renderer.setTextSubpixelGlyphVariants(false) + of tsmUvShift: + renderer.setTextSubpixelPositioning(true) + renderer.setTextSubpixelGlyphVariants(false) + of tsmGlyphVariants: + renderer.setTextSubpixelPositioning(true) + renderer.setTextSubpixelGlyphVariants(true) + +proc detectTextSubpixelMode[BackendState]( + renderer: FigRenderer[BackendState] +): TextSubpixelMode = + let + subpixel = renderer.textSubpixelPositioning() + glyphVariants = renderer.textSubpixelGlyphVariants() + if subpixel: + if glyphVariants: + return tsmGlyphVariants + return tsmUvShift + tsmOff + +proc detectLcdFilteringEnabled[BackendState]( + renderer: FigRenderer[BackendState] +): bool = + renderer.textLcdFiltering() + +proc findPhraseRange(text, phrase: string): Slice[int16] = + let startByte = text.find(phrase) + if startByte < 0: + return 0'i16 .. -1'i16 + let endByte = startByte + phrase.len + var startRune = 0 + var endRune = -1 + var runeIdx = 0 + var byteIdx = 0 + while byteIdx < text.len: + if byteIdx == startByte: + startRune = runeIdx + if byteIdx < endByte: + endRune = runeIdx + else: + break + byteIdx += runeLenAt(text, byteIdx) + runeIdx.inc + result = startRune.int16 .. endRune.int16 + +proc buildBodyTextLayout*( + uiFont: FigFont, textRect: Rect, modeLine: string +): tuple[layout: GlyphArrangement, highlightRange: Slice[int16]] = + let text = + "Mode: " & modeLine & " (G/U/V: subpixel, L: LCD)\n\n" & "FigDraw text demo\n\n" & + "This example uses `src/figdraw/common/fontutils.nim` typesetting + glyph caching,\n" & + "then renders glyph atlas sprites via the active renderer backend.\n" + let highlightRange = findPhraseRange(text, "renders glyph atlas sprites") + let bodyFill = rgba(20, 20, 20, 255) + let accentFill = linear(rgba(255, 120, 66, 255), rgba(72, 197, 255, 255), axis = fgaY) + let accentToken = "renderer backend" + let accentIdx = text.find(accentToken) + var spans: seq[(FontStyle, string)] + if accentIdx >= 0: + let prefix = text[0 ..< accentIdx] + let suffix = text[accentIdx + accentToken.len .. ^1] + if prefix.len > 0: + spans.add(span(uiFont, bodyFill, prefix)) + spans.add(span(uiFont, accentFill, accentToken)) + if suffix.len > 0: + spans.add(span(uiFont, bodyFill, suffix)) + else: + spans = @[span(uiFont, bodyFill, text)] + result.layout = typeset( + rect(0, 0, textRect.w, textRect.h), + spans, + hAlign = Left, + vAlign = Top, + minContent = false, + wrap = true, + ) + result.highlightRange = highlightRange + +proc buildMonoWordLayouts*( + monoFont: FigFont, monoText: string, pad: float32, colors: openArray[Fill] +): seq[GlyphArrangement] = + let (_, monoPx) = monoFont.convertFont() + let monoLineHeight = + (if monoPx.lineHeight >= 0: monoPx.lineHeight + else: monoPx.defaultLineHeight()) + let monoAdvance = (monoPx.typeface.getAdvance(Rune('M')) * monoPx.scale) + let colorsSeq = @colors + + var x = pad + var y = pad + var wordIdx = 0 + var glyphs: seq[(Rune, Vec2)] + var layouts: seq[GlyphArrangement] + proc flushWord( + glyphs: var seq[(Rune, Vec2)], + layouts: var seq[GlyphArrangement], + monoFont: FigFont, + colors: seq[Fill], + wordIdx: var int, + ) = + if glyphs.len == 0: + return + let wordColor = + if colors.len > 0: + colors[wordIdx mod colors.len] + else: + fill(rgba(0, 0, 0, 255)) + layouts.add(placeGlyphs(fs(monoFont, wordColor), glyphs, origin = GlyphTopLeft)) + wordIdx.inc + glyphs.setLen(0) + + for rune in monoText.runes: + if rune == Rune(10): + flushWord(glyphs, layouts, monoFont, colorsSeq, wordIdx) + x = pad + y += monoLineHeight + continue + if rune == Rune(32): + flushWord(glyphs, layouts, monoFont, colorsSeq, wordIdx) + x += monoAdvance + continue + glyphs.add((rune, vec2(x, y))) + x += monoAdvance + + flushWord(glyphs, layouts, monoFont, colorsSeq, wordIdx) + result = layouts + +proc makeRenderTree*( + w, h: float32, uiFont, monoFont: FigFont, modeLine: string +): Renders = + result = Renders(layers: initOrderedTable[ZLevel, RenderList]()) + let z = 0.ZLevel + + let rootIdx = result.addRoot( + z, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: z, + screenBox: rect(0, 0, w, h), + fill: rgba(245, 245, 245, 255), + ), + ) + + let pad = 40'f32 + let cardRect = rect(pad, pad, w - pad * 2, h - pad * 2) + let cardIdx = result.addChild( + z, + rootIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: z, + screenBox: cardRect, + fill: rgba(255, 255, 255, 255), + stroke: RenderStroke(weight: 2.0, fill: rgba(0, 0, 0, 25).color), + corners: [16.0'f32, 16.0, 16.0, 16.0], + shadows: [ + RenderShadow( + style: DropShadow, + blur: 24, + spread: 0, + x: 0, + y: 8, + fill: rgba(0, 0, 0, 30).color, + ), + RenderShadow(), + RenderShadow(), + RenderShadow(), + ], + ), + ) + + let textPad = 28'f32 + let innerRect = rect( + cardRect.x + textPad, + cardRect.y + textPad, + cardRect.w - textPad * 2, + cardRect.h - textPad * 2, + ) + + let monoText = "Manual glyphs: Hack Nerd Font\n$ printf(\"hello\")" + let (_, monoPx) = monoFont.convertFont() + let monoLineHeight = + (if monoPx.lineHeight >= 0: monoPx.lineHeight + else: monoPx.defaultLineHeight()) + let monoPad = max(8.0'f32, monoFont.size * 0.6'f32) + var monoLines = 1 + for rune in monoText.runes: + if rune == Rune(10): + monoLines.inc + let monoHeight = monoLines.float32 * monoLineHeight + monoPad * 2 + let invertedBoxHeight = uiFont.size * 5.0'f32 + let sectionGap = 60.0'f32 + + proc mirroredInputRect(finalRect: Rect): Rect = + rect(finalRect.x, h - finalRect.y - finalRect.h, finalRect.w, finalRect.h) + + let textRect = rect( + innerRect.x, + innerRect.y, + innerRect.w, + innerRect.h - monoHeight - invertedBoxHeight * 2.0'f32 - sectionGap * 3.0'f32, + ) + let invertedTextRect = rect( + innerRect.x, textRect.y + textRect.h + sectionGap, innerRect.w, invertedBoxHeight + ) + let mirroredInvertedTextRect = rect( + innerRect.x, + invertedTextRect.y + invertedTextRect.h + sectionGap, + innerRect.w, + invertedBoxHeight, + ) + let monoRect = rect( + innerRect.x, + mirroredInvertedTextRect.y + mirroredInvertedTextRect.h + sectionGap, + innerRect.w, + monoHeight, + ) + + let (layout, highlightRange) = buildBodyTextLayout(uiFont, textRect, modeLine) + let invertedText = "Inverted text line (NfInvertY)\nwith selection" + let invertedSelectionRange = findPhraseRange(invertedText, "NfInvertY") + let invertedLayout = typeset( + rect(0, 0, invertedTextRect.w, invertedTextRect.h), + [span(uiFont, rgba(30, 30, 30, 255), invertedText)], + hAlign = Left, + vAlign = Top, + minContent = false, + wrap = false, + ) + + discard result.addChild( + z, + cardIdx, + Fig( + kind: nkText, + childCount: 0, + zlevel: z, + screenBox: textRect, + selectionRange: highlightRange, + fill: linear(rgba(255, 242, 170, 255), rgba(255, 192, 128, 255), axis = fgaY), + flags: + if highlightRange.a <= highlightRange.b: + {NfSelectText} + else: + {}, + textLayout: layout, + ), + ) + + discard result.addChild( + z, + cardIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: z, + screenBox: monoRect, + fill: rgba(27, 29, 36, 255), + stroke: RenderStroke(weight: 1.5, fill: rgba(0, 0, 0, 50).color), + corners: [10.0'f32, 10.0, 10.0, 10.0], + ), + ) + + discard result.addChild( + z, + cardIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: z, + screenBox: invertedTextRect, + fill: clearColor, + stroke: RenderStroke(weight: 1.5, fill: rgba(38, 38, 38, 155).color), + corners: [4.0'f32, 4.0, 4.0, 4.0], + ), + ) + + discard result.addChild( + z, + cardIdx, + Fig( + kind: nkText, + childCount: 0, + zlevel: z, + flags: {NfInvertY, NfSelectText}, + screenBox: invertedTextRect, + selectionRange: invertedSelectionRange, + fill: linear(rgba(255, 244, 175, 255), rgba(255, 200, 140, 255), axis = fgaY), + textLayout: invertedLayout, + ), + ) + + discard result.addChild( + z, + cardIdx, + Fig( + kind: nkRectangle, + childCount: 0, + zlevel: z, + screenBox: mirroredInvertedTextRect, + fill: clearColor, + stroke: RenderStroke(weight: 1.5, fill: rgba(42, 96, 168, 170).color), + corners: [4.0'f32, 4.0, 4.0, 4.0], + ), + ) + + let mirroredTransformIdx = result.addChild( + z, + cardIdx, + Fig( + kind: nkTransform, + childCount: 0, + zlevel: z, + transform: TransformStyle( + translation: vec2(0.0'f32, h), + matrix: scale(vec3(1.0'f32, -1.0'f32, 1.0'f32)), + useMatrix: true, + ), + ), + ) + + discard result.addChild( + z, + mirroredTransformIdx, + Fig( + kind: nkText, + childCount: 0, + zlevel: z, + flags: {NfInvertY, NfSelectText}, + screenBox: mirroredInputRect(mirroredInvertedTextRect), + selectionRange: invertedSelectionRange, + fill: linear(rgba(180, 220, 255, 220), rgba(130, 180, 255, 220), axis = fgaY), + textLayout: invertedLayout, + ), + ) + + let monoColors = [ + linear(rgba(236, 238, 245, 255), rgba(182, 214, 255, 255), axis = fgaX), + rgba(255, 210, 160, 255), + linear(rgba(166, 223, 255, 255), rgba(196, 255, 198, 255), axis = fgaDiagTLBR), + rgba(196, 255, 198, 255), + linear(rgba(255, 187, 229, 255), rgba(255, 214, 152, 255), axis = fgaX), + ] + let monoLayouts = buildMonoWordLayouts(monoFont, monoText, monoPad, monoColors) + for monoLayout in monoLayouts: + discard result.addChild( + z, + cardIdx, + Fig( + kind: nkText, + childCount: 0, + zlevel: z, + screenBox: monoRect, + fill: clearColor, + textLayout: monoLayout, + ), + ) + +proc main() {.inline.} = + setFigDataDir(getCurrentDir() / "data") + + let fontName = getEnv("FONT", FontName) + # looks for fonts, fallback to static fonts if not found + registerStaticTypeface("Ubuntu.ttf", "../data/Ubuntu.ttf") + registerStaticTypeface("HackNerdFont-Regular.ttf", "../data/HackNerdFont-Regular.ttf") + + let typefaceId = loadTypeface(fontName, @["Ubuntu.ttf"]) + let uiFont = FigFont(typefaceId: typefaceId, size: 18.0'f32) + let monoTypefaceId = loadTypeface("HackNerdFont-Regular.ttf") + let monoFont = FigFont(typefaceId: monoTypefaceId, size: MonoFontSize) + + let size = ivec2(900, 690) + let renderer = newFigRenderer(atlasSize = 4096, backendState = SurferRenderBackend()) + let app = newApp("Surfer + Text", appId = "io.github.elcritch.figdraw") + app.initialize() + app.createWindow(size, Renderer.Vulkan) + + var + textSubpixelMode = tsmOff + lcdFilteringEnabled = true + + proc redraw() = + renderer.beginFrame() + + let modeLine = textStatusLine(textSubpixelMode, lcdFilteringEnabled) + var renders = makeRenderTree( + app.windowSize.x.float32, app.windowSize.y.float32, uiFont, monoFont, modeLine + ) + renderer.renderFrame(renders, vec2(app.windowSize)) + renderer.endFrame() + + while not app.closureRequested: + let eventOpt = app.flushQueue() + if eventOpt.isNone: + continue + + let event = get(eventOpt) + case event.kind + of EventKind.WindowResized: + if renderer.backendState.app == nil: + surfershim.setupBackend(renderer, app) + redraw() # we need to push through an initial frame to get the chain going + app.queueRedraw() + of EventKind.RedrawRequested: + redraw() + of EventKind.PreferredRenderScale: + # neat wayland fractional scaling support :^) + setFigUiScale(float32(event.preferredScale) / 120'f32) + else: + discard + +when isMainModule: + main() diff --git a/examples/surfer_text_shaping_demo.nim b/examples/surfer_text_shaping_demo.nim new file mode 100644 index 0000000..c258fe9 --- /dev/null +++ b/examples/surfer_text_shaping_demo.nim @@ -0,0 +1,650 @@ +import std/[os, strutils, times, unicode] + +import chroma +import chronicles + +import pkg/surfer/app + +import figdraw/commons +import figdraw/common/fonttypes +import figdraw/fignodes +import figdraw/figrender as glrenderer +import figdraw/windowing/surfershim + +const + RunOnce {.booldefine: "figdraw.runOnce".}: bool = false + ExampleDir = currentSourcePath().parentDir + RepoDir = ExampleDir.parentDir + UbuntuFontFile = RepoDir / "data" / "Ubuntu.ttf" + ArabicFontFile = ExampleDir / "fonts" / "NotoNaskhArabic-wght.ttf" + HebrewFontFile = ExampleDir / "fonts" / "NotoSansHebrew-wdth-wght.ttf" + DevanagariFontFile = ExampleDir / "fonts" / "NotoSansDevanagari-wdth-wght.ttf" + CodeFontFile = ExampleDir / "fonts" / "FiraCode-wght.ttf" + +const + ArabicBody = + "السلام عليكم ورحمة الله وبركاته\n" & + "النص العربي يحتاج إلى تشكيل واتجاه صحيح ولف أسطر هادئ." + HebrewBody = + "שָׁלוֹם עוֹלָם וּבְרוּכִים הַבָּאִים\n" & + "טֶקְסְט עִבְרִי צָרִיךְ נִקּוּד, כִּוּוּן נָכוֹן וּשְׁבִירַת שׁוּרוֹת יַצִּיבָה." + DevanagariBody = + "नमस्ते दुनिया और आपका स्वागत है\n" & + "देवनागरी पाठ को मात्रा, संयुक्ताक्षर और स्थिर पंक्ति-विन्यास चाहिए." + +type DemoFonts = object + title: FigFont + body: FigFont + metric: FigFont + codePlain: FigFont + code: FigFont + arabic: FigFont + hebrew: FigFont + devanagari: FigFont + +type LigatureSample = object + label: string + unfused: string + fused: string + +proc requireFile(path: string) = + if not fileExists(path): + raise newException(IOError, "Missing demo asset: " & path) + +proc initDemoFonts(): DemoFonts = + for path in [ + UbuntuFontFile, ArabicFontFile, HebrewFontFile, DevanagariFontFile, CodeFontFile + ]: + requireFile(path) + + let + ubuntu = loadTypeface(UbuntuFontFile) + arabic = loadTypeface(ArabicFontFile) + hebrew = loadTypeface(HebrewFontFile) + devanagari = loadTypeface(DevanagariFontFile) + code = loadTypeface(CodeFontFile) + commonFeatures = @[fontFeature("kern"), fontFeature("liga")] + codePlainFeatures = + @[fontFeature("kern"), fontFeature("liga", 0), fontFeature("calt", 0)] + codeFeatures = @[fontFeature("kern"), fontFeature("liga"), fontFeature("calt")] + fallbackTypefaces = @[arabic, hebrew, devanagari] + + result = DemoFonts( + title: FigFont( + typefaceId: ubuntu, + size: 22.0'f32, + fallbackTypefaceIds: fallbackTypefaces, + features: commonFeatures, + ), + body: FigFont( + typefaceId: ubuntu, + size: 18.0'f32, + fallbackTypefaceIds: fallbackTypefaces, + features: commonFeatures, + ), + metric: FigFont( + typefaceId: ubuntu, + size: 13.0'f32, + fallbackTypefaceIds: fallbackTypefaces, + features: commonFeatures, + ), + codePlain: FigFont( + typefaceId: code, + size: 24.0'f32, + fallbackTypefaceIds: fallbackTypefaces, + features: codePlainFeatures, + variations: @[fontVariation("wght", 520.0'f32)], + ), + code: FigFont( + typefaceId: code, + size: 24.0'f32, + fallbackTypefaceIds: fallbackTypefaces, + features: codeFeatures, + variations: @[fontVariation("wght", 520.0'f32)], + ), + arabic: FigFont( + typefaceId: arabic, + size: 32.0'f32, + fallbackTypefaceIds: @[hebrew, devanagari, ubuntu], + features: commonFeatures, + variations: @[fontVariation("wght", 560.0'f32)], + ), + hebrew: FigFont( + typefaceId: hebrew, + size: 32.0'f32, + fallbackTypefaceIds: @[arabic, devanagari, ubuntu], + features: commonFeatures, + variations: @[fontVariation("wght", 560.0'f32), fontVariation("wdth", 96.0'f32)], + ), + devanagari: FigFont( + typefaceId: devanagari, + size: 32.0'f32, + fallbackTypefaceIds: @[arabic, hebrew, ubuntu], + features: commonFeatures, + variations: @[fontVariation("wght", 560.0'f32), fontVariation("wdth", 100.0'f32)], + ), + ) + +proc addRect( + renders: var Renders, + parent: FigIdx, + box: Rect, + fill: Fill, + corners = 0.0'f32, + zlevel = 0.ZLevel, + stroke = RenderStroke(), + shadows: array[ShadowCount, RenderShadow] = + [RenderShadow(), RenderShadow(), RenderShadow(), RenderShadow()], +): FigIdx {.discardable.} = + renders.addChild( + zlevel, + parent, + Fig( + kind: nkRectangle, + zlevel: zlevel, + screenBox: box, + fill: fill, + corners: [corners, corners, corners, corners], + stroke: stroke, + shadows: shadows, + ), + ) + +proc addTextLayout( + renders: var Renders, + parent: FigIdx, + box: Rect, + layout: GlyphArrangement, + zlevel = 0.ZLevel, +) = + discard renders.addChild( + zlevel, + parent, + Fig( + kind: nkText, zlevel: zlevel, screenBox: box, fill: clearColor, textLayout: layout + ), + ) + +proc textLayout( + box: Rect, + spans: openArray[(FontStyle, string)], + hAlign = Left, + vAlign = Top, + wrap = true, +): GlyphArrangement = + typeset( + rect(0, 0, box.w, box.h), + spans, + hAlign = hAlign, + vAlign = vAlign, + minContent = false, + wrap = wrap, + ) + +proc runeRange(text, phrase: string): Slice[int] = + let startByte = text.find(phrase) + if startByte < 0: + return 0 .. -1 + + let endByte = startByte + phrase.len + var + runeIndex = 0 + byteIndex = 0 + startRune = -1 + endRune = -1 + + while byteIndex < text.len: + if byteIndex == startByte: + startRune = runeIndex + if byteIndex < endByte: + endRune = runeIndex + else: + break + byteIndex += runeLenAt(text, byteIndex) + inc runeIndex + + if startRune < 0 or endRune < startRune: + return 0 .. -1 + startRune .. endRune + +proc addSourceHighlight( + renders: var Renders, + parent: FigIdx, + origin: Vec2, + layout: GlyphArrangement, + sourceRange: Slice[int], + fill: Fill, +) = + if sourceRange.a > sourceRange.b: + return + + for selection in layout.selectionRectsFor(sourceRange): + if selection.h <= 0: + continue + let box = rect( + origin.x + selection.x, + origin.y + selection.y, + max(selection.w, 2.0'f32), + selection.h, + ) + discard renders.addRect(parent, box, fill, corners = 4.0'f32) + +proc addCaretMarkers( + renders: var Renders, + parent: FigIdx, + origin: Vec2, + layout: GlyphArrangement, + sourceRune: int, + fill: Fill, +) = + for caret in layout.caretPositionsFor(sourceRune): + let box = + rect(origin.x + caret.pos.x - 1.0'f32, origin.y + caret.pos.y, 2, caret.rect.h) + discard renders.addRect(parent, box, fill, corners = 1.0'f32) + +proc layoutStats(name: string, layout: GlyphArrangement): string = + name & " glyphs " & $layout.arrangedGlyphs.len & " source " & $layout.sourceRunes.len & + " lines " & $layout.lines.len + +proc addText( + renders: var Renders, + parent: FigIdx, + box: Rect, + font: FigFont, + text: string, + fill: Fill, + hAlign = Left, + vAlign = Top, + wrap = false, +) = + let layout = textLayout( + box, [(fs(font, fill), text)], hAlign = hAlign, vAlign = vAlign, wrap = wrap + ) + renders.addTextLayout(parent, box, layout) + +proc addCenteredText( + renders: var Renders, + parent: FigIdx, + box: Rect, + font: FigFont, + text: string, + fill: Fill, +) = + let layout = textLayout( + box, [(fs(font, fill), text)], hAlign = Center, vAlign = Middle, wrap = false + ) + renders.addTextLayout(parent, box, layout) + +proc addSampleCard( + renders: var Renders, + root: FigIdx, + box: Rect, + title: string, + body: string, + highlightPhrase: string, + font: FigFont, + labelFont: FigFont, + metricFont: FigFont, + accent: Fill, + hAlign: FontHorizontal, + ligatures: seq[LigatureSample] = @[], +) = + let card = renders.addRect( + root, + box, + rgba(255, 255, 255, 255), + corners = 8.0'f32, + stroke = RenderStroke(weight: 1.0'f32, fill: rgba(0, 0, 0, 32).color), + shadows = [ + RenderShadow( + style: DropShadow, + blur: 20, + spread: 0, + x: 0, + y: 8, + fill: rgba(0, 0, 0, 24).color, + ), + RenderShadow(), + RenderShadow(), + RenderShadow(), + ], + ) + + let titleBox = rect(box.x + 22, box.y + 18, box.w - 44, 30) + renders.addText(card, titleBox, labelFont, title, rgba(40, 45, 50, 255)) + + let hasLigatures = ligatures.len > 0 + let + metricBox = rect(box.x + 22, box.y + box.h - 43, box.w - 44, 30) + ligatureH = 36.0'f32 + 38.0'f32 * ligatures.len.float32 + ligatureBox = + if hasLigatures: + rect(box.x + 22, metricBox.y - ligatureH - 14.0'f32, box.w - 44, ligatureH) + else: + rect(0, 0, 0, 0) + textBottom = + if hasLigatures: + ligatureBox.y - 12 + else: + metricBox.y - 12 + textBox = + rect(box.x + 22, box.y + 62, box.w - 44, max(24.0'f32, textBottom - box.y - 62)) + let layout = textLayout( + textBox, [(fs(font, rgba(18, 20, 24, 255)), body)], hAlign = hAlign, wrap = true + ) + + renders.addSourceHighlight( + card, + textBox.xy, + layout, + body.runeRange(highlightPhrase), + linear(rgba(80, 190, 255, 70), rgba(30, 100, 210, 48), axis = fgaY), + ) + renders.addCaretMarkers( + card, textBox.xy, layout, body.runeRange(highlightPhrase).a, rgba(33, 92, 185, 210) + ) + renders.addTextLayout(card, textBox, layout) + + if hasLigatures: + renders.addRect( + card, + ligatureBox, + linear(rgba(246, 248, 249, 255), rgba(231, 236, 239, 255), axis = fgaY), + corners = 5.0'f32, + stroke = RenderStroke(weight: 1.0'f32, fill: rgba(0, 0, 0, 22).color), + ) + let + labelW = min(86.0'f32, ligatureBox.w * 0.28'f32) + sampleW = max(44.0'f32, (ligatureBox.w - labelW - 32.0'f32) / 2.0'f32) + labelHeaderBox = rect(ligatureBox.x + 10, ligatureBox.y + 8, labelW, 16) + unfusedLabelBox = + rect(ligatureBox.x + labelW + 12, ligatureBox.y + 8, sampleW, 16) + fusedLabelBox = + rect(unfusedLabelBox.x + sampleW + 12, ligatureBox.y + 8, sampleW, 16) + sampleFont = FigFont( + typefaceId: font.typefaceId, + size: max(22.0'f32, min(font.size * 0.82'f32, 30.0'f32)), + fallbackTypefaceIds: font.fallbackTypefaceIds, + features: font.features, + variations: font.variations, + ) + renders.addText(card, labelHeaderBox, metricFont, "form", rgba(98, 106, 114, 225)) + renders.addText( + card, unfusedLabelBox, metricFont, "unfused", rgba(98, 106, 114, 225) + ) + renders.addText(card, fusedLabelBox, metricFont, "fused", rgba(98, 106, 114, 225)) + for i, ligature in ligatures: + let + rowY = ligatureBox.y + 27.0'f32 + 38.0'f32 * i.float32 + labelBox = rect(labelHeaderBox.x, rowY, labelW, 38) + unfusedBox = rect(unfusedLabelBox.x, rowY, sampleW, 38) + fusedBox = rect(fusedLabelBox.x, rowY, sampleW, 38) + renders.addText( + card, + labelBox, + metricFont, + ligature.label, + rgba(78, 86, 94, 235), + vAlign = Middle, + ) + renders.addCenteredText( + card, unfusedBox, sampleFont, ligature.unfused, rgba(24, 28, 32, 255) + ) + renders.addCenteredText( + card, fusedBox, sampleFont, ligature.fused, rgba(24, 28, 32, 255) + ) + + renders.addRect(card, metricBox, accent, corners = 5.0'f32) + renders.addText( + card, + metricBox, + metricFont, + layoutStats(title, layout), + rgba(255, 255, 255, 235), + hAlign = Center, + vAlign = Middle, + ) + +proc makeRenderTree*(w, h: float32, fonts: DemoFonts): Renders = + result = Renders() + let root = result.addRoot( + 0.ZLevel, + Fig( + kind: nkRectangle, + zlevel: 0.ZLevel, + screenBox: rect(0, 0, w, h), + fill: linear(rgba(236, 240, 241, 255), rgba(215, 222, 226, 255), axis = fgaY), + ), + ) + + let + pad = 28.0'f32 + titleHeight = 66.0'f32 + gap = 18.0'f32 + usableW = max(360.0'f32, w - pad * 2) + columnCount = + if usableW >= 1460.0'f32: + 4 + elif usableW >= 1120.0'f32: + 3 + elif usableW >= 760.0'f32: + 2 + else: + 1 + scriptCount = 3 + scriptRows = (scriptCount + columnCount - 1) div columnCount + cardW = (usableW - gap * (columnCount.float32 - 1.0'f32)) / columnCount.float32 + mixedMinH = 200.0'f32 + availableH = max(0.0'f32, h - pad * 2 - titleHeight - mixedMinH - gap) + topCardH = + max(190.0'f32, (availableH - gap * scriptRows.float32) / scriptRows.float32) + lowerY = pad + titleHeight + (topCardH + gap) * scriptRows.float32 + lowerH = max(0.0'f32, h - lowerY - pad) + + proc cardRect(index: int): Rect = + let + col = index mod columnCount + row = index div columnCount + rect( + pad + (cardW + gap) * col.float32, + pad + titleHeight + (topCardH + gap) * row.float32, + cardW, + topCardH, + ) + + let titleBox = rect(pad, pad, usableW, 34) + result.addText( + root, + titleBox, + fonts.title, + "FigDraw Text Shaping", + linear(rgba(30, 42, 58, 255), rgba(45, 92, 145, 255), axis = fgaX), + ) + + let backendBox = rect(pad, pad + 34, usableW, 24) + result.addText( + root, + backendBox, + fonts.metric, + "backend: " & figdrawTextBackend, + rgba(74, 84, 94, 255), + ) + + let arabicCard = cardRect(0) + result.addSampleCard( + root, + arabicCard, + "Arabic", + ArabicBody, + "العربي", + fonts.arabic, + fonts.body, + fonts.metric, + linear(rgba(21, 135, 115, 235), rgba(25, 92, 145, 235), axis = fgaX), + Right, + @[ + LigatureSample(label: "la", unfused: "ل + ا", fused: "لا"), + LigatureSample(label: "lm", unfused: "ل + م", fused: "لم"), + ], + ) + + let hebrewCard = cardRect(1) + result.addSampleCard( + root, + hebrewCard, + "Hebrew", + HebrewBody, + "עִבְרִי", + fonts.hebrew, + fonts.body, + fonts.metric, + linear(rgba(114, 68, 160, 235), rgba(58, 112, 188, 235), axis = fgaX), + Right, + ) + + let devanagariCard = cardRect(2) + result.addSampleCard( + root, + devanagariCard, + "Devanagari", + DevanagariBody, + "देवनागरी", + fonts.devanagari, + fonts.body, + fonts.metric, + linear(rgba(185, 96, 34, 235), rgba(118, 113, 34, 235), axis = fgaX), + Left, + @[ + LigatureSample(label: "ksha", unfused: "क् + ष", fused: "क्ष"), + LigatureSample(label: "rta", unfused: "र् + ट", fused: "र्ट"), + ], + ) + + let mixedCard = rect(pad, lowerY, usableW, lowerH) + let mixed = result.addRect( + root, + mixedCard, + rgba(252, 253, 253, 255), + corners = 8.0'f32, + stroke = RenderStroke(weight: 1.0'f32, fill: rgba(0, 0, 0, 32).color), + ) + result.addText( + mixed, + rect(mixedCard.x + 22, mixedCard.y + 18, mixedCard.w - 44, 30), + fonts.body, + "Mixed Fallback Runs", + rgba(40, 45, 50, 255), + ) + + let + mixedContentBox = + rect(mixedCard.x + 22, mixedCard.y + 58, mixedCard.w - 44, mixedCard.h - 80) + fallbackBox = rect(mixedContentBox.x, mixedContentBox.y, mixedContentBox.w, 40) + codeLabelBox = + rect(mixedContentBox.x, fallbackBox.y + fallbackBox.h + 10, mixedContentBox.w, 18) + codeBoxY = codeLabelBox.y + 22 + codeBox = rect( + mixedContentBox.x, + codeBoxY, + mixedContentBox.w, + max(64.0'f32, mixedContentBox.y + mixedContentBox.h - codeBoxY), + ) + let mixedText = + "FigDraw fallback: العربية + עברית + देवनागरी + English\n" & + "glyph ids, source ranges, wrapping, and caret positions" + let mixedLayout = textLayout( + fallbackBox, + [(fs(fonts.body, rgba(20, 22, 24, 255)), mixedText)], + hAlign = Left, + wrap = true, + ) + result.addTextLayout(mixed, fallbackBox, mixedLayout) + result.addText( + mixed, codeLabelBox, fonts.metric, "Coding ligatures", rgba(74, 84, 94, 235) + ) + result.addRect( + mixed, + codeBox, + linear(rgba(245, 247, 248, 255), rgba(231, 236, 239, 255), axis = fgaY), + corners = 5.0'f32, + stroke = RenderStroke(weight: 1.0'f32, fill: rgba(0, 0, 0, 22).color), + ) + let + codeText = "!= === !== <= >= -> => |> &&" + codeGap = 16.0'f32 + codeColW = max(80.0'f32, (codeBox.w - 24.0'f32 - codeGap) / 2.0'f32) + plainLabelBox = rect(codeBox.x + 12, codeBox.y + 8, codeColW, 16) + fusedLabelBox = + rect(plainLabelBox.x + codeColW + codeGap, codeBox.y + 8, codeColW, 16) + plainTextBox = rect(codeBox.x + 12, codeBox.y + 25, codeColW, codeBox.h - 31) + fusedTextBox = rect(fusedLabelBox.x, plainTextBox.y, codeColW, plainTextBox.h) + result.addText(mixed, plainLabelBox, fonts.metric, "unfused", rgba(98, 106, 114, 225)) + result.addText(mixed, fusedLabelBox, fonts.metric, "fused", rgba(98, 106, 114, 225)) + let plainCodeLayout = textLayout( + plainTextBox, + [(fs(fonts.codePlain, rgba(22, 28, 34, 255)), codeText)], + hAlign = Left, + wrap = false, + ) + result.addTextLayout(mixed, plainTextBox, plainCodeLayout) + let fusedCodeLayout = textLayout( + fusedTextBox, + [(fs(fonts.code, rgba(22, 28, 34, 255)), codeText)], + hAlign = Left, + wrap = false, + ) + result.addTextLayout(mixed, fusedTextBox, fusedCodeLayout) + +proc main() {.inline.} = + let + title = "surfer: FigDraw Text Shaping" + size = ivec2(1280, 800) + app = newApp(title, appId = "io.github.elcritch.figdraw") + + app.initialize() + app.createWindow(size, Renderer.Vulkan) + + let fonts = initDemoFonts() + let renderer = + glrenderer.newFigRenderer(atlasSize = 2048, backendState = SurferRenderBackend()) + + info "Text shaping demo startup", + backend = figdrawTextBackend, windowW = app.windowSize.x, windowH = app.windowSize.y + + var + renders = makeRenderTree(0.0'f32, 0.0'f32, fonts) + lastSize = vec2(0.0'f32, 0.0'f32) + frames = 0 + fpsFrames = 0 + fpsStart = epochTime() + + proc redraw() = + renderer.beginFrame() + let sz = vec2(app.windowSize) + if sz != lastSize: + lastSize = sz + renders = makeRenderTree(sz.x, sz.y, fonts) + renderer.renderFrame(renders, sz) + renderer.endFrame() + + while not app.closureRequested: + let eventOpt = app.flushQueue() + if eventOpt.isNone: + continue + + let event = eventOpt.get() + case event.kind + of EventKind.WindowResized: + if renderer.backendState.app == nil: + surfershim.setupBackend(renderer, app) + redraw() # we need to push through an initial frame to get the chain going + app.queueRedraw() + of EventKind.RedrawRequested: + redraw() + of EventKind.PreferredRenderScale: + setFigUiScale(float32(event.preferredScale) / 120'f32) + else: + discard + +when isMainModule: + main() diff --git a/figdraw.nimble b/figdraw.nimble index fe9b3e2..313699d 100644 --- a/figdraw.nimble +++ b/figdraw.nimble @@ -1,8 +1,8 @@ -version = "0.25.0" -author = "Jaremy Creechley" -description = "UI Engine for Nim" -license = "MIT" -srcDir = "src" +version = "0.25.0" +author = "Jaremy Creechley" +description = "UI Engine for Nim" +license = "MIT" +srcDir = "src" # Dependencies @@ -34,7 +34,9 @@ feature "sdl2": feature "windy": requires "windy" feature "surfer": - requires "https://github.com/nim-windowing/surfer" + requires "surfer >= 0.2.5" + requires "xkb#b4d50f4cccad1cd9e39d2f5a5e1fef2710edcc31" + # TODO: Put this in surfer's manifest. feature "siwin": requires "siwin >= 1.0.1" feature "vulkan": diff --git a/shell.nix b/shell.nix new file mode 100755 index 0000000..ba04107 --- /dev/null +++ b/shell.nix @@ -0,0 +1,32 @@ +with import { }; + +mkShell { + nativeBuildInputs = [ + pkg-config + wayland + vulkan-loader + + libX11 + libxcb + libxcursor + libxkbcommon + libxrender + libGL + + harfbuzz + fribidi + ]; + + LD_LIBRARY_PATH = lib.makeLibraryPath [ + wayland.dev + vulkan-loader.dev + libX11.dev + libxcb.dev + libxcursor.dev + libxrender.dev + libxkbcommon.dev + libGL.dev + harfbuzz.dev + fribidi.dev + ]; +} diff --git a/src/figdraw/windowing/surfershim.nim b/src/figdraw/windowing/surfershim.nim new file mode 100644 index 0000000..b599182 --- /dev/null +++ b/src/figdraw/windowing/surfershim.nim @@ -0,0 +1,31 @@ +import pkg/surfer/app +import ../commons +import ../figrender + +when UseVulkanBackend: + import ../vulkan/vulkan_context + +type SurferRenderBackend* = object + # Surfer really doesn't need to juggle around a massive amount of state. :^) + app*: App + +proc setupBackend*(renderer: FigRenderer, app: App) = + if renderer.backendKind() != rbVulkan: + raise newException(Defect, "Surfer shim exclusively supports the Vulkan backend.") + + renderer.backendState.app = app + + let vkCtx = renderer.ctx.VulkanContext + + let surfacePtr = cast[pointer](app.vkSurface) + if surfacePtr.isNil: + raise newException(ValueError, "Surfer failed to provide a valid Vulkan surface.") + + vkCtx.setExternalSurface(surfacePtr, presentTargetWayland, ownedByContext = true) + +proc beginFrame*(renderer: FigRenderer[SurferRenderBackend]) = + discard + +proc endFrame*(renderer: FigRenderer[SurferRenderBackend]) = + if not renderer.backendState.app.isNil: + renderer.backendState.app.queueRedraw()