Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion src/silky/atlas.nim
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ type
advance*: float32
kerning*: Table[string, float32]

GlyphEntries* = ref object
## Runtime reference to the stored variants for one glyph.
entries*: seq[LetterEntry]

GlyphLookup* = object
## Runtime lookup tables for allocation-free Unicode text rendering.
entries*: Table[Rune, GlyphEntries]
kerning*: Table[uint64, float32]

FontAtlas* = ref object
## The font atlas that is used to draw text.
size*: float32
Expand All @@ -45,6 +54,7 @@ type
lineGap*: float32
subpixelSteps*: int
entries*: Table[string, seq[LetterEntry]]
lookup*: GlyphLookup

SilkyAtlas* = ref object
## The pixel atlas that gets converted to JSON.
Expand All @@ -60,6 +70,69 @@ type
atlasImage*: Image
atlas*: SilkyAtlas

FontAtlasObject = typeof(FontAtlas()[])

proc skipHook*(T: typedesc[FontAtlasObject], key: static string): bool =
## Skips runtime-only lookup tables during atlas JSON serialization.
key == "lookup"

proc kerningKey(left, right: Rune): uint64 {.inline.} =
## Packs two Unicode scalar values into one table key.
(uint64(uint32(int32(left))) shl 32) or uint64(uint32(int32(right)))

proc rebuildGlyphLookups*(fontAtlas: FontAtlas) =
## Rebuilds runtime Unicode lookup tables from serialized atlas entries.
fontAtlas.lookup.entries = initTable[Rune, GlyphEntries](
fontAtlas.entries.len
)
fontAtlas.lookup.kerning = initTable[uint64, float32]()

for glyphStr, entries in fontAtlas.entries:
if glyphStr.len == 0:
continue
let left = glyphStr.runeAt(0)
fontAtlas.lookup.entries[left] = GlyphEntries(entries: entries)
if entries.len == 0:
continue
for rightStr, adjustment in entries[0].kerning:
if rightStr.len == 0:
continue
let right = rightStr.runeAt(0)
fontAtlas.lookup.kerning[kerningKey(left, right)] = adjustment

proc rebuildGlyphLookups*(atlas: SilkyAtlas) =
## Rebuilds runtime text lookup tables for every font in an atlas.
for fontAtlas in atlas.fonts.mvalues:
fontAtlas.rebuildGlyphLookups()

proc lookupLetter*(
fontAtlas: FontAtlas,
rune: Rune,
variant: int,
entry: var ptr LetterEntry
): bool {.inline.} =
## Looks up one glyph entry without allocating a string key.
if rune in fontAtlas.lookup.entries:
let glyphEntries = fontAtlas.lookup.entries[rune]
if glyphEntries.entries.len > 0:
entry = unsafeAddr glyphEntries.entries[
min(variant, glyphEntries.entries.len - 1)
]
return true

let fallback = Rune(63)
if fallback in fontAtlas.lookup.entries:
let glyphEntries = fontAtlas.lookup.entries[fallback]
if glyphEntries.entries.len > 0:
entry = unsafeAddr glyphEntries.entries[0]
return true

false

proc lookupKerning*(fontAtlas: FontAtlas, left, right: Rune): float32 {.inline.} =
## Looks up kerning between two glyphs without allocating string keys.
fontAtlas.lookup.kerning.getOrDefault(kerningKey(left, right))

proc newAtlasBuilder*(size, margin: int): AtlasBuilder =
## Generate a pixel atlas from the given directories.
let
Expand Down Expand Up @@ -186,7 +259,8 @@ proc extractAtlasJsonFromPng*(pngData: string): string =
proc readAtlasFromPng*(path: string): SilkyAtlas =
## Reads and decodes the atlas JSON embedded in an atlas PNG file.
try:
extractAtlasJsonFromPng(readFile(path)).fromJson(SilkyAtlas)
result = extractAtlasJsonFromPng(readFile(path)).fromJson(SilkyAtlas)
result.rebuildGlyphLookups()
except IOError as e:
raise newException(SilkyAtlasError, e.msg, e)

Expand All @@ -197,6 +271,7 @@ proc readAtlas*(
try:
let pngData = readFile(path)
result.atlas = extractAtlasJsonFromPng(pngData).fromJson(SilkyAtlas)
result.atlas.rebuildGlyphLookups()
result.image = decodePng(pngData).convertToImage()
except IOError as e:
raise newException(SilkyAtlasError, e.msg, e)
Expand Down Expand Up @@ -332,6 +407,7 @@ proc addFont*(builder: AtlasBuilder, path: string, name: string, size: float32,
let kerning = typeface.getKerningAdjustment(rune, rune2)
if kerning != 0:
fontAtlas.entries[glyphStr][0].kerning[glyphStr2] = kerning * scale
fontAtlas.rebuildGlyphLookups()
builder.atlas.fonts[name] = fontAtlas

proc write*(builder: AtlasBuilder, outputPngPath: string) =
Expand Down
143 changes: 85 additions & 58 deletions src/silky/contexts.nim
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import
when defined(profile):
import fluffy/measure, std/os
export measure

const
SilkyProfileFrames* {.intdefine.} = 0
SilkyProfileTracePath* {.strdefine.} = "tmp/trace.json"

var profileFrameCount*: int
else:
macro measure*(fn: untyped) =
## Passes procedures through unchanged when profiling is off.
Expand All @@ -32,6 +38,8 @@ else:
const
NormalLayer* = 0
PopupsLayer* = 1
LineFeedRune = Rune(10)
SpaceRune = Rune(32)

type
StackDirection* = enum
Expand Down Expand Up @@ -241,6 +249,18 @@ proc getImageSize*(sk: Silky, image: string): Vec2 =
let uv = sk.atlas.entries[image]
vec2(uv.width.float32, uv.height.float32)

proc nextRune(text: string, i: var int): Rune {.inline.} =
## Reads one UTF-8 rune and advances the byte index.
fastRuneAt(text, i, result, true)

proc peekRune(text: string, i: int, rune: var Rune): bool {.inline.} =
## Reads one UTF-8 rune without changing the caller's byte index.
if i >= text.len:
return false
var j = i
fastRuneAt(text, j, rune, true)
return true

proc shouldShowTooltip*(sk: Silky): bool =
## Returns true when a tooltip should be shown.
sk.hover and sk.mouseIdleTime >= sk.tooltipThreshold
Expand All @@ -254,6 +274,10 @@ proc resetInteractions*(sk: Silky) =
proc beginUiShared*(sk: Silky, window: Window, size: IVec2) =
## Starts a frame and updates the shared UI state.
when defined(profile):
if SilkyProfileFrames > 0 and not traceActive:
traceActive = true
startTrace()

if window.buttonPressed[KeyF3]:
if not traceActive:
traceActive = true
Expand Down Expand Up @@ -316,6 +340,18 @@ proc endUiShared*(sk: Silky) =
sk.inFrame = false
measurePop()

when defined(profile):
if SilkyProfileFrames > 0 and traceActive:
inc profileFrameCount
if profileFrameCount >= SilkyProfileFrames:
traceActive = false
endTrace()
let traceDir = splitFile(SilkyProfileTracePath).dir
if traceDir.len > 0:
createDir(traceDir)
dumpMeasures(SilkyProfileTracePath)
sk.window.closeRequested = true

proc drawQuad*(
sk: Silky,
pos: Vec2,
Expand Down Expand Up @@ -438,7 +474,7 @@ proc drawText*(
wordWrap = false,
hAlign: HorizontalAlignment = LeftAlign,
vAlign: VerticalAlignment = TopAlign
): Vec2 =
): Vec2 {.measure.} =
## Queues text glyphs using atlas-backed font data.
assert sk.inFrame
if font notin sk.atlas.fonts:
Expand All @@ -454,7 +490,6 @@ proc drawText*(
let
fontData = sk.atlas.fonts[font]
maxPos = pos + vec2(maxWidth, maxHeight)
runedText = text.toRunes
hasSubpixel = fontData.subpixelSteps > 0
layer = sk.drawer.currentLayer
needsHAlign = hAlign != LeftAlign
Expand Down Expand Up @@ -497,41 +532,42 @@ proc drawText*(
sk.drawer.layers[layer][j].pos.x += dx
lineStartIdx = sk.drawer.layers[layer].len

var i = 0
while i < runedText.len:
let rune = runedText[i]

if rune == Rune(10):
var
i = 0
previousRune = Rune(0)
hasPreviousRune = false
while i < text.len:
let runeStart = i
let rune = text.nextRune(i)

if rune == LineFeedRune:
alignLine(currentPos.x - pos.x)
currentPos.x = pos.x
currentPos.y += fontData.lineHeight
inc i
hasPreviousRune = false
continue

if wordWrap and currentPos.x > pos.x and rune != Rune(32):
if wordWrap and currentPos.x > pos.x and rune != SpaceRune:
let isWordStart =
i == 0 or
runedText[i - 1] == Rune(32) or
runedText[i - 1] == Rune(10)
not hasPreviousRune or
previousRune == SpaceRune or
previousRune == LineFeedRune
if isWordStart:
var
wordW = 0.0'f
j = i
while j < runedText.len and
runedText[j] != Rune(32) and
runedText[j] != Rune(10):
let gs = $runedText[j]
if gs in fontData.entries:
wordW += fontData.entries[gs][0].advance
elif "?" in fontData.entries:
wordW += fontData.entries["?"][0].advance
inc j
j = runeStart
while j < text.len:
let wordRune = text.nextRune(j)
if wordRune == SpaceRune or wordRune == LineFeedRune:
break
var wordEntry: ptr LetterEntry
if fontData.lookupLetter(wordRune, 0, wordEntry):
wordW += wordEntry.advance
if currentPos.x + wordW > pos.x + maxWidth:
alignLine(currentPos.x - pos.x)
currentPos.x = pos.x
currentPos.y += fontData.lineHeight

let glyphStr = $rune
let variant =
if hasSubpixel:
let frac = currentPos.x - currentPos.x.floor
Expand All @@ -540,13 +576,10 @@ proc drawText*(
else:
0

var entry: LetterEntry
if glyphStr in fontData.entries:
entry = fontData.entries[glyphStr][variant]
elif "?" in fontData.entries:
entry = fontData.entries["?"][0]
else:
inc i
var entry: ptr LetterEntry
if not fontData.lookupLetter(rune, variant, entry):
previousRune = rune
hasPreviousRune = true
continue

if currentPos.x >= maxPos.x:
Expand All @@ -555,8 +588,11 @@ proc drawText*(
currentPos.x = pos.x
currentPos.y += fontData.lineHeight
elif glyphClip:
while i < runedText.len and runedText[i] != Rune(10):
inc i
while i < text.len:
var next: Rune
if not text.peekRune(i, next) or next == LineFeedRune:
break
discard text.nextRune(i)
continue

if glyphClip and currentPos.y + entry.boundsY >= maxPos.y:
Expand All @@ -578,14 +614,12 @@ proc drawText*(
)

currentPos.x += entry.advance
if i < runedText.len - 1:
let nextGlyphStr = $runedText[i + 1]
if glyphStr in fontData.entries and
nextGlyphStr in fontData.entries[glyphStr][0].kerning:
currentPos.x +=
fontData.entries[glyphStr][0].kerning[nextGlyphStr]
var next: Rune
if text.peekRune(i, next):
currentPos.x += fontData.lookupKerning(rune, next)

inc i
previousRune = rune
hasPreviousRune = true

alignLine(currentPos.x - pos.x)

Expand All @@ -607,40 +641,33 @@ proc drawText*(

currentPos - pos

proc getTextSize*(sk: Silky, font: string, text: string): Vec2 =
proc getTextSize*(sk: Silky, font: string, text: string): Vec2 {.measure.} =
## Returns the size of text in pixels.
if font notin sk.atlas.fonts:
return vec2(0, 0)

let
fontData = sk.atlas.fonts[font]
runedText = text.toRunes
let fontData = sk.atlas.fonts[font]
var
i = 0
currentPos = vec2(0, fontData.lineHeight)
maxWidth = 0.0'f

for i in 0 ..< runedText.len:
let rune = runedText[i]
if rune == Rune(10):
while i < text.len:
let rune = text.nextRune(i)
if rune == LineFeedRune:
maxWidth = max(maxWidth, currentPos.x)
currentPos.x = 0
currentPos.y += fontData.lineHeight
continue

let glyphStr = $rune
var entry: LetterEntry
if glyphStr in fontData.entries:
entry = fontData.entries[glyphStr][0]
elif "?" in fontData.entries:
entry = fontData.entries["?"][0]
else:
var entry: ptr LetterEntry
if not fontData.lookupLetter(rune, 0, entry):
continue

currentPos.x += entry.advance
if i < runedText.len - 1:
let nextGlyphStr = $runedText[i + 1]
if nextGlyphStr in entry.kerning:
currentPos.x += entry.kerning[nextGlyphStr]
var next: Rune
if text.peekRune(i, next):
currentPos.x += fontData.lookupKerning(rune, next)

maxWidth = max(maxWidth, currentPos.x)
vec2(maxWidth, currentPos.y)
Expand Down Expand Up @@ -793,7 +820,7 @@ proc clearScreen*(sk: Silky, color: ColorRGBX) {.measure.} =
## Clears or updates the frame clear color through the drawer.
sk.drawer.clearScreen(color)

proc endUi*(sk: Silky) {.measure.} =
proc endUi*(sk: Silky) =
## Flushes the queued draws through the active drawer.
for i in 1 ..< sk.drawer.layers.len:
sk.drawer.layers[NormalLayer].add(sk.drawer.layers[i])
Expand Down
Loading
Loading