///|
/// Session-scoped shaping cache (ADR 0034 batch, renderer-owned per P16):
/// every `DrawText` frame re-shapes identical `(font, text, resolution)`
/// runs. Resolved layouts are immutable after construction (see `resolve`),
/// so an LRU over them is hit-free to replay and hits share the cached layout
/// instead of copying it. Keys are structural (`SkiaTextShapingKey`, hashed
/// without building JSON strings); the per-spec portion is memoized so a
/// repeated font reuses one key object. Hit/miss deltas fold into the
/// existing `RenderFrameResult` cache counters; no protocol field changes.
///|
/// Hashable mirror of one `FontFamily`. `@core.FontFamily` derives `Eq` but
/// not `Hash`, so the structured key carries a stable tag plus the `Name`
/// payload. Kept package-local: the shaping cache owns its key format.
priv struct SkiaShapingFamilyKey {
tag : Int32
name : String
} derive(Eq, Hash)
///|
/// Hashable mirror of one `FontVariation` axis/value pair.
priv struct SkiaShapingVariationKey {
axis : String
value : Double
} derive(Eq, Hash)
///|
/// Hashable mirror of `@core.FontSpec`. Field-for-field: family stack (tagged
/// because the enum has a `Name(String)` payload), size, weight, style, and
/// variation axes. Double fields are zero-normalized so `0.0` and `-0.0`
/// (equal under `Eq`) cannot end up with different hashes in a `Map` key.
priv struct SkiaShapingFontKey {
families : Array[SkiaShapingFamilyKey]
size : Double
weight : Int32
style : Int32
variations : Array[SkiaShapingVariationKey]
} derive(Eq, Hash)
///|
/// Full shaping cache key: structural font identity plus resolution and the
/// text itself. Replaces the former `font_spec.to_json().stringify() + tag +
/// text` concatenation that allocated a JSON tree, a stringified document,
/// and a merged string on every lookup.
priv struct SkiaTextShapingKey {
font : SkiaShapingFontKey
system_font_mgr : Bool
text : String
} derive(Eq, Hash)
///|
priv struct SkiaTextShapingEntry {
layout : SkiaResolvedTextLayout
mut last_used : Int32
}
///|
priv struct SkiaTextShapingCache {
entries : Map[SkiaTextShapingKey, SkiaTextShapingEntry]
/// Bounded memo of `FontSpec -> SkiaShapingFontKey`. Building the key copies
/// the family and variation arrays, and fonts repeat across a whole frame
/// (usually one or two distinct specs), so the memo turns the per-lookup
/// key construction into a short structural scan with zero array copies.
font_keys : Array[(@core.FontSpec, SkiaShapingFontKey)]
mut tick : Int32
mut total_hits : Int32
mut total_misses : Int32
mut frame_mark_hits : Int32
mut frame_mark_misses : Int32
}
///|
let skia_text_shaping_cache_capacity = 256
///|
/// Distinct font specs per session are the app's font configurations —
/// small. The memo is a plain FIFO: overflowing it only costs rebuilding a
/// key later (keys are compared structurally in the map, never by identity,
/// so eviction cannot alias), which is why no LRU bookkeeping is needed.
let skia_font_key_memo_capacity = 64
///|
fn SkiaTextShapingCache::new() -> SkiaTextShapingCache {
{
entries: Map([]),
font_keys: [],
tick: 0,
total_hits: 0,
total_misses: 0,
frame_mark_hits: 0,
frame_mark_misses: 0,
}
}
///|
/// Normalize equal-under-`Eq` zeros so the derived `Hash` of a `Map` key
/// cannot disagree with its derived `Eq` (`0.0 == -0.0` but their bits — and
/// therefore a bit-derived hash — differ).
fn skia_shaping_hash_double(value : Double) -> Double {
if value == 0.0 {
0.0
} else {
value
}
}
///|
fn skia_shaping_family_key(family : @core.FontFamily) -> SkiaShapingFamilyKey {
match family {
Name(name) => { tag: 0, name, }
SystemUi => { tag: 1, name: "", }
UiSansSerif => { tag: 2, name: "", }
UiSerif => { tag: 3, name: "", }
UiMonospace => { tag: 4, name: "", }
SansSerif => { tag: 5, name: "", }
Serif => { tag: 6, name: "", }
Monospace => { tag: 7, name: "", }
Emoji => { tag: 8, name: "", }
Math => { tag: 9, name: "", }
FangSong => { tag: 10, name: "", }
}
}
///|
fn skia_shaping_font_key(font_spec : @core.FontSpec) -> SkiaShapingFontKey {
let families : Array[SkiaShapingFamilyKey] = []
for family in font_spec.families.families {
families.push(skia_shaping_family_key(family))
}
let variations : Array[SkiaShapingVariationKey] = []
for variation in font_spec.variations {
variations.push({
axis: variation.axis,
value: skia_shaping_hash_double(variation.value),
})
}
{
families,
size: skia_shaping_hash_double(font_spec.size),
weight: font_spec.weight,
style: match font_spec.style {
Normal => 0
Italic => 1
},
variations,
}
}
///|
fn SkiaTextShapingCache::memoized_font_key(
self : SkiaTextShapingCache,
font_spec : @core.FontSpec,
) -> SkiaShapingFontKey {
for memo in self.font_keys {
if memo.0 == font_spec {
return memo.1
}
}
let key = skia_shaping_font_key(font_spec)
if self.font_keys.length() >= skia_font_key_memo_capacity {
ignore(self.font_keys.remove(0))
}
self.font_keys.push((font_spec, key))
key
}
///|
fn SkiaTextShapingCache::shaping_key(
self : SkiaTextShapingCache,
font_spec : @core.FontSpec,
text : String,
resolution : SkiaFontResolution,
) -> SkiaTextShapingKey {
{
font: self.memoized_font_key(font_spec),
system_font_mgr: resolution is SystemFontMgr,
text,
}
}
///|
fn SkiaTextShapingCache::begin_frame(self : SkiaTextShapingCache) -> Unit {
self.frame_mark_hits = self.total_hits
self.frame_mark_misses = self.total_misses
}
///|
fn SkiaTextShapingCache::frame_delta(
self : SkiaTextShapingCache,
) -> (Int32, Int32) {
(
self.total_hits - self.frame_mark_hits,
self.total_misses - self.frame_mark_misses,
)
}
///|
fn SkiaTextShapingCache::resolve(
self : SkiaTextShapingCache,
font_spec : @core.FontSpec,
text : String,
resolution : SkiaFontResolution,
shape : () -> SkiaResolvedTextLayout?,
) -> SkiaResolvedTextLayout? {
self.tick = self.tick + 1
let key = self.shaping_key(font_spec, text, resolution)
match self.entries.get(key) {
Some(entry) => {
entry.last_used = self.tick
self.total_hits = self.total_hits + 1
// Shared-layout replay, no per-hit copy. The cached layout is immutable
// after construction: `SkiaResolvedTextLayout.segments` is only
// appended to inside `skia_resolved_text_segments`/`
// skia_resolved_text_layout_for_resolution` before the layout is handed
// to the cache, and every consumer (`draw_text`, caret measurement,
// diagnostics) only reads fields and iterates. MoonBit structs are
// reference values, so returning the cached layout shares it; the old
// `copy_layout` only duplicated the segments array anyway (glyph runs
// and font handles were already shared), so sharing gives up nothing
// while removing the per-hit array allocation.
Some(entry.layout)
}
None => {
self.total_misses = self.total_misses + 1
match shape() {
Some(layout) => {
self.entries[key] = { layout, last_used: self.tick, }
self.evict_if_needed()
Some(layout)
}
None => None
}
}
}
}
///|
fn SkiaTextShapingCache::evict_if_needed(self : SkiaTextShapingCache) -> Unit {
while self.entries.length() > skia_text_shaping_cache_capacity {
let mut oldest_key : SkiaTextShapingKey? = None
let mut oldest_tick = self.tick + 1
for key, entry in self.entries {
if entry.last_used < oldest_tick {
oldest_key = Some(key)
oldest_tick = entry.last_used
}
}
match oldest_key {
Some(key) => ignore(self.entries.remove(key))
None => break
}
}
}