Compare commits

...
47 Commits
Author SHA1 Message Date
Bitsy b8f7595a5f build(publish): publish to maven.bitsycore.com alongside GitHub Packages
- Bitsycore Reposilite repo in root + bridge publishing blocks
- per-host Bitsycore publish step in the workflow (task names derived
  from the matrix); releases repo has redeployment enabled so plain
  retries are safe
- workflow_dispatch fallback trigger (run with --ref <tag>)
- bridge: default skiko fork version -> 0.150.1-mingw.2 (republished to
  both repos so consumers need no GitHub Packages auth)
- README + bridge README: maven.bitsycore.com first, GH Packages fallback
2026-08-06 22:48:18 +02:00
Bitsy cdce0575e3 fix(metadata): unblock Windows common-metadata compile (K2 @file:Suppress)
compileCommonMainKotlinMetadata (the Windows publish path) failed at
:foundation:compileNativeMainKotlinMetadata on the two committed manual
vendors Scrollbar.skiko.kt / v2/Scrollbar.skiko.kt:
OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE for their JVM-only @JvmName
(upstream keeps these in skikoMain which has a JVM target; our nativeMain is
native-only, orphaning the annotation).

sync.py already injects the K2 @file:Suppress into every src/vendor/ .kt
(sync.py:313), so the regenerated tree was fine — the gap was only these two
files in src/nativeMain, which sync doesn't regenerate. Added the identical
@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE",
"LESS_VISIBLE_TYPE_ACCESS_IN_INLINE") to both (provenance comments updated).

Full `gradlew compileCommonMainKotlinMetadata` now BUILD SUCCESSFUL across all
modules; check-vendor-drift still clean. PLAN §5 updated (blocker -> fixed;
actual publish + downstream-consume smoke still owed).
2026-08-04 22:26:40 +02:00
Bitsy 623266d467 docs(plan): record §5 Windows common-metadata publish blocker
WIN-SMOKE gate run on this host found a pre-existing blocker:
:material3:compileCommonMainKotlinMetadata (and the aggregate) fail at
:foundation:compileNativeMainKotlinMetadata on Scrollbar.skiko.kt /
v2/Scrollbar.skiko.kt with OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE.

Root cause: upstream keeps these .skiko.kt in skikoMain (which includes the
JVM-desktop target, so @JvmName/@JvmStatic resolve); the port vendors them into
native-only nativeMain (no JVM target), orphaning the JVM annotations -> the
metadata compile rejects them (per-target compile tolerates them, so apps
build). 14 vendored native files use @Jvm*, 11 in the regenerated
src/vendor/native tree -> fix must be build/sync-level (inject @file:Suppress,
precedent: components-resources ResourceState.blocking.kt). Likely why the
Windows metadata publish never went green. Also marked §1b/§1a WIN-SMOKE
sub-items done.
2026-08-04 22:17:22 +02:00
Bitsy 52817af282 merge: Windows text-fidelity pass (§1a/§1b/§1c) + engine-vendor spike finding 2026-08-04 22:06:28 +02:00
Bitsy 749a405a03 docs(plan): record engine-vendor spike finding — font-loader is the real gate
Read-only feasibility map for §6 (vendor upstream skiko text engine):
- Layer 1 (move the sealed Paragraph actual + factories from skiko-free
  nativeMain down into skikoRendererMain): FEASIBLE, mechanical.
- Layer 2 (THE blocker): upstream's engine resolves typefaces through
  FontFamily.Resolver -> PlatformFontLoader, which the port stubs as a NOP
  (SdlPlatformFontLoader returns Unit). The hand-rolled engine exists to bypass
  it via SkiaFonts (icons/variable-axes/data.kres). So a verbatim engine-vendor
  needs the upstream font-loader path implemented first (SkiaFontLoader.skiko.kt
  reference) -- i.e. §6 and §2-P1 PlatformFontLoader are ONE effort, and need
  macOS/Linux verification. Cross-linked both sections. No code changed.
2026-08-04 22:01:35 +02:00
Bitsy 675bc99b23 fix(text): repair fork LineMetrics ascent/descent decode; verify Windows text fidelity
Windows (mingwX64) text-fidelity pass on a real host (PLAN.md §1a/§1b/§1c):

- §1a tab fix CONFIRMED on-device (literal \t -> space, no .notdef box).
- §1b vertical metrics RESOLVED: native --metricsprobe heights + non-M3
  baselines == JVM --metrics EXACTLY (byte-identical NotoSans, md5 37de02cd).
  The feared "fork drops lineHeight leading" hypothesis is disproven.
- Fork's flat extern-C Paragraph.lineMetrics mis-decodes per-line
  ascent/descent (constant ~18.837/5.163, non-scaling) while baseline/height
  are correct. SkiaParagraphOps.lineMetrics() now rebuilds ascent/descent from
  baseline+cumulative-height when internally inconsistent (guarded no-op on
  official skiko). Fixes Windows caret height / getLineTop/Bottom / hit-test.
- §1c FontMgr/ICU audited: CJK + full-color emoji + control chars all render
  (zero tofu) -> the fork FontMgr.default is a real DirectWrite system manager,
  not an empty FreeType stub. ICU classification works.
- FontRasterizationSettings.native.kt: add VENDOR-BASE provenance (mirrors
  upstream per-OS defaults verbatim; now the 11th tracked Rule-3 vendor).
  Rasterization stays upstream-faithful per-OS (user decision).
- PLAN.md: record the Windows-pass results and the approved next major task --
  vendor the upstream skiko text engine verbatim, deleting the hand-rolled
  engine (new §6).
2026-08-04 21:56:14 +02:00
Bitsy eae87b98ba docs(plan): remove Windows vertical metrics check; update fork divergence documentation 2026-08-04 20:59:50 +02:00
Bitsy 982dc50d2a build(sdl): update SDL reference to release-3.4.14 2026-08-04 20:51:55 +02:00
Bitsy 5ec5f4cb9a docs(plan): make PLAN.md a self-contained Windows handoff
Add a '▶ Continuing on Windows — START HERE' runbook (build/run commands, ordered
tasks with exact CDN_TEXT_METRICS commands + accept criteria), embed the macOS
metric baseline raw lines for diffing, and a status pointer up top. A fresh agent
on mingwX64 can now 'continue PLAN.md' with everything it needs.
2026-08-03 23:53:11 +02:00
Bitsy 3c759476ac docs(vendor): record the reduced-local text engine as an accepted deviation
Add a rationale header to ui-text/compose-fork.txt so nobody tries to 'fill' the
intentionally-unselected skikoMain text-engine gaps — explains the flat-source-set
reason and points RTL/stroke/grapheme reductions to PLAN.md §2.
2026-08-03 23:45:33 +02:00
Bitsy 7ee2e4a09f docs: remove stale RENDERER.md + SKIKO-MINGW-FEASIBILITY.md, fold renderer essentials into CLAUDE.md
Both were historical/archival (RENDERER.md is a convergence log whose forward work
is now in PLAN.md; the feasibility doc a pre-ship assessment). Their still-current
essentials — the one-Skia-RenderBackend model, the retained-layer no-re-record
engine, key renderer files, and the FreeType-scaler lead — are inlined into
CLAUDE.md's doc map and PLAN.md §1c; the rest stays in git history. Removes the
matching CLAUDE.md links so the doc set stays coherent and lean.
2026-08-03 23:44:08 +02:00
Bitsy 2b49ececa7 docs(plan): add Landed-this-pass summary; sync DoD gates after framework trim + drift check 2026-08-03 23:26:32 +02:00
Bitsy f16ceea0da build(sdl): drop unreferenced macOS frameworks from sdl3.def after slimming
With audio/gamepad/haptic/camera disabled in the SDL build, the regenerated
sdl3.pc no longer references CoreAudio/AudioToolbox/AVFoundation/GameController/
ForceFeedback/CoreHaptics. Remove them from linkerOpts.osx to match the pc.
Demo relinks + runs clean.
2026-08-03 23:25:28 +02:00
Bitsy 7f332ead0a docs: restore SKIKO-MINGW-FEASIBILITY.md (fork build recipe); record FreeType lead
Deleted in bdb5c64d but still linked from CLAUDE.md and holding the fork's Skia
build recipe. The recipe uses skia_use_freetype — the Windows text scaler is
FreeType, not CoreText/DirectWrite, a concrete lead for the mac-vs-Windows
metric delta (PLAN §1b/§1c).
2026-08-03 23:22:41 +02:00
Bitsy d022462cad docs: document SDL slimming + CDN_TEXT_METRICS; add fork-fidelity WIN-SMOKE checks
TOOLING.md: note the slimmed SDL subsystem set and the CDN_TEXT_METRICS text-
metrics diagnostic. RENDERER.md: WIN-SMOKE now owns the tab-tofu and vertical-
metrics fork checks, pointing at PLAN.md.
2026-08-03 23:21:36 +02:00
Bitsy 9637215931 feat(text): CDN_TEXT_METRICS diagnostic + sharpen §1b line-metrics hypothesis
Add an env-gated (CDN_TEXT_METRICS=1) dump of the font scaler's ascent/descent/
leading and the resulting paragraph line box, so the mac-vs-Windows vertical
spacing can be compared native-vs-JVM per host (PLAN §1b confirm-first). macOS
baseline captured: the button line box (48px) exceeds the font box (40.86px) with
leading distributed ~5.6px above / ~1.5px below — the reported 'extra space on
top' is correct lineHeight distribution, so the likely Windows bug is the fork
dropping that leading (mirror of the tab-flag issue). Also re-scope §1c FontMgr
fallback and Serif/Cursive in the plan after reading the code (both already
functional; real gaps are fork-only / app-opt-in).
2026-08-03 23:20:12 +02:00
Bitsy 66653f903e docs(text): correct misleading TODO(CMP-7819) on vestigial context-menu seams
Right-click Cut/Copy/Paste/SelectAll already works via the vendored legacy
CommonContextMenuArea path (isNewContextMenuEnabled = false → legacy branch,
Popup-hosted). The three add*TextContextMenuComponents seams belong to the
DISABLED new context-menu API and are unreachable, so their no-op is correct.
Replace the 'wire this later' TODOs with an accurate explanation.
2026-08-03 23:20:03 +02:00
Bitsy 88efdba9f7 docs(plan): sync checkboxes with landed work + correct registerGenericFonts note
Mark tab-fix / float-pointer / screen-reader / SDL-slim / doc-hygiene done, update
the DoD gates, and correct the stale claim that registerGenericFonts re-registers
per frame (it's guarded by fRegistered). Note apidemo also links against slimmed SDL.
2026-08-03 23:12:12 +02:00
Bitsy 487e151ac8 build(sdl): disable unused subsystems to slim the static lib
The port uses SDL only for video/events/clipboard/dialogs/GL+Metal/render/
filesystem/cursor/locale/theme/IME. Disable audio, joystick, haptic, hidapi,
sensor, power, camera, GPU, offscreen, and virtual-joystick (all zero-reference,
verified by grep) in the shared configure flags; promote SDL_GPU=OFF out of the
Windows-only branch. Rebuilt clean; demo links + runs on macOS/Metal.
2026-08-03 23:10:22 +02:00
Bitsy e68de8794f fix(a11y): default LocalPlatformScreenReader to an inactive no-op instead of throwing
Reading the local without a provider crashed the app via error(...). The SDL
desktop backend has no screen-reader bridge, so default to an inactive reader
(isActive = false) — a11y-gated code degrades gracefully instead of crashing.
2026-08-03 23:09:56 +02:00
Bitsy c8aefebbf1 fix(input): carry pointer coordinates as Float to avoid HiDPI caret quantization
SDL3EventMapper truncated SDL's sub-pixel Float mouse coords to Int before the
per-frame DPR multiply, so on 2x displays the caret snapped to 2px steps near
glyph edges. Widen LegacyPointerEvent.x/y and MouseWheel.x/y to Float end to end.
2026-08-03 23:09:41 +02:00
Bitsy cc04c82042 docs: restore RENDERER.md, fix CLAUDE.md line-1 typo + stale TODO.md link
RENDERER.md was inadvertently removed by a pre-staged deletion; restore it from
history. Fix the stray 'ean' prefix on CLAUDE.md line 1 and repoint the TODO.md
doc-map link (never committed) to PLAN.md §2, which now carries the stub audit.
2026-08-03 23:06:31 +02:00
Bitsy 416ae3a03e fix(text): normalize tab chars before shaping so Windows fork doesn't render .notdef
The Windows skiko fork's flat extern-C surface doesn't wire
ParagraphStyle.replaceTabCharacters, so raw U+0009 reached HarfBuzz and rendered
as a tofu box. Replace tabs with spaces on the shaped copy (length-preserving,
offset queries keep using the original text) — exactly what Skia's flag does
internally, now platform-independent.
2026-08-03 23:00:28 +02:00
Bitsy 9e97b4357f docs(plan): second hardening pass — fidelity clean, scrollbar vendored, double-shape done 2026-07-31 22:01:05 +02:00
Bitsy 8c5a795055 perf(text): reuse the intrinsics-pass shaped paragraph for final layout (P1.2)
Every measured Text shaped TWICE — once in NativeParagraphIntrinsics (throwaway,
disposed) for min/max intrinsic width, then again in Paragraph(intrinsics, ...)
for the final layout. Now NativeParagraphIntrinsics RETAINS its shaped ops and
Paragraph(intrinsics, ...) reuses it, calling paragraph.layout(finalWidth) — a
line re-break with skiko's cached shaped runs, no HarfBuzz re-shape — instead of
building a second paragraph. Falls back to a fresh build when maxLines/ellipsis
are set (those bake into ParagraphStyle at build time).

Mirrors upstream's ParagraphLayouter reuse model. Halves per-measure shaping,
which is the cost paid on scroll-in / navigation (new text measured each frame);
net-fewer native allocations too (removed the throwaway paragraphIntrinsicWidths
build+dispose). Steady-state force-render is unchanged (no re-measure), so the win
doesn't show there. Verified: text renders pixel-identically (parity BasicText
4.28% / AnnotatedString 4.89% / LazyColumn 2.33% — unchanged).
2026-07-31 21:59:53 +02:00
Bitsy 9a8540499d refactor(foundation): use vendored upstream Scrollbar, delete project reimpl
The prior pass vendored androidx.compose.foundation.Scrollbar (skiko). Migrate the
demo to it and delete the now-dead com.compose.sdl.scrollbar reimpl (~200 lines of
divergent library surface duplicating the exact upstream API). Verified the
vendored scrollbar renders a correctly-sized thumb under the Option-B physical-px
density flow (the ratios are dimensionless; Dp thickness -> px via density is
correct), so the reimpl's defensive density handling isn't needed.

apidemo keeps its own app-level compat copy (used on its jvm parity target).
2026-07-31 21:52:02 +02:00
Bitsy 68ea57204c docs(plan): release-readiness pass toward 1.0.0 (99% API coverage, audit findings) 2026-07-31 21:26:14 +02:00
Bitsy e3f3d64d0b feat(text-input): position OS IME candidate window on focus-gain
ImeBridge gains onSessionActiveChange, invoked when a field gains/loses focus;
ComposeWindow wires it (per-window, in installGlobals) to call updateImeArea() on
activation. Previously the IME candidate/preedit rect was only set on the first
SDL_EVENT_TEXT_EDITING, so the first keystroke's IME popup could be mis-placed.
Safe no-op when the field isn't laid out yet (updateImeArea returns early).

(Per-focus SDL_StartTextInput/StopTextInput scoping deliberately NOT changed: the
always-on start feeds the dispatchTypedText fallback for typed text with no
focused field; removing it risks that path for edge-case IME-state correctness.)
2026-07-31 21:24:27 +02:00
Bitsy 229a39b38d feat(text): inline-content placeholders (Text inlineContent reserves space)
Threads placeholders through buildParagraphOps / paragraphIntrinsicWidths /
NativeParagraphIntrinsics into SkiaParagraphOps.build(), which now interleaves
pb.addPlaceholder(PlaceholderStyle(...)) at each placeholder's text range
(skipping the covered text) — mirroring upstream ParagraphBuilder.makeOps +
toSkPlaceholderAlignment. placeholderRects() now returns real rects, so
Text/BasicText with inlineContent (inline icons, chips, badges) reserve space
and position their composables correctly instead of collapsing to zero size.

Existing text is unaffected (the placeholder branch only activates when
placeholders are non-empty). Verified end-to-end: a 40x20sp inline box in an
AnnotatedString renders with reserved space (before: 'Before after' collapsed;
after: gap reserved). Divergence audit's top functional gap.
2026-07-31 21:20:45 +02:00
Bitsy 57cf99608b feat(foundation): vendor upstream desktop Scrollbar (androidx.compose.foundation)
Vendors the real upstream VerticalScrollbar / HorizontalScrollbar / ScrollbarAdapter
/ ScrollbarStyle / rememberScrollbarAdapter + the v2 adapter machinery
(Lazy{List,Grid}/Scrollable/TextField adapters) from skikoMain, giving the exact
upstream desktop scrollbar API (closes 34 missing API-coverage decls) instead of
only the project com.compose.sdl.scrollbar reimpl.

Manually vendored to src/nativeMain (refused in compose-fork.txt) with VENDOR-BASE
provenance: the flat native source set can't hold the v2 'expect fun
runBlockingIfPossible' + its actual together, so it's inlined to runBlocking
(native always has it). TextFieldScrollState dependency was already vendored.

Verified: foundation compiles; demo + apidemo link.
2026-07-31 21:09:55 +02:00
Bitsy 05ec6d5b40 docs(plan): P3.3 SVG fully done (both paths), P4.5 done (Ripple already vendored), P4.1 rationale 2026-07-29 23:43:50 +02:00
Bitsy e2d125dfea feat(resources): resolution-independent SVG on the painterResource path
painterResource(Res.drawable.x.svg) now renders through a size-driven SvgPainter
instead of a fixed intrinsic-size BitmapPainter, so scaled-up SVGs stay crisp
(matching upstream desktop's SVGPainter + JVM parity). XML vector drawables were
already scalable via ImageVector; this closes the SVG gap.

- EncodedImageDecoder gains svgIntrinsicSize() + decodeSvgAt(bytes,w,h) hooks
  (default fall back to intrinsic decode); SkiaEncodedImageDecoder implements
  them via SVGDOM parse + rasterizeSvgAt.
- SvgPainter reports intrinsic size for layout (unchanged) but re-rasterises at
  the draw size (1-entry size cache), honouring alpha + colorFilter.

Verified: demo links + Images/Icons render clean; parity Images 3.09% / Icons 1.29%.
2026-07-29 23:41:57 +02:00
Bitsy 50b31641ed docs(plan): record macOS execution-pass status + parity results
Phases 0-1 landed; macOS measurement closed P0.4/P0.6 as non-issues. This pass
closed the high-value parity gaps (text lineHeight/indent/baseline/background,
generic fonts, date formatter, SVG size-driven). Parity harness across
Buttons/Cards/Chips/Colors/Dialogs/Fab/Shapes now 0.6-2.2% differing (was
~16% Buttons / ~14% Shapes historically) — text line-metric ghosting largely
eliminated. P3.2/P3.3-official/P4.1 deferred with rationale.
2026-07-29 23:34:15 +02:00
Bitsy 71738dafb7 feat(graphics): size-driven SVG rasterisation on the project image path
SkiaImageCache now rasterises SVG / Android-vector at the DESTINATION pixel size
(size-keyed raster cache + rasterizeSvgAt, canvas-scaled from intrinsic) instead
of caching one intrinsic-size raster and upscaling it — vectors drawn larger than
their intrinsic box stay crisp. Intrinsic size is parsed/cached separately so the
layout pass is unchanged. Mirrors upstream's size-driven DrawCache.

The official painterResource path (:components-resources → SkiaEncodedImageDecoder)
still returns a fixed ImageBitmap (its dims are the painter's intrinsic size, so
rasterising bigger would break layout); true resolution independence there needs
a size-driven SVGPainter vendored into the resources pipeline (PLAN.md P3.3).

Verified: demo links + Images/Icons/Cards render clean.
2026-07-29 23:32:50 +02:00
Bitsy 739d6e4852 feat(text): upstream parity for lineHeight/textIndent/baselineShift/background + generic fonts + date formatter
- SkiaParagraphEngine: apply lineHeight (+ LineHeightStyle trim heightMode),
  textIndent, baselineShift, and span/base background — mirroring upstream
  ParagraphBuilder.skiko.kt (Material3 Typography sets lineHeight on nearly
  every style, so this closes a broad spacing divergence). replaceTabCharacters
  on to match upstream tab handling.
- SkiaFonts: resolve generic families (serif/cursive/monospace/sans-serif) via
  the per-OS concrete-name alias table (upstream GenericFontFamiliesMapping)
  instead of silently falling back to Noto Sans.
- NamedFont: include axes + variationSettings in equals/hashCode so
  axis-differing icon fonts don't collide in the typeface cache.
- material3 PlatformDateFormat: real pattern/skeleton formatter (honours the
  requested CLDR pattern) so DatePicker/TimePicker read 'Jul 29, 2026' instead
  of a raw ISO date.

Verified: demo links + BasicText/AnnotatedString/M3Pickers render clean.
2026-07-29 23:24:25 +02:00
Bitsy c828888590 fix(build): disable native incremental (value-class link break) + split GPU-acquire profiler phase
kotlin.incremental.native=true broke the macOS/native executable link:
per-file incremental compilation omits a value class's box/unbox helpers
from the declaring file's cache unit when only other files box the value,
so ld fails on NavigationItemIconPosition's box ctor (referenced from
ShortNavigationBar/WideNavigationRail). Turned it off.

Profiler: the 'layout' phase folded in ensureSize()'s nextDrawable() vsync
block, reading ~6.7ms of pacing as layout cost. Split out an 'acquire'
phase so the profiler shows real layout (0.02ms) vs vsync wait.

Measured on macOS Metal (120Hz): no steady-state perf gap; P0.4/P0.6
closed as non-issues (see PLAN.md §12).
2026-07-29 22:52:35 +02:00
Bitsy 9b165a07d2 refactor(plan): improve formatting and structure of action items 2026-07-29 22:27:40 +02:00
Bitsy 971047b799 refactor(window): split ComposeWindow.kt into focused files (P4.2, partial)
Extract three self-contained units out of the 1131-line ComposeWindow.kt (now
978 lines) as pure, compiler-verified moves — no behaviour change:

- FrameProfiler.kt   — the FrameProfiler object + kForceRender
- WindowArchitectureOwner.kt — the per-window lifecycle/VM/savedstate owner
- WindowInputHelpers.kt — BackNavigationInput + dispatchTypedText
  (carries the @file:OptIn(InternalComposeUiApi) + key extension imports the
  KeyEvent constructor / .type/.key need)

Moved declarations changed private -> internal where the loop/WindowInstance
still reference them; dropped the now-unused enableSavedStateHandles import.
Verified: :desktop-native-window mingwX64 compiles.

The remaining bulk (main loop, WindowInstance, probe/virtual-frame timing
cluster) is left in place — deeply intertwined, marginal gain to extract.
2026-07-29 21:44:12 +02:00
Bitsy 1e8ccdbeee docs(phase4): correct RENDERER.md text-engine claims + purge SDL-renderer residue
Phase 4 (docs/cleanup) from PLAN.md. Verified: :desktop-native-window mingwX64
compiles (comment-only code edits).

- P4.3 RENDERER.md: fix the biggest doc-vs-reality gap — it claimed text was
  "upstream's own SkiaParagraph, vendored verbatim" with upstream
  PlatformFont/FontCache "replacing the port's name-to-bytes engine", the
  opposite of reality. Now describes the reduced local port
  (SkiaParagraph.native.kt + SkiaParagraphEngine) that keeps the port's own
  SkiaFonts model; §5 B6.3 row downgraded to Partial; dropped the two-renderer
  "leg" framing throughout; marked the LazyColumn perf number historical (vs
  the removed SDL leg); updated §6 native-resource lifecycle for P0.7/P1.1/P3.4.
- P4.4 purge factually-wrong residue in code comments: deleted-module refs
  (renderer-sdl3) and removed-backend refs ("SDL3 backend", "Skia or SDL3",
  "SDL renderer's per-vertex gradient sampler") across GradientBridge,
  DrawShape, ComposeOwner, IconFont, Popup.native, BrushScreen.

P4.1 (reverse manual vendors — macOS/verify-mac-gated), P4.2 (decompose the
1131-line ComposeWindow.kt — large blind refactor, maintenance-only) and P4.5
(Ripple vendor re-sync + kotlinx-datetime formatter) deferred.
2026-07-29 21:28:36 +02:00
Bitsy 8f53880854 perf(phase3): system-font fallback + bounded image cache
Phase 3 from PLAN.md. Verified: :ui-graphics + :ui-text mingwX64 compile.

- P3.1 unbundled font family names now resolve against the OS font set via
  FontMgr.matchFamilyStyle (e.g. FontFamily("Arial")) instead of silently
  becoming Noto Sans. Generic families (serif/cursive/...) still fall through
  to the bundled default pending the per-OS alias table (P3.2).
- P3.4 SkiaImageCache is now a bounded (256) access-order LRU that closes the
  evicted image, so long-running apps showing many distinct runtime images
  (registerMemoryResource) no longer grow image memory without limit;
  on-screen images stay hot, an evicted one re-decodes on next use.

Font-cache bounding skipped deliberately (evicted typefaces stay referenced by
live paragraphs + registered in the provider, so eviction wouldn't free
memory). P3.2 (async FontList resolver), P3.3 (resolution-independent SVG /
XML->ImageVector) and P3.5 (loadImageBitmap public APIs) deferred: L-effort,
need visual/parity verification.
2026-07-29 21:22:36 +02:00
Bitsy 82d2e9b16f perf(phase2): display-refresh fallback cap + coarser idle wait
Phase 2 from PLAN.md. Verified: :desktop-native-window mingwX64 compiles.

- P2.1 the non-vsync fallback pacing now derives its cap from the rendered
  window's real display refresh (SDL_GetDisplayForWindow +
  SDL_GetCurrentDisplayMode.refresh_rate), min across non-vsync windows,
  instead of a hardcoded 16ms — a 144Hz panel on a Software/vsync-unavailable
  path is no longer capped to 60. No sdl3.def change (cinterop binds all of
  SDL3). Post-P0.1 this path only triggers when vsync is unavailable.
- P2.3 idle SDL_WaitEventTimeout 10ms -> 100ms; a real event still wakes it
  immediately, the timeout only bounds async-work re-checks while idle.

P2.2 (full GlobalSnapshotManager/FrameRecomposer vendor) deferred: its
coalescing intent is already met by P1.3; the full invalidation-driven
scheduler is a larger skiko-windowing-coupled change (RENDERER.md §8 non-goal)
that needs probe/parity verification.
2026-07-29 21:19:03 +02:00
Bitsy 27d1dc1637 perf(phase1): text-engine paint fast-path + coalesced snapshot apply
Phase 1 from PLAN.md. Verified: :ui-text + :desktop-native-window mingwX64
compile.

- P1.1 color-only repaints of single-style, undecorated text now re-apply the
  foreground paint via skia updateForegroundPaint + markDirty + re-layout
  (shaping stays cached) instead of a full re-shape, mirroring upstream
  ParagraphLayouter.setColor. Combined with P0.7 (build once at measure, no
  re-shape when paint attrs unchanged), the per-frame text reshape is gone for
  the common cases. One reused SkPaint, closed in dispose().
- P1.3 the global snapshot write-observer now only schedules a frame
  (markAllNeedFrame) instead of calling sendApplyNotifications() inline on
  every state write; the apply is coalesced to the once-per-iteration calls
  (loop top / before each pump / before layout). Mirrors upstream
  GlobalSnapshotManager's schedule-don't-apply model.

P1.2 (reuse intrinsics paragraph for final layout) evaluated and deferred:
maxLines/ellipsis are baked at build time so the unbounded intrinsics paragraph
can't be re-laid-out with a line cap, and holding it live trades away the clean
dispose() from P0.7 — needs a profiler measurement of the double-shape cost
before it's a clear win.
2026-07-29 21:15:35 +02:00
Bitsy b16f456aaf perf(phase0): vsync pacing, text rasterization + deterministic paragraph free
Phase 0 quick wins from PLAN.md (P0.1-P0.3, P0.5, P0.7). Verified: :ui-text
and :desktop-native-window mingwX64 compile; :ui-graphics klibApiCheck green.

- P0.1 fix vsync double-pacing: GL/Metal now report vsyncEnabled, so the main
  loop drops its redundant SDL_Delay(16) stacked on the already-vsync-blocked
  present (was capping GPU windows to ~30fps). (SDL3Backend.kt)
- P0.2 pace-block: yield (SDL_Delay 1ms) instead of spinning when the app
  recomposer has pending work but nothing rendered this iteration.
- P0.3 delete dead DrawStats (SDL-renderer residue; nothing wrote it so the
  profiler printed zeros for draw work) + drop it from the ui-graphics klib
  API baseline.
- P0.5 set FontRasterizationSettings (edging/hinting/subpixel) on every text
  style from a per-OS PlatformDefault (Slight hinting on Linux, Normal on
  Win/macOS), matching upstream ParagraphBuilder.skiko.kt. Text was rendering
  with skiko's raw defaults.
- P0.7 explicit native-resource lifecycle for paragraphs (not a GC tweak):
  close the previous SkParagraph + the ParagraphBuilder on rebuild, skip the
  rebuild entirely when paint attrs are unchanged (static text shapes once at
  measure), and dispose the intrinsics-only throwaway (paragraph + SkFont).
  Removes the per-paint/per-measure native churn behind issue #2. The 10s
  GC.collect stays as the backstop for navigation-time paragraphs (Compose
  holds Paragraph by GC, no dispose seam).

Deferred to a verify-mac pass: P0.4 (per-frame clear) + P0.6 (Metal drawable
reacquire) — macOS-only, can't verify on this Windows host.
2026-07-29 20:53:52 +02:00
Bitsy bdb5c64df3 misc: cleanup plans 2026-07-29 20:03:06 +02:00
Bitsy 5dc78f47b5 docs(readme): explain the two-repo consumer setup + fix stale coord scheme
The bridge-plugin section still said klibs publish under com.bitsycore.compose.sdl:*
(now per-area, com.bitsycore.compose.<area>:*). Also document WHY consumers
declare two GitHub Packages repos: mingwX64 renders through the bitsycore skiko
fork, and GitHub binds each package name to a single repo, so the fork can't be
mirrored into the port's repo — it stays in its own (bitsycore/skiko).
2026-07-26 14:43:13 +02:00
Bitsy 862da85de0 docs(bridge): document the skiko-fork repo in consumer setup
mingwX64 pulls com.bitsycore.skiko (the fork), published to its own GitHub
Packages repo — a separate repo from the port's, because GitHub binds each
package name to one repo. Add the exact repositories snippet (scoped via
include/excludeGroup) so consumers declare both repos copy-paste.
2026-07-26 14:31:54 +02:00
Bitsy 25689c1f17 fix(fonts): fontTools fallback for icon subsetting when hb-subset is absent
The subset task previously bundled the full ~10MB Material Symbols font whenever
hb-subset wasn't on PATH (e.g. the CI release build, so published apidemo zips
shipped the full font). Now it falls back to fontTools' subsetter via Python —
already a build prerequisite, variable-font-aware so the FILL/wght/GRAD/opsz axes
survive, and auto-installed via pip if missing. Order: hb-subset → fontTools →
full font (last resort). Verified both paths through Gradle (10393KB → 45KB,
axes preserved) including the forced-fallback case.
2026-07-26 01:46:47 +02:00
60 changed files with 3739 additions and 1574 deletions
+30
View File
@@ -7,6 +7,9 @@ on:
push:
tags:
- 'v*'
# Manual fallback: run with --ref <tag> so github.ref_name is the tag itself
# (PUBLISH_VERSION is derived from it).
workflow_dispatch:
# Never overlap two publish runs for the same tag.
concurrency:
@@ -224,6 +227,33 @@ jobs:
echo "::error::Publish failed after 3 attempts"
exit 1
# Second target: the self-hosted Reposilite instance. Same publications,
# task names derived from the matrix by swapping the repository name.
# The releases repo has redeployment enabled, so a plain retry after a
# mid-upload failure is safe (no GitHub-Packages-style 409 poisoning).
- name: Publish to maven.bitsycore.com
shell: bash
env:
BITSYCORE_MAVEN_USER: ${{ secrets.BITSYCORE_MAVEN_USER }}
BITSYCORE_MAVEN_TOKEN: ${{ secrets.BITSYCORE_MAVEN_TOKEN }}
run: |
set -e
BITSY_TASKS=$(echo "${{ matrix.tasks }}" | sed 's/GitHubPackagesRepository/BitsycoreRepository/g')
for attempt in 1 2 3; do
echo "::group::Bitsycore publish attempt $attempt"
if ./gradlew $BITSY_TASKS --continue --no-daemon --stacktrace; then
echo "::endgroup::"
exit 0
fi
echo "::endgroup::"
if [ $attempt -lt 3 ]; then
echo "::warning::Bitsycore publish attempt $attempt failed — retrying"
sleep 30
fi
done
echo "::error::Bitsycore publish failed after 3 attempts"
exit 1
# Release binaries for the host target — the binary plus the data.kres
# (bundled composeResources + fonts + subset icon-font). Each app's
# linkReleaseExecutable<Target> auto-runs its data.kres zip task —
+15 -7
View File
@@ -7,14 +7,22 @@ context — read it first, then look at the files it points to.
- [README.md](README.md) — public overview + quickstart (bridge plugin,
`nativeComposeWindow`, sample apps).
- [RENDERER.md](RENDERER.md) — the rendering layer: architecture, the retained-
layer engine, convergence decisions, learnings, and open work. Read it before
touching any renderer, graphics-actual, or layer-engine code.
- Renderer — one Skia `RenderBackend` (Metal on macOS / OpenGL on Linux+Windows /
CPU-raster fallback) driving upstream's vendored GraphicsLayer + Canvas engine.
Retained per-node display lists: transform / alpha / clip changes REPLAY without
re-recording (only a content change or resize re-records; dirty-region rendering
is a non-goal). Text is a reduced-local port over Skia `skparagraph`. Read these
before touching renderer / graphics-actual / layer-engine code:
`SkiaRenderBackend.kt`, `RenderBackend.kt`, `GpuMode.kt`, `ComposeRootHost.kt`
(root host + snapshot-observation sweep — omitting `clearInvalidObservations()`
once leaked the whole graph), `ComposeOwner.kt`, and `SkiaParagraph.native.kt` /
`SkiaParagraphEngine.kt`. Open renderer/fidelity work lives in [PLAN.md](PLAN.md).
- [TOOLING.md](TOOLING.md) — build/vendor/verify scripts and workflows
(build-sdl, sync + drift checks, parity, probe, profiler, coverage,
verify-mac) + the version map and the ref-bump / release runbooks.
- [TODO.md](TODO.md) — audited list of no-ops, stubs, and hardcodes left in the
port, with severity, plus what's missing for a stable 1.12 release.
- [PLAN.md](PLAN.md) §2 — audited list of no-ops, stubs, and hardcodes left in the
port, with P0/P1/P2 severity, plus the road-to-1.0.0 fidelity / SDL / release work
(this subsumes the former TODO.md, which was never committed).
- This file — architecture, module layout, vendoring rules, source-set
hierarchy, density flow, conventions, and common pitfalls.
@@ -33,7 +41,7 @@ Rendering is **Skia everywhere** behind one `RenderBackend` — Metal / OpenGL
an embedded GNU import lib, published to GitHub Packages as
`com.bitsycore.skiko:skiko:0.150.1-mingw.1` (override with
`-PskikoMingwVersion`). The runtime DLL is auto-provisioned next to the exe by
the bridge plugin (`installWindowsSkiaDll`). See SKIKO-MINGW-FEASIBILITY.md.
the bridge plugin (`installWindowsSkiaDll`).
Windowing, input, audio, filesystem access, and the OS-integration surface
(file dialogs, clipboard, "open in Finder/Explorer"…) all go through
@@ -535,7 +543,7 @@ profiler. Quick rules of thumb:
- Slow frame → **profiler** first (`CDN_PROFILE=1`), optimize second.
- Any renderer change → the **`verify-mac`** runbook gates it before commit.
See [RENDERER.md](RENDERER.md) for the renderer work these support.
Open renderer / fidelity / release work is tracked in [PLAN.md](PLAN.md).
## Conventions
+736
View File
@@ -0,0 +1,736 @@
# ROAD TO 1.0.0
**Objective:** get ComposeNativeSDL3 **as close to upstream Compose Multiplatform as
possible** — vendor every file we can instead of rewriting, stay performant, and look
faithful (ideally pixel-identical across macOS / Linux / Windows). The port is already at
99% upstream API coverage with maximal vendoring and no steady-state perf gap on
macOS/Metal; the remaining 1.0.0 work is **cross-platform rendering fidelity** (Windows
skiko-fork divergences), **completing native-actual stubs**, and the release mechanics
(1.12.0-stable re-pin + WIN-SMOKE).
**Status / handoff:** the macOS/Linux-verifiable work is done and committed on `main` (see
**"Landed toward 1.0.0"** below — don't redo it). The remaining blockers need a **Windows host**
→ jump to **"▶ Continuing on Windows (mingwX64) — START HERE"**. This file is self-contained: it
has the build/run commands, the exact confirm steps, the macOS baseline data to diff against, and
per-task fixes. So on Windows you can say **"continue PLAN.md"** and have everything.
## Definition of done for 1.0.0
- [x] No text tofu on any platform — Windows tab fix **CONFIRMED on-device** (§1a, 2026-08-04:
literal `\t`→space, no `.notdef`); CJK / color-emoji / control chars render clean too (§1c).
- [x] Fork-vs-official divergence surface (FontMgr, gamma, ICU) **audited on native Windows**
(2026-08-04): metrics == JVM (§1b), FontMgr fallback works (CJK + color emoji, §1c), ICU
classification works (§1c). Net — the fork's Skia matches upstream; the only deltas are its
thin extern-C bindings, handled in shared Kotlin (LineMetrics reconstruction). No fork-side
fix needed for 1.0.
- [~] Native-actual fidelity blockers closed: float pointer coords **DONE**, screen-reader
no-op **DONE**, text context menu **RECONCILED — already works** via the legacy path
(§2). Date/time localization is P1 polish (formatter already works).
- [x] Vendor hygiene stays clean: zero drift (all 10 manual vendors match pin), zero
commonMain rule-1 violations, zero vendored files touched this session — verified via
`check-vendor-drift.py`.
- [~] SDL static lib slimmed to the used subsystem surface (§3) — **DONE + verified on
macOS/Metal**; other hosts pending WIN-SMOKE.
- [ ] Refs re-pinned to Compose **1.12.0 stable**, JVM parity versions bumped, WIN-SMOKE
fidelity pass green on a real Windows host.
- [x] `CLAUDE.md` documentation map consistent with tree — line-1 typo fixed; the stale
historical docs `RENDERER.md` + `SKIKO-MINGW-FEASIBILITY.md` removed (current renderer
essentials inlined into CLAUDE.md, the rest is in git history); all dead doc links
(TODO.md / RENDERER.md / SKIKO) removed or repointed to PLAN.md.
## Landed toward 1.0.0 (this pass — all build + run verified on macOS/Metal)
Everything below is committed; details + file refs are in the sections that follow.
1. **Tab tofu fix** (§1a P0) — normalize `\t`→space before shaping so the Windows fork
can't render `.notdef`. Platform-independent, length-preserving.
2. **HiDPI caret quantization fix** (§2 P0) — pointer/wheel coords carried as `Float`
end-to-end instead of truncated to `Int`.
3. **Screen-reader no-op** (§2 P0) — `LocalPlatformScreenReader` defaults to inactive
instead of throwing.
4. **SDL slimming** (§3) — 10 unused subsystems disabled in `build-all.py`; 6 now-dead
macOS frameworks dropped from `sdl3.def`. Rebuilt; demo + apidemo link, demo runs.
5. **Context menu reconciled** (§2 P0) — confirmed already working via the vendored legacy
path; the 3 "NOP" seams are vestigial (disabled new API) — misleading TODOs corrected.
6. **`CDN_TEXT_METRICS` diagnostic** (§1b) — env-gated line-metrics dump; macOS baseline
captured, hypothesis sharpened (fork likely drops lineHeight leading).
7. **Doc hygiene** (§5) — removed the stale historical `RENDERER.md` +
`SKIKO-MINGW-FEASIBILITY.md` (renderer essentials folded into `CLAUDE.md`; rest in git
history), fixed the `CLAUDE.md` typo + all dead doc links; extracted the fork
FreeType-scaler lead into §1c before deleting. Vendor drift verified clean.
**Windows pass (2026-08-04, this host — DPR 1).** On a real Windows host at last: (8) tab fix
**CONFIRMED on-device** (§1a); (9) vertical metrics **RESOLVED** — native `--metricsprobe` heights
== JVM `--metrics` exactly, feared "dropped leading" DISPROVEN (§1b); (10) fork `LineMetrics.
ascent/descent` mis-decode **FIXED in shared Kotlin** (`SkiaParagraphOps.lineMetrics()`, guarded
no-op on official skiko) — repairs Windows caret/selection; (11) fork FontMgr/ICU **AUDITED** — CJK
+ color-emoji + control-char fallback all work → the "empty FreeType stub" fear DISPROVEN (§1c);
(12) `FontRasterizationSettings.native.kt` verified to mirror upstream VERBATIM + given a
`VENDOR-BASE` provenance line; rasterization stays upstream-faithful per-OS (user decision). Net:
the fork's Skia matches upstream — the only deltas are its thin extern-C bindings, fixed in shared
Kotlin, **zero fork rebuild**.
**Still not done — WIN-SMOKE interactive + release** (§5): interactive caret-height check (the one
behavioral consequence of the LineMetrics fix), the `PrintWindow` probe, `compileCommonMainKotlin
Metadata`, apiDump + publish FROM Windows, and the Compose 1.12.0-stable re-pin. **Deliberately
deferred** (large/structural, rationale in-section): RTL, `PlatformFontLoader`, grapheme source-set
move, date CLDR, brush/gradient text — plus the big **vendor-the-upstream-skiko-text-engine
refactor (§6)**, sequenced after these fixes land.
---
## ▶ Continuing on Windows (mingwX64) — START HERE
**Read this before doing anything.** This file is the single source of truth for the road to
1.0.0. Everything in **"Landed toward 1.0.0"** above is **already implemented and committed on
`main`** — do NOT redo it, just verify it on this host. Every open `[ ]` / `[~]` below is
remaining work. The macOS/Linux side is done and verified; the remaining blockers need a **real
Windows host** — the mingwX64 build cross-compiled from mac/Linux fails at cinterop, and the
bitsycore skiko **fork** can only be rebuilt on Windows.
**The Windows fidelity reference is skiko-on-JVM Compose Desktop on THIS Windows host**
(`gradlew :demo:run` — also DirectWrite/system fonts), NOT macOS. mac↔Windows metric deltas are
expected in upstream Compose Desktop too, so "matches Windows-JVM" is the acceptance bar, not
"matches macOS".
### Build / run (Windows shell, repo root)
```bat
gradlew.bat :demo:runDebugExecutableMingwX64 :: native demo (the port under test)
gradlew.bat :apidemo:runDebugExecutableMingwX64 :: native apidemo (has the Session/Override buttons)
gradlew :demo:run :: JVM Compose Desktop = the FIDELITY REFERENCE
gradlew :apidemo:run :: JVM apidemo reference
```
- The bridge plugin auto-drops `skiko-windows-x64.dll` next to the exe (`installWindowsSkiaDll`).
Fork coords `com.bitsycore.skiko:skiko:0.150.1-mingw.1` (override `-PskikoMingwVersion`).
- `:demo` CLI flags (native AND jvm): `--screen=<Name>`, `--screenshot=<path.bmp>` (capture at
quiescence then quit), `--gpu=auto|software|skia.opengl`. `CDN_TEXT_METRICS=1` env var dumps
text metrics (§1b). `CDN_PROFILE=1` frame profiler. `:apidemo` has NO screenshot CLI — drive it
by hand for the button-metrics visual.
- If an IC-cache error appears after any module churn: delete `demo\build\kotlin-native-ic-cache`.
### ✅ Windows session results (2026-08-04, this host — DPR 1.0)
Items 1–3 below are **DONE + verified on the shipped mingwX64 demo binary**; findings folded into
§1a/§1b/§1c. Only 4–5 (WIN-SMOKE interactive gate + apiDump/publish) remain. TL;DR:
- **§1a tab fix — CONFIRMED.** `--screen=BasicText` with `"start>a\tb<end\tcols\tand\tthere"`
rendered `start>a b<end cols and there` — every `\t` a SPACE, zero `.notdef`. (Structural proof
too: the whole module has exactly 2 `.addText(` sites, both `shapedText`.)
- **§1b vertical metrics — RESOLVED, the feared bug is DISPROVEN.** Native `--metricsprobe` vs JVM
`--metrics` (same host, NotoSans, density 1): **every paragraph height + non-M3 baseline matches
JVM EXACTLY** (size 11→24). Leading is NOT dropped. Two residual deltas found (details in §1b):
(a) the fork's `LineMetrics.ascent/descent` are mis-decoded — **fixed in Kotlin** this session;
(b) M3 baseline drifts ≤0.77px (halfLeading, a shared-engine omission on ALL platforms, sub-pixel).
- **§1c FontMgr/ICU — the "empty FreeType stub" fear is DISPROVEN.** Native render of CJK
(你好世界 日本語 한국어) + **full-COLOR emoji** (😀🎉🚀❤) + control chars (nbsp/zwsp/en-dash) = zero
tofu. Color emoji + CJK fallback prove `FontMgr.default` is a real DirectWrite-backed system
manager, not an empty custom FreeType one. Remaining §1c work is doc-only (capture `args.gn`).
Reference commands actually used (both probes print `metrics:` lines; native runs a real window):
```bat
demo\build\bin\mingwX64\debugExecutable\demo.exe --metricsprobe :: native/fork metrics
gradlew :demo:run --args="--metrics" :: JVM/upstream metrics
set CDN_TEXT_METRICS=1 && ...demo.exe --screen=Buttons :: raw per-paragraph dump
```
(The JVM leg's screenshot flag is `--screenshot-all=<dir>`, not the native `--screenshot=<file>`.)
### Ordered Windows work (do top-to-bottom; full detail in the numbered sections below)
1. **[§1a] Confirm the tab fix — ✅ DONE** (see session results above). Rendered a literal-tab
string on the fork; all `\t`→space, no tofu. The `SkiaParagraphEngine.shapedText` normalization
holds; no bypass path exists (2 `.addText(` sites, both `shapedText`).
2. **[§1b] Confirm/resolve vertical metrics — ✅ RESOLVED (feared bug DISPROVEN).** Used the
`--metricsprobe` (native) vs `--metrics` (JVM) pair, NOT the screenshot diff (the JVM leg has no
`--screenshot=<file>`; it dumps the same `metrics:` table). Every paragraph HEIGHT matches JVM
exactly (leading applied identically) → correct-by-reference. Only a ≤0.77px M3-baseline drift
remains (halfLeading, all platforms). Separately fixed the fork's broken `LineMetrics.ascent/
descent` in Kotlin. Full data in §1b.
3. **[§1c] Audit the fork FontMgr / gamma / ICU — ✅ AUDITED (fallback WORKS).** CJK + color emoji +
control chars all render with zero tofu on the fork → `FontMgr.default` is a real system
(DirectWrite) manager with working glyph fallback, NOT an empty FreeType stub. Remaining: capture
the fork's REAL `args.gn` (`SK_GAMMA_*`, ICU packaging, freetype-vs-dwrite) into the fork repo so
drift is auditable (doc-only, not a code blocker).
4. **[§5] WIN-SMOKE gate** (pre-ship): (1) the §1b metrics dump, (2) tab/control-char render clean,
(3) the Windows-only `PrintWindow` probe (`scripts/probe/`), (4) `gradlew
:<module>:compileCommonMainKotlinMetadata` (only the Windows job compiles common metadata).
5. **[§5] apiDump + publish FROM Windows** (host-specific; only the Windows job carries the full
mingwX64 variant table). Then, when Compose **1.12.0 stable** ships, do the re-pin + version
bump. Release runbook: [TOOLING.md](TOOLING.md).
---
## 1. Cross-platform FIDELITY (headline)
All Windows divergences live **below Kotlin** — the mingw source set reuses
`src/skikoRendererMain/kotlin` byte-for-byte (`compose/ui/ui/build.gradle.kts:102-113`),
the only mingw-unique file is `PlatformGpu.mingw.kt` (13 lines, GPU only). So every
divergence is in the **fork's Skia binary**, in **`FontMgr.default`'s per-OS impl**, or in
the **GPU backend**. The fork is external (`bitsycore/skiko`,
`com.bitsycore.skiko:skiko:0.150.1-mingw.1`) and its GN args are **not verifiable from this
tree** — recovering them is a prerequisite for several fixes below.
**Fidelity reference for Windows is skiko-on-JVM Compose Desktop on Windows** (also
DirectWrite), NOT macOS/Linux. macOS↔Windows metric deltas partly exist in upstream
Compose Desktop too (per-platform font backends). Reframe V-items as "match JVM-Windows"
acceptance tests, not "bugs vs macOS".
### 1a. Windows tab-character tofu — HIGH CONFIDENCE (root cause traced)
Kotlin sets `ParagraphStyle.replaceTabCharacters = true`
(`compose/ui/ui-text/src/skikoRendererMain/kotlin/.../renderer/skia/SkiaParagraphEngine.kt:234-236`,
exact mirror of upstream `ParagraphBuilder.skiko.kt:625`, CMP-6589). Works on
macOS/Linux (official skiko honors the flag → `U+0009` replaced by space before shaping).
On Windows the same call runs against the fork's flat extern-C DLL, which evidently does
**not** wire the setter → raw `\t` reaches HarfBuzz → NotoSans has no tab glyph → `.notdef`
box. Font fallback is a **red herring**: no font has a tab glyph, only tab→space
replacement cures it.
- [x] **P0** Confirm — ✅ DONE (Windows 2026-08-04): rendered a literal-tab string on the fork
(`--screen=BasicText`); every `\t` shows a SPACE, no `.notdef`. The fork renders everything
else clean too, so tab→space was the only gap. Structural backstop: the module has exactly 2
`.addText(` sites, both `shapedText` — no raw tab can reach skiko.
- [x] **P0** Fix (ship now, author project code): in
`.../SkiaParagraphEngine.kt`, feed skiko a normalized copy — added
`private val shapedText = if (text.indexOf('\t') >= 0) text.replace('\t', ' ') else text`,
used at `addText` (no-span path) and via `shapedText.substring(segStart, segEnd)`
(span path); `text` kept for ALL length/index queries (`getRectsForRange`,
`getCursorRect`, `wordBoundary`, `lineMetrics`, span cut-points). Length-preserving so
every offset consumer stays correct; this is exactly what Skia's flag does internally →
upstream-faithful; makes behavior platform-independent (no fork rebuild needed).
Kept `replaceTabCharacters = true` (harmless/redundant elsewhere).
**DONE** — compiles clean on macosArm64. Windows tofu render still needs on-device
confirmation (WIN-SMOKE §5).
- [ ] **P2** Long-term true fix (fork, out of tree): wire
`ParagraphStyle::setReplaceTabCharacters` through the fork's extern-C surface,
rebuild/republish `skiko-windows-x64.dll`, bump `-PskikoMingwVersion`. Do this *in
addition to* the Kotlin fix, not instead — it keeps the port thin. Can't be verified
in-tree.
### 1b. macOS-vs-Windows vertical text metrics — ✅ RESOLVED (Windows 2026-08-04): heights match JVM, feared bug DISPROVEN
**WINDOWS RESULT (2026-08-04).** All targets render through the SAME Skia, so metrics are expected
to be similar — and they are. Native `--metricsprobe` (fork) vs JVM `--metrics` (upstream skiko) on
this host (NotoSans, density 1) print IDENTICAL paragraph heights and non-M3 baselines for every
size 11→24 (`cell`/`one`/`three`/`oneM3`/`threeM3`, `base1`/`base3` all match). The fork does NOT
drop lineHeight leading — the hypothesis below is disproven. Two small deltas remained, both
handled in SHARED Kotlin (no fork rebuild, keeping fork-reliance minimal):
1. **Fork `LineMetrics.ascent/descent` mis-decode → FIXED in Kotlin (this session).**
`CDN_TEXT_METRICS` showed the fork's per-line `ascent`/`descent` constant (18.837/5.163)
regardless of `fontPx`, while `baseline`/`height`/`width` are correct (mac scales them:
37.674@30px). This is the fork's flat extern-C LineMetrics binding, NOT a Skia difference.
Lines stack contiguously so true ascent = `baseline - lineTop`; `SkiaParagraphOps.lineMetrics()`
now rebuilds ascent/descent from the reliable baseline+height when they're internally
inconsistent (guard `|ascent-(baseline-lineTop)|>0.5` = no-op on official skiko). Repairs caret
height / getLineTop/Bottom / vertical hit-test on Windows. **Behavioral caret check → WIN-SMOKE.**
2. **M3 baseline drift ≤0.77px (halfLeading) — SHARED-engine omission, all platforms.** `base1M3`
differs native-vs-JVM by ≤0.77px in the 11→24 sweep (size 14 → 0.28px, sub-pixel), 2.19px in
the contrived `boundary 24/25` case. Root cause is NOT the fork: `SkiaParagraphEngine` maps
`LineHeightStyle.trim`→HeightMode but never wires `halfLeading`/`LineHeightStyle.Alignment`, so
macOS-native drifts from JVM by the same amount (parity tolerated it as sub-pixel). Fixable in
shared Kotlin (`textStyle.halfLeading` from the alignment); deferred pending a cross-platform
parity re-check (can't run mac parity from a Windows host).
Native-fork metricsprobe vs JVM `--metrics` (this host) — identical except `base?M3`:
```
size=14 lh=20 cell=19 one=19 three=59 base1=14.898 base3=54.898 oneM3=20 threeM3=60 base1M3: 15.682 fork / 15.398 jvm
size=24 lh=30 cell=33 one=33 three=93 base1=25.968 base3=85.968 oneM3=30 threeM3=90 base1M3: 23.546 fork / 24.312 jvm
boundary 24/25 m3=25 base: 19.622 fork / 21.812 jvm
```
The vertical-layout code path is **identical** on all targets. The lineHeight/HeightMode
path is a byte-faithful port of upstream and is NOT the cause: m3 `labelLarge` has
lineHeight 20sp > fontSize 14sp with `LineHeightStyle.Default.trim == Both` →
`HeightMode.DISABLE_ALL`, which collapses the single-line box to `fontAscent + fontDescent`
of the font itself (`SkiaParagraphEngine.kt:244-249`, matches upstream
`ParagraphBuilder.skiko.kt:631-646`). So box height is driven entirely by the font's
reported ascent/descent.
Root cause: **`FontMgr.default` resolves to a different Skia font scaler per build** for
the *same* `font/NotoSans.ttf` bytes (`SkiaFonts.kt:38,44,50`) — CoreText on macOS,
fontconfig/FreeType on Linux, DirectWrite (or FreeType, unverified) in the fork on Windows.
Different backends select different metric tables (hhea `ascender/descender` vs OS/2
`sTypoAscender/Descender` vs `usWinAscent/Descent`) and honor the OS/2 `USE_TYPO_METRICS`
fs_selection bit differently. NotoSans is the textbook case (large top-heavy ascent).
`SkiaParagraphEngine.kt:147-148` read `-defaultFont.metrics.ascent`/`.descent`;
`:150-169 lineMetrics()` forwards skiko's numbers verbatim; `SkiaParagraph.native.kt:116,148-153`
consume them. Contributors V2 (hinting Normal on Windows grid-fits vertically, CoreText
ignores it) and V3 (subpixel/edging) are secondary and per-scaler.
**Hypothesis, not confirmed:** whether Windows-native actually *diverges from
Windows-JVM*. If it matches JVM-Windows it's correct-by-reference (not a bug), and the
macOS↔Windows delta is expected upstream behavior.
- [x] **P0** Confirm FIRST — ✅ RESOLVED (Windows 2026-08-04; heights == JVM, see WINDOWS RESULT
above). Diagnostic **LANDED**: `SkiaParagraphEngine.kt` dumps
`defaultFont.metrics.{ascent,descent,leading}`, `paragraph.height`,
`lineMetrics[0].{ascent,descent,baseline,height}`, `familyName` + `fontPx` per built
paragraph when `CDN_TEXT_METRICS=1` (mirrors the `CDN_PROFILE` env-flag convention).
Run `CDN_TEXT_METRICS=1 <app> --screen=Buttons` (native) and the JVM parity app on the
SAME host, diff the lines. macOS baseline captured (see below). **REMAINING (Windows
host):** compare Windows-native vs **skiko-JVM-Windows**. Expectation: `fontPx` +
lineHeight + heightMode identical, `metrics.ascent` differs by scaler.
**Acceptance test:** Windows-native == skiko-JVM-Windows (→ correct-by-reference, not a
bug); if they differ, it's a fork scaler fix (next item).
**macOS baseline captured (`--screen=Buttons`, DPR 2):** for the fontPx=30 button
labels ("Filled Button" / "OutlinedButton" / "TextButton"): font metrics
`ascent=-32.07 descent=8.79 leading=0` (font box = **40.86px**), but the line box is
`line0[ascent=37.67 descent=10.33 height=48.0]`, `paraHeight=48`. So macOS DOES apply
the lineHeight leading (48 > 40.86) and distributes it **asymmetrically — ~5.6px above,
~1.5px below** → exactly the "extra space on top of the text" the report describes.
**SHARPENED HYPOTHESIS:** this is NOT a mac bug — it's correct lineHeight distribution.
The likely Windows story is the MIRROR of §1a: the fork's flat extern-C surface doesn't
wire the `ParagraphStyle` height/strut path, so Windows renders **tight to the font box
(~41px, no leading)** = the "fitted to text" look. **Sharpened WIN-SMOKE check:** for the
SAME label, compare `paraHeight` + `line0.height`. If macOS/JVM = 48 but Windows-native
≈ 41, the fork drops lineHeight leading (fork fix: wire `heightMode`/strut, like
`replaceTabCharacters`); a Kotlin fallback is a manual `StrutStyle`. If Windows-native
== Windows-JVM, it's correct-by-reference.
**macOS baseline raw lines** (`CDN_TEXT_METRICS=1 demo --screen=Buttons`, DPR 2) — diff
Windows-native + Windows-JVM against these; the fontPx=30 rows are the cleanest signal:
```
text='Filled Button' fontPx=30.0 ascent=-32.070007 descent=8.789978 leading=0.0 paraHeight=48.0 line0[ascent=37.674 descent=10.326 baseline=37.674 height=48.0]
text='OutlinedButton' fontPx=30.0 ascent=-32.070007 descent=8.789978 leading=0.0 paraHeight=48.0 line0[ascent=37.674 descent=10.326 baseline=37.674 height=48.0]
text='TextButton' fontPx=30.0 ascent=-32.070007 descent=8.789978 leading=0.0 paraHeight=48.0 line0[ascent=37.674 descent=10.326 baseline=37.674 height=48.0]
text='Outlined' fontPx=28.0 ascent=-29.932007 descent=8.203980 leading=0.0 paraHeight=40.0 line0[ascent=31.395 descent=8.605 baseline=31.364 height=40.0]
```
(family='Noto Sans' on all.) Expectation if correct: `fontPx`, `paraHeight`, `line0.height`
match JVM-Windows exactly; the raw `ascent`/`descent` may differ from these macOS numbers
because the Windows scaler is FreeType (§1c), but the LINE BOX must still match JVM-Windows.
- [x] **P1** ~~If Windows-native != Windows-JVM: fix in the fork~~ — **N/A (2026-08-04):** heights
MATCH JVM-Windows exactly, so no fork scaler/FontMgr change is needed for vertical metrics.
The `makeFromData` typefaces already get the same metric-table selection as skiko-JVM-Windows.
The one residual (M3 halfLeading) is a shared-Kotlin fix, not a fork one.
- [ ] **P2** (Optional, STRONGER than upstream) If cross-platform pixel-identity is
wanted over matching each host's stock Compose Desktop: build a single
FreeType/`SkFontMgr_Custom_Empty`-backed `SkFontMgr` used on ALL native targets for
the bundled fonts, so NotoSans yields identical ascent/descent everywhere.
Intentional departure from per-platform upstream behavior — decide deliberately.
- [ ] **DO NOT** "fix" by injecting a default `LineHeightStyle` or clamping ascent in
`SkiaParagraphEngine.kt` — that desyncs from upstream `DISABLE_ALL` and masks, not
resolves, the metric divergence.
### 1c. Other fork-vs-official divergences
- [x] **P1** ~~`FontMgr.default` may be an empty/stub manager on the fork~~ — **DISPROVEN
(Windows 2026-08-04).** Native render of CJK (你好世界 日本語 한국어) + **full-COLOR emoji**
(😀🎉🚀❤) + control chars = zero tofu. Color emoji + CJK fallback are impossible with an empty
custom FreeType manager, so the fork's `FontMgr.default` IS a real DirectWrite-backed system
manager with working glyph enumeration/fallback. No fork change needed. `SkiaFonts.kt` wiring
stays as-is.
- [x] **P1** `FontMgrWithFallback` — ANALYZED + fallback now VERIFIED on Windows. The port
does `setDefaultFontManager(fontMgr)` [system] + `setAssetFontManager(provider)`
[bundled] (`SkiaFonts.kt:49-52`). This is **functionally equivalent** for missing-glyph
fallback on macOS/Linux/Windows-JVM: skiko's shaper tries the asset provider (bundled
Noto + aliases) first, then the default/system manager for CJK/emoji fallback, which
resolves because the system FontMgr has those fonts. Rewriting to
`setDefaultFontManager(FontMgrWithFallback(provider))` risks a macOS fallback
regression with no observable gain, and I can't visually verify CJK/emoji fallback on
this host. **The real gap was thought to be Windows-fork-only** (fork `FontMgr.default`
empty stub → no system fallback) — but that's **DISPROVEN (2026-08-04):** CJK + color-emoji
fallback works on native Windows too (item above). So the setDefault/setAsset wiring is fine
on ALL targets. **Decision:** leave the Kotlin as-is; nothing to fix in the fork here. Re-open
only if a CJK/emoji fallback bug is actually observed.
- [x] **P2** ~~ICU/unicode data packaged differently in the fork~~ — **VERIFIED OK (2026-08-04).**
Rendered U+00A0 (nbsp → visible space), U+200B (zwsp → zero-width/collapsed), en-dash, and
U+0009 (tab, pre-replacement) on native Windows: all classified/handled correctly, no tofu.
The force-exported `uloc_*_skiko` symbols do their job; skunicode classification works. (This
is also why HarfBuzz shaping + skparagraph bidi work at all — ICU data is present via
icudtl.dat.)
- [~] **P2** Text gamma/AA edges: a Skia **build-time constant**
(`SK_GAMMA_EXPONENT`/`SK_GAMMA_CONTRAST`/`SK_GAMMA_APPLY_TO_A8`), NOT a GL-vs-Metal
runtime difference. **Note (2026-08-04):** native Windows text AA looked clean in the render
(no visible AA anomaly), so no fork gamma fix is pursued for 1.0. Still worth capturing the
fork's values in the fork repo (see the args.gn item below). **Kotlin-side rasterization
settled:** the `RASTER_EDGING/HINTING/SUBPIXEL` in `SkiaParagraphEngine` come from
`FontRasterizationSettings.PlatformDefault`, which was verified (2026-08-04) to reproduce
upstream Compose Desktop's per-OS defaults VERBATIM (Win/mac=Normal hinting, Linux=Slight).
**User decision:** keep the upstream-faithful per-OS behavior (match each platform's stock
CMP), NOT a forced uniform cross-platform look. So there is no SDL_TTF-era gamma hack left to
strip — Skia is configured exactly as upstream does it.
- [x] **GL-vs-Metal AA/gamma/color-space — RULED OUT as a primary cause.** Both bridges
create the surface `colorSpace = null` (`SkiaGLBridge.kt:68-74`,
`SkiaMetalBridge.kt:133-139`) — identical un-color-managed legacy blending. Only
deltas are cosmetic-correct: `RGBA_8888`/`BOTTOM_LEFT` (GL) vs `BGRA_8888`/`TOP_LEFT`
(Metal) — channel order + Y-flip, matched to buffers. Windows uses OpenGL
(`PlatformGpu.kt:20`), same backend as Linux, so GL-vs-Metal can't explain a
Windows-vs-Linux gap at all. Deprioritized — no action.
- [~] **P1** The fork's Skia build config (GN args). **Lead extracted from git history**
(the old `SKIKO-MINGW-FEASIBILITY.md`, deleted as stale — its build recipe lives in
`git show bdb5c64d^:SKIKO-MINGW-FEASIBILITY.md`): the fork's Route-1a recipe builds Skia
with **`skia_use_freetype`**, i.e. the Windows text scaler is **FreeType**, not
DirectWrite and not macOS CoreText — a concrete reason the SAME NotoSans bytes yield
different ascent/descent on Windows (different metric-table selection) AND why fork
`FontMgr.default` may not enumerate Windows system fonts for glyph fallback (FreeType
has no system fontmgr without fontconfig). **CAVEAT:** that's the *recommended recipe*,
not a verified dump of the shipped `0.150.1-mingw.1` GN args. **REMAINING:** whoever
rebuilds the fork should capture the real `args.gn` (`SK_GAMMA_*`, ICU packaging,
freetype-vs-dwrite) alongside the fork sources so drift is auditable — NOT as a doc in
this repo (kept lean); a comment in the fork repo or `build-sdl.properties`-style pin.
---
## 2. VENDORING & upstream-fidelity debt
Vendor hygiene is **release-ready**: `check-vendor-drift.py` reports all 10 manual vendors
match pin `v1.12.0-beta03+dev4483`; zero commonMain rule-1 violations (the 3 authored
`androidx.compose.*` files in commonMain are all provenance-tracked Rule-3 vendors). The
macosMain DIAGNOSTIC GAP families (`ui/ui` 12, `foundation` 23: `CoreTextField.macos.kt`,
`SelectionManager.macos.kt`, `PlatformClipboard.macos.kt`, …) are **correctly excluded**
(AppKit/NSView; the port uses SDL) — keep them in gaps. **Do not spend 1.0.0 effort
re-vendoring.** The debt is **completing native-actual stubs**.
### P0 — blocks fidelity/correctness
- [~] **P0** Text context menu (right-click copy/paste/select-all) — **RECONCILED: already
works, no reimplementation.** Static trace confirmed the menu is wired end-to-end
through the vendored LEGACY path: text-level `ContextMenuArea` (`ContextMenu.native.kt`)
→ `CommonContextMenuArea` (`vendor/common/.../text/CommonContextMenuArea.kt`) → (native
`ComposeFoundationFlags.isNewContextMenuEnabled = false`, so the legacy branch) →
`contextmenu.ContextMenuArea` → `contextMenuGestures`/`onRightClickDown`
(`isSecondaryPressed`) → `ContextMenuPopup` → `Popup.native.kt` (hosted by
`LocalPopupHost` at the app root). Items are real: `TextFieldSelectionManager` /
`SelectionManager.contextMenuBuilder` emit Cut/Copy/Paste/SelectAll with real actions.
SDL right-click → `PointerButton.Secondary` (`SDL3EventMapper.kt`) →
`isSecondaryPressed` (`PointerEventBridge`). The three "NOP" seams belong to the
DISABLED *new* context-menu API and are unreachable — **DONE:** their misleading
`TODO(CMP-7819)` comments corrected to say so, in all three files.
**REMAINING:** an interactive right-click smoke check (no headless driver here — fold
into manual/WIN-SMOKE). Known limitation, not a breakage: Paste enablement is
plain-text-only (`ClipboardPasteState.hasClip = hasText`, see P2 below).
- [ ] **P1** DatePicker/TimePicker localization — VERIFIED current state:
`material3/.../internal/PlatformDateFormat.native.kt` is a real kotlinx-datetime
formatter that DOES honor CLDR patterns/skeletons (renders "Jul 29, 2026" /
"July 2026" correctly — the agent's "always yyyy-MM-dd" finding was stale). Genuine
remaining gaps, all narrower than a correctness blocker: field NAMES English-only
(`:29 weekdayNames`, `:147-154 MONTH_NAMES/ABBR` — no CLDR data bundled), `:27
firstDayOfWeek=1` fixed Sunday, `:67 is24HourFormat()=true` fixed, `:46 parse()`
ISO-8601-only. Upstream `darwinMain` uses `NSDateFormatter` (not portable). **Fix
(author):** bundle a CLDR subset or K/N i18n lib for localized names + locale-aware
first-day/24h; also unblocks `CalendarLocale.native.kt:20` (fixed `"en"`). Ships
readable English dates today — polish, not a P0 gate.
- [x] **P0** Float pointer coordinates — `SDL3EventMapper.kt` `.toInt()`-truncated SDL's
Float `mb.x`/`mb.y`/`mm.x`/`mm.y` and the wheel `mouse_x/y` before the DPR multiply in
`ComposeWindow.kt`. On 2× displays a click at logical 100.9 → physical 200 not ~201,
quantizing the caret to 2px steps near glyph edges. **DONE:** widened
`LegacyPointerEvent.x/y` and `AppEvent.MouseWheel.x/y` to `Float`, dropped the
`.toInt()` at the three mapper construction sites, and removed the now-redundant
`.toFloat()` at the two `ComposeWindow` read sites. Consumers fully contained (verified
by grep). Builds + runs clean on macosArm64.
### P1 — quality / parity
- [ ] **P1** RTL text unsupported — `ui-text/.../SkiaParagraph.native.kt:112`
`textDirection = ResolvedTextDirection.Ltr` fixed field; `getParagraphDirection()`
(`:202`) always Ltr. Bidi run direction (`:204`) does read box direction (partial
machinery). Root cause: `text/intl/Locale.native.kt isRtl()` unimplemented. Fix
(author) tied to P1.2 engine.
- [ ] **P1** `PlatformFontLoader` NOP — `ui-text/.../font/FontFamilyResolver.native.kt`
`SdlPlatformFontLoader.loadBlocking`/`awaitLoad` NOP;
`PlatformFontFamilyTypefaceAdapter.resolve` returns `Immutable(Unit)`. Raw androidx
`Font(bytes)`/`ResourceFont` don't load (compose-resources `Font()` works). Upstream
`SkiaFontLoader.skiko.kt` is the vendorable reference. This is the long-deferred P3.2
font resolver — **high-risk L**, parity harness wouldn't exercise it. Scope
deliberately for 1.0.0 (accept-and-document vs attempt). **NOW ALSO GATES §6** — the
2026-08-04 spike found the verbatim engine-vendor routes font resolution through this NOP, so
implementing this loader (so `SkiaFonts`' icon/variable-axis model rides upstream's seam) and
the §6 engine-vendor are ONE effort. Do them together, with macOS/Linux verification.
- [ ] **P1** `CharHelpers.skiko.kt` grapheme-break logic — the ONE cheap selective-vendor
win from the hand-rolled text engine. Fixes `findPrecedingBreak`/`findFollowingBreak`
splitting emoji/combining marks in `StringHelpers.native.kt:31` +
`CharHelpers.native.kt:14`. (Giving `:foundation` a `skikoRenderer` source set would
unblock this + `DragAndDropSource.skiko` — L-effort structural move, per §13a.)
- [~] **P1** `Serif`/`Cursive` generic families — RE-SCOPED after reading `SkiaFonts.kt`.
They do NOT silently collapse to sans: `baseTypeface()` routes `generic:serif` /
`generic:cursive` through `resolveGeneric()` → `fontMgr.matchFamilyStyle()` over a
per-OS candidate list (`genericFamilyAliases`: Times/Noto Serif/… on each host), only
falling back to the bundled default if NONE is installed. So serif/cursive already
render as a real serif/cursive wherever the OS ships one (all three target OSes do).
The only true gap is **cross-platform pixel-identity** / a host with no serif installed,
which needs a BUNDLED Noto Serif — an app-level opt-in (buildSrc `downloadNotoFonts` +
data.kres size cost), not a library correctness bug. **Decision:** leave library
resolution as-is; offer bundled-serif as an opt-in later if an app needs it.
- [x] **P1** Document the hand-rolled text engine as an **accepted architectural
deviation** — **DONE:** rationale header added to the top of
`ui-text/compose-fork.txt` (the exact place someone would go to "fill" the gaps),
explaining the reduced local engine, the flat-source-set reason, and that RTL /
stroke-DrawStyle / grapheme reductions are tracked here (§2), not fixed by vendoring.
Also reflected in `SkiaParagraphEngine.kt`'s header and CLAUDE.md's renderer summary.
### P1/P2 — reduced-coverage + accessibility
- [ ] **P1** Text paint path handles only `SolidColor`
(`SkiaParagraph.native.kt:253-256`) — no brush/gradient text fill, `drawStyle`
(stroke), or non-`SrcOver` blendMode. Part of the reduced style coverage; extend on
the local engine.
- [ ] **P1** `SkiaParagraphEngine.kt:150-164` span segmentation drops partial `SpanStyle`
overlaps (keeps only fully-covering `start<=segStart && end>=segEnd`). Documented
divergence (`b63-upstream-text-mingw.md`) — widen to true interval segmentation.
- [~] **P1** Accessibility absent — `ComposeOwner.kt:~340` builds a `semanticsOwner` never
traversed to any OS a11y API; `SemanticsRegion.native.kt intersect()/difference()`
hardcode `false`. Not vendorable (NSAccessibility/UIA/AT-SPI). **Decision for 1.0.0:
out of scope for desktop.** The full a11y pipeline stays P2/out.
- [x] **P0** No-op `PlatformScreenReader` — `CompositionLocals.native.kt`
`LocalPlatformScreenReader` default no longer throws (`error(...)`); it returns an
`InactivePlatformScreenReader` (`isActive = false`) so a11y-gated vendored code
degrades to "no reader present" instead of crashing. **DONE** — builds + runs clean.
- [ ] **P2** `SkiaFonts.kt` fidelity: `resolveCache` unbounded (font-cache half of P3.4
deferred); variation key passes `null` density (`:65,78`) so density-dependent `opsz`
in sp resolves wrong. `NamedFont.equals/hashCode` axis identity reportedly fixed —
re-verify.
### P2 — cosmetic / edge
- [ ] **P2** `BlendMode.Multiply` renders wrong — opaque cyan×yellow reads back blue;
likely Metal premultiply in graphics-layer flatten, not the `toSkia()` map. Isolate on
the Skia draw path.
- [ ] **P2** Drag-OUT of window NOP — `ui/.../draganddrop/Sdl3DragAndDropOwner.kt:39`
`requestDragAndDropTransfer`; `DragAndDropSource.native.kt:36` no drag-shadow. SDL3
has no portable start-drag → needs NSDraggingSession/DoDragDrop/XDND per-OS.
Drop-INTO works. Accepted 1.0 gap (SDL platform limit) — document.
- [ ] **P2** `NativeStringDelegate.native.kt:17` `toUpper/toLowerCase` ignore `locale`
(Turkish i). Upstream `darwin` unvendorable (NSString).
- [ ] **P2** Prefetch scheduler NOP — `platform/PrefetchLocals.native.kt:18`, lazy lists
skip ahead-of-time composition (scroll-in jank).
- [ ] **P2** `ClipboardPasteState.hasClip` aliased to `hasText`
(`TextFieldSelectionState.native.kt:43`) — image-only clipboard undetected.
- [ ] **P2** `ComposeOwner.kt` gaps: `autofill`/`autofillManager` null (`:324`),
`hapticFeedBack` NOP (`:279`), deprecated `clipboardManager` NOP (`:287`, real
`LocalClipboard` works), `textToolbar` stub (`:311`, touch-gated). No multi-monitor
enumeration (`SDL_GetDisplays` unused). Most match upstream desktop — verify, then
leave or fill.
- [x] **P2** `installGlobals()` → `registerGenericFonts()`: VERIFIED not a hot-path cost —
`registerGenericFonts()` already early-returns on a `fRegistered` flag
(`GenericFonts.kt:20-26`), so the per-event call is a single boolean check, not a
re-register. Original premise inaccurate; no change needed. (Minor residue: the
`ImeBridge.onSessionActiveChange` lambda is reassigned per event — negligible, left
as-is since it repoints per active window.)
---
## 3. SDL slimming
Static lib built from source per host (`scripts/build-sdl/build-all.py`, ref
`release-3.4.12`); linked into the exe via `sdl3.def` (`staticLibraries=libSDL3.a`). Today
only tests/examples + Windows D3D12/GPU are off (`build-all.py:311-319`). `SDL_Init` uses
**`SDL_INIT_VIDEO` only** (`SDL3Backend.kt:49`).
**USED (keep ON):** Video/window, Events, Clipboard, Dialog (file open/save — no cheaper
substitute; Linux uses portal/zenity, keep deps), OpenGL + Metal contexts, Render (2D CPU-
raster blit path: `SkiaSurfaceBridge` uploads to `SDL_Texture` + `SDL_RenderPresent`),
Filesystem, Cursor, Locale, System theme, Text input/IME, OpenURL, timing/hints.
**UNUSED (disable — zero references, no transitive need):** Audio, Joystick (→disables
Gamepad), Haptic, HIDAPI, Sensor, Power, Camera, GPU API, Offscreen video driver, virtual
joystick.
- [x] **P1** Added to `build-all.py` shared `vExtra`: `-DSDL_AUDIO=OFF`,
`-DSDL_JOYSTICK=OFF`, `-DSDL_HAPTIC=OFF`, `-DSDL_HIDAPI=OFF`, `-DSDL_SENSOR=OFF`,
`-DSDL_POWER=OFF`, `-DSDL_CAMERA=OFF`, `-DSDL_GPU=OFF`, `-DSDL_OFFSCREEN=OFF`,
`-DSDL_VIRTUAL_JOYSTICK=OFF`. Promoted `-DSDL_GPU=OFF` from the Windows-only branch to
the shared list; kept the per-host `-DSDL_RENDER_D3D12=OFF` (Windows). (Skipped
`-DSDL_DISABLE_INSTALL_DOCS` — not a real SDL3 option; the build's `cmake --install`
is relied upon.) All ten confirmed zero-reference by grep before disabling.
- [~] **P1** After the flag change, rebuild + run apps to confirm no regression.
**DONE on macOS/Metal:** `build-all.py` rebuilt libSDL3.a clean with the new flags;
`:demo` links + runs to a settled screenshot (TextField screen, no visual regression);
`:apidemo` links clean too. **PENDING:** both apps on Linux + Windows hosts (fold into
WIN-SMOKE §5). If audio/gamepad is ever needed by a consumer app, re-enable the flag
and rebuild the static lib (build-time only).
- [x] **P2** Prune now-unreferenced macOS frameworks from `sdl3.def`. **DONE:** verified
against the regenerated `libs/SDL3/lib/pkgconfig/sdl3.pc` `Libs:` line (source of
truth), which after slimming no longer references `CoreAudio`, `AudioToolbox`,
`AVFoundation`, `GameController`, `ForceFeedback`, or weak `CoreHaptics` — removed all
six from `linkerOpts.osx` (kept `CoreMedia`, which the `.pc` still lists). Demo relinks
clean. (Left Linux `linkerOpts` alone — those `-l` entries serve Skia/GL/X11, not the
disabled SDL subsystems.)
- [ ] **P2** (higher-risk, flag-don't-apply) Render-driver pruning to software-only. Only
the CPU-raster fallback uses `SDL_Render`; GL/Metal go direct. But
`SDL_CreateRenderer(window, null)` (`SDL3Backend.kt:117`) lets SDL pick the first
driver — with only software present it picks software (fine) but couples the fallback
to that assumption. **Conservative: leave render drivers alone.**
**Do NOT** add `-DSDL_DISABLE_INSTALL` — the build relies on `cmake --install`
(`build-all.py:342`) to stage `libSDL3.a` + headers. `SDL_DYNAMIC_API` auto-disables for
static builds (no flag needed). **Risk:** a future consumer app needing SDL audio/gamepad
requires a static-lib rebuild (build-time, not a code change).
---
## 4. Performance
Prior PLAN Phases 0–3 landed; §12 measurement showed **no steady-state perf gap on
macOS/Metal**. Remaining perf question (if any) is Windows-GL or heavy-interaction
specific. Retained-layer engine is byte-for-byte upstream — not the gap.
- [ ] **P2** WON'T-FIX confirmed, keep as-is (don't relitigate): P0.4 per-frame full-surface
clear + P0.6 per-frame Metal drawable reacquire (`CAMetalDrawable` single-use per
frame, matches upstream `MetalRedrawer`).
- [ ] **P2** P2.2 upstream `GlobalSnapshotManager` invalidation-driven scheduling —
deferred; coalescing half done by P1.3; full manager is skiko-windowing-coupled
(an explicit renderer non-goal — skiko-windowing-coupled). Leave deferred unless a
Windows-GL perf gap surfaces.
- [ ] **P2** `SkiaImageCache` font-cache half of P3.4 — evicted typefaces stay referenced by
live paragraphs + `TypefaceFontProvider` (no clean unregister) so eviction frees no
memory. Native-resource lifecycle backstop is the periodic `GC.collect()` nudge (top-
level `Paragraph` a live `Text` holds has no Compose dispose seam). Accept for 1.0 or
add a provider-unregister path.
- [ ] **P2** P4.1 second-renderer manual-vendor reversal (restore upstream RenderNode
shadows via `SkiaGraphicsContext.setLightingInfo` + relocate
`prepareTransformationMatrix`; deltas D2–D6). Buys vendoring cleanliness at
shadow-lighting/hit-test regression risk (current `NativeShadowCanvas` shadows render,
draw=0.08ms). Low ROI — deferred, needs full verify-mac + parity.
- [ ] **P2** Profile the shipped **Windows-GL** binary (`CDN_PROFILE=1`) under heavy
interaction before ship (present phase is vsync-capped by display refresh — profile on
the target monitor). Only open perf unknown.
---
## 5. API coverage / release mechanics
API coverage vs upstream is **99%** (8665/8751 decls via `compose-coverage.py`). Misses are
AppKit/UIKit/web host actuals (N/A), `ImageComposeScene`/`renderComposeScene` (test util),
and the `ui-text.platform` font layer (= P3.2 font resolver). The 1076 "extra" decls are
umbrella-repo modules the tool can't compare + version skew, not invented surface.
- [ ] **P0** Re-pin refs to Compose **1.12.0 stable** when it ships (currently
`v1.12.0-beta03+dev4483` — no clean beta03 tag on Maven). Bump both
`scripts/compose-fork/compose.properties` refs, re-sync (`scripts/compose-fork/sync.sh`),
let the build surface breakage.
- [ ] **P0** Bump `vComposeJvmVersion` in `:demo` / `:apidemo` / `:material-symbols` (JVM
parity leg currently forced to beta02 by documented skew — native leads).
- [ ] **P1** Run `check-vendor-drift.py` after the ref bump — re-stamp `VENDOR-BASE` on the
10 manual vendors, hand-reconcile any that actually changed base..pin.
- [~] **P0** WIN-SMOKE fidelity pass (Windows host only, pre-ship gate): the Mac runbook
cannot cover the shipped mingwX64 binary. Assert: (1) NotoSans `FontMetrics` dump
native-vs-JVM-Windows (§1b acceptance) — **✅ DONE 2026-08-04 (`--metricsprobe`==`--metrics`)**;
(2) `\t`/control-char render clean (§1a) — **✅ DONE**; (3) the Windows-only `PrintWindow`
probe — pending (interactive); (4) the common-metadata publish job — compile gate now
**✅ GREEN (2026-08-04, see the fix below)**; the actual publish + downstream-consume smoke
still owed. Remaining: (3) `PrintWindow` probe + the interactive caret-height check for the
§1b LineMetrics fix.
- [ ] **P0** apiDump is **host-specific** — do NOT commit macOS dumps. Only the **Windows
publish job compiles common metadata** (owns the root KotlinMultiplatform publications
— the only host declaring every target, so only its `.module` files carry the full
variant table; macOS-published roots left v0.1.15 without mingwX64 variants). Test
`gradlew :<module>:compileCommonMainKotlinMetadata` before tagging; publish from
Windows.
- [x] **P0** **FIXED (2026-08-04) — common-metadata compile is now GREEN** (`compileCommonMainKotlin
Metadata` across all modules → BUILD SUCCESSFUL). Was RED, blocking the Windows publish.
Running `:material3:compileCommonMainKotlinMetadata` (or the aggregate) had failed at
`:foundation:compileNativeMainKotlinMetadata` (pulled in via `:foundation:allMetadataJar`):
`Scrollbar.skiko.kt` + `v2/Scrollbar.skiko.kt` — `Declaration annotated with
'@OptionalExpectation' can only be used in common module sources`
(`OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE`). **Root cause:** upstream keeps these
`.skiko.kt` files in `skikoMain`, which INCLUDES the JVM-desktop target, so `@JvmName`/
`@JvmStatic`/`@file:JvmName` resolve; the port vendors them into **native-only `nativeMain`**
(no JVM target under it), so those JVM annotations are orphaned and the *metadata* compile
rejects them (per-target compile tolerates them — that's why the APPS build fine, but the
publish's `allMetadataJar` recompiles nativeMain metadata and dies). `:ui` and below pass;
`:foundation` is the first break. **Blast radius:** 14 vendored native files use `@Jvm*`
(`grep -rE "@file:JvmName|@JvmName|@JvmStatic" .../src/vendor/native .../src/nativeMain`); 11
are in the GITIGNORED, sync-regenerated `src/vendor/native/` tree, so per-file edits DON'T
survive a re-sync. **Fix (build/sync-level, not per-file):** either (a) a `sync.sh`
post-step that injects `@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE")`
(or strips `@Jvm*`, which are no-ops with no JVM library target) into vendored native files —
there's precedent: `components-resources/…/ResourceState.blocking.kt` already carries that
suppress; or (b) a KGP-level relaxation of the native metadata compilation. Needs a real
publish + downstream-consume verification after. Dedicated effort — likely THE reason the
Windows metadata publish never went green.
**FIX APPLIED (2026-08-04):** the root turned out narrower — sync.py ALREADY injects the K2
`@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE", "LESS_VISIBLE_TYPE_ACCESS_IN_INLINE")`
into every `src/vendor/` .kt (`sync.py:313`), so the regenerated tree was fine. The only gap
was the two COMMITTED manual vendors in `src/nativeMain` that sync doesn't regenerate:
`foundation/…/Scrollbar.skiko.kt` + `v2/Scrollbar.skiko.kt`. Added the identical `@file:Suppress`
to both (provenance comment updated; `check-vendor-drift` still clean). Full
`compileCommonMainKotlinMetadata` now green. **Remaining before publish:** run the actual
publish + a downstream-consume smoke (the compile gate is necessary, not proof the `.module`
variant table is complete).
- [ ] **P1** Version bump to `1.0.0` across published coords once the above are green.
- [x] **P0** Doc-hygiene blocker: `CLAUDE.md` documentation map referenced `RENDERER.md`,
`SKIKO-MINGW-FEASIBILITY.md`, and `TODO.md`. **DONE:** removed `RENDERER.md` +
`SKIKO-MINGW-FEASIBILITY.md` (stale/historical — current renderer essentials inlined in
CLAUDE.md's doc map, the rest recoverable from git history); fixed the `CLAUDE.md`
line-1 `ean` typo; dropped the `TODO.md` link (never committed) → its content is PLAN.md
§2. `git grep` confirms no remaining links to any deleted doc.
---
## 6. Vendor the upstream skiko text engine (next major refactor — approved 2026-08-04)
**Goal:** honour "VENDOR ALL WE CAN". Delete the hand-rolled reduced text engine
(`SkiaParagraph.native.kt` + `NativeParagraphOps` seam + `SkiaParagraphEngine.kt` +
`FontRasterizationSettings.native.kt` port) and vendor upstream's skiko text subsystem VERBATIM.
**Why it was hand-rolled (the REAL reason, not SDL_TTF):** `Paragraph` is `expect sealed interface`
(`vendor/common/…/Paragraph.kt:56`) whose `actual` sits in the **skiko-FREE `nativeMain`**, so a
sealed implementer can't touch skiko → the hand seam. `nativeMain` is skiko-free because it's the
shared parent of the **official-skiko** (mac/linux, `libs.skiko`) and **fork-skiko** (mingw,
`com.bitsycore.skiko:skiko`) legs — two different artifacts, so the shared parent can carry neither.
**Why it's feasible NOW (all-skiko + srcDir reuse):** every native leg already reaches
`src/skikoRendererMain/kotlin` (mingw via `kotlin.srcDir(...)` in `skikoRendererMingwSharedMain`,
`ui-text/build.gradle.kts:54`), and the fork exposes the SAME `org.jetbrains.skia.*` Kotlin API. So
the `Paragraph` **actual + engine can move DOWN into the skiko source set** (served to all targets)
instead of skiko-free `nativeMain` — which lets upstream's skiko files compile as-is.
**⚠️ SPIKE FINDING (2026-08-04) — the real gate is the FONT LOADER, not the source-set move.**
A read-only feasibility map (no code changed) found two layers:
- **Layer 1 — source-set / sealed move: FEASIBLE.** The `actual sealed interface Paragraph`
(`vendor/native/Paragraph.native.kt:36`, signature-only mirror) + its implementer + the 11 factory
actuals can move to `skikoRendererMain` (all leaves reach it; sealed same-source-set holds). Just
mechanical.
- **Layer 2 — font model: THIS is the blocker, and it's the SAME task as §2-P1 `PlatformFontLoader`.**
Upstream's engine (`SkiaParagraphIntrinsics.skiko.kt:38,61`) resolves typefaces through
`FontFamily.Resolver` → `PlatformFontLoader`. The port's `SdlPlatformFontLoader`
(`font/FontFamilyResolver.native.kt:30-49`) is a **NOP** (`loadBlocking`/`awaitLoad`→`Unit`,
`resolve`→`Immutable(Unit)`). The port's hand-rolled engine exists precisely to BYPASS that NOP by
resolving through `SkiaFonts` (icon fonts + variable axes + data.kres bytes). So vendoring the
upstream engine verbatim routes font resolution through the NOP → **all text goes blank/tofu**
until the loader is real.
- **Consequence:** the verbatim engine-vendor REQUIRES first implementing upstream's font-loader path
(vendor `SkiaFontLoader.skiko.kt` as the reference) so the port's `SkiaFonts` icon/variable-axis
model rides upstream's `PlatformFontLoader`/`FontLoadResult` seam. This is the deferred high-risk P1
in §2 — it and this §6 are one effort. **Intermediate option:** vendor the engine files but keep
`SkiaFonts` via a Rule-3 edit at the one resolve call site (vendors most files verbatim, 1–2 stay
hand-reconciled). **Verification needs macOS + Linux hosts** (parity), so this is a dedicated
effort, not a Windows-only session — do it with mac access, on its own branch.
**Plan (spike on a branch, verify on THIS Windows host — build + render mingwX64):**
1. Move the `Paragraph`/`ParagraphIntrinsics` **actuals** into `skikoRendererMain` (reused by mingw
via the existing `srcDir`). Confirm the `expect sealed` actual is accepted there for every leaf
target (the b63 memo hit a sealed-in-`nativeMain` wall — test whether hosting the actual in the
skiko set clears it; the sealed same-module rule is per-target-compilation, which should hold).
2. Vendor VERBATIM into `src/vendor/skikoRenderer/`: `SkiaParagraph.skiko.kt`,
`ParagraphBuilder.skiko.kt`, `SkiaParagraphIntrinsics.skiko.kt`, `SkiaTextPaint.skiko.kt`,
`ParagraphLayouter.skiko.kt`, `PlatformFont.skiko.kt`, `FontRasterizationSettings.skiko.kt`,
`TextStyle.skiko.kt`; add them to `ui-text/compose-fork.txt`; delete the hand-rolled files.
3. Reconcile the ~7 hierarchy-crossing `expect/actual` pairs the inverted layout needed; keep the
`SkiaFonts` family/variable-axis model (it's the port's real value-add) behind upstream's seams.
4. **Fork-only Rule-3 edit:** upstream's code calls `paragraph.lineMetrics` — the fork's extern-C
binding mis-decodes ascent/descent (§1b finding). Either carry the guarded reconstruction (from
`SkiaParagraphOps.lineMetrics()`) into the vendored `SkiaParagraph.skiko.kt` as a Rule-3 edit, or
fix it in the fork DLL. **Bonus:** vendoring `ParagraphBuilder.skiko.kt` verbatim wires
`halfLeading` → the §1b M3-baseline drift disappears for free.
5. Verify: mingwX64 build + `--screen=BasicText/Buttons/AnnotatedString` render clean +
`--metricsprobe` == JVM `--metrics`. Then macOS/Linux verify + parity (needs those hosts).
**Sequencing:** AFTER the current shared-Kotlin fixes land (this session). If the sealed-actual move
proves infeasible, fall back to keeping the seam but at least converting the hand files to tracked
Rule-3 vendors (like `FontRasterizationSettings.native.kt` now has a `VENDOR-BASE` line).
## Accepted 1.0.0 gaps (documented, not fixed)
Drag-OUT of window (SDL platform limit; drop-IN works), full accessibility pipeline (out of
scope for desktop 1.0 — but ship the non-throwing `PlatformScreenReader` no-op), the
hand-rolled text engine as an architectural deviation (§2 P1), per-focus
`SDL_StartTextInput/StopTextInput`, `loadImageBitmap`/`loadSvgPainter` (JVM `InputStream`
signatures, N/A on K/N).
+13 -2
View File
@@ -80,7 +80,10 @@ commonMain.dependencies {
### Building from your own project: the bridge plugin
The klibs publish to GitHub Packages under `com.bitsycore.compose.sdl:*`. Apply
The klibs publish to [maven.bitsycore.com](https://maven.bitsycore.com/releases)
(no auth) and GitHub Packages (authenticated fallback) under per-area coordinates that mirror
upstream — `com.bitsycore.compose.ui:ui`, `com.bitsycore.compose.foundation:foundation`,
… (the `com.bitsycore` fork of each `org.jetbrains.compose.*`). Apply
the bridge Gradle plugin once, declare the **official** Compose Multiplatform
coordinates, and the plugin swaps in the port's klibs on native desktop targets
while android, jvm, iOS, and wasm keep resolving the official artifacts.
@@ -103,7 +106,15 @@ commonMain.dependencies {
}
```
Repositories, credentials, and version pinning:
**Two GitHub Packages repos are needed.** A consumer declares the port's repo
(`bitsycore/compose-desktop-native`) **and** the skiko fork's (`bitsycore/skiko`).
The Windows (mingwX64) target renders through the bitsycore skiko fork, and
GitHub Packages binds each package name to a **single** repository — so
`com.bitsycore.skiko` can't be mirrored into the port's repo, it stays in its
own. Scope them with `includeGroup`/`excludeGroup("com.bitsycore.skiko")`
(macOS/Linux pull the official skiko from Maven Central instead; if you build no
mingwX64 you can omit the fork repo). The exact `repositories {}` snippet,
credentials, and version pinning:
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
For a complete project that applies the bridge and builds one shared UI for
-214
View File
@@ -1,214 +0,0 @@
# RENDERER.md
The durable reference for the rendering layer: how it is structured, the
convergence work that shaped it, the decisions worth remembering, and what is
left. Read this before touching anything under `renderer/`, the graphics
actuals, or the layer engine.
For build and verification commands see [TOOLING.md](TOOLING.md). For module
layout, source-set hierarchy, and vendoring rules see [CLAUDE.md](CLAUDE.md).
## 1. The renderer
> **This branch is Skia-only.** The from-scratch SDL renderer leg
> (`sdlRendererMain`: `Sdl3Canvas` / `Sdl3TextRenderer` / `FreeTypeIcons` / the
> SDL render nodes, ~3,800 lines) and its `SDL3_ttf` / `SDL3_image` / FreeType
> cinterops were removed once Skiko-on-mingwX64 landed (Route 1a — see
> [SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md)). SDL3 remains as the
> windowing / input / platform layer only. Below, references to "the SDL leg", a
> second `RenderBackend` actual, `-Prenderer=sdl3`, and the SDL-leg fidelity work
> are **historical** — kept because the convergence decisions still explain the
> Skia leg's shape.
Rendering goes through one `RenderBackend`, implemented once: the **Skia leg**
(`skikoRendererMain`). It draws through upstream Compose Multiplatform's own Skia
stack, vendored verbatim: `SkiaBackedCanvas`, `SkiaBackedPaint`, `SkiaShader`,
`actual class GraphicsLayer` backed by `org.jetbrains.skiko.node.RenderNode`,
`SkiaGraphicsContext` — the layer and draw engine internals are literally
upstream. macOS/Linux link the official Skiko klibs; mingwX64 links the
bitsycore skiko **fork** (`skikoRendererMingwMain`, Route 1a).
The text pipeline is now upstream's own `SkiaParagraph` (skparagraph, vendored
verbatim); glyph rasterization is Skia. GPU path per platform: Metal (macOS),
OpenGL (Linux + Windows), with a CPU-raster `Software` fallback.
The seam is kept as narrow and low as possible. Code flows
`Common (upstream) -> shared native engine -> Skia actual`. What we
minimize is hand-rolled actual surface, not actual-side line count: a fat
vendored-upstream actual (the Skia leg carrying upstream's whole GraphicsLayer
stack) is preferred over a thin hand-written one.
## 2. The retained-layer engine (the model we copied)
Upstream skiko skips work at three levels. The Skia leg has all three.
- **L1 frame scheduling.** A frame schedules the next only if still dirty.
Ours: `ComposeWindow.shouldRender()` gates `renderFrame()`; the loop blocks on
`SDL_WaitEventTimeout` when idle.
- **L2 measure/layout only dirty nodes.** Vendored `MeasureAndLayoutDelegate`
used verbatim through `ComposeOwner.measureAndLayout()`.
- **L3 draw record-once / replay** (the expensive one). Each isolated
`LayoutNode` gets an `OwnedLayer` (`GraphicsLayerOwnerLayer`) owning a
`GraphicsLayer`. The display list re-records only when content is dirty;
otherwise it replays.
The critical property: **transform, alpha, and clip changes do NOT re-record.**
Moving, scaling, rotating, or fading a layer replays cached content under a new
transform. Only a genuine content change (a state read inside the draw block, or
a resize) re-records. Upstream replays the whole scene every frame with no
dirty-region present; the entire win is not re-recording clean layers, not
drawing less screen. Dirty-region rendering is therefore an explicit non-goal.
The compositing-strategy contract (`requiresLayer()`, from
`SkiaGraphicsLayer.skiko.kt`) decides when a layer needs an offscreen:
| Condition | Auto | Offscreen | ModulateAlpha |
|---|---|---|---|
| `alpha < 1` | offscreen | offscreen | per-op alpha multiply (no offscreen) |
| `colorFilter != null` | offscreen | offscreen | offscreen |
| `blendMode != SrcOver` | offscreen | offscreen | offscreen |
| `renderEffect != null` | offscreen | offscreen | offscreen |
| none of the above | replay in place | always offscreen | replay in place |
## 3. Current state
- **Skia leg runs upstream's engine.** Canvas (`SkiaBackedCanvas`) and
GraphicsLayer/GraphicsContext (`org.jetbrains.skiko.node.RenderNode`) are
vendored verbatim. The Skia leg gets real display-list caching and correct
clip/shadow/renderEffect for free from upstream. After this landed, Skia
`draw` on LazyColumn fell from 1.75 ms to 0.2 ms.
- **Parity is a golden-master against JVM** (about 2% median, dominated by the
shared text engine's small metric delta). See TOOLING.md for how to read it.
- **Memory is stable.** The historical composition leak is fixed (see section 6)
and guarded by the `--soaktest` gate in the verify runbook.
## 4. Decisions to remember
- **Goal is G1, cheap upstream-tracking.** The target is low per-bump
reconciliation cost when following upstream. All platforms now render through
real Skia — mingwX64 via the bitsycore skiko fork (Route 1a; see
[SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md)) — so there is no
fidelity tier below the JVM cross-check. The JVM Compose Desktop target
(`:demo:run`) remains the fidelity/parity reference on any host.
- **Vendor, do not hand-roll.** Copy upstream verbatim wherever it compiles.
Edit-to-compile becomes a manual vendor with a `// VENDOR-BASE:` header so the
drift tripwire can track it. The litmus test for any divergence: "Is this what
upstream does? If not, what real platform constraint forces the difference?"
Valid answers name a constraint (e.g. no windowing/input toolkit in K/N ->
SDL3). "It was easier" is not valid.
- **Text is now upstream's engine (B6.3 done).** Skia-leg text draws through
upstream's own `SkiaParagraph` (skparagraph), vendored verbatim — the
font-subsystem replacement landed: upstream `PlatformFont`/`FontCache`/
`FontCollection` back a `data.kres`-fed font supply, replacing the port's
name-to-bytes engine. glyph rasterization stays Skia. This subsumes the P3.1
metrics work (measurement is now upstream's, so the numbers match by
construction).
- **Module split (`:ui-graphics` / `:ui-text`) is done.** The blocker — the
`sdl3` cinterop being a shared substrate that Kotlin/Native can't cleanly
share across a module boundary — was resolved by extracting a non-upstream
`:sdl-core` base module that owns the cinterop and api-exposes it. With that
base in place, `androidx.compose.ui.graphics.*` / `.text.*` split off into
`:ui-graphics` / `:ui-text` (matching upstream artifacts), leaving `:ui` as
the Compose core + Skia renderer + SDL bridges. `RenderBackend.drawRoot` takes
`(Canvas) -> Unit`, decoupling the backends from `ComposeRootHost`.
- **Real Skia on Windows K/N shipped (Route 1a).** mingwX64 links the bitsycore
skiko fork against `skiko-windows-x64.dll` (with an embedded GNU import lib),
published to GitHub Packages and auto-provisioned by the bridge plugin. This
replaced the SDL renderer as the Windows path. See
[SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md) for the ABI details
and the route trade-offs.
## 5. Convergence status
| Item | Status |
|------|--------|
| Guardrails (parity gate, verify-mac runbook, drift + vendor-clean checks) | Done |
| B2: Skia leg on upstream GraphicsLayer/GraphicsContext | Done |
| B6.1: Skia leg on upstream `SkiaBackedCanvas` + paint/shader | Done |
| B6.2: upstream `GraphicsLayer` + delete transient port cluster | Done |
| B6.3: upstream text (`SkiaParagraph`) on the Skia leg | Done |
| B5: engine-convergence deltas audit | Done (clean wins spent by B6) |
| P2.2: composition memory leak | Fixed + soak-gated |
| P2.3: outsets / blur / renderEffect | Done via upstream `SkiaGraphicsLayer` |
| P3.1: text metrics parity (17% median -> 2%) | Done |
| Module split (`:ui-graphics` / `:ui-text` via `:sdl-core`) | Done |
| Track A: real Skia on Windows (Route 1a) | Done |
## 6. Remaining and future work
Nothing here blocks day-to-day work. These are the open threads worth
remembering.
- **WIN-SMOKE (pre-ship, Windows only).** The Mac runbook cannot cover the
shipped mingwX64 binary, the Windows-only `PrintWindow` probe, or the
common-metadata publish job. Run these on a Windows host before any release.
This is the only outstanding verification.
- **Stabilization at Compose 1.12.0 stable.** The vendored refs are pinned to
`v1.12.0-beta03+dev4483` (no clean beta03 tag exists yet, and it is not on
Maven). The native side leads the JVM parity leg (forced to beta02, the latest
published) by a documented skew. When 1.12.0 stable ships: re-pin both refs to
the clean tag, bump the `vComposeJvmVersion` forcing in demo/apidemo/
material-symbols to close the skew, then cut the release. The concrete
step-by-step (the version map, the ref-bump flow, and the release flow) lives
in [TOOLING.md](TOOLING.md#versioning-and-releasing).
- **Deferred engine deltas (D2-D6, all low ROI under G1).**
- D2: split `GraphicsLayerOwnerLayer` per-leg to restore upstream's exact
`setLightingInfo` shadow lighting. Feasible (the old RootNodeOwner-coupling
blocker was outdated) but high blast radius for a marginal gain; elevation
shadows already match JVM.
- D3: dedupe `LayerTransformationMatrix.kt` against the now-vendored
`Matrices.skiko`. Blocked on D2 (the shared owner-layer hit-test needs a
matrix fn usable from the owner layer).
- D4/D5: `SemanticsRegion` intersect/difference are stubs, and `CharHelpers`
is naive grapheme/bidi vs upstream ICU. Real fidelity gaps, gated on an
accessibility or complex-script roadmap.
- D6: `Focusability` / `PlatformVelocityTracker` are byte-equal to upstream;
vendoring them saves nothing.
- **Native-resource lifecycle.** Wire `GraphicsLayer`/RenderNode +
`SdlImageBitmap.close()` fully into cache eviction and the renderer
`destroy()` chain; demote the periodic GC nudge once ownership covers it.
## 7. Hard-won learnings (do not relearn)
- **Minimize-divergence is load-bearing.** A hand-written `notifyLayerIsDirty`
that diverged from upstream `OwnedLayerManagerImpl` removed a layer from
`dirtyLayers` mid-loop and crashed on any navigation. Screenshots missed it;
the interaction probe caught it. Match upstream verbatim; do not hand-roll
engine plumbing from a summary.
- **The composition memory leak (root cause).** The port never called
`OwnerSnapshotObserver.clearInvalidObservations()`, which upstream
`RootNodeOwner` runs after every measure. Snapshot read-observations for
scopes invalidated on dispose lingered forever, each pinning its observed
object graph via a Kotlin/Native `ExternalRCRef` (a K/N-heap leak, vmmap
Memory Tag 246). Most visible on ripple/indication draws. Fix:
`ComposeRootHost.measureAndLayout()` sweeps `clearInvalidObservations()` after
layout, plus `ComposeOwner.onDetach` clears the detached node's observations.
Found by exact live-counters, static-mode isolation, macOS `leaks`/`heap`/
`vmmap`, and component bisection. RSS alone cannot pinpoint a referenced leak;
budget heap tooling for this class.
- **Screenshots miss crashes and settle-timing.** Free-running screenshots on
animated/settling screens give false signals. Use render-to-quiescence +
virtual frame time for parity, the probe for interaction/crash coverage.
- **The profiler `present` phase is vsync-capped by the display refresh.**
Profile on the target monitor before concluding a frame-rate gap.
## 8. Explicit non-goals
- **Dirty-region / partial present.** Upstream replays the whole scene; the win
is not re-recording, not redrawing less.
- **A custom `cacheKey` API.** Superseded by the real per-node display list.
- **Vendoring `RootNodeOwner` / the `ComposeScene` stack.** Coupled to skiko's
`SkiaLayer` and windowing; it fights the `:desktop-native-window` SDL loop. The port borrows
only the layer engine.
## 9. Key files
- `compose/ui/ui/src/nativeMain/.../RenderBackend.kt`: the interface.
- `compose/ui/ui/src/nativeMain/.../GpuMode.kt`: renderer / driver picker.
- `compose/ui/ui/src/skikoRendererMain/.../renderer/skia/SkiaRenderBackend.kt`.
- `compose/ui/ui/src/commonMain/.../node/ComposeRootHost.kt`: root host,
hit-test, event dispatch, snapshot observer sweep.
- `compose/ui/ui/src/commonMain/.../node/impl/ComposeOwner.kt`: the project
`Owner` + `GraphicsLayerOwnerLayer` bridge.
- `compose/ui/ui-text/src/nativeMain/.../ui/text/SkiaParagraph.native.kt`: the
upstream `Paragraph` actual (measurement, hit-test, line metrics, span painting).
-402
View File
@@ -1,402 +0,0 @@
# Skiko on mingwX64 — Feasibility & Effort Assessment
> **STATUS: SHIPPED (Route 1a).** The real Skia leg now runs on mingwX64 via the
> bitsycore skiko fork + `skiko-windows-x64.dll`. This document is retained as
> **historical design context** — the feasibility findings and route trade-offs
> that led to the shipped implementation.
Investigation of the effort to run the real Skia leg (`org.jetbrains.skiko`)
on the Kotlin/Native **mingwX64** target, under the relaxed constraint that a
**DLL is acceptable** (the static / no-DLL invariant may be dropped). Companion
to [RENDERER.md](RENDERER.md) §4 "Track A" — which this assessment **partly
supersedes** (see [Corrections](#corrections-to-renderermd-4) below).
Pins at time of writing: skiko `0.150.1` (Skia m150), kotlin `2.4.0`,
compose `1.12.0-beta02`.
---
## Bottom line
Feasible — and **more tractable than RENDERER.md §4 claims**. Two of that
section's load-bearing premises are now factually stale:
1. **"Building C++20 Skia to a GNU/mingw archive is an open-ended fork (only
abandoned Mozilla-era precedent)."** — **False.** MSYS2 maintains
`mingw-w64-skia` at **Skia milestone 143 (C++20)** for `mingw64` (GCC),
`ucrt64`, and `clang64` (clang → `x86_64-w64-windows-gnu`), producing
`libskia.dll` + a GNU import lib `libskia.dll.a`. Last rebuilt **2026-07-24**.
The GNU-ABI recipe is ~8 small GN patches (the key one adds
`is_mingw = is_win && (cxx=="g++"||cxx=="clang++")`).
2. **"The only working route is a runtime DLL, which breaks the no-DLL
invariant."** — The DLL route is real, but the reason it works is **not**
what the section implies. See the crux below.
**Effort:** ~**1–2 weeks** to a proven spike + a CPU-raster Skia-on-Windows
prototype; ~**1–2 months** to a tested, GPU-accelerated Windows Skia leg with
published fork klibs and repo wiring — **plus** permanent
`bitsycore/skiko`-fork maintenance (re-sync at every skiko bump). Difficulty:
**research-grade / high**, because no working prior art exists (the two GitHub
`skiko-mingw64` forks are vaporware — 0 commits ahead of upstream, no mingw
source set). Confidence: **medium** (high on mechanism, medium on timeline).
---
## Spike result — EXECUTED, GREEN ✅ (2026-07-24)
The core de-risking experiment has been **run on this machine** and passed. The
whole Route 1a link mechanism works on the exact target toolchain (Kotlin
**2.4.0**, mingwX64, LLVM **21** `ld.lld`, `x86_64-pc-windows-gnu`).
**What was built** (`spike-skiko-mingw/` in this repo):
- A tiny DLL reproducing skiko's binding shape — an **opaque handle over a real
C++/STL object** (`std::string`), **alloc+free paired inside the DLL**, flat
`extern "C"` surface. Compiled **MSVC-ABI via `clang-cl /LD /MT /EHsc`**
(static CRT). Exports verified undecorated: `spike_make/size/free`.
- A **GNU import library** (`libspike.dll.a`) generated with `dlltool`.
- A **Kotlin/Native mingwX64 executable** binding it via cinterop (plain C
header) + `linkerOpts("-L… -lspike")`.
**Results:**
- `linkReleaseExecutableMingwX64` **linked clean** — **KT-65671 did NOT bite**
(the DLL path puts zero C++ objects in the K/N link graph, so the
`--allow-multiple-definition` / `.drectve __clang_call_terminate` failures
never arise). LLVM 21 `ld.lld` is also far newer than the 1.9.x-era reports.
- Runtime: `SPIKE-RESULT size=7 expected=7 ok=true`, exit 0 — the C++
`std::string(7,'x').size()` round-trips through the opaque handle correctly.
- `objdump -p` confirms the exe imports `spike.dll`, and `spike.dll` depends on
**`KERNEL32.dll` only** → **self-contained, no VC++ redist needed**. The exe
carries K/N's `msvcrt.dll` while the DLL carries its own static MSVC CRT —
**two CRTs coexist in one process** with no issue (ownership paired inside the
DLL), empirically confirming the CRT-boundary discipline.
**Implication:** the one load-bearing unknown (does the DLL route link on
2.4.0?) is resolved **in favour**. Route 1a is de-risked at the mechanism level;
remaining effort is engineering the real `skiko.dll` (compile skiko's shim +
skia-pack MSVC Skia into one DLL) and repo wiring — not proving feasibility.
Feasibility confidence is now **high**; residual medium confidence is on
timeline only.
Reproduce: `./gradlew -p spike-skiko-mingw linkReleaseExecutableMingwX64`, then
run the exe with `spike-skiko-mingw/native` on `PATH`.
---
## The crux — and why "just use a DLL" is only half the story
**The blocker is a C++ ABI mismatch at the binding boundary, and it is
orthogonal to static-vs-dynamic linkage.**
- **K/N mingwX64 is GNU/Itanium ABI.** `konan.properties`:
`targetTriple.mingw_x64 = x86_64-pc-windows-gnu`,
`linker.mingw_x64 = ld.lld.exe`,
`linkerKonanFlags.mingw_x64 = -static-libgcc -static-libstdc++ … -lwinpthread`
— GCC libstdc++/libgcc, Itanium mangling (`_Z3fooi`).
- **skia-pack's only Windows Skia is MSVC-ABI** — built with `clang-cl`,
`is_trivial_abi=false`, shipped as MSVC `.lib`; C++ exports are MSVC-mangled
(`?foo@@YAHH@Z`).
LLVM issue #60847 ("LLD cannot link against MSVC libraries in MinGW toolchain",
closed *not planned*) confirms the two mangling schemes never reconcile.
**Wrapping the same MSVC C++ Skia in a DLL does not change this** — a DLL's C++
exports are still MSVC-mangled. So dynamic-vs-static is a red herring; **the C++
ABI at whatever boundary the mangled C++ crosses is the wall.**
What makes it tractable is *where that boundary sits in skiko*:
- **skiko's Kotlin↔native boundary is already a flat, unmangled C symbol
boundary.** Kotlin declares
`@ExternalSymbolName("org_jetbrains_skia_Data__1nSize") external fun _nSize(…)`;
`ExternalSymbolName` is a typealias for `kotlin.native.SymbolName`, which
resolves to a *plain named symbol* at link. The generated `.def` is
header-less (only `linkerOpts`). **No C++ type ever crosses into Kotlin.**
- **The C++ ABI coupling lives one layer down**, at the *shim↔Skia* link:
~78 `extern "C"` (`SKIKO_EXPORT`) `.cc` files under `src/nativeJsMain/cpp/`
that `reinterpret_cast<SkData*>(ptr)->size()` etc. This shim **must share one
C++ ABI with Skia** — today they're one GNU-ABI island fused via
`-include-binary`.
**Consequence:** a DLL is viable **iff** the *entire* C++ island (shim + Skia)
is one consistent ABI internally, and only the **flat extern-C export surface**
crosses to K/N via a generated import library. On x86_64 there is a single
Windows calling convention and extern-C exports are undecorated on both MSVC and
mingw, so `ld.lld` links such a DLL via `gendef`+`dlltool` (or directly, LLD
13+). **The repo already does exactly this for system DLLs** (`sdl3.def`:
`linkerOpts.mingw_x64 = -lkernel32 -luser32 -lgdi32 …`).
Crucially the extern-C façade **already exists** (the `nativeJsMain/cpp` shim
exports precisely the `org_jetbrains_skia_*` symbols the K/N side binds) — it is
**reused, not authored anew**. The catch: no ownership / exceptions / STL may
cross the C line — skiko is already handle/RefCnt-based, so this holds if
authored carefully. (The JVM `jvmMain/cpp` bridge exports `Java_…` JNI symbols
and is **not** reusable — the *native* shim is the right one.)
The non-negotiable that survives all of this: **upstream skiko ships no
mingwX64 K/N target at all** (`throw GradleException("$os not yet supported")`
for non-{Mac,iOS,tvOS,Linux}; "Windows" in skiko means the JVM/AWT clang-cl
path). So this is a **from-scratch skiko build-system port**, not a flag flip.
---
## The dominant risk: KT-65671
`KT-65671` ("Kotlin/Native: Failed linking cinterop static library for
mingwX64", **Open**, filed against 1.9.x, status on 2.4.0 unverified): K/N's
mingw link step chokes when **fusing an external C++ static archive** —
`ld.lld` rejects `--allow-multiple-definition`, and
`-exclude-symbols:__clang_call_terminate` in the objects is rejected in
`.drectve`. This is skiko's **exact static-fusion pattern**.
Key implication for route choice: a **pure extern-C DLL boundary sidesteps this
bug class entirely** — no C++ objects enter the K/N link graph; only a flat
import surface crosses. So, counter-intuitively, the **DLL route is the
risk-*reducing* route** w.r.t. KT-65671, and it is exactly what the user is
willing to accept.
---
## Routes
### ★ Recommended — DLL with a flat extern-C export, K/N binds via import lib
Put **all** the C++ (skiko's existing shim + Skia) inside one DLL; export only
the flat `org_jetbrains_skia_*` symbols; bind from a new mingwX64 skiko target
via a generated import lib. K/N resolves at link; the Windows loader binds
`skiko.dll` at process start — **no dlopen/`staticLoad` code needed** (the
shipped `staticLoad()` no-op is fine for the import-lib path).
Two sub-choices for the DLL internals (K/N neither knows nor cares which):
- **1a — MSVC internals (minimal fork).** Recompile skiko's `nativeJsMain/cpp`
shim with `clang-cl` against **skia-pack's existing MSVC Windows `.lib`**
(`windows-x64` archives are already published). **Zero Skia fork.** Net-new:
add `__declspec(dllexport)` to `SKIKO_EXPORT` on this build, a mingw/DLL
branch in skiko's build logic, import-lib generation. CRT discipline (MSVC CRT
in the DLL vs K/N's static libgcc) is a correctness constraint on the C API
(handles + primitives only), not a linkability blocker.
- **1b — GNU internals.** Build the shim + Skia GNU-ABI (reuse MSYS2's
`mingw-w64-skia` patch set) into the DLL. Slightly more Skia-side work, but a
single toolchain end-to-end.
**Effort:** ~2–4 weeks once the spike is green. **Risk: medium** (sidesteps
KT-65671). **"Just works" preserved:** none — ships `skiko.dll` (tens of MB)
next to `data.kres` (accepted). **Sub-choice 1a is the least-fork option.**
### Route 2 — GNU-ABI static Skia + real mingwX64 skiko target (purist)
Fork skia-pack (or reuse MSYS2's patches) to emit **GNU-ABI static** Skia;
fork skiko to add a mingwX64 target that recompiles the existing extern-C shim
GNU-ABI and **static-fuses** exactly as macOS/Linux do today.
**Effort:** Skia build itself is now **days** (MSYS2 proves it). skiko port +
link reconciliation ~3–6 weeks. **Risk: medium-high** — this route hits
**KT-65671 head-on** (static C++ archive fusion). **Preserves the static /
no-DLL invariant** — the only reason to prefer it, and the user said they don't
need it.
### Route 3 — CPU-raster-only (a scope reduction, not an ABI shortcut)
The repo's `SkiaSurfaceBridge` (`Surface.makeRasterDirect` → `SDL_UpdateTexture`
→ `SDL_RenderTexture`) needs **no GPU context** and compiles on mingwX64 the
moment a skiko klib exists. Use it to **skip authoring the Windows GPU bridge**
for a first cut. Still requires solving Route 1/2's binding problem first.
**Saves ~1–2 weeks** of GPU-bridge work → the natural **milestone 1** of
whichever route you pick.
### Baseline — do nothing (already shipping)
Windows renders via the from-scratch **SDL leg** (`SDL_RenderGeometry` +
SDL3_ttf + FreeType); the **JVM parity target** is the full-fidelity Skia
reference. The bar any Skia-on-Windows work must clear is "materially better
than the SDL leg's fidelity, worth the DLL + maintenance cost."
---
## Recommended work breakdown (Route 1a, CPU-raster first)
**A. Skia — none (1a) / days (1b).**
Route 1a reuses skia-pack's published `windows-x64` MSVC `.lib`. (1b: reuse the
MSYS2 `mingw-w64-skia` PKGBUILD + patches at skiko's pinned milestone; backends
CPU-raster + `skia_use_gl`, `skia_use_freetype`.)
**B. `bitsycore/skiko` fork — the bulk, medium risk.**
1. Add `mingwX64()` + a `configureNativeTarget(OS.Windows, Arch.X64, …)` branch;
replace the `throw GradleException("$os not yet supported")` / empty
`linkerFlags` fall-throughs in `NativeTasksConfiguration.kt` with a Windows
branch (PE/COFF, `llvm-ar`).
2. Compile the **existing** `nativeJsMain/cpp` shim (not the JVM JNI bridge)
into `skiko.dll`; make `SKIKO_EXPORT` export the flat symbols
(`__declspec(dllexport)` or `--export-all-symbols`/def file).
3. Generate the GNU import lib (`gendef` + `dlltool` → `libskiko.dll.a`).
4. cinterop `.def` stays header-less (`linkerOpts`/`libraryPaths`), matching the
repo's own idiom. Publish mingwX64 klibs + the DLL.
**C. Windows context actual — deferred behind milestone 1.**
5. **Milestone 1:** wire only the CPU-raster `SkiaSurfaceBridge` (already
Windows-ready).
6. **Milestone 2:** author `SkiaD3D11Bridge` / `SkiaVulkanBridge` — extract HWND
via `SDL_GetWindowProperties`, wrap `DirectContext.makeD3D11`/`makeVulkan`,
mirror the Metal/GL bridges' per-frame acquire/present.
**D. This repo's wiring — small, low risk (integration points confirmed).**
7. `compose/ui/ui/build.gradle.kts` — flip `isSkiaTarget("mingwX64")` to `true`
(currently hardcoded `false`, ~line 58); create a `skikoRendererMingwMain`
and `mingwX64Main.dependsOn(...)` it (today mingw attaches only to
`sdlRendererMingwMain`, lines ~203–206; the skiko tree, lines ~220–235, is
created only under `!useSdl3Everywhere` and never for mingw). Add the fork's
coords to that source set.
8. **No new expect/actual needed:** `createRenderBackend` /
`rendererPreferredGpuMode` are `expect`s in `nativeMain` with `actual`s in
`skikoRendererMain` and `sdlRendererMain`; attaching mingwX64 to the skiko
source set makes the Skia actual resolve for Windows (one renderer source set
per target — the existing invariant).
9. Add a Windows GPU-bridge branch to `rendererPreferredGpuMode()`; verify with
the demo/apidemo (`--gpu`, `--screenshot`) and the parity harness.
Repo surface to cover is narrow: **~67 `org.jetbrains.skia` classes + 5
`org.jetbrains.skiko.node` APIs** (`RenderNode`, `RenderNodeContext`,
`SkikoRenderDelegate`, `SystemTheme`, `currentSystemTheme`).
---
## The one spike to run first (~1–2 days)
Before any fork work, **empirically test the K/N-mingw link path**: build a
trivial `extern "C"` C++ DLL (one function returning a handle + one that uses
it) — or consume MSYS2's `mingw-w64-x86_64-skia` DLL + `libskia.dll.a`
directly — from a throwaway mingwX64 K/N project via cinterop + import lib, and
**run it**. This answers, in a day instead of a month:
- Does K/N mingw link + call a GNU-ABI DLL via an import lib at all?
- Does **KT-65671** reproduce on Kotlin 2.4.0 (import-lib vs static fusion)?
- DLL vs static-fusion behaviour on this toolchain.
If green, Routes 1/2 are de-risked. (Caveat: MSYS2 Skia exports Skia's *mangled
C++* symbols, so a direct cinterop of it tests only the *link mechanism*, not
the skiko API — for the API you still need the extern-C shim. The trivial-C-DLL
variant is the cleaner mechanism test.)
---
## Biggest risks / unknowns
1. **KT-65671** — whether K/N's mingw link works on 2.4.0 for external C++;
the DLL route sidesteps it, the static route hits it. *The single fact that
swings the effort.*
2. **Toolchain matching** — Konan bundles LLVM 16 clang + `ld.lld`; the C++20
Skia + shim must be built by a toolchain whose objects/imports LLD accepts.
3. **CRT / ownership discipline** across the C boundary (only matters for 1a).
4. **Maintenance** — SKIKO-446 / SKIKO-611 are Open, unassigned → almost
certainly a **permanent `bitsycore/skiko` fork** with per-bump re-sync tax.
---
## Recommendation
Pursue **Route 1a** (skiko fork producing a DLL with a flat extern-C export over
skia-pack's existing MSVC Windows Skia), **scoped CPU-raster first** (Route 3
milestone), GPU bridge second. It matches the user's accepted DLL tolerance,
requires **zero Skia fork**, and **dodges KT-65671**. Keep Route 2 (static,
GNU-ABI) in reserve only if reclaiming the no-DLL invariant later becomes
valuable. **Run the MSYS2/trivial-DLL spike before committing to any fork.**
---
## Implementation status — WORKING END-TO-END ✅ (2026-07-24)
**`demo.exe` (mingwX64) renders the Material 3 Buttons screen through real Skia**
and wrote a 1000×700 screenshot ("settled at frame 3"), using the CPU-raster
`SkiaSurfaceBridge` backed by the forked `skiko-windows-x64.dll`. Route 1a is
complete. Build/run:
```
# fork (C:/Dev/skiko), one-time env: SKIKO_VSBT_PATH=<VS BuildTools dir>
./gradlew :skiko:publishKotlinMultiplatformPublicationToMavenLocal \
:skiko:publishMingwX64PublicationToMavenLocal -Pskiko.native.windows.enabled=true
# compose repo
./gradlew :demo:linkDebugExecutableMingwX64 -PwindowsSkia=true
cp <fork>/skiko/build/out/link/Release-windows-native-x64/skiko-windows-x64.dll <demo exe dir>/
<demo exe dir>/demo.exe --screenshot=out.bmp --screen=Buttons
```
Fork fixes needed beyond the DLL build (skiko uses a CUSTOM hierarchy template,
`applyDefaultHierarchyTemplate=false`):
1. `sourceHierarchy.kt` — add `group("windows"){ withMingwX64() }` under `native`
(else the mingwX64 klib compiles EMPTY — missing all of `org.jetbrains.skia`).
2. `SkikoProjectContext` — add `supportNativeWindows` into `supportAnyNative` so
the `@SymbolName` opt-in reaches the mingw compile.
3. `Resources.native.kt` — `ftell` is 32-bit `Int` on Windows (LLP64); `==` fix.
4. `windowsMain` actuals for `SkiaLayer` / `currentSystemTheme` (linux is a stub).
5. Force-export the 2 ICU symbols (`uloc_getDefault_skiko`,
`uloc_toLanguageTag_skiko`) that Kotlin binds via `@SymbolName`.
Compose-repo wiring (all behind `-PwindowsSkia=true`): `mavenLocal()` in
settings, `isSkiaTarget("mingwX64")`, a separate `skikoRendererMingwMain` tree
on the fork's **root** coord (`org.jetbrains.skiko:skiko:0.0.0-SNAPSHOT` — the
platform artifact alone doesn't expose api-elements), `PlatformGpu` Windows →
`Software`, and a one-line `PlatformGpu.mingw.kt`.
## Earlier milestone — fork BUILDS ✅ (2026-07-24)
Route 1a is not just designed — the `bitsycore/skiko` fork now **builds the
native Windows Skia DLL + a mingwX64 klib** on this machine (`C:/Dev/skiko`,
skiko `v0.150.1`).
Edits made (all in the fork):
- `src/nativeJsMain/cpp/common.h` — `SKIKO_EXPORT` gains `__declspec(dllexport)`
under a `SKIKO_WINDOWS_DLL` guard.
- `skiko/build.gradle.kts` — `configureNativeTarget(OS.Windows, Arch.X64,
mingwX64())`, gated behind `-Pskiko.native.windows.enabled=true`.
- `buildSrc/.../NativeTasksConfiguration.kt` — a Windows compile branch
(clang-cl + `-DSKIKO_WINDOWS_DLL`) and a new `configureWindowsNativeTarget`
that links `skiko-windows-x64.dll` via `lld-link` (reusing the JVM template +
`resolveBinaryInputs(…, TargetEnv.JVM, …)`), auto-generates the GNU import lib
(`dumpbin`→`.def`→`dlltool`), and wires the K/N cinterop `linkerOpts`.
Results:
- The native bridge (`nativeJsMain/cpp`) **compiled on Windows with clang-cl,
zero source changes.** The only link fix was adding `d3d12.lib
d3dcompiler.lib dxgi.lib` for Skia's `SK_DIRECT3D` backend.
- Produced **`skiko-windows-x64.dll` (14.2 MB, 1005 exported
`org_jetbrains_skia_*` symbols)**, `libskiko-windows-x64.dll.a` (GNU import
lib), and the **mingwX64 klib** (`build/classes/kotlin/mingwX64/main/klib`).
Prerequisite for any skiko Windows build on this box:
`SKIKO_VSBT_PATH='C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools'`
(Gradle's VS locator doesn't auto-find VS 18 BuildTools).
Remaining: publish the fork to `mavenLocal` → a tiny K/N mingw consumer that
calls a real Skia function (links the import lib, runs with the DLL on `PATH`)
for the definitive end-to-end proof → then wire the compose repo (flip
`isSkiaTarget("mingwX64")`, add `skikoRendererMingwMain`, ship the DLL next to
the exe, CPU-raster `SkiaSurfaceBridge` first). One known TODO: the klib's
`linkerOpts` use an absolute import-lib path (fine same-machine; make relative /
bundled for distribution).
## Corrections to RENDERER.md §4
The shelving note should be updated — its two central technical claims are stale
as of 2026-07:
- "GNU-ABI Skia is an open-ended fork / only abandoned Mozilla-era precedent" —
**false**: MSYS2 maintains a modern (m143, C++20) GNU-ABI Skia, rebuilt
routinely.
- The accurate residual blocker is **the K/N-side link/binding port**
(KT-65671 + authoring the mingwX64 skiko target), **not** building Skia, and
the DLL route is *risk-reducing*, not merely "the only working route."
---
## Sources (primary, load-bearing)
- skiko v0.150.1: `skiko/build.gradle.kts`, `buildSrc/.../NativeTasksConfiguration.kt`,
`src/commonMain/kotlin/org/jetbrains/skia/Data.kt`,
`src/nativeMain/kotlin/org/jetbrains/skia/Actuals.native.kt`,
`src/nativeJsMain/cpp/common.h`, `.../impl/Library.native.kt`
- skia-pack `script/build.py`; skia.org build docs
- Kotlin `konan.properties` (v2.2.0); kotlinlang native-c-interop / target-support
- LLVM issue #60847; **KT-65671**; **SKIKO-446**, **SKIKO-611**
- MSYS2 `mingw-w64-skia` (packages.msys2.org; MINGW-packages PKGBUILD + patches)
- Vaporware forks: github.com/Cdm2883/skiko-mingw64, crowforkotlin/skiko-mingw64
- Repo: `compose/ui/ui/build.gradle.kts`, the `SkiaMetalBridge` / `SkiaGLBridge`
/ `SkiaSurfaceBridge` / `RenderBackendFactory` seam
-166
View File
@@ -1,166 +0,0 @@
# TODO.md
Everything the port currently leaves as a no-op, stub, hardcode, or partial
implementation, plus the gaps worth closing before a stable 1.12 release. This
is an audit of PROJECT code only (the `*.native.kt` / `com.compose.sdl.*`
actuals); vendored upstream under `src/vendor/` is out of scope.
Severity is a rough guide, not a mandate:
- **Blocker** for stable: a capability a typical desktop app expects that is
currently absent or broken.
- **Nice-to-have**: real gap, but the app works without it.
- **Cosmetic**: minor fidelity or edge-case behavior.
Context for prioritizing: the project goal is **G1, cheap upstream-tracking**
(see [RENDERER.md](RENDERER.md)). There is now ONE renderer — **Skia**
(macOS/Linux official Skiko; mingwX64 the bitsycore skiko fork), so Windows
renders through real Skia. The priorities below are the cross-cutting platform
gaps that affect every OS. JVM Compose Desktop remains a parity cross-check.
Renderer-internal cleanups (the deferred D2 shadow-lighting split, D3 matrix
dedupe) live in [RENDERER.md](RENDERER.md#6-remaining-and-future-work), not
here.
## Completed
Platform / cross-cutting:
- **Locale / i18n** (`5d03711f`) — `Locale.current` / `LocaleList.current` read the
OS locale via SDL, unlocking the 40+ Material 3 translations. The date/number
format tail remains (section C).
- **IME text input** (`500c49e4`) — a real `PlatformTextInputSession` routes SDL
committed + composing text to the focused field (commit replaces the
composition); `SDL_SetTextInputArea` positions the OS candidate window.
- **Cursor icons** (`1fcc0828`) — `Modifier.pointerHoverIcon` drives the OS cursor
via SDL (I-beam over text, hand over links).
- **WindowInfo** (`bad23073`) — `isWindowFocused` + `containerSize` / `containerDpSize`
report real values (were hardcoded `true` / `Zero`).
- **ImageBitmap from bytes** — `ByteArray.decodeToImageBitmap()` /
`createImageBitmap(bytes)` decodes encoded image bytes; was
`UnsupportedOperationException`. Verified `demo --imagebytestest`.
- **`FontFamily.Monospace`** — generic families map to `"generic:<name>"`;
`registerGenericFonts` registers bundled NotoSansMono under `generic:monospace`
(bundled only when the app references it). Verified `demo --fonttest`.
---
## Release-blocking shortlist for stable 1.12
The cross-cutting items to weigh first (all detailed below):
1. **No copy/paste context menu** (section B). Keyboard copy works; there is no
right-click or selection toolbar (the desktop path is the right-click context
menu, upstream TODO CMP-7819).
2. **Accessibility is entirely absent** (section A). Decide whether stable
requires any screen-reader support at all.
3. Fonts (section D): compose-resources `Font(Res.font.x)` works, and
`FontFamily.Monospace` now renders NotoSansMono. Remaining: `Serif`/`Cursive`
generics (no bundled font) and the androidx `Font(bytes)`/`PlatformFontLoader`
path. Nice-to-have, not a blocker.
---
## A. Accessibility (entirely absent)
No accessibility bridge exists. A real `SemanticsOwner` is built but never
surfaced to any OS accessibility API (no NSAccessibility / UIA / AT-SPI, no SDL
a11y). Screen readers get nothing.
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:340` · `semanticsOwner` is instantiated but never read; no traversal into a platform a11y tree. A real a11y controller would walk the semantics tree and map roles/labels/actions to a native AT bridge. **Blocker for a11y.**
- `compose/ui/ui/src/nativeMain/.../platform/CompositionLocals.native.kt:31` · `LocalPlatformScreenReader` default throws (`error(...)`); never provided by any window. Screen-reader-active queries are unavailable. **Blocker for a11y.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:297` · `accessibilityManager` implements only `calculateRecommendedTimeoutMillis` (passthrough); no announce/focus/event surface. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../semantics/SemanticsRegion.native.kt:14` · `StubSemanticsRegion.intersect()`/`difference()` hardcode `false`; only `set`/`bounds` work. Region math for semantics culling is inert. **Nice-to-have.**
## B. Text toolbar and context menus (copy / paste / select-all)
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:311` · `textToolbar` is a stub: `showMenu`/`hide` NOP, `status` always `Hidden`. NOTE: the floating toolbar is gated on `isInTouchMode`, so on desktop (mouse) it never shows anyway — the desktop path is the right-click context menu below. **Nice-to-have (touch only).**
- `compose/foundation/.../text/selection/SelectionActuals.native.kt:42` · `addSelectionContainerTextContextMenuComponents` NOP (upstream TODO CMP-7819). No right-click copy/select-all menu for `SelectionContainer`. **Blocker for desktop text UX.**
- `compose/foundation/.../text/selection/TextFieldSelectionManager.native.kt:24` and `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:36` · `addBasicTextFieldTextContextMenuComponents` NOP for both the legacy and state-based `BasicTextField`. **Blocker for desktop text UX.**
## C. Locale / i18n tail (needs locale-aware DATA, no ICU on K/N)
`Locale.current` / `LocaleList.current` are wired to the OS (see Completed). The
rest needs month/weekday name tables and date/number patterns, which Kotlin/Native
has no ICU for; SDL reports language + country only (no script subtag, no
calendar/clock/number prefs). These require a bundled CLDR subset or a K/N i18n lib.
- `compose/material3/.../CalendarLocale.native.kt:20` · locale-agnostic stub: `toString()` fixed `"en"`, single shared instance, `equals` treats all instances as equal. A separate type from `ui.text.intl.Locale`; needs its own wiring so `CalendarModel`/`PlatformDateFormat` can vary by locale. Inert until `PlatformDateFormat` is localized. **Nice-to-have (DatePicker/TimePicker).**
- `compose/material3/.../internal/PlatformDateFormat.native.kt` · multiple hardcodes:
- `:27` `weekdayNames` hardcoded English full/short names (DatePicker shows English day initials regardless of locale). **Blocker for localized DatePicker.**
- `:32` `formatWithPattern`/`formatWithSkeleton` ignore the pattern/skeleton and always emit ISO `yyyy-MM-dd`. **Blocker for localized DatePicker.**
- `:25` `firstDayOfWeek` hardcoded `1` (Sunday); locale-dependent. **Nice-to-have.**
- `:44` `parse()` only accepts ISO `yyyy-MM-dd`, ignoring pattern/locale. **Nice-to-have.**
- `:62` `getDateInputFormat()` hardcoded `yyyy-MM-dd` / `'-'`. **Nice-to-have.**
- `:65` `is24HourFormat()` hardcoded `true`. **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../text/platform/NativeStringDelegate.native.kt:17` · `toUpperCase`/`toLowerCase` use locale-independent stdlib casing; the `locale` param is ignored (wrong for Turkish dotted/dotless i, etc.). **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt` · `Locale.isRtl()` not implemented; the pipeline is LTR-only (`SkiaParagraph.native.kt` hardcodes `textDirection = Ltr`, so `getParagraphDirection` always returns `Ltr`; owners/DrawScopes default `LayoutDirection.Ltr`). No RTL shaping. **Nice-to-have.**
- No `NumberFormat` / currency / decimal-grouping actual exists; numbers render via `toString()` with `.` decimal and no grouping regardless of locale. **Cosmetic.**
## D. Font resolution
**Custom fonts via compose-resources work.** `org.jetbrains.compose.resources.Font(Res.font.x)`
loads the bytes and registers them with the project font registry (IconFont → NamedFont),
so the standard CMP way to bundle a font renders correctly.
- `FontFamily.Serif` / `FontFamily.Cursive` still collapse to the default sans — no bundled
serif/cursive font yet (`downloadNotoFonts` fetches Sans + SansMono only). Register one under
`generic:serif` / `generic:cursive` the same way to enable them. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:32` · `SdlPlatformFontLoader.loadBlocking`/`awaitLoad` are NOP; the androidx `PlatformFontLoader` path (`ResourceFont` / `Font(bytes)` / `LoadedFontFamily`) doesn't load. Use the working compose-resources `Font()` instead. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:38` · `PlatformFontFamilyTypefaceAdapter.resolve` returns `TypefaceResult.Immutable(Unit)` (the renderer resolves by name, so harmless). **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:69` · deprecated `createFontFamilyResolver(fontResourceLoader)` ignores the supplied loader. **Cosmetic.**
## E. Text rendering details
- `compose/ui/ui-text/src/nativeMain/.../text/SkiaParagraph.native.kt` · text `Shadow` + `TextDecoration` now render (the `paint(...)` overloads forward them to `ops.rebuildAndPaint`), but stroke `DrawStyle` and non-`SrcOver` `blendMode` are still accepted and ignored, so those never render on text. **Cosmetic.**
- `compose/foundation/.../text/StringHelpers.native.kt:31` and `compose/ui/ui/src/nativeMain/.../text/CharHelpers.native.kt:14` · `findPrecedingBreak`/`findFollowingBreak` (and `offsetByCodePoints`) do a codepoint/surrogate walk only, with no ICU grapheme clusters, so caret movement and backspace split combining marks and emoji ZWJ sequences. **Nice-to-have.**
## F. Drag and drop
Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:38` · `requestDragAndDropTransfer` is a NOP and `isRequestDragAndDropTransferRequired` returns `false`; `Modifier.dragAndDropSource` compiles but no OS-level drag leaves the window. Needs per-OS native work (NSDraggingSession / DoDragDrop / XDND); SDL3 has no portable start-drag. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:119` · incoming transfer carries only file paths + a text blob; no image/URL/custom-MIME payloads. **Cosmetic.**
- `compose/foundation/.../draganddrop/DragAndDropSource.native.kt:36` · no drag-shadow / drag-image feedback for internal DnD (upstream skiko renders one). **Cosmetic.**
## G. Window and platform info
- Multi-monitor: no display enumeration or per-monitor placement anywhere (`SDL_GetDisplays`/`SDL_GetDisplayBounds`/`SDL_GetDisplayForWindow` unused). **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../platform/PrefetchLocals.native.kt:18` · `LocalPlatformPrefetchScheduler` default is a NOP scheduler; lazy lists skip ahead-of-time item composition (correctness fine, possible scroll-in jank). **Nice-to-have (perf).**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:324` · `autofill`/`autofillManager` are null; `requestAutofill` NOP. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:279` · `hapticFeedBack.performHapticFeedback` NOP (no desktop pointer haptics API). **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:287` · deprecated `clipboardManager` `setText`/`getText` NOP, and `clipboard.getClipEntry` null. Harmless: the real `LocalClipboard` (text + PNG image via SDL3) works. **Cosmetic.**
- `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:44` · `ClipboardPasteState.hasClip` aliased to `hasText`, so image-only clipboard is not detected for the paste affordance. **Cosmetic.**
## H. Skia renderer anomalies
Skia is the renderer everywhere, so its bugs are rarely tracked here. This one
surfaced while adding BlendMode coverage (`demo --blendtest`):
- `BlendMode.Multiply` renders wrong. A filled `drawRect` with opaque
`Color.Cyan` over opaque `Color.Yellow` reads back `(0,0,255)` blue; the
multiply formula can only yield `(0,255,0)` green for opaque cyan×yellow.
`Plus` and `Modulate` composite correctly on the same path, so
`paint.blendMode` IS applied — the fault is specific to `MULTIPLY` (likely a
Metal-backend / premultiply interaction in the graphics-layer flatten, not
the `BlendMode.toSkia()` map, which is correct). Needs isolating on the Skia
draw path. **Nice-to-have.**
## J. Needs further review (2026-07 bug audit)
An audit (parallel passes over the renderer and the text/input/event actuals)
surfaced these. The clear, contained bugs were fixed in the same pass; the items
here are left open because they are either invasive, risky, or a judgement call.
(The renderer-side fixes from that pass predate the single-Skia consolidation and
are no longer tracked here.)
Fixed in the audit (for reference, not open work): `Locale.region` no longer
returns the language for a single-subtag tag; and a `MOUSE_LEAVE` now clears
hover so a widget doesn't stay highlighted after the cursor exits.
Open — platform / input:
- **Pointer coords are truncated to Int before DPR scaling** (`SDL3EventMapper.kt` ~78-97, `LegacyPointerEvent.kt` x/y are `Int`, consumed in `ComposeWindow.kt:756`). On a 2× display a click at logical (100.9, 50.4) becomes physical (200,100) instead of (~201,~100), so hit-testing / caret placement quantizes to 2-physical-px steps near glyph boundaries. Fix means widening the pointer event to `Float` through the pipeline — invasive, hence deferred. **Review.**
- **A window created unfocused is still promoted to RESUMED** (`ComposeWindow.kt:575-576, 713-719`). `windowFocused`/`windowVisible` default `true`, so a second `Window {}` opened while another holds focus (or one opened minimized) reports `RESUMED` until SDL later delivers a focus/minimize event. Should query `SDL_GetWindowFlags` at creation. **Review.**
- **IME text-input area is only pushed on the first `TEXT_EDITING`** (`ComposeWindow.kt:803`; `updateImeArea()` not called on focus gain / first `StartTextInput` / caret move). The first candidate popup can appear at (0,0) before correcting. The rect math itself is correct. **Review.**
- **`Snapshot.sendApplyNotifications()` is called synchronously inside the global write observer** (`ComposeWindow.kt:143-146`). Compose Desktop's `GlobalSnapshotManager` deliberately defers it to a separate dispatch; calling it per-write is redundant (the main loop already calls it each frame) and risks re-entrancy during snapshot application. Verify against the runtime's guard before changing — behaviour-sensitive. **Review (risky).**
+24
View File
@@ -21,6 +21,15 @@ python3 scripts/build-sdl/build-all.py sdl3 # rebuild the step
Needs `git`, `cmake`, and Python 3 on every host (`ninja` is fetched when
absent). Run it once per machine, or after bumping the pinned version.
The static lib is built **slim**: the port uses SDL only for video/window,
events, clipboard, file dialogs, GL/Metal contexts, the CPU-raster `SDL_Render`
blit, filesystem, cursor, locale, theme, and text input/IME. `build-all.py`
disables every unused subsystem — audio, joystick, haptic, hidapi, sensor,
power, camera, GPU, offscreen, virtual-joystick (all zero-reference), plus
tests/examples and the Windows D3D12 driver. If a consumer app ever needs one of
these (e.g. SDL audio), re-enable its `-DSDL_*` flag in `build-all.py` and
rebuild — a build-time change, no code edit.
## Vendoring upstream Compose
Most `androidx.compose.*` code is copied byte for byte from
@@ -110,6 +119,21 @@ CDN_PROFILE=1 CDN_FORCERENDER=1 ./demo/build/bin/macosArm64/debugExecutable/demo
Note: `present` is vsync-blocking, so profile on the target refresh rate before
drawing conclusions about a frame-rate gap.
### Text metrics diagnostic
Set `CDN_TEXT_METRICS=1` and run any app to dump, per built paragraph, the font
scaler's `ascent`/`descent`/`leading`, the resolved `familyName` + `fontPx`, and
the resulting paragraph line box (`paraHeight`, `line0[ascent/descent/baseline/
height]`). Its job is the mac-vs-Windows vertical-spacing question (PLAN.md §1b):
the SAME `NotoSans.ttf` bytes go through a different Skia `FontMgr` scaler per
host (CoreText / fontconfig-FreeType / DirectWrite), which can pick different
metric tables. Run it on native AND on the JVM parity app on the SAME host and
diff the lines — Windows-native must match skiko-JVM-Windows.
```bash
CDN_TEXT_METRICS=1 demo.kexe --screen=Buttons --screenshot=/tmp/b.bmp
```
### Demo probes
`:demo` ships headless regression probes driven by CLI flags, used by the
+8
View File
@@ -141,6 +141,14 @@ subprojects {
password = System.getenv("GITHUB_TOKEN")
}
}
maven {
name = "Bitsycore"
url = uri("https://maven.bitsycore.com/releases")
credentials {
username = System.getenv("BITSYCORE_MAVEN_USER")
password = System.getenv("BITSYCORE_MAVEN_TOKEN")
}
}
}
publications.withType<MavenPublication>().configureEach {
pom {
@@ -49,9 +49,11 @@ class ComposeFontBundlingConfig {
var bundleMaterialSymbols: Boolean = false
/**
* hb-subset each bundled Material Symbols font to the glyphs the sources reference
* (scripts/subset-material-symbols.py + hb-subset; full font when hb-subset is
* absent). Needs [bundleMaterialSymbols]; still gated by -PsubsetIcons.
* Subset each bundled Material Symbols font to the glyphs the sources reference
* (scripts/subset-material-symbols.py picks the codepoints; the subset task uses
* hb-subset when present, else fontTools via Python — auto-installed — and only
* bundles the full font if neither is available). Needs [bundleMaterialSymbols];
* still gated by -PsubsetIcons.
*/
var enableIconSubsetting: Boolean = false
}
@@ -247,8 +249,10 @@ private fun Project.registerIconSubsetPipeline(inUsedStyles: List<String>): Map<
}
/**
* hb-subset inStyle's downloaded TTF to the codepoints in usage-codepoint.txt, into this
* app's build/icons/. Bundles the full font instead when hb-subset is not on PATH.
* Subset inStyle's downloaded TTF to the codepoints in usage-codepoint.txt, into this
* app's build/icons/. Prefers hb-subset (harfbuzz); falls back to fontTools via Python
* (variable-font-aware, auto-installed via pip) when hb-subset is absent; bundles the
* full font only if neither subsetter is available.
*/
private fun Project.registerSubsetTask(
inStyle: String,
@@ -279,30 +283,67 @@ private fun Project.registerSubsetTask(
vOut.parentFile.mkdirs()
val vInputFile = vInputProvider.get().asFile
val vBefore = vInputFile.length()
// ProcessBuilder: project.exec isn't usable inside doLast on Gradle 9.
// hb-subset is optional : without it, bundle the full font.
val vProc = try {
ProcessBuilder(
"hb-subset",
vInputFile.absolutePath,
"-o", vOut.absolutePath,
"--unicodes=$vUnicodes",
).redirectErrorStream(true).start()
} catch (_: java.io.IOException) {
// project.exec isn't usable inside doLast on Gradle 9, so shell out via
// ProcessBuilder. Returns (exitedZero, combinedOutput); a missing binary
// (IOException) yields (false, message) so callers can fall through.
fun runCmd(vararg inCmd: String): Pair<Boolean, String> =
try {
val vProc = ProcessBuilder(*inCmd).redirectErrorStream(true).start()
val vText = vProc.inputStream.bufferedReader().readText()
(vProc.waitFor() == 0) to vText
} catch (e: java.io.IOException) {
false to (e.message ?: "not found")
}
fun logShrink(inTool: String) {
val vAfter = vOut.length()
val vPct = if (vBefore == 0L) 0 else (100 - 100 * vAfter / vBefore).coerceAtLeast(0)
logger.lifecycle(
"[subset $inStyle] ${vBefore / 1024}KB → ${vAfter / 1024}KB (-$vPct%) · " +
"${vCodepoints.size} glyphs kept [$inTool]"
)
}
// 1. hb-subset (harfbuzz) — fastest path when it is on PATH.
if (runCmd("hb-subset", vInputFile.absolutePath, "-o", vOut.absolutePath, "--unicodes=$vUnicodes").first
&& vOut.length() > 0L
) {
logShrink("hb-subset"); return@doLast
}
// 2. fontTools (pyftsubset) via Python — the portable fallback. Python is
// already a build prerequisite (SDL build + vendor sync) and fontTools is
// variable-font-aware, so the FILL/wght/GRAD/opsz axes survive. It is
// auto-installed if missing, so no manual harfbuzz install is ever required.
val vPython = listOf("python3", "python", "py").firstOrNull { runCmd(it, "--version").first }
if (vPython != null) {
if (!runCmd(vPython, "-c", "import fontTools").first) {
logger.lifecycle("[subset $inStyle] fontTools not present — installing it via pip…")
// --user (no root) → --break-system-packages (PEP 668) → plain.
listOf(listOf("--user"), listOf("--break-system-packages"), emptyList<String>())
.firstOrNull { vExtra ->
runCmd(
vPython, "-m", "pip", "install", "-q", "--disable-pip-version-check",
*vExtra.toTypedArray(), "fonttools"
).first
}
}
val (vFtOk, vFtOut) = runCmd(
vPython, "-m", "fontTools.subset", vInputFile.absolutePath,
"--unicodes=$vUnicodes", "--output-file=${vOut.absolutePath}"
)
if (vFtOk && vOut.length() > 0L) { logShrink("fontTools"); return@doLast }
logger.warn("[subset $inStyle] fontTools subset failed :\n$vFtOut")
}
// 3. Last resort : the full font — correct, just larger.
vInputFile.copyTo(vOut, overwrite = true)
logger.warn(
"[subset $inStyle] hb-subset not found on PATH : bundling the full font " +
"(${vBefore / 1024}KB). Install harfbuzz to shrink it (brew install harfbuzz / " +
"pacman -S mingw-w64-x86_64-harfbuzz / apt install harfbuzz-utils)."
"[subset $inStyle] no working subsetter found (hb-subset or python3+fontTools) : " +
"bundling the full font (${vBefore / 1024}KB). Install harfbuzz " +
"(brew install harfbuzz / apt install harfbuzz-utils / " +
"pacman -S mingw-w64-x86_64-harfbuzz) or put python3 on PATH."
)
return@doLast
}
val vOutput = vProc.inputStream.bufferedReader().readText()
val vCode = vProc.waitFor()
if (vCode != 0) throw GradleException("hb-subset failed (exit $vCode):\n$vOutput")
val vAfter = vOut.length()
val vPct = if (vBefore == 0L) 0 else ((100 - 100 * vAfter / vBefore)).coerceAtLeast(0)
logger.lifecycle("[subset $inStyle] ${vBefore / 1024}KB → ${vAfter / 1024}KB (-$vPct%) · ${vCodepoints.size} glyphs kept")
}
}
}
@@ -5,11 +5,18 @@ package org.jetbrains.compose.resources
import org.jetbrains.compose.resources.InternalResourceApi
import org.jetbrains.compose.resources.ExperimentalResourceApi
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import com.compose.sdl.graphics.decodeEncodedImageBitmap
import com.compose.sdl.graphics.decodeSvgAt
import com.compose.sdl.graphics.svgIntrinsicSize
import kotlin.math.roundToInt
// ==================
// MARK: Image actuals — Skia decode via the :ui-graphics hook
@@ -23,15 +30,51 @@ internal actual fun ByteArray.toImageBitmap(resourceDensity: Int, targetDensity:
decodeEncodedImageBitmap(this)
?: error("Image decode failed — is the render backend initialised before painterResource ran?")
/** SVG: Skia rasterises it at its intrinsic size, so the "element" is just the
raw bytes and the painter is the rasterised bitmap. No DOM, no vector scaling —
prefer XML vector drawables for resolution-independent art. */
/** SVG element = the raw document bytes; rendering is size-driven (see SvgPainter). */
internal actual class SvgElement(val bytes: ByteArray)
internal actual fun ByteArray.toSvgElement(): SvgElement = SvgElement(this)
internal actual fun SvgElement.toSvgPainter(density: Density): Painter =
BitmapPainter(
decodeEncodedImageBitmap(bytes)
?: error("SVG rasterisation failed — is the render backend initialised before painterResource ran?")
/** Resolution-independent SVG painter: reports the document's intrinsic size for
layout, but RE-RASTERISES the vector at the actual draw size each time the draw
size changes (1-entry size cache), so a scaled-up SVG stays crisp instead of
upscaling one intrinsic-size bitmap. Mirrors upstream desktop's SVGPainter. */
internal actual fun SvgElement.toSvgPainter(density: Density): Painter = SvgPainter(bytes)
private class SvgPainter(private val bytes: ByteArray) : Painter() {
override val intrinsicSize: Size = svgIntrinsicSize(bytes) ?: Size.Unspecified
private var fAlpha: Float = 1f
private var fColorFilter: ColorFilter? = null
private var fCacheKey: Long = -1L
private var fCacheBitmap: ImageBitmap? = null
override fun applyAlpha(alpha: Float): Boolean { fAlpha = alpha; return true }
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { fColorFilter = colorFilter; return true }
private fun rasterFor(inWidthPx: Int, inHeightPx: Int): ImageBitmap? {
val vKey = (inWidthPx.toLong() shl 32) or inHeightPx.toLong()
if (vKey == fCacheKey && fCacheBitmap != null) return fCacheBitmap
val vBitmap = decodeSvgAt(bytes, inWidthPx, inHeightPx) ?: return null
fCacheKey = vKey
fCacheBitmap = vBitmap
return vBitmap
}
override fun DrawScope.onDraw() {
val vWpx = size.width.roundToInt()
val vHpx = size.height.roundToInt()
if (vWpx <= 0 || vHpx <= 0) return
val vBitmap = rasterFor(vWpx, vHpx) ?: return
drawImage(
vBitmap,
srcOffset = IntOffset.Zero,
srcSize = IntSize(vBitmap.width, vBitmap.height),
dstOffset = IntOffset.Zero,
dstSize = IntSize(vWpx, vHpx),
alpha = fAlpha,
colorFilter = fColorFilter,
)
}
}
@@ -16,8 +16,7 @@ plugins {
alias(libs.plugins.compose.multiplatform)
}
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
val vHostSupportsMingw: Boolean by rootProject.extra
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64()
@@ -13,8 +13,7 @@ plugins {
alias(libs.plugins.kotlin.plugin.compose)
}
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
val vHostSupportsMingw: Boolean by rootProject.extra
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64()
+1 -2
View File
@@ -13,8 +13,7 @@ plugins {
alias(libs.plugins.kotlin.plugin.compose)
}
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
val vHostSupportsMingw: Boolean by rootProject.extra
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64()
@@ -22,7 +22,6 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.lifecycle.enableSavedStateHandles
import com.compose.sdl.node.ComposeRootHost
import com.compose.sdl.res.currentImageLoader
import com.compose.sdl.window.LocalPopupHost
@@ -35,7 +34,11 @@ import kotlinx.cinterop.toKString
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.alloc
import kotlinx.cinterop.ptr
import kotlinx.cinterop.COpaquePointer
import kotlinx.cinterop.pointed
import sdl3.SDL_Delay
import sdl3.SDL_GetCurrentDisplayMode
import sdl3.SDL_GetDisplayForWindow
import sdl3.SDL_GetPerformanceCounter
import sdl3.SDL_GetPerformanceFrequency
import sdl3.SDL_GetTicks
@@ -142,8 +145,12 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
val appScope = ApplicationScopeImpl(runtime)
val snapshotHandle = Snapshot.registerGlobalWriteObserver {
// Only SCHEDULE a frame here; the apply is coalesced to once per loop
// iteration (top of loop + before each pump + before layout in
// renderFrame) instead of walking every snapshot observer on every
// individual state write. Mirrors upstream GlobalSnapshotManager,
// which schedules rather than applying inline.
runtime.markAllNeedFrame()
Snapshot.sendApplyNotifications()
}
appComposition.setContent { appScope.content() }
@@ -216,6 +223,10 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
// Per-window pump + render.
var vAnyRendered = false
var vAllVsync = true
// Non-vsync fallback cap: the shortest frame interval among the
// rendered non-vsync windows' displays (60Hz default). Only used when
// no window is vsync-paced (Software renderer / vsync unavailable).
var vFallbackDelayMs = 16u
val vAppPending = appRecomposer.hasPendingWork
for (vW in runtime.windows.toList()) {
vW.installGlobals()
@@ -228,7 +239,10 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
if (vW.shouldRender()) {
vW.renderFrame()
vAnyRendered = true
if (!vW.backend.vsyncEnabled) vAllVsync = false
if (!vW.backend.vsyncEnabled) {
vAllVsync = false
vFallbackDelayMs = minOf(vFallbackDelayMs, displayFrameDelayMs(vW.backend.window))
}
}
FrameProfiler.phase("render")
}
@@ -246,12 +260,19 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
// ============
// Pace / idle-skip.
if (vAnyRendered) {
SDL_Delay(if (vAllVsync) 1u else 16u)
SDL_Delay(if (vAllVsync) 1u else vFallbackDelayMs)
} else if (!vAppPending) {
// Nothing invalidated anywhere: block for events instead of
// spinning. Keep FPS windows anchored to active periods.
SDL_WaitEventTimeout(null, 10)
// spinning. A real input/redraw event wakes this immediately; the
// timeout only bounds how often async work (dispatcher/timers) is
// re-checked while truly idle, so keep it coarse to cut wakeups.
SDL_WaitEventTimeout(null, 100)
for (vW in runtime.windows) vW.resetFpsWindow()
} else {
// App composition has pending work but nothing rendered this
// iteration — yield briefly instead of spinning at full speed
// until the composition settles into a renderable state.
SDL_Delay(1u)
}
// ============
@@ -290,79 +311,6 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
SDL_Quit()
}
/** CDN_PROFILE=1 — per-phase timings, printed every ~2s of rendered frames.
A named-phase SINGLETON so both the main loop (events / app / pump / render)
AND renderFrame's sub-steps (render.layout / render.draw / render.present)
report into one line. `mark()` resets the stopwatch; `phase(name)` charges
the elapsed since the last mark/phase to that name. Measure first, optimize
second — see ROADMAP.md. */
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
internal object FrameProfiler {
// null until first checked; then true/false for the run's lifetime.
private var fEnabled: Boolean? = null
// Output file — resolved once from CDN_PROFILE. Writing to a file (not
// stdout) lets GUI-subsystem apps (the demo links --subsystem,windows, so
// it has no console) be profiled too. CDN_PROFILE=1 → "cdn_profile.log" in
// the cwd; CDN_PROFILE=<path> → that path.
private var fPath: String = "cdn_profile.log"
val enabled: Boolean
get() = fEnabled ?: run {
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv
(vEnv != null).also { fEnabled = it }
}
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
// Insertion-ordered so the printed line follows the call order.
private val fSum = LinkedHashMap<String, Double>()
private val fMax = LinkedHashMap<String, Double>()
private var fFrames = 0
private var fLastPrintMs = SDL_GetTicks()
private var fMark = 0uL
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
fun phase(inName: String) {
if (!enabled) return
val vNow = SDL_GetPerformanceCounter()
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
fSum[inName] = (fSum[inName] ?: 0.0) + vMs
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
fMark = vNow
}
fun frameDone(inRendered: Boolean) {
if (!enabled) return
if (inRendered) fFrames++
val vNowMs = SDL_GetTicks()
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
val vParts = fSum.keys.map { vName ->
val vAvg = (fSum[vName] ?: 0.0) / fFrames
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
}
// Per-frame draw-work averages (see DrawStats): what's inside `draw`.
val vStats = com.compose.sdl.graphics.DrawStats
val vDraw = "geo=${vStats.geometrySubmits / fFrames} verts=${vStats.vertices / fFrames} " +
"masks=${vStats.maskRealizations / fFrames} text=${vStats.textDraws / fFrames} img=${vStats.imageBlits / fFrames}"
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " | per-frame " + vDraw + "\n"
val vFile = platform.posix.fopen(fPath, "a")
if (vFile != null) {
platform.posix.fputs(vLine, vFile)
platform.posix.fclose(vFile)
}
vStats.reset()
fSum.clear(); fMax.clear()
fFrames = 0
fLastPrintMs = vNowMs
}
}
}
/** CDN_FORCERENDER=1 forces every frame to render so sustained steady-state
timings can be measured on otherwise-idle screens (see TOOLING.md, frame
profiler). */
private val kForceRender: Boolean = platform.posix.getenv("CDN_FORCERENDER") != null
// ==================
// MARK: Probe / screenshot support (render-to-quiescence)
// ==================
@@ -419,6 +367,18 @@ fun windowHasInvalidations(): Boolean = renderingWindow?.hasInvalidations() ?: f
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
/** Frame interval (ms) of the window's current display, for the non-vsync
fallback pacing (SDL_Delay). Falls back to 16ms (~60Hz) when the mode can't
be read, so a 144Hz panel on a non-vsync path isn't capped to 60. */
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
private fun displayFrameDelayMs(window: COpaquePointer?): UInt {
val vWindow = window ?: return 16u
val vDisplay = SDL_GetDisplayForWindow(vWindow.reinterpret())
val vMode = SDL_GetCurrentDisplayMode(vDisplay) ?: return 16u
val vHz = vMode.pointed.refresh_rate
return if (vHz > 0f) (1000f / vHz).toUInt().coerceAtLeast(1u) else 16u
}
/** Single-window compatibility wrapper — the pre-multi-window entry point.
Closing the window exits the app, exactly as before. */
fun nativeComposeWindow(
@@ -776,6 +736,12 @@ internal class WindowInstance(
// Register bundled generic fonts (FontFamily.Monospace → NotoSansMono) once
// data.kres is loadable; idempotent, no-op if the font isn't bundled.
com.compose.sdl.text.registerGenericFonts()
// Position the OS IME candidate window the moment a field in THIS window
// gains focus, not only on the first TEXT_EDITING event (repointed per
// window here so multi-window focus targets the right SDL window).
com.compose.sdl.text.input.ImeBridge.onSessionActiveChange = { active ->
if (active) updateImeArea()
}
}
// ============
@@ -797,8 +763,8 @@ internal class WindowInstance(
// SDL3 delivers mouse coords in logical points on HiDPI — multiply by
// DPR so hit-testing lands in the pixel space layout uses.
val vDpr = backend.pixelDensity
val vPx = inEvent.event.x.toFloat() * vDpr
val vPy = inEvent.event.y.toFloat() * vDpr
val vPx = inEvent.event.x * vDpr
val vPy = inEvent.event.y * vDpr
if (inEvent.event.type == PointerEventType.Press) {
popupHost?.notifyOutsidePress(vPx.toInt(), vPy.toInt())
}
@@ -823,7 +789,7 @@ internal class WindowInstance(
needsFrame = true
installGlobals()
val vDpr = backend.pixelDensity
host.onWheel(inEvent.x.toFloat() * vDpr, inEvent.y.toFloat() * vDpr, inEvent.deltaX, inEvent.deltaY, SDL_GetTicks().toLong())
host.onWheel(inEvent.x * vDpr, inEvent.y * vDpr, inEvent.deltaX, inEvent.deltaY, SDL_GetTicks().toLong())
}
fun onKeyEvent(inEvent: AppEvent.Key) {
@@ -936,6 +902,11 @@ internal class WindowInstance(
backend.updateWindowSize()
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
// Charge the GPU back-buffer acquire to its own phase. On Metal this is
// where nextDrawable() blocks on vsync (the natural frame pacing), so
// folding it into "layout" made the profiler read the vsync wait as
// layout cost — a ~6-7ms phantom that hid the real (tiny) layout time.
FrameProfiler.phase(" acquire")
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
// Deliver any state written by this iteration's frame-clock continuations
// (withFrameNanos animations — notably smooth wheel scrolling) BEFORE we lay
@@ -1014,118 +985,5 @@ internal class WindowInstance(
}
}
// ==================
// MARK: WindowArchitectureOwner
// ==================
/** Per-window architecture-components owner, modeled on upstream desktop's
DefaultArchitectureComponentsOwner (compose/ui skikoMain
PlatformOwnerProvider.skiko.kt): one object implements LifecycleOwner +
ViewModelStoreOwner + SavedStateRegistryOwner (+ the SavedState-aware
default ViewModel factory), so viewModel(), SavedStateHandle and
rememberSaveable-backed registries all resolve against the WINDOW scope.
The lifecycle registry uses createUnsafe (no main-thread enforcement) —
same as the root owner this replaces; the SDL loop is single-threaded
anyway. RESUMED from construction; destroy() moves to DESTROYED and clears
the ViewModelStore (onCleared runs). SavedState restores from nothing (no
process-death persistence on desktop — upstream desktop passes null too). */
private class WindowArchitectureOwner :
androidx.lifecycle.LifecycleOwner,
androidx.lifecycle.ViewModelStoreOwner,
androidx.lifecycle.HasDefaultViewModelProviderFactory,
androidx.savedstate.SavedStateRegistryOwner {
override val lifecycle = androidx.lifecycle.LifecycleRegistry.createUnsafe(this)
override val viewModelStore = androidx.lifecycle.ViewModelStore()
private val savedStateController = androidx.savedstate.SavedStateRegistryController.create(this)
override val savedStateRegistry: androidx.savedstate.SavedStateRegistry
get() = savedStateController.savedStateRegistry
override val defaultViewModelProviderFactory = androidx.lifecycle.SavedStateViewModelFactory()
override val defaultViewModelCreationExtras: androidx.lifecycle.viewmodel.CreationExtras
get() = androidx.lifecycle.viewmodel.MutableCreationExtras().also {
it[androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY] = this
it[androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY] = this
}
init {
savedStateController.performAttach()
savedStateController.performRestore(null)
// SavedStateHandle support for WINDOW-scoped ViewModels — must run while
// the lifecycle is still ≤ CREATED; upstream desktop's ComposeContainer
// calls this at the same point. With it, `viewModel { ... }` against the
// window owner (the activityViewModels() analog) can take a
// SavedStateHandle instead of needing a saved-state-less child owner.
enableSavedStateHandles()
// CREATED (not RESUMED) until the first composition is done — code that
// runs enableSavedStateHandles() during composition (nav3's decorators,
// rememberViewModelStoreOwner, …) requires INITIALIZED/CREATED, and
// upstream desktop windows likewise compose first and resume after.
lifecycle.currentState = androidx.lifecycle.Lifecycle.State.CREATED
}
/** Focus/visibility-driven state (see WindowInstance.onActivationEvent).
Ignored once destroyed — a stray SDL event during teardown must not
resurrect the registry. */
fun setLifecycleState(inState: androidx.lifecycle.Lifecycle.State) {
if (lifecycle.currentState != androidx.lifecycle.Lifecycle.State.DESTROYED &&
lifecycle.currentState != inState
) {
lifecycle.currentState = inState
}
}
fun destroy() {
lifecycle.currentState = androidx.lifecycle.Lifecycle.State.DESTROYED
viewModelStore.clear()
}
}
// ==================
// MARK: Input helpers
// ==================
/** Escape → back: port of upstream desktop's BackNavigationEventInput. An
unconsumed Escape KeyDown completes a back navigation on the dispatcher
this input is registered with (BackHandler / PredictiveBackHandler
consumers: m3 SearchBar collapse, dialog dismissal, …). */
private class BackNavigationInput : androidx.navigationevent.NavigationEventInput() {
fun onKeyEvent(inEvent: androidx.compose.ui.input.key.KeyEvent): Boolean {
if (inEvent.type == androidx.compose.ui.input.key.KeyEventType.KeyDown &&
inEvent.key == androidx.compose.ui.input.key.Key.Escape
) {
dispatchOnBackCompleted()
return true
}
return false
}
}
/** Re-dispatches committed text (SDL TEXT_INPUT) as one synthetic typed
KeyDown per Unicode codepoint (surrogate pairs folded). Key.Unknown +
codePoint + no modifiers matches both vendored text stacks' isTypedEvent
criteria; the SDL key mapper leaves codePoint = 0 on real key events, so
the physical KeyDown and the synthetic one can never double-insert. */
private fun dispatchTypedText(inHost: ComposeRootHost, inText: String) {
var vI = 0
while (vI < inText.length) {
val vHigh = inText[vI]
val vCodepoint: Int
if (vHigh.isHighSurrogate() && vI + 1 < inText.length && inText[vI + 1].isLowSurrogate()) {
vCodepoint = 0x10000 + ((vHigh.code - 0xD800) shl 10) + (inText[vI + 1].code - 0xDC00)
vI += 2
} else {
vCodepoint = vHigh.code
vI += 1
}
inHost.dispatchKeyEvent(
androidx.compose.ui.input.key.KeyEvent(
key = androidx.compose.ui.input.key.Key.Unknown,
type = androidx.compose.ui.input.key.KeyEventType.KeyDown,
codePoint = vCodepoint,
),
)
}
}
// WindowArchitectureOwner -> WindowArchitectureOwner.kt
// BackNavigationInput / dispatchTypedText -> WindowInputHelpers.kt
@@ -0,0 +1,78 @@
package com.compose.sdl
import kotlinx.cinterop.toKString
import sdl3.SDL_GetPerformanceCounter
import sdl3.SDL_GetPerformanceFrequency
import sdl3.SDL_GetTicks
// ==================
// MARK: FrameProfiler
// ==================
/** CDN_PROFILE=1 — per-phase timings, printed every ~2s of rendered frames.
A named-phase SINGLETON so both the main loop (events / app / pump / render)
AND renderFrame's sub-steps (render.layout / render.draw / render.present)
report into one line. `mark()` resets the stopwatch; `phase(name)` charges
the elapsed since the last mark/phase to that name. Measure first, optimize
second — see ROADMAP.md. */
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
internal object FrameProfiler {
// null until first checked; then true/false for the run's lifetime.
private var fEnabled: Boolean? = null
// Output file — resolved once from CDN_PROFILE. Writing to a file (not
// stdout) lets GUI-subsystem apps (the demo links --subsystem,windows, so
// it has no console) be profiled too. CDN_PROFILE=1 → "cdn_profile.log" in
// the cwd; CDN_PROFILE=<path> → that path.
private var fPath: String = "cdn_profile.log"
val enabled: Boolean
get() = fEnabled ?: run {
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv
(vEnv != null).also { fEnabled = it }
}
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
// Insertion-ordered so the printed line follows the call order.
private val fSum = LinkedHashMap<String, Double>()
private val fMax = LinkedHashMap<String, Double>()
private var fFrames = 0
private var fLastPrintMs = SDL_GetTicks()
private var fMark = 0uL
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
fun phase(inName: String) {
if (!enabled) return
val vNow = SDL_GetPerformanceCounter()
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
fSum[inName] = (fSum[inName] ?: 0.0) + vMs
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
fMark = vNow
}
fun frameDone(inRendered: Boolean) {
if (!enabled) return
if (inRendered) fFrames++
val vNowMs = SDL_GetTicks()
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
val vParts = fSum.keys.map { vName ->
val vAvg = (fSum[vName] ?: 0.0) / fFrames
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
}
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + "\n"
val vFile = platform.posix.fopen(fPath, "a")
if (vFile != null) {
platform.posix.fputs(vLine, vFile)
platform.posix.fclose(vFile)
}
fSum.clear(); fMax.clear()
fFrames = 0
fLastPrintMs = vNowMs
}
}
}
/** CDN_FORCERENDER=1 forces every frame to render so sustained steady-state
timings can be measured on otherwise-idle screens (see TOOLING.md, frame
profiler). */
internal val kForceRender: Boolean = platform.posix.getenv("CDN_FORCERENDER") != null
@@ -0,0 +1,72 @@
package com.compose.sdl
import androidx.lifecycle.enableSavedStateHandles
// ==================
// MARK: WindowArchitectureOwner
// ==================
/** Per-window architecture-components owner, modeled on upstream desktop's
DefaultArchitectureComponentsOwner (compose/ui skikoMain
PlatformOwnerProvider.skiko.kt): one object implements LifecycleOwner +
ViewModelStoreOwner + SavedStateRegistryOwner (+ the SavedState-aware
default ViewModel factory), so viewModel(), SavedStateHandle and
rememberSaveable-backed registries all resolve against the WINDOW scope.
The lifecycle registry uses createUnsafe (no main-thread enforcement) —
same as the root owner this replaces; the SDL loop is single-threaded
anyway. RESUMED from construction; destroy() moves to DESTROYED and clears
the ViewModelStore (onCleared runs). SavedState restores from nothing (no
process-death persistence on desktop — upstream desktop passes null too). */
internal class WindowArchitectureOwner :
androidx.lifecycle.LifecycleOwner,
androidx.lifecycle.ViewModelStoreOwner,
androidx.lifecycle.HasDefaultViewModelProviderFactory,
androidx.savedstate.SavedStateRegistryOwner {
override val lifecycle = androidx.lifecycle.LifecycleRegistry.createUnsafe(this)
override val viewModelStore = androidx.lifecycle.ViewModelStore()
private val savedStateController = androidx.savedstate.SavedStateRegistryController.create(this)
override val savedStateRegistry: androidx.savedstate.SavedStateRegistry
get() = savedStateController.savedStateRegistry
override val defaultViewModelProviderFactory = androidx.lifecycle.SavedStateViewModelFactory()
override val defaultViewModelCreationExtras: androidx.lifecycle.viewmodel.CreationExtras
get() = androidx.lifecycle.viewmodel.MutableCreationExtras().also {
it[androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY] = this
it[androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY] = this
}
init {
savedStateController.performAttach()
savedStateController.performRestore(null)
// SavedStateHandle support for WINDOW-scoped ViewModels — must run while
// the lifecycle is still ≤ CREATED; upstream desktop's ComposeContainer
// calls this at the same point. With it, `viewModel { ... }` against the
// window owner (the activityViewModels() analog) can take a
// SavedStateHandle instead of needing a saved-state-less child owner.
enableSavedStateHandles()
// CREATED (not RESUMED) until the first composition is done — code that
// runs enableSavedStateHandles() during composition (nav3's decorators,
// rememberViewModelStoreOwner, …) requires INITIALIZED/CREATED, and
// upstream desktop windows likewise compose first and resume after.
lifecycle.currentState = androidx.lifecycle.Lifecycle.State.CREATED
}
/** Focus/visibility-driven state (see WindowInstance.onActivationEvent).
Ignored once destroyed — a stray SDL event during teardown must not
resurrect the registry. */
fun setLifecycleState(inState: androidx.lifecycle.Lifecycle.State) {
if (lifecycle.currentState != androidx.lifecycle.Lifecycle.State.DESTROYED &&
lifecycle.currentState != inState
) {
lifecycle.currentState = inState
}
}
fun destroy() {
lifecycle.currentState = androidx.lifecycle.Lifecycle.State.DESTROYED
viewModelStore.clear()
}
}
@@ -0,0 +1,54 @@
@file:OptIn(androidx.compose.ui.InternalComposeUiApi::class)
package com.compose.sdl
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import com.compose.sdl.node.ComposeRootHost
// ==================
// MARK: Input helpers
// ==================
/** Escape → back: port of upstream desktop's BackNavigationEventInput. An
unconsumed Escape KeyDown completes a back navigation on the dispatcher
this input is registered with (BackHandler / PredictiveBackHandler
consumers: m3 SearchBar collapse, dialog dismissal, …). */
internal class BackNavigationInput : androidx.navigationevent.NavigationEventInput() {
fun onKeyEvent(inEvent: androidx.compose.ui.input.key.KeyEvent): Boolean {
if (inEvent.type == androidx.compose.ui.input.key.KeyEventType.KeyDown &&
inEvent.key == androidx.compose.ui.input.key.Key.Escape
) {
dispatchOnBackCompleted()
return true
}
return false
}
}
/** Re-dispatches committed text (SDL TEXT_INPUT) as one synthetic typed
KeyDown per Unicode codepoint (surrogate pairs folded). Key.Unknown +
codePoint + no modifiers matches both vendored text stacks' isTypedEvent
criteria; the SDL key mapper leaves codePoint = 0 on real key events, so
the physical KeyDown and the synthetic one can never double-insert. */
internal fun dispatchTypedText(inHost: ComposeRootHost, inText: String) {
var vI = 0
while (vI < inText.length) {
val vHigh = inText[vI]
val vCodepoint: Int
if (vHigh.isHighSurrogate() && vI + 1 < inText.length && inText[vI + 1].isLowSurrogate()) {
vCodepoint = 0x10000 + ((vHigh.code - 0xD800) shl 10) + (inText[vI + 1].code - 0xDC00)
vI += 2
} else {
vCodepoint = vHigh.code
vI += 1
}
inHost.dispatchKeyEvent(
androidx.compose.ui.input.key.KeyEvent(
key = androidx.compose.ui.input.key.Key.Unknown,
type = androidx.compose.ui.input.key.KeyEventType.KeyDown,
codePoint = vCodepoint,
),
)
}
}
@@ -13,8 +13,7 @@ plugins {
alias(libs.plugins.kotlin.plugin.compose)
}
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
val vHostSupportsMingw: Boolean by rootProject.extra
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64()
@@ -34,8 +34,7 @@ plugins {
alias(libs.plugins.compose.multiplatform)
}
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
val vHostSupportsMingw: Boolean by rootProject.extra
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64()
@@ -517,6 +517,11 @@ skikoMain/kotlin/ -> src/vendor/native/kotlin/
!skikoMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.skiko.kt
!skikoMain/kotlin/androidx/compose/foundation/Clickable.skiko.kt
!skikoMain/kotlin/androidx/compose/foundation/DarkTheme.skiko.kt
# Scrollbar.skiko.kt + v2/Scrollbar.skiko.kt are MANUALLY VENDORED to
# src/nativeMain/.../foundation/{,v2}/ (VENDOR-BASE headers there): the flat
# native source set can't hold the v2 `expect fun runBlockingIfPossible` and its
# actual together, so it's inlined to runBlocking. Refused here so sync doesn't
# shadow the hand-vendored copies.
!skikoMain/kotlin/androidx/compose/foundation/Scrollbar.skiko.kt
!skikoMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSource.skiko.kt
!skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt
@@ -1,342 +0,0 @@
package com.compose.sdl.scrollbar
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.hoverable
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
// ==================
// MARK: Scrollbar (Compose Desktop-style)
// ==================
/** A faithful subset of Compose Desktop's scrollbar (androidx.compose.foundation).
You overlay a VerticalScrollbar / HorizontalScrollbar on a scrollable and feed
it a ScrollbarAdapter built from the same ScrollState. This project's Box has
no BoxScope (so no Modifier.align) — pin the bar with the Box's
contentAlignment; the fillMaxSize content fills the rest:
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
val state = rememberScrollState()
Column(Modifier.fillMaxSize().verticalScroll(state)) { ... }
VerticalScrollbar(
adapter = rememberScrollbarAdapter(state),
modifier = Modifier.fillMaxHeight(),
)
}
Always visible (no auto-hide); the thumb tweens unhoverColor -> hoverColor on
hover over hoverDurationMillis, matching upstream. Drag the thumb to scroll;
click the track to page. */
@Immutable
class ScrollbarStyle(
val minimalHeight: Dp,
val thickness: Dp,
val shape: Shape,
val hoverDurationMillis: Int,
val unhoverColor: Color,
val hoverColor: Color,
)
/** Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
provide a white-tinted style via LocalScrollbarStyle or the `style` param. */
fun defaultScrollbarStyle(): ScrollbarStyle = ScrollbarStyle(
minimalHeight = 16.dp,
thickness = 8.dp,
shape = RoundedCornerShape(4.dp),
hoverDurationMillis = 300,
unhoverColor = Color.Black.copy(alpha = 0.12f),
hoverColor = Color.Black.copy(alpha = 0.50f),
)
val LocalScrollbarStyle = compositionLocalOf { defaultScrollbarStyle() }
// ==================
// MARK: ScrollbarAdapter
// ==================
/** Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
contentSize / viewportSize are along the scroll axis; the thumb is sized by
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
interface ScrollbarAdapter {
val scrollOffset: Double
val contentSize: Double
val viewportSize: Double
suspend fun scrollTo(scrollOffset: Double)
}
@Composable
fun rememberScrollbarAdapter(scrollState: ScrollState): ScrollbarAdapter =
remember(scrollState) { ScrollStateScrollbarAdapter(scrollState) }
private class ScrollStateScrollbarAdapter(private val state: ScrollState) : ScrollbarAdapter {
override val scrollOffset: Double get() = state.value.toDouble()
override val contentSize: Double get() = state.contentSize.toDouble()
override val viewportSize: Double get() = state.viewportSize.toDouble()
override suspend fun scrollTo(scrollOffset: Double) {
// Scrollbar thumb drag → instant scroll-to. dispatchRawDelta takes a
// delta in pixels; convert the target offset into a delta from where
// we are now (clamping at the scroll edges is the dispatchRawDelta
// contract, so we don't have to coerce here).
state.dispatchRawDelta((scrollOffset - state.value.toDouble()).toFloat())
}
}
/** Adapter for a virtualized LazyColumn / LazyRow. A lazy list only knows the
size of its VISIBLE items, so exact pixel geometry of off-screen content is
unknowable — we estimate it from the average visible item size × total item
count (the same approach as Compose Desktop). Accurate enough for a thumb
whose length/position track the scroll position; the estimate self-corrects
as items of different sizes scroll through the viewport. */
@Composable
fun rememberScrollbarAdapter(scrollState: LazyListState): ScrollbarAdapter =
remember(scrollState) { LazyListScrollbarAdapter(scrollState) }
private class LazyListScrollbarAdapter(private val state: LazyListState) : ScrollbarAdapter {
// Mean main-axis size of the currently-visible items, in px (>= 1 to avoid /0).
private fun averageItemSize(): Double {
val vVisible = state.layoutInfo.visibleItemsInfo
if (vVisible.isEmpty()) return 0.0
val vSpan = (vVisible.last().offset + vVisible.last().size) - vVisible.first().offset
return (vSpan.toDouble() / vVisible.size).coerceAtLeast(1.0)
}
override val scrollOffset: Double
get() = averageItemSize() * state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset
override val contentSize: Double
get() = averageItemSize() * state.layoutInfo.totalItemsCount
override val viewportSize: Double
get() = (state.layoutInfo.viewportEndOffset - state.layoutInfo.viewportStartOffset).toDouble()
override suspend fun scrollTo(scrollOffset: Double) {
val vDistance = scrollOffset - this.scrollOffset
// Within a viewport, scroll by EXACT pixels so a drag stays smooth. A lazy
// list's pixel geometry is only ESTIMATED (average visible item size), and
// that estimate drifts as differently-sized items scroll through the
// viewport — so index-snapping every frame makes the thumb jump. scrollBy
// sidesteps the estimate entirely. This is what Compose Desktop's own
// LazyListScrollbarAdapter does, and for the same reason.
if (abs(vDistance) <= viewportSize) {
state.scrollBy(vDistance.toFloat())
return
}
// Far jump (track-click paging past a viewport): no exact geometry to lean
// on, so estimate an index/offset from the average and snap to it.
val vAvg = averageItemSize()
if (vAvg <= 0.0) return
val vTarget = scrollOffset.coerceAtLeast(0.0)
val vTotal = state.layoutInfo.totalItemsCount
val vIndex = (vTarget / vAvg).toInt().coerceIn(0, (vTotal - 1).coerceAtLeast(0))
val vItemOffset = (vTarget - vIndex * vAvg).toInt().coerceAtLeast(0)
state.scrollToItem(vIndex, vItemOffset)
}
}
// ==================
// MARK: VerticalScrollbar / HorizontalScrollbar
// ==================
@Composable
fun VerticalScrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean = false,
style: ScrollbarStyle = LocalScrollbarStyle.current,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = true)
@Composable
fun HorizontalScrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean = false,
style: ScrollbarStyle = LocalScrollbarStyle.current,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = false)
/** Shared impl. The root Box is the track (fixed thickness on the cross axis,
filling the main axis from the caller's modifier); a child Box is the thumb,
positioned with an offset. Drag + track-click are handled on the track in
track-relative coordinates (a stable origin), so the grab point stays under
the pointer with no feedback from the thumb moving. interactionSource is
accepted for API parity; hover here is driven by Modifier.hoverable. */
@Composable
private fun Scrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier,
reverseLayout: Boolean,
style: ScrollbarStyle,
@Suppress("UNUSED_PARAMETER") interactionSource: MutableInteractionSource,
isVertical: Boolean,
) {
val vScope = rememberCoroutineScope()
var vTrackPx by remember { mutableStateOf(0) }
val vHoverSource = remember { MutableInteractionSource() }
val vHovered by vHoverSource.collectIsHoveredAsState()
var vDraggingThumb by remember { mutableStateOf(false) }
// onSizeChanged reports PHYSICAL pixels (Option-B density flow), so every
// int/px carried through here — vTrackPx, vThumbLen, vThumbPos — is in
// physical pixels. Passing them into `.dp` doubles again through
// density.toPx() on Retina, which visibly bloated the thumb (filled the
// whole track) and made drag/track-click positions land 2× too far down.
// Route pixel-sized modifiers through the current density so `.dp` and the
// pixel value stay in the same coord space.
val vDensity = LocalDensity.current
val vContent = adapter.contentSize
val vViewport = adapter.viewportSize
val vOffset = adapter.scrollOffset
val vMaxScroll = vContent - vViewport
val vScrollable = vMaxScroll > 0.5 && vViewport > 0.0
val vColor by animateColorAsState(
targetValue = if (vHovered || vDraggingThumb) style.hoverColor else style.unhoverColor,
animationSpec = tween(style.hoverDurationMillis),
)
val vRoot = (if (isVertical) modifier.width(style.thickness) else modifier.height(style.thickness))
.onSizeChanged { vTrackPx = if (isVertical) it.height else it.width }
.hoverable(vHoverSource)
Box(modifier = vRoot) {
if (!vScrollable || vTrackPx <= 0) return@Box
// ============
// Thumb metrics (px)
val vMinLen = style.minimalHeight.value.toInt()
val vThumbLen = (vTrackPx * (vViewport / vContent)).roundToInt()
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
val vTravel = vTrackPx - vThumbLen
val vRawPos = if (vTravel <= 0) 0 else (vTravel * (vOffset / vMaxScroll)).roundToInt()
val vThumbPos = (if (reverseLayout) vTravel - vRawPos else vRawPos).coerceIn(0, vTravel)
val vThumbLenDp = with(vDensity) { vThumbLen.toDp() }
val vThumbMod = (
if (isVertical) Modifier.offset { IntOffset(0, vThumbPos) }.width(style.thickness).height(vThumbLenDp)
else Modifier.offset { IntOffset(vThumbPos, 0) }.height(style.thickness).width(vThumbLenDp)
)
.clip(style.shape)
.background(vColor)
Box(modifier = vThumbMod)
// ============
// Drag the thumb / page on track click — handled on the track so the
// coordinates have a stable (non-moving) origin. The press is CONSUMED:
// a scrollbar owns the pointer input it handles, so content sharing this
// region (the scrollable beneath, or a parent that also reads pointer
// input) never reacts to the same press. Geometry is read LIVE from the
// adapter at press time — not from the captured render metrics above,
// which go stale between pointerInput relaunches — so a grab after a
// wheel-scroll still hit-tests against the thumb's current position.
Box(
modifier = Modifier
.matchTrackSize(isVertical, with(vDensity) { vTrackPx.toDp() }, style.thickness)
.pointerInput(isVertical, adapter, vTrackPx, reverseLayout) {
awaitEachGesture {
val vDown = awaitFirstDown(requireUnconsumed = false)
vDown.consume()
// Live geometry — stable for the drag's duration (content /
// viewport / track sizes don't change mid-drag).
val vContentPx = adapter.contentSize
val vViewportPx = adapter.viewportSize
val vMaxScrollPx = vContentPx - vViewportPx
if (vMaxScrollPx <= 0.0 || vTrackPx <= 0) return@awaitEachGesture
val vMinLen = style.minimalHeight.value.toInt()
val vLen = (vTrackPx * (vViewportPx / vContentPx)).roundToInt()
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
val vTravelPx = vTrackPx - vLen
val vRaw = if (vTravelPx <= 0) 0
else (vTravelPx * (adapter.scrollOffset / vMaxScrollPx)).roundToInt()
val vPos = (if (reverseLayout) vTravelPx - vRaw else vRaw).coerceIn(0, vTravelPx)
// Map a track-relative thumb-top to a scroll offset. The
// target uses the LIVE max scroll (read fresh each move),
// not the press-time value — the adapter compares it against
// its live scrollOffset, and mixing a stale scale with a live
// one makes the delta run away (grab at the bottom, drag up,
// and the list overshoots to the middle).
fun scrollToTrackPos(inThumbTop: Int) {
if (vTravelPx <= 0) return
val vLiveMax = adapter.contentSize - adapter.viewportSize
if (vLiveMax <= 0.0) return
val vClamped = inThumbTop.coerceIn(0, vTravelPx)
val vFrac = (if (reverseLayout) vTravelPx - vClamped else vClamped).toDouble() / vTravelPx
vScope.launch { adapter.scrollTo(vFrac * vLiveMax) }
}
val vPress = (if (isVertical) vDown.position.y else vDown.position.x).toInt()
val vGrab: Int
if (vPress in vPos..(vPos + vLen)) {
vDraggingThumb = true
vGrab = vPress - vPos
} else {
// Page toward the click by one viewport.
vDraggingThumb = false
vGrab = 0
val vDir = if (vPress < vPos) -1.0 else 1.0
vScope.launch { adapter.scrollTo(adapter.scrollOffset + vDir * vViewportPx) }
}
// Follow the pressed pointer to release, consuming every
// change so the interaction can't leak. Only a thumb grab
// drags-to-scroll; a track press just consumes.
do {
val vEvent = awaitPointerEvent()
val vChange = vEvent.changes.firstOrNull { it.id == vDown.id } ?: break
if (vDraggingThumb) {
val vCur = (if (isVertical) vChange.position.y else vChange.position.x).toInt()
scrollToTrackPos(vCur - vGrab)
}
vChange.consume()
} while (vEvent.changes.any { it.id == vDown.id && it.pressed })
vDraggingThumb = false
}
}
)
}
}
/** Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
comes from the caller pre-converted via LocalDensity (Option-B: layout
coords are physical px, so px-to-dp goes through the current density). */
private fun Modifier.matchTrackSize(inVertical: Boolean, inTrackDp: Dp, inThickness: Dp): Modifier =
if (inVertical) this.width(inThickness).height(inTrackDp)
else this.height(inThickness).width(inTrackDp)
@@ -15,7 +15,10 @@ import kotlinx.coroutines.CoroutineScope
/**
Byte-close mirror of upstream macosMain — desktop tap / selection gestures
delegate to the default helpers baked into TextFieldSelectionState.
addBasicTextFieldTextContextMenuComponents = no-op (TODO CMP-7819).
addBasicTextFieldTextContextMenuComponents is a NO-OP by design: it belongs to
the NEW text-context-menu API (ComposeFoundationFlags.isNewContextMenuEnabled),
which is false on native. The working right-click Cut/Copy/Paste/SelectAll menu
goes through the vendored LEGACY CommonContextMenuArea path instead.
ClipboardPasteState uses our project Clipboard's plaintext presence — no
NSPasteboard access on native.
*/
@@ -36,9 +36,11 @@ internal actual fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean =
/** No selection magnifier on desktop — mobile-only affordance. */
internal actual fun Modifier.selectionMagnifier(manager: SelectionManager): Modifier = this
/** Desktop text-context-menu doesn't add SelectionContainer-specific
items yet. TODO(CMP-7819): wire the paste-into-selection / clear
/ select-all extras through here when the toolbar lands. */
/** NO-OP by design: this seam belongs to the NEW text-context-menu API
(ComposeFoundationFlags.isNewContextMenuEnabled), which is false on native, so
it's never reached. The working SelectionContainer right-click menu (Copy /
SelectAll) goes through the vendored LEGACY CommonContextMenuArea path via
SelectionManager.contextMenuBuilder. */
internal actual fun Modifier.addSelectionContainerTextContextMenuComponents(
selectionManager: SelectionManager
): Modifier = this
@@ -20,7 +20,11 @@ internal actual fun TextFieldSelectionManager.isSelectionHandleInVisibleBound(
isStartHandle: Boolean,
): Boolean = isSelectionHandleInVisibleBoundDefault(isStartHandle)
// TODO(CMP-7819): wire text-field context-menu components when the desktop toolbar lands.
// NO-OP by design: this seam belongs to the NEW text-context-menu API
// (ComposeFoundationFlags.isNewContextMenuEnabled = false on native), so it's
// unreachable. The working legacy-field right-click menu goes through the vendored
// CommonContextMenuArea path (TextFieldSelectionManager.contextMenuBuilder → Cut /
// Copy / Paste / SelectAll).
internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents(
manager: TextFieldSelectionManager,
coroutineScope: CoroutineScope,
@@ -0,0 +1,509 @@
// VENDOR-BASE: compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/v2/Scrollbar.skiko.kt @ v1.12.0-beta03+dev4483
// MANUAL VENDOR: expect fun runBlockingIfPossible inlined to runBlocking (flat native source set can't hold expect+actual together).
// Also carries the K2 @file:Suppress below (same as sync.py injects into src/vendor) so the native metadata compile / Windows publish accepts the orphaned JVM annotation.
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE", "LESS_VISIBLE_TYPE_ACCESS_IN_INLINE")
@file:JvmName("Scrollbar_desktopKt")
package androidx.compose.foundation.v2
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.text.TextFieldScrollState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.ui.geometry.Offset
import kotlin.js.JsName
import kotlin.jvm.JvmName
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Defines how to scroll the scrollable component and how to display a scrollbar for it.
*
* The values of this interface are typically in pixels, but do not have to be.
* It's possible to create an adapter with any scroll range of `Double` values.
*/
interface ScrollbarAdapter {
// We use `Double` values here in order to allow scrolling both very large (think LazyList with
// millions of items) and very small (think something whose natural coordinates are less than 1)
// content.
/**
* Scroll offset of the content inside the scrollable component.
*
* For example, a value of `100` could mean the content is scrolled by 100 pixels from the
* start.
*/
val scrollOffset: Double
/**
* The size of the scrollable content, on the scrollable axis.
*/
val contentSize: Double
/**
* The size of the viewport, on the scrollable axis.
*/
val viewportSize: Double
/**
* Instantly jump to [scrollOffset].
*
* @param scrollOffset target offset to jump to, value will be coerced to the valid
* scroll range.
*/
suspend fun scrollTo(scrollOffset: Double)
}
/**
* The maximum scroll offset of the scrollable content.
*/
val ScrollbarAdapter.maxScrollOffset: Double
get() = (contentSize - viewportSize).coerceAtLeast(0.0)
internal class ScrollableScrollbarAdapter(
private val scrollState: ScrollState
) : ScrollbarAdapter {
override val scrollOffset: Double get() = scrollState.value.toDouble()
override suspend fun scrollTo(scrollOffset: Double) {
scrollState.scrollTo(scrollOffset.roundToInt())
}
override val contentSize: Double
// This isn't strictly correct, as the actual content can be smaller
// than the viewport when scrollState.maxValue is 0, but the scrollbar
// doesn't really care as long as contentSize <= viewportSize; it's
// just not showing itself
get() = scrollState.maxValue + viewportSize
override val viewportSize: Double
get() = scrollState.viewportSize.toDouble()
}
/**
* Base class for [LazyListScrollbarAdapter] and [LazyGridScrollbarAdapter],
* and in the future maybe other lazy widgets that lay out their content in lines.
*/
internal abstract class LazyLineContentAdapter: ScrollbarAdapter {
// Implement the adapter in terms of "lines", which means either rows,
// (for a vertically scrollable widget) or columns (for a horizontally
// scrollable one).
// For LazyList this translates directly to items; for LazyGrid, it
// translates to rows/columns of items.
class VisibleLine(
val index: Int,
val offset: Int
)
/**
* Return the first visible line, if any.
*/
protected abstract fun firstVisibleLine(): VisibleLine?
/**
* Return the total number of lines.
*/
protected abstract fun totalLineCount(): Int
/**
* The sum of content padding (before+after) on the scrollable axis.
*/
protected abstract fun contentPadding(): Int
/**
* Scroll immediately to the given line, and offset it by [scrollOffset] pixels.
*/
protected abstract suspend fun snapToLine(lineIndex: Int, scrollOffset: Int)
/**
* Scroll from the current position by the given amount of pixels.
*/
protected abstract suspend fun scrollBy(value: Float)
/**
* Return the average size (on the scrollable axis) of the visible lines.
*/
protected abstract fun averageVisibleLineSize(): Double
/**
* The spacing between lines.
*/
protected abstract val lineSpacing: Int
@JsName("averageVisibleLineSizeProperty")
private val averageVisibleLineSize by derivedStateOf {
if (totalLineCount() == 0)
0.0
else
averageVisibleLineSize()
}
private val averageVisibleLineSizeWithSpacing get() = averageVisibleLineSize + lineSpacing
override val scrollOffset: Double
get() {
val firstVisibleLine = firstVisibleLine()
return if (firstVisibleLine == null)
0.0
else
firstVisibleLine.index * averageVisibleLineSizeWithSpacing - firstVisibleLine.offset
}
override val contentSize: Double
get() {
val totalLineCount = totalLineCount()
return averageVisibleLineSize * totalLineCount +
lineSpacing * (totalLineCount - 1).coerceAtLeast(0) +
contentPadding()
}
override suspend fun scrollTo(scrollOffset: Double) {
val distance = scrollOffset - this@LazyLineContentAdapter.scrollOffset
// if we scroll less than viewport we need to use scrollBy function to avoid
// undesirable scroll jumps (when an item size is different)
//
// if we scroll more than viewport we should immediately jump to this position
// without recreating all items between the current and the new position
if (abs(distance) <= viewportSize) {
scrollBy(distance.toFloat())
} else {
snapTo(scrollOffset)
}
}
private suspend fun snapTo(scrollOffset: Double) {
val scrollOffsetCoerced = scrollOffset.coerceIn(0.0, maxScrollOffset)
val index = (scrollOffsetCoerced / averageVisibleLineSizeWithSpacing)
.toInt()
.coerceAtLeast(0)
.coerceAtMost(totalLineCount() - 1)
val offset = (scrollOffsetCoerced - index * averageVisibleLineSizeWithSpacing)
.toInt()
.coerceAtLeast(0)
snapToLine(lineIndex = index, scrollOffset = offset)
}
}
internal class LazyListScrollbarAdapter(
private val scrollState: LazyListState
) : LazyLineContentAdapter() {
override val viewportSize: Double
get() = with(scrollState.layoutInfo) {
if (orientation == Orientation.Vertical)
viewportSize.height
else
viewportSize.width
}.toDouble()
/**
* A heuristic that tries to ignore the "currently stickied" header because it breaks the other
* computations in this adapter:
* - The currently stickied header always appears in the list of visible items, with its
* regular index. This makes [firstVisibleLine] always return its index, even if the list has
* been scrolled far beyond it.
* - [averageVisibleLineSize] calculates the average size in O(1) by assuming that items don't
* overlap, and the stickied item breaks this assumption.
*
* Attempts to return the index into `visibleItemsInfo` of the first non-currently-stickied (it
* could be sticky, but not stickied to the top of the list right now) item, if there is one.
*
* Note that this heuristic breaks down if the sticky header covers the entire list, so that
* it's the only visible item for some portion of the scroll range. But there's currently no
* known better way to solve it, and it's a relatively unusual case.
*/
private fun firstFloatingVisibleItemIndex() = with(scrollState.layoutInfo.visibleItemsInfo){
when (size) {
0 -> null
1 -> 0
else -> {
val first = this[0]
val second = this[1]
// If either the indices or the offsets aren't continuous, then the first item is
// sticky, so we return 1
if ((first.index < second.index - 1) ||
(first.offset + first.size + lineSpacing > second.offset))
1
else
0
}
}
}
override fun firstVisibleLine(): VisibleLine? {
val firstFloatingVisibleIndex = firstFloatingVisibleItemIndex() ?: return null
val firstFloatingItem = scrollState.layoutInfo.visibleItemsInfo[firstFloatingVisibleIndex]
return VisibleLine(
index = firstFloatingItem.index,
offset = firstFloatingItem.offset
)
}
override fun totalLineCount() = scrollState.layoutInfo.totalItemsCount
override fun contentPadding() = with(scrollState.layoutInfo){
beforeContentPadding + afterContentPadding
}
override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
scrollState.scrollToItem(lineIndex, scrollOffset)
}
override suspend fun scrollBy(value: Float) {
scrollState.scrollBy(value)
}
override fun averageVisibleLineSize() = with(scrollState.layoutInfo.visibleItemsInfo){
val firstFloatingIndex = firstFloatingVisibleItemIndex() ?: return@with 0.0
val first = this[firstFloatingIndex]
val last = last()
val count = size - firstFloatingIndex
(last.offset + last.size - first.offset - (count-1)*lineSpacing).toDouble() / count
}
override val lineSpacing get() = scrollState.layoutInfo.mainAxisItemSpacing
}
internal class LazyGridScrollbarAdapter(
private val scrollState: LazyGridState
): LazyLineContentAdapter() {
override val viewportSize: Double
get() = with(scrollState.layoutInfo) {
if (orientation == Orientation.Vertical)
viewportSize.height
else
viewportSize.width
}.toDouble()
private val isVertical = scrollState.layoutInfo.orientation == Orientation.Vertical
private val unknownLine = with(LazyGridItemInfo) {
if (isVertical) UnknownRow else UnknownColumn
}
private fun LazyGridItemInfo.line() = if (isVertical) row else column
private fun LazyGridItemInfo.mainAxisSize() = with (size) {
if (isVertical) height else width
}
private fun LazyGridItemInfo.mainAxisOffset() = with(offset) {
if (isVertical) y else x
}
private fun lineOfIndex(index: Int) = index / scrollState.slotsPerLine
private fun indexOfFirstInLine(line: Int) = line * scrollState.slotsPerLine
override fun firstVisibleLine(): VisibleLine? {
return scrollState.layoutInfo.visibleItemsInfo
.firstOrNull { it.line() != unknownLine } // Skip exiting items
?.let { firstVisibleItem ->
VisibleLine(
index = firstVisibleItem.line(),
offset = firstVisibleItem.mainAxisOffset()
)
}
}
override fun totalLineCount(): Int{
val itemCount = scrollState.layoutInfo.totalItemsCount
return if (itemCount == 0)
0
else
lineOfIndex(itemCount - 1) + 1
}
override fun contentPadding() = with(scrollState.layoutInfo){
beforeContentPadding + afterContentPadding
}
override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
scrollState.scrollToItem(
index = indexOfFirstInLine(lineIndex),
scrollOffset = scrollOffset
)
}
override suspend fun scrollBy(value: Float) {
scrollState.scrollBy(value)
}
private val lineIsKnown = { itemInfo: LazyGridItemInfo -> itemInfo.line() != unknownLine }
override fun averageVisibleLineSize(): Double {
val visibleItemsInfo = scrollState.layoutInfo.visibleItemsInfo
// First and last visible, non-exiting LazyGridItemInfo
val first = visibleItemsInfo.firstOrNull(lineIsKnown) ?: return 0.0
val last = visibleItemsInfo.last(lineIsKnown)
// Compute the size (e.g. height for vertical grid) of the last line
val lastLine = last.line()
val lastLineSize = visibleItemsInfo
.asReversed()
.asSequence()
.filter(lineIsKnown)
.takeWhile { it.line() == lastLine }
.maxOf { it.mainAxisSize() }
val lineCount = last.line() - first.line() + 1
val lineSpacingSum = (lineCount - 1) * lineSpacing
return (
last.mainAxisOffset() + lastLineSize - first.mainAxisOffset() - lineSpacingSum
).toDouble() / lineCount
}
override val lineSpacing get() = scrollState.layoutInfo.mainAxisItemSpacing
}
@OptIn(ExperimentalFoundationApi::class)
internal class TextFieldScrollbarAdapter(
private val scrollState: TextFieldScrollState
): ScrollbarAdapter {
override val scrollOffset: Double
get() = scrollState.offset.toDouble()
override val contentSize: Double
get() = scrollState.maxOffset + viewportSize
override val viewportSize: Double
get() = scrollState.viewportSize.toDouble()
override suspend fun scrollTo(scrollOffset: Double) {
scrollState.offset = scrollOffset.toFloat().coerceIn(0f, scrollState.maxOffset)
}
}
internal class SliderAdapter(
val adapter: ScrollbarAdapter,
private val trackSize: Int,
private val minHeight: Float,
private val reverseLayout: Boolean,
private val isVertical: Boolean,
private val coroutineScope: CoroutineScope
) {
private val contentSize get() = adapter.contentSize
private val visiblePart: Double
get() {
val contentSize = contentSize
return if (contentSize == 0.0)
1.0
else
(adapter.viewportSize / contentSize).coerceAtMost(1.0)
}
val thumbSize
get() = (trackSize * visiblePart).coerceAtLeast(minHeight.toDouble())
private val scrollScale: Double
get() {
val extraScrollbarSpace = trackSize - thumbSize
val extraContentSpace = adapter.maxScrollOffset // == contentSize - viewportSize
return if (extraContentSpace == 0.0) 1.0 else extraScrollbarSpace / extraContentSpace
}
private val rawPosition: Double
get() = scrollScale * adapter.scrollOffset
val position: Double
get() = if (reverseLayout) trackSize - thumbSize - rawPosition else rawPosition
val bounds get() = position..position + thumbSize
// How much of the current drag was ignored because we've reached the end of the scrollbar area
private var unscrolledDragDistance = 0.0
/** Called when the thumb dragging starts */
fun onDragStarted() {
unscrolledDragDistance = 0.0
}
private suspend fun setPosition(value: Double) {
val rawPosition = if (reverseLayout) {
trackSize - thumbSize - value
} else {
value
}
adapter.scrollTo(rawPosition / scrollScale)
}
private val dragMutex = Mutex()
/** Called on every movement while dragging the thumb */
fun onDragDelta(offset: Offset) {
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
// Mutex is used to ensure that all earlier drag deltas were applied
// before calculating a new raw position
dragMutex.withLock {
val dragDelta = if (isVertical) offset.y else offset.x
val maxScrollPosition = adapter.maxScrollOffset * scrollScale
val currentPosition = position
val targetPosition =
(currentPosition + dragDelta + unscrolledDragDistance).coerceIn(
0.0,
maxScrollPosition
)
val sliderDelta = targetPosition - currentPosition
// Have to add to position for smooth content scroll if the items are of different size
val newPos = position + sliderDelta
setPosition(newPos)
unscrolledDragDistance += dragDelta - sliderDelta
}
}
}
}
// Because k/js and k/wasm don't have runBlocking. This port targets only
// native desktop (macos/linux/mingw), which all have runBlocking, so the
// upstream expect/actual is inlined here — our flat native source set can't
// hold the expect (in this .skiko.kt) and its actual in the same set.
internal fun runBlockingIfPossible(block: suspend CoroutineScope.() -> Unit) =
kotlinx.coroutines.runBlocking(block = block)
@@ -13,8 +13,7 @@ plugins {
alias(libs.plugins.kotlin.plugin.compose)
}
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
val vHostSupportsMingw: Boolean by rootProject.extra
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64()
@@ -13,10 +13,12 @@ import kotlinx.datetime.toLocalDateTime
/**
Upstream ships this via NSDateFormatter (darwinMain) or java.text.DateFormat
(desktopMain). Neither is available for K/N on Linux/Windows, so we ship a
minimal English-only stub that unblocks DatePicker + TimePicker compilation.
Localization is TODO; format strings are matched via a small dispatcher on the
subset of patterns material3 actually asks for.
(desktopMain). Neither is available for K/N on Linux/Windows, so this actual is
a cross-platform kotlinx-datetime formatter that HONOURS the requested
CLDR-style pattern / skeleton (so DatePicker/TimePicker headlines read
"Jul 29, 2026" / "July 2026" instead of a raw ISO date). Field VALUES are
localized by the requested TimeZone.UTC calendar; field NAMES (month / weekday)
stay English — full CLDR name localization needs ICU data we don't bundle.
*/
@OptIn(ExperimentalTime::class)
internal actual class PlatformDateFormat actual constructor(
@@ -33,13 +35,13 @@ internal actual class PlatformDateFormat actual constructor(
utcTimeMillis: Long,
pattern: String,
cache: MutableMap<String, Any>,
): String = defaultFormat(utcTimeMillis)
): String = formatPattern(utcTimeMillis, pattern)
actual fun formatWithSkeleton(
utcTimeMillis: Long,
skeleton: String,
cache: MutableMap<String, Any>,
): String = defaultFormat(utcTimeMillis)
): String = formatPattern(utcTimeMillis, skeletonToPattern(skeleton))
actual fun parse(
date: String,
@@ -64,11 +66,91 @@ internal actual class PlatformDateFormat actual constructor(
actual fun is24HourFormat(): Boolean = true
private fun defaultFormat(utcTimeMillis: Long): String {
val vInstant = Instant.fromEpochMilliseconds(utcTimeMillis)
val vDt = vInstant.toLocalDateTime(TimeZone.UTC)
return "${vDt.year}-${vDt.monthNumber.pad2()}-${vDt.dayOfMonth.pad2()}"
// ============
// Pattern / skeleton formatting
/** Format [utcTimeMillis] against a CLDR-style [pattern] (y/M/d/E/H/h/m/s/a
field runs; `'...'` quotes literal text). */
private fun formatPattern(utcTimeMillis: Long, pattern: String): String {
val dt = Instant.fromEpochMilliseconds(utcTimeMillis).toLocalDateTime(TimeZone.UTC)
val out = StringBuilder()
var i = 0
while (i < pattern.length) {
val c = pattern[i]
when {
c == '\'' -> {
// Quoted literal; '' is an escaped single quote.
var j = i + 1
while (j < pattern.length && pattern[j] != '\'') { out.append(pattern[j]); j++ }
i = j + 1
}
c.isLetter() -> {
var j = i
while (j < pattern.length && pattern[j] == c) j++
out.append(formatField(c, j - i, dt))
i = j
}
else -> { out.append(c); i++ }
}
}
return out.toString()
}
private fun formatField(field: Char, count: Int, dt: kotlinx.datetime.LocalDateTime): String = when (field) {
'y' -> if (count == 2) (dt.year % 100).pad2() else dt.year.toString()
'M' -> when {
count >= 4 -> MONTH_NAMES[dt.monthNumber - 1]
count == 3 -> MONTH_ABBR[dt.monthNumber - 1]
count == 2 -> dt.monthNumber.pad2()
else -> dt.monthNumber.toString()
}
'd' -> if (count >= 2) dt.dayOfMonth.pad2() else dt.dayOfMonth.toString()
'E' -> {
val idx = dt.dayOfWeek.ordinal // kotlinx DayOfWeek: MONDAY=0 → matches weekdayNames order
if (count >= 4) weekdayNames[idx].first else weekdayNames[idx].second
}
'H' -> if (count >= 2) dt.hour.pad2() else dt.hour.toString()
'h' -> {
val h12 = ((dt.hour + 11) % 12) + 1
if (count >= 2) h12.pad2() else h12.toString()
}
'm' -> if (count >= 2) dt.minute.pad2() else dt.minute.toString()
's' -> if (count >= 2) dt.second.pad2() else dt.second.toString()
'a' -> if (dt.hour < 12) "AM" else "PM"
else -> "" // unsupported field: drop rather than echo raw pattern letters
}
/** Map the CLDR skeletons material3 requests (unordered field sets) to concrete
English patterns. Unknown skeletons fall back to the skeleton itself, which
still renders the correct field VALUES (just without separators). */
private fun skeletonToPattern(skeleton: String): String = when (skeleton) {
"yMMMM" -> "MMMM yyyy"
"yMMMMd", "yMMMMdd" -> "MMMM d, yyyy"
"yMMMd" -> "MMM d, yyyy"
"yMMM" -> "MMM yyyy"
"yMMMMEEEEd" -> "EEEE, MMMM d, yyyy"
"yMMMEd" -> "EEE, MMM d, yyyy"
"MMMMEEEEd" -> "EEEE, MMMM d"
"MMMMd" -> "MMMM d"
"MMMd" -> "MMM d"
"MMMEd" -> "EEE, MMM d"
"y" -> "yyyy"
"MMMM" -> "MMMM"
"Hm" -> "HH:mm"
"hm", "hma" -> "h:mm a"
else -> skeleton
}
private fun Int.pad2(): String = if (this < 10) "0$this" else "$this"
private companion object {
val MONTH_NAMES = listOf(
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
)
val MONTH_ABBR = listOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)
}
}
@@ -2099,27 +2099,6 @@ final object androidx.compose.ui.graphics.colorspace/Illuminant { // androidx.co
final object androidx.compose.ui.graphics.drawscope/Fill : androidx.compose.ui.graphics.drawscope/DrawStyle // androidx.compose.ui.graphics.drawscope/Fill|null[0]
final object com.compose.sdl.graphics/DrawStats { // com.compose.sdl.graphics/DrawStats|null[0]
final var geometrySubmits // com.compose.sdl.graphics/DrawStats.geometrySubmits|{}geometrySubmits[0]
final fun <get-geometrySubmits>(): kotlin/Int // com.compose.sdl.graphics/DrawStats.geometrySubmits.<get-geometrySubmits>|<get-geometrySubmits>(){}[0]
final fun <set-geometrySubmits>(kotlin/Int) // com.compose.sdl.graphics/DrawStats.geometrySubmits.<set-geometrySubmits>|<set-geometrySubmits>(kotlin.Int){}[0]
final var imageBlits // com.compose.sdl.graphics/DrawStats.imageBlits|{}imageBlits[0]
final fun <get-imageBlits>(): kotlin/Int // com.compose.sdl.graphics/DrawStats.imageBlits.<get-imageBlits>|<get-imageBlits>(){}[0]
final fun <set-imageBlits>(kotlin/Int) // com.compose.sdl.graphics/DrawStats.imageBlits.<set-imageBlits>|<set-imageBlits>(kotlin.Int){}[0]
final var maskRealizations // com.compose.sdl.graphics/DrawStats.maskRealizations|{}maskRealizations[0]
final fun <get-maskRealizations>(): kotlin/Int // com.compose.sdl.graphics/DrawStats.maskRealizations.<get-maskRealizations>|<get-maskRealizations>(){}[0]
final fun <set-maskRealizations>(kotlin/Int) // com.compose.sdl.graphics/DrawStats.maskRealizations.<set-maskRealizations>|<set-maskRealizations>(kotlin.Int){}[0]
final var textDraws // com.compose.sdl.graphics/DrawStats.textDraws|{}textDraws[0]
final fun <get-textDraws>(): kotlin/Int // com.compose.sdl.graphics/DrawStats.textDraws.<get-textDraws>|<get-textDraws>(){}[0]
final fun <set-textDraws>(kotlin/Int) // com.compose.sdl.graphics/DrawStats.textDraws.<set-textDraws>|<set-textDraws>(kotlin.Int){}[0]
final var vertices // com.compose.sdl.graphics/DrawStats.vertices|{}vertices[0]
final fun <get-vertices>(): kotlin/Int // com.compose.sdl.graphics/DrawStats.vertices.<get-vertices>|<get-vertices>(){}[0]
final fun <set-vertices>(kotlin/Int) // com.compose.sdl.graphics/DrawStats.vertices.<set-vertices>|<set-vertices>(kotlin.Int){}[0]
final fun reset() // com.compose.sdl.graphics/DrawStats.reset|reset(){}[0]
final fun summary(): kotlin/String // com.compose.sdl.graphics/DrawStats.summary|summary(){}[0]
}
final object com.compose.sdl.graphics/NativeReleaseQueue { // com.compose.sdl.graphics/NativeReleaseQueue|null[0]
final fun drain(): kotlin/Int // com.compose.sdl.graphics/NativeReleaseQueue.drain|drain(){}[0]
final fun enqueue(kotlin/Function0<kotlin/Unit>) // com.compose.sdl.graphics/NativeReleaseQueue.enqueue|enqueue(kotlin.Function0<kotlin.Unit>){}[0]
@@ -2243,7 +2222,6 @@ final val androidx.compose.ui.graphics/union // androidx.compose.ui.graphics/uni
final fun (androidx.compose.ui.graphics/PathOperation.Companion).<get-union>(): androidx.compose.ui.graphics/PathOperation // androidx.compose.ui.graphics/union.<get-union>|<get-union>@androidx.compose.ui.graphics.PathOperation.Companion(){}[0]
final val androidx.compose.ui.graphics/xor // androidx.compose.ui.graphics/xor|@androidx.compose.ui.graphics.PathOperation.Companion{}xor[0]
final fun (androidx.compose.ui.graphics/PathOperation.Companion).<get-xor>(): androidx.compose.ui.graphics/PathOperation // androidx.compose.ui.graphics/xor.<get-xor>|<get-xor>@androidx.compose.ui.graphics.PathOperation.Companion(){}[0]
final val com.compose.sdl.graphics/com_compose_sdl_graphics_DrawStats$stableprop // com.compose.sdl.graphics/com_compose_sdl_graphics_DrawStats$stableprop|#static{}com_compose_sdl_graphics_DrawStats$stableprop[0]
final val com.compose.sdl.graphics/com_compose_sdl_graphics_NativeReleaseQueue$stableprop // com.compose.sdl.graphics/com_compose_sdl_graphics_NativeReleaseQueue$stableprop|#static{}com_compose_sdl_graphics_NativeReleaseQueue$stableprop[0]
final val com.compose.sdl.graphics/com_compose_sdl_graphics_PathCommand_Close$stableprop // com.compose.sdl.graphics/com_compose_sdl_graphics_PathCommand_Close$stableprop|#static{}com_compose_sdl_graphics_PathCommand_Close$stableprop[0]
final val com.compose.sdl.graphics/com_compose_sdl_graphics_PathCommand_CubicTo$stableprop // com.compose.sdl.graphics/com_compose_sdl_graphics_PathCommand_CubicTo$stableprop|#static{}com_compose_sdl_graphics_PathCommand_CubicTo$stableprop[0]
@@ -2459,7 +2437,6 @@ final fun androidx.compose.ui.graphics/findFirstRoot(androidx.compose.ui.graphic
final fun androidx.compose.ui.graphics/lerp(androidx.compose.ui.graphics/Color, androidx.compose.ui.graphics/Color, kotlin/Float): androidx.compose.ui.graphics/Color // androidx.compose.ui.graphics/lerp|lerp(androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;kotlin.Float){}[0]
final fun androidx.compose.ui.graphics/lerp(androidx.compose.ui.graphics/Shadow, androidx.compose.ui.graphics/Shadow, kotlin/Float): androidx.compose.ui.graphics/Shadow // androidx.compose.ui.graphics/lerp|lerp(androidx.compose.ui.graphics.Shadow;androidx.compose.ui.graphics.Shadow;kotlin.Float){}[0]
final fun androidx.compose.ui.graphics/prepareTransformationMatrix(androidx.compose.ui.graphics/Matrix, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float) // androidx.compose.ui.graphics/prepareTransformationMatrix|prepareTransformationMatrix(androidx.compose.ui.graphics.Matrix;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
final fun com.compose.sdl.graphics/com_compose_sdl_graphics_DrawStats$stableprop_getter(): kotlin/Int // com.compose.sdl.graphics/com_compose_sdl_graphics_DrawStats$stableprop_getter|com_compose_sdl_graphics_DrawStats$stableprop_getter(){}[0]
final fun com.compose.sdl.graphics/com_compose_sdl_graphics_NativeReleaseQueue$stableprop_getter(): kotlin/Int // com.compose.sdl.graphics/com_compose_sdl_graphics_NativeReleaseQueue$stableprop_getter|com_compose_sdl_graphics_NativeReleaseQueue$stableprop_getter(){}[0]
final fun com.compose.sdl.graphics/com_compose_sdl_graphics_PathCommand_Close$stableprop_getter(): kotlin/Int // com.compose.sdl.graphics/com_compose_sdl_graphics_PathCommand_Close$stableprop_getter|com_compose_sdl_graphics_PathCommand_Close$stableprop_getter(){}[0]
final fun com.compose.sdl.graphics/com_compose_sdl_graphics_PathCommand_CubicTo$stableprop_getter(): kotlin/Int // com.compose.sdl.graphics/com_compose_sdl_graphics_PathCommand_CubicTo$stableprop_getter|com_compose_sdl_graphics_PathCommand_CubicTo$stableprop_getter(){}[0]
@@ -19,9 +19,9 @@ import androidx.compose.ui.unit.LayoutDirection
* Internal helper invoked by the project's `BackgroundNode.draw()` body
* (Phase 8 chain-driven draw). Resolves the [Shape] into a project
* [Outline] and dispatches to the appropriate `DrawScope.drawXxx`. The
* receiver [DrawScope] is the renderer's wrap scope (Skia or SDL3) —
* any of its `drawRect / drawRoundRect / drawPath` calls becomes a
* concrete paint operation on the backend canvas.
* receiver [DrawScope] is the Skia renderer's wrap scope — any of its
* `drawRect / drawRoundRect / drawPath` calls becomes a concrete paint
* operation on the backend canvas.
*
* Note `Shape.createOutline` is the upstream three-arg signature
* (`size`, `layoutDirection`, `density`); project shapes implement it
@@ -16,11 +16,11 @@ import androidx.compose.ui.graphics.TileMode
// LinearGradient / RadialGradient / SweepGradient field accessors
// `internal`, which keeps `androidx.compose.ui.graphics.LinearGradient.colors`
// etc. out of the public ABI. Sealed-class scope forces the concrete
// gradient classes to live in `androidx.compose.ui.graphics`, but our
// renderer-skia / renderer-sdl3 modules can't read the resulting `internal`
// fields directly. The extensions below sit in :core (same module as the
// internal fields, so the bodies CAN read them) and are themselves public,
// so :renderer-skia / :renderer-sdl3 import them and reach gradient state
// gradient classes to live in `androidx.compose.ui.graphics`, but the Skia
// renderer code in skikoRendererMain can't read the resulting `internal`
// fields directly. The extensions below sit in this module's commonMain (same
// module as the internal fields, so the bodies CAN read them) and are
// themselves public, so the renderer imports them and reaches gradient state
// through them.
// ============
@@ -1,34 +0,0 @@
package com.compose.sdl.graphics
// ==================
// MARK: DrawStats — per-frame renderer work counters (diagnostic)
// ==================
/**
* Cheap per-frame counters the SDL draw path bumps and the frame profiler
* reads, to answer "what inside `draw` costs the time?" — geometry submissions
* (SDL_RenderGeometry calls), total vertices tessellated, rounded-clip mask
* realizations (offscreen render-target passes — the expensive ones), text
* blits and image blits. Reset each frame by the profiler after reading.
*
* Single-threaded (all draw is on the main thread), so plain vars are fine.
* Zero overhead when the profiler isn't reading — just integer increments.
*/
object DrawStats {
var geometrySubmits: Int = 0
var vertices: Int = 0
var maskRealizations: Int = 0
var textDraws: Int = 0
var imageBlits: Int = 0
fun reset() {
geometrySubmits = 0
vertices = 0
maskRealizations = 0
textDraws = 0
imageBlits = 0
}
fun summary(): String =
"geo=$geometrySubmits verts=$vertices masks=$maskRealizations text=$textDraws img=$imageBlits"
}
@@ -1,5 +1,6 @@
package com.compose.sdl.graphics
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ImageBitmap
// ==================
@@ -17,6 +18,15 @@ import androidx.compose.ui.graphics.ImageBitmap
renderer internals. */
interface EncodedImageDecoder {
fun decode(inBytes: ByteArray): ImageBitmap?
/** Intrinsic (viewport) size of an SVG document, or null if it can't be
parsed. Used by the resolution-independent SvgPainter for layout. */
fun svgIntrinsicSize(inBytes: ByteArray): Size? = null
/** Rasterise an SVG at a specific PIXEL size (resolution-independent draw).
Default falls back to the intrinsic-size [decode] for impls that don't
support size-driven rendering. */
fun decodeSvgAt(inBytes: ByteArray, inWidthPx: Int, inHeightPx: Int): ImageBitmap? = decode(inBytes)
}
/** Volatile: written by the render backend on the main thread, read by the
@@ -28,3 +38,12 @@ var encodedImageDecoder: EncodedImageDecoder? = null
* has initialised yet or the bytes aren't a supported image. */
fun decodeEncodedImageBitmap(inBytes: ByteArray): ImageBitmap? =
encodedImageDecoder?.decode(inBytes)
/** Intrinsic size of an SVG document via the active decoder, or null. */
fun svgIntrinsicSize(inBytes: ByteArray): Size? =
encodedImageDecoder?.svgIntrinsicSize(inBytes)
/** Rasterise an SVG at [inWidthPx]×[inHeightPx] via the active decoder — the
* resolution-independent draw path used by the resources SvgPainter. */
fun decodeSvgAt(inBytes: ByteArray, inWidthPx: Int, inHeightPx: Int): ImageBitmap? =
encodedImageDecoder?.decodeSvgAt(inBytes, inWidthPx, inHeightPx)
@@ -1,8 +1,10 @@
package com.compose.sdl.renderer.skia
import androidx.compose.ui.geometry.isSpecified
import com.compose.sdl.res.AndroidVectorToSvg
import com.compose.sdl.res.ResourceKind
import com.compose.sdl.res.composeResourceReader
import kotlin.math.roundToInt
import org.jetbrains.skia.Canvas
import org.jetbrains.skia.Color
import org.jetbrains.skia.Data
@@ -26,20 +28,49 @@ import org.jetbrains.skia.svg.SVGDOM
NOTE: this source set isn't compiled on the mingwX64 host, so it is built
only on macOS / Linux — keep it to the Skiko APIs already used elsewhere in
this module. */
private const val MAX_CACHED_IMAGES = 256
class SkiaImageCache {
// Value is null when a decode failed — cached to avoid retrying each frame.
private val fCache = HashMap<String, Image?>()
// Bounded access-order LRU. Value is null when a decode failed — cached to
// avoid retrying each frame. Bounding + closing the least-recently-used image
// stops long-running apps that show many distinct runtime images
// (registerMemoryResource — e.g. downloaded PNGs) from growing image memory
// without limit (issue #2). On-screen images stay hot; an evicted one just
// re-decodes on next use.
private val fCache = LinkedHashMap<String, Image?>()
// SVG / Android-vector kinds are RESOLUTION-INDEPENDENT: instead of caching one
// intrinsic-size raster and letting drawImageRect upscale it (blurry when an icon
// is drawn larger than its intrinsic box), we re-rasterise the vector at the
// destination pixel size and cache that raster keyed by "path@WxH". Mirrors
// upstream's size-driven DrawCache for SVGPainter. Intrinsic dims are cached
// separately so the layout pass (intrinsicSize) doesn't force a raster.
private val fSvgRasterCache = LinkedHashMap<String, Image?>()
private val fSvgIntrinsic = HashMap<String, androidx.compose.ui.geometry.Size>()
fun intrinsicSize(inPath: String, inKind: ResourceKind): androidx.compose.ui.geometry.Size {
if (inKind == ResourceKind.Svg || inKind == ResourceKind.AndroidVector) {
return svgIntrinsicSize(inPath, inKind)
}
val vImg = get(inPath, inKind) ?: return androidx.compose.ui.geometry.Size.Unspecified
return androidx.compose.ui.geometry.Size(vImg.width.toFloat(), vImg.height.toFloat())
}
private fun get(inPath: String, inKind: ResourceKind): Image? {
if (fCache.containsKey(inPath)) return fCache[inPath]
if (fCache.containsKey(inPath)) {
// Re-insert to mark most-recently-used (LinkedHashMap keeps first =
// least-recently-used for eviction below).
val vExisting = fCache.remove(inPath)
fCache[inPath] = vExisting
return vExisting
}
val vImage = decode(inPath, inKind)
fCache[inPath] = vImage
if (fCache.size > MAX_CACHED_IMAGES) {
val vEldest = fCache.keys.firstOrNull()
if (vEldest != null) fCache.remove(vEldest)?.close()
}
return vImage
}
@@ -71,6 +102,10 @@ class SkiaImageCache {
inAlpha: Float,
) {
if (inW <= 0f || inH <= 0f) return
if (inKind == ResourceKind.Svg || inKind == ResourceKind.AndroidVector) {
drawSvg(inCanvas, inPath, inKind, inX, inY, inW, inH, inAlpha)
return
}
val vImg = get(inPath, inKind) ?: return
val vIw = vImg.width.toFloat()
val vIh = vImg.height.toFloat()
@@ -85,9 +120,74 @@ class SkiaImageCache {
vPaint.close()
}
// ==================
// MARK: Size-driven SVG / Android-vector
// ==================
/** Raw SVG bytes for the resource (Android-vector XML is converted first). */
private fun svgBytes(inPath: String, inKind: ResourceKind): ByteArray? {
val vRaw = composeResourceReader?.invoke(inPath) ?: return null
return if (inKind == ResourceKind.AndroidVector) {
runCatching { AndroidVectorToSvg.convert(vRaw.decodeToString()).encodeToByteArray() }.getOrNull()
} else {
vRaw
}
}
/** Intrinsic (viewport) size of the vector, parsed once and cached. */
private fun svgIntrinsicSize(inPath: String, inKind: ResourceKind): androidx.compose.ui.geometry.Size {
fSvgIntrinsic[inPath]?.let { return it }
val vBytes = svgBytes(inPath, inKind) ?: return androidx.compose.ui.geometry.Size.Unspecified
val vSize = runCatching {
val vDom = SVGDOM(Data.makeFromBytes(vBytes))
val vRoot = vDom.root
val vW = (vRoot?.width?.value ?: 0f).takeIf { it > 0f } ?: 100f
val vH = (vRoot?.height?.value ?: 0f).takeIf { it > 0f } ?: 100f
vDom.close()
androidx.compose.ui.geometry.Size(vW, vH)
}.getOrDefault(androidx.compose.ui.geometry.Size.Unspecified)
fSvgIntrinsic[inPath] = vSize
return vSize
}
private fun drawSvg(
inCanvas: Canvas, inPath: String, inKind: ResourceKind,
inX: Float, inY: Float, inW: Float, inH: Float, inAlpha: Float,
) {
val vWpx = inW.roundToInt().coerceAtLeast(1)
val vHpx = inH.roundToInt().coerceAtLeast(1)
val vKey = "$inPath@${vWpx}x$vHpx"
val vImg = if (fSvgRasterCache.containsKey(vKey)) {
val vExisting = fSvgRasterCache.remove(vKey)
fSvgRasterCache[vKey] = vExisting
vExisting
} else {
val vIntrinsic = svgIntrinsicSize(inPath, inKind)
val vBytes = svgBytes(inPath, inKind)
val vRaster = if (vBytes != null && vIntrinsic.isSpecified) {
rasterizeSvgAt(vBytes, vIntrinsic.width, vIntrinsic.height, vWpx, vHpx)
} else null
fSvgRasterCache[vKey] = vRaster
if (fSvgRasterCache.size > MAX_CACHED_IMAGES) {
fSvgRasterCache.keys.firstOrNull()?.let { fSvgRasterCache.remove(it)?.close() }
}
vRaster
} ?: return
val vPaint = Paint()
vPaint.color = Color.makeARGB((inAlpha * 255f).toInt().coerceIn(0, 255), 255, 255, 255)
// Raster is already at destination pixel size → 1:1 blit (crisp, no upscale).
inCanvas.drawImageRect(vImg, Rect.makeWH(vImg.width.toFloat(), vImg.height.toFloat()),
Rect.makeXYWH(inX, inY, inW, inH), vPaint)
vPaint.close()
}
fun destroy() {
for (vImg in fCache.values) vImg?.close()
fCache.clear()
for (vImg in fSvgRasterCache.values) vImg?.close()
fSvgRasterCache.clear()
fSvgIntrinsic.clear()
}
}
@@ -98,6 +198,26 @@ class SkiaImageCache {
/** SVGDOM → offscreen raster → Image. Falls back to a 100×100 canvas when
the document declares no explicit width/height (the Android-vector path
always supplies them). */
/** Size-driven SVG raster: renders the vector into a [inWpx]×[inHpx] surface by
scaling the intrinsic-sized document up to the target, so a vector drawn larger
than its intrinsic box stays crisp (vector render at target resolution) instead
of upscaling a small raster. Canvas-scale (rather than only setContainerSize)
keeps it correct whether or not the document declares a viewBox. */
internal fun rasterizeSvgAt(inBytes: ByteArray, inIntrinsicW: Float, inIntrinsicH: Float, inWpx: Int, inHpx: Int): Image? = runCatching {
val vDom = SVGDOM(Data.makeFromBytes(inBytes))
val vIw = inIntrinsicW.takeIf { it > 0f } ?: 100f
val vIh = inIntrinsicH.takeIf { it > 0f } ?: 100f
vDom.setContainerSize(vIw, vIh)
val vSurface = Surface.makeRasterN32Premul(inWpx, inHpx)
val vCanvas = vSurface.canvas
vCanvas.scale(inWpx / vIw, inHpx / vIh)
vDom.render(vCanvas)
val vImage = vSurface.makeImageSnapshot()
vDom.close()
vSurface.close()
vImage
}.getOrNull()
internal fun rasterizeSvg(inBytes: ByteArray): Image? = runCatching {
val vDom = SVGDOM(Data.makeFromBytes(inBytes))
val vRoot = vDom.root
@@ -1,9 +1,12 @@
package com.compose.sdl.renderer.skia
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import com.compose.sdl.graphics.EncodedImageDecoder
import org.jetbrains.skia.Data
import org.jetbrains.skia.Image
import org.jetbrains.skia.svg.SVGDOM
// ==================
// MARK: SkiaEncodedImageDecoder
@@ -31,4 +34,22 @@ class SkiaEncodedImageDecoder : EncodedImageDecoder {
vImg.close()
return vBmp
}
override fun svgIntrinsicSize(inBytes: ByteArray): Size? = runCatching {
val vDom = SVGDOM(Data.makeFromBytes(inBytes))
val vRoot = vDom.root
val vW = (vRoot?.width?.value ?: 0f).takeIf { it > 0f } ?: 100f
val vH = (vRoot?.height?.value ?: 0f).takeIf { it > 0f } ?: 100f
vDom.close()
Size(vW, vH)
}.getOrNull()
override fun decodeSvgAt(inBytes: ByteArray, inWidthPx: Int, inHeightPx: Int): ImageBitmap? {
if (inBytes.isEmpty() || inWidthPx <= 0 || inHeightPx <= 0) return null
val vSize = svgIntrinsicSize(inBytes) ?: return null
val vImg = rasterizeSvgAt(inBytes, vSize.width, vSize.height, inWidthPx, inHeightPx) ?: return null
val vBmp = vImg.toComposeImageBitmap()
vImg.close()
return vBmp
}
}
+14
View File
@@ -1,3 +1,17 @@
# ACCEPTED DEVIATION — do NOT try to "fill" the skikoMain text-engine gaps below.
# The upstream skikoMain text stack (ParagraphBuilder / ParagraphLayouter /
# PlatformFont / FontCache / SkiaParagraph.skiko + siblings, ~17 files) is
# intentionally UNSELECTED (it shows up in the DIAGNOSTIC GAPS tail). The port
# instead ships a reduced local engine: a skiko-free SkiaParagraph.native.kt over
# SkiaParagraphEngine.kt (skikoRendererMain), which keeps the project's own
# SkiaFonts (name->bytes + variable-axis) font model instead of upstream's
# PlatformFont/FontCache/FontCollection. Reason: the flat nativeMain source-set
# layout can't host upstream's skiko/nonJvm-split actuals as-is (see the memory
# note "flat nativeMain vs upstream skiko+nonJvm hierarchy"). This is the single
# biggest "not upstream" surface and the root of the RTL / stroke-DrawStyle /
# grapheme reductions — those are tracked in PLAN.md §2, not fixed by vendoring
# these files. If you select them, expect the source-set hierarchy to fight you.
SET_REPO=https://github.com/JetBrains/compose-multiplatform-core@<COMPOSE_CORE_REF>
SET_FOLDER=compose/ui/ui-text/src
@@ -32,9 +32,9 @@ object IconFont {
fFonts[inFamily] = inBytes
}
/** Register an ICON font family (Material Symbols et al.) — variable-axis,
drawn one glyph at a time on the SDL3 backend. Same byte store as
register(), but also flags the family as an icon font. */
/** Register an ICON font family (Material Symbols et al.) — variable-axis.
Same byte store as register(), but also flags the family as an icon
font. */
fun registerIcon(inFamily: String, inBytes: ByteArray) {
fFonts[inFamily] = inBytes
fIconFamilies += inFamily
@@ -38,12 +38,18 @@ class NamedFont(
override val loadingStrategy: FontLoadingStrategy = FontLoadingStrategy.Blocking
override fun equals(other: Any?): Boolean =
other is NamedFont && other.name == name && other.weight == weight && other.style == style
other is NamedFont && other.name == name && other.weight == weight && other.style == style &&
other.axes == axes && other.variationSettings == variationSettings
override fun hashCode(): Int {
var h = name.hashCode()
h = 31 * h + weight.hashCode()
h = 31 * h + style.hashCode()
// Axis settings (Material Symbols FILL/wght/GRAD/opsz) are part of identity —
// without them two axis-differing icon fonts compare equal and collide in the
// FontFamily → typeface cache, rendering one icon's axes for the other.
h = 31 * h + (axes?.hashCode() ?: 0)
h = 31 * h + variationSettings.hashCode()
return h
}
@@ -1,18 +1,25 @@
package androidx.compose.ui.text
import kotlin.experimental.ExperimentalNativeApi
// VENDOR-BASE: compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/FontRasterizationSettings.skiko.kt @ v1.12.0-beta03+dev4483
// ==================
// MARK: FontRasterizationSettings native actual (Skia-free)
// ==================
/**
* Hand-written port of upstream `FontRasterizationSettings.skiko.kt` minus
* the Skia-only `toSkFontEdging` / `toSkFontHinting` extensions and the
* `currentPlatform()` lookup. Vendored TextStyle.native.kt references
* `FontRasterizationSettings.PlatformDefault` so we need the class +
* companion to exist; the renderer doesn't read these values yet so a
* single shared "PlatformDefault" instance (AntiAlias / Normal /
* subpixel-on / autohint-off — matches upstream's macOS/Linux default)
* is fine.
* Rule-3 manual vendor of upstream `FontRasterizationSettings.skiko.kt` minus the
* Skia-only `toSkFontEdging` / `toSkFontHinting` extensions (the skiko renderer
* maps `PlatformDefault` to skiko enums itself, in SkiaParagraphEngine). It lives
* in the skiko-FREE `nativeMain` because vendored `TextStyle.native.kt` references
* `FontRasterizationSettings.PlatformDefault`, and `nativeMain` is the shared parent
* of the official-skiko (mac/linux) and fork-skiko (mingw) legs — so it can't carry
* skiko. `PlatformDefault` reproduces upstream's per-OS defaults VERBATIM (verified
* against the pin): anti-aliased + subpixel everywhere, hinting Slight on Linux and
* Normal on Windows/macOS (macOS ignores hinting). This split — and the hand port —
* goes away if the port hosts the `Paragraph` actual in the skiko source set and
* vendors upstream's skiko text files verbatim (PLAN.md §6).
*/
@ExperimentalTextApi
@@ -38,9 +45,10 @@ class FontRasterizationSettings(
val autoHintingForced: Boolean,
) {
companion object {
@OptIn(ExperimentalNativeApi::class)
val PlatformDefault: FontRasterizationSettings = FontRasterizationSettings(
smoothing = FontSmoothing.AntiAlias,
hinting = FontHinting.Normal,
hinting = if (Platform.osFamily == OsFamily.LINUX) FontHinting.Slight else FontHinting.Normal,
subpixelPositioning = true,
autoHintingForced = false,
)
@@ -27,19 +27,12 @@ private fun makeSkiaParagraph(
ellipsize: Boolean,
density: Float,
spanStyles: List<AnnotatedString.Range<SpanStyle>>,
placeholders: List<AnnotatedString.Range<Placeholder>>,
): Paragraph = SkiaParagraph(
text, style, width,
buildParagraphOps(text, style, width, maxLines, ellipsize, density, spanStyles),
buildParagraphOps(text, style, width, maxLines, ellipsize, density, spanStyles, placeholders),
)
/** [min, max] intrinsic width from a throwaway unbounded skiko layout. */
internal expect fun paragraphIntrinsicWidths(
text: String,
style: TextStyle,
density: Float,
spanStyles: List<AnnotatedString.Range<SpanStyle>>,
): FloatArray
/** Carries text+style for the intrinsics-based Paragraph factories; intrinsic
widths come from an unbounded skiko layout. `density` is the LocalDensity
scalar (dpr on Retina): it converts sp → pixels so intrinsic widths land in
@@ -49,13 +42,35 @@ internal class NativeParagraphIntrinsics(
val paragraphStyle: TextStyle,
val density: Float,
val spanStyles: List<AnnotatedString.Range<SpanStyle>> = emptyList(),
val placeholders: List<AnnotatedString.Range<Placeholder>> = emptyList(),
) : ParagraphIntrinsics {
private val widths = paragraphIntrinsicWidths(paragraphText, paragraphStyle, density, spanStyles)
override val minIntrinsicWidth: Float = widths[0]
override val maxIntrinsicWidth: Float = widths[1]
// Shape ONCE here (unbounded, no line cap). The shaped paragraph is RETAINED
// and reused by Paragraph(this, …) for the final layout — skiko re-breaks at
// the final width without re-shaping — so measured text shapes once, not twice
// (the P1.2 double-shape: cold text frames were dominated by shaping).
val ops: NativeParagraphOps = buildParagraphOps(
paragraphText, paragraphStyle, Float.POSITIVE_INFINITY, Int.MAX_VALUE, false, density, spanStyles, placeholders,
)
override val minIntrinsicWidth: Float = ops.minIntrinsicWidth
override val maxIntrinsicWidth: Float = ops.maxIntrinsicWidth
override val hasStaleResolvedFonts: Boolean = false
}
/** Build the final Paragraph from a pre-shaped intrinsics. Reuse the intrinsics'
shaped paragraph (just re-break at [width]) when there's no line cap / ellipsis
— those are baked into the paragraph at build time and need a fresh build. */
private fun paragraphFromIntrinsics(
i: NativeParagraphIntrinsics, width: Float, maxLines: Int, ellipsize: Boolean,
): Paragraph {
if (maxLines == Int.MAX_VALUE && !ellipsize) {
i.ops.relayout(width)
return SkiaParagraph(i.paragraphText, i.paragraphStyle, width, i.ops)
}
return makeSkiaParagraph(
i.paragraphText, i.paragraphStyle, width, maxLines, ellipsize, i.density, i.spanStyles, i.placeholders,
)
}
private fun widthFrom(constraints: Constraints): Float =
if (constraints.hasBoundedWidth) constraints.maxWidth.toFloat() else Float.POSITIVE_INFINITY
@@ -69,7 +84,7 @@ actual fun ParagraphIntrinsics(
placeholders: List<AnnotatedString.Range<Placeholder>>,
density: Density,
resourceLoader: Font.ResourceLoader,
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, spanStyles)
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, spanStyles, placeholders)
actual fun ParagraphIntrinsics(
text: String,
@@ -78,7 +93,7 @@ actual fun ParagraphIntrinsics(
placeholders: List<AnnotatedString.Range<Placeholder>>,
density: Density,
fontFamilyResolver: FontFamily.Resolver,
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, spanStyles)
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, spanStyles, placeholders)
actual fun ParagraphIntrinsics(
text: String,
@@ -87,7 +102,7 @@ actual fun ParagraphIntrinsics(
density: Density,
fontFamilyResolver: FontFamily.Resolver,
placeholders: List<AnnotatedString.Range<Placeholder>>,
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, annotations.filterSpanStyles())
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, annotations.filterSpanStyles(), placeholders)
actual fun ParagraphIntrinsics(
text: String,
@@ -97,7 +112,7 @@ actual fun ParagraphIntrinsics(
fontFamilyResolver: FontFamily.Resolver,
placeholders: List<AnnotatedString.Range<Placeholder>>,
softWrap: Boolean,
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, annotations.filterSpanStyles())
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, annotations.filterSpanStyles(), placeholders)
// AnnotatedString.Annotation is a sealed interface (SpanStyle, ParagraphStyle,
// LinkAnnotation, StringAnnotation, TtsAnnotation, …). Only SpanStyle affects
@@ -120,7 +135,7 @@ actual fun Paragraph(
width: Float,
density: Density,
resourceLoader: Font.ResourceLoader,
): Paragraph = makeSkiaParagraph(text, style, width, maxLines, ellipsis, density.density, spanStyles)
): Paragraph = makeSkiaParagraph(text, style, width, maxLines, ellipsis, density.density, spanStyles, placeholders)
actual fun Paragraph(
text: String,
@@ -132,7 +147,7 @@ actual fun Paragraph(
placeholders: List<AnnotatedString.Range<Placeholder>>,
maxLines: Int,
ellipsis: Boolean,
): Paragraph = makeSkiaParagraph(text, style, width, maxLines, ellipsis, density.density, spanStyles)
): Paragraph = makeSkiaParagraph(text, style, width, maxLines, ellipsis, density.density, spanStyles, placeholders)
actual fun Paragraph(
text: String,
@@ -144,7 +159,7 @@ actual fun Paragraph(
placeholders: List<AnnotatedString.Range<Placeholder>>,
maxLines: Int,
ellipsis: Boolean,
): Paragraph = makeSkiaParagraph(text, style, widthFrom(constraints), maxLines, ellipsis, density.density, spanStyles)
): Paragraph = makeSkiaParagraph(text, style, widthFrom(constraints), maxLines, ellipsis, density.density, spanStyles, placeholders)
actual fun Paragraph(
text: String,
@@ -157,7 +172,7 @@ actual fun Paragraph(
maxLines: Int,
overflow: TextOverflow,
): Paragraph = makeSkiaParagraph(
text, style, widthFrom(constraints), maxLines, overflow == TextOverflow.Ellipsis, density.density, spanStyles,
text, style, widthFrom(constraints), maxLines, overflow == TextOverflow.Ellipsis, density.density, spanStyles, placeholders,
)
actual fun Paragraph(
@@ -165,30 +180,24 @@ actual fun Paragraph(
maxLines: Int,
ellipsis: Boolean,
width: Float,
): Paragraph {
val i = paragraphIntrinsics as NativeParagraphIntrinsics
return makeSkiaParagraph(i.paragraphText, i.paragraphStyle, width, maxLines, ellipsis, i.density, i.spanStyles)
}
): Paragraph =
paragraphFromIntrinsics(paragraphIntrinsics as NativeParagraphIntrinsics, width, maxLines, ellipsis)
actual fun Paragraph(
paragraphIntrinsics: ParagraphIntrinsics,
constraints: Constraints,
maxLines: Int,
ellipsis: Boolean,
): Paragraph {
val i = paragraphIntrinsics as NativeParagraphIntrinsics
return makeSkiaParagraph(i.paragraphText, i.paragraphStyle, widthFrom(constraints), maxLines, ellipsis, i.density, i.spanStyles)
}
): Paragraph =
paragraphFromIntrinsics(paragraphIntrinsics as NativeParagraphIntrinsics, widthFrom(constraints), maxLines, ellipsis)
actual fun Paragraph(
paragraphIntrinsics: ParagraphIntrinsics,
constraints: Constraints,
maxLines: Int,
overflow: TextOverflow,
): Paragraph {
val i = paragraphIntrinsics as NativeParagraphIntrinsics
return makeSkiaParagraph(
i.paragraphText, i.paragraphStyle, widthFrom(constraints), maxLines,
overflow == TextOverflow.Ellipsis, i.density, i.spanStyles,
): Paragraph =
paragraphFromIntrinsics(
paragraphIntrinsics as NativeParagraphIntrinsics, widthFrom(constraints), maxLines,
overflow == TextOverflow.Ellipsis,
)
}
@@ -78,6 +78,16 @@ internal interface NativeParagraphOps {
fun wordBoundary(offset: Int): IntArray // [start, end]
fun rebuildAndPaint(canvas: Canvas, color: Color, shadow: Shadow?, decoration: TextDecoration?)
/** Re-break the ALREADY-SHAPED paragraph at a new width (skiko reuses the
cached shaped runs — no HarfBuzz re-shape). Used to reuse the intrinsics
pass's shaped paragraph for the final layout instead of shaping twice. */
fun relayout(width: Float)
/** Explicitly free the underlying native paragraph. Call when the ops is
discarded (e.g. an intrinsics-only throwaway) so the native memory is
released deterministically instead of waiting for a GC-driven Cleaner. */
fun dispose()
}
/** Bridge to the skiko ops impl (actual in skikoRendererMain). */
@@ -89,6 +99,7 @@ internal expect fun buildParagraphOps(
ellipsize: Boolean,
density: Float,
spanStyles: List<AnnotatedString.Range<SpanStyle>>,
placeholders: List<AnnotatedString.Range<Placeholder>>,
): NativeParagraphOps
internal class SkiaParagraph(
@@ -5,6 +5,7 @@ import com.compose.sdl.icons.IconFont
import com.compose.sdl.res.composeResourceReader
import org.jetbrains.skia.Data
import org.jetbrains.skia.FontMgr
import org.jetbrains.skia.FontStyle as SkiaFontStyle
import org.jetbrains.skia.FontVariation as SkiaFontVariation
import org.jetbrains.skia.Typeface
import org.jetbrains.skia.paragraph.FontCollection
@@ -32,6 +33,7 @@ import org.jetbrains.skia.paragraph.TypefaceFontProvider
*/
internal object SkiaFonts {
private const val DEFAULT_ALIAS = "Noto Sans"
private const val GENERIC_PREFIX = "generic:"
private val fontMgr = FontMgr.default
private val provider = TypefaceFontProvider()
@@ -58,7 +60,31 @@ internal object SkiaFonts {
private fun baseTypeface(family: String?): Typeface? =
baseCache.getOrPut(family) {
val bytes = family?.let { IconFont.bytesFor(it) }
if (bytes != null) fontMgr.makeFromData(Data.makeFromBytes(bytes), 0) else defaultTypeface
when {
bytes != null -> fontMgr.makeFromData(Data.makeFromBytes(bytes), 0)
// Generic family (serif / cursive / …) not bundled: resolve through the
// per-OS concrete-name alias list (upstream GenericFontFamiliesMapping),
// so `FontFamily.Serif` picks Times/Noto Serif instead of silently
// becoming the sans-serif default. (Monospace is caught by IconFont above
// when NotoSansMono is bundled; sans-serif maps to null → default Noto Sans.)
family != null && family.startsWith(GENERIC_PREFIX) ->
resolveGeneric(family.removePrefix(GENERIC_PREFIX)) ?: defaultTypeface
// A concrete family name: resolve against the OS font set (e.g. "Arial",
// "Times New Roman"), so a requested system font isn't silently Noto Sans.
family != null ->
runCatching { fontMgr.matchFamilyStyle(family, SkiaFontStyle.NORMAL) }.getOrNull()
?: defaultTypeface
else -> defaultTypeface
}
}
/** Resolve a generic family name (serif / sans-serif / monospace / cursive) to a
concrete OS typeface by trying the per-OS candidate names in order. */
private fun resolveGeneric(genericName: String): Typeface? {
for (name in genericFamilyAliases(genericName)) {
runCatching { fontMgr.matchFamilyStyle(name, SkiaFontStyle.NORMAL) }.getOrNull()?.let { return it }
}
return null
}
private fun variationsKey(variations: List<ComposeFontVariation.Setting>): String =
@@ -86,3 +112,35 @@ internal object SkiaFonts {
}
}
}
// ==================
// MARK: Generic font family aliases (per-OS)
// ==================
/** Concrete OS family-name candidates for a generic family, tried in order.
Mirrors upstream `GenericFontFamiliesMapping` (PlatformFont.skiko.kt); unknown
generics get no candidates so they fall back to the bundled default. */
@OptIn(kotlin.experimental.ExperimentalNativeApi::class)
private fun genericFamilyAliases(genericName: String): List<String> = when (kotlin.native.Platform.osFamily) {
kotlin.native.OsFamily.MACOSX -> when (genericName) {
"sans-serif" -> listOf(".AppleSystemUIFont", "Helvetica Neue", "Helvetica")
"serif" -> listOf(".AppleSystemUIFontSerif", "Times", "Times New Roman")
"monospace" -> listOf(".AppleSystemUIFontMonospaced", "Menlo", "Courier")
"cursive" -> listOf("Apple Chancery", "Snell Roundhand")
else -> emptyList()
}
kotlin.native.OsFamily.WINDOWS -> when (genericName) {
"sans-serif" -> listOf("Segoe UI", "Arial")
"serif" -> listOf("Times New Roman")
"monospace" -> listOf("Consolas")
"cursive" -> listOf("Comic Sans MS")
else -> emptyList()
}
else -> when (genericName) { // Linux + everything else
"sans-serif" -> listOf("Noto Sans", "DejaVu Sans", "Arial")
"serif" -> listOf("Noto Serif", "DejaVu Serif", "Times New Roman")
"monospace" -> listOf("Noto Sans Mono", "DejaVu Sans Mono", "Consolas")
"cursive" -> listOf("Comic Sans MS")
else -> emptyList()
}
}
@@ -8,6 +8,8 @@ import androidx.compose.ui.graphics.skiaCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
@@ -16,17 +18,25 @@ import com.compose.sdl.renderer.skia.SkiaFonts
import com.compose.sdl.text.projectFontName
import com.compose.sdl.text.projectFontVariations
import org.jetbrains.skia.Font as SkFont
import org.jetbrains.skia.FontEdging as SkFontEdging
import org.jetbrains.skia.FontHinting as SkFontHinting
import org.jetbrains.skia.FontStyle as SkFontStyle
import org.jetbrains.skia.Paint as SkPaint
import org.jetbrains.skia.paragraph.Alignment as SkAlignment
import org.jetbrains.skia.paragraph.BaselineMode
import org.jetbrains.skia.paragraph.PlaceholderAlignment
import org.jetbrains.skia.paragraph.PlaceholderStyle
import org.jetbrains.skia.paragraph.DecorationLineStyle as SkDecorationLineStyle
import org.jetbrains.skia.paragraph.DecorationStyle as SkDecorationStyle
import org.jetbrains.skia.paragraph.Direction as SkDirection
import org.jetbrains.skia.paragraph.HeightMode as SkHeightMode
import org.jetbrains.skia.paragraph.Paragraph as SkParagraph
import org.jetbrains.skia.paragraph.ParagraphBuilder as SkParagraphBuilder
import org.jetbrains.skia.paragraph.ParagraphStyle
import org.jetbrains.skia.paragraph.RectHeightMode
import org.jetbrains.skia.paragraph.RectWidthMode
import org.jetbrains.skia.paragraph.Shadow as SkShadow
import org.jetbrains.skia.paragraph.TextIndent as SkTextIndent
import org.jetbrains.skia.paragraph.TextStyle as SkTextStyle
// ==================
@@ -43,6 +53,42 @@ import org.jetbrains.skia.paragraph.TextStyle as SkTextStyle
private const val INTRINSIC_WIDTH = 100_000f
// ==================
// MARK: Rasterization defaults
// ==================
//
// Edging / hinting / subpixel positioning applied to every text style, mirroring
// upstream ParagraphBuilder.skiko.kt (which sets these on every SkTextStyle).
// The values come from the port's FontRasterizationSettings.PlatformDefault
// (per-OS). Without them skiko falls back to its raw defaults and text renders
// less crisp — noticeably so on Windows/Linux. Computed once (platform is fixed).
@OptIn(ExperimentalTextApi::class)
private val RASTER_EDGING: SkFontEdging = when (FontRasterizationSettings.PlatformDefault.smoothing) {
FontSmoothing.None -> SkFontEdging.ALIAS
FontSmoothing.AntiAlias -> SkFontEdging.ANTI_ALIAS
FontSmoothing.SubpixelAntiAlias -> SkFontEdging.SUBPIXEL_ANTI_ALIAS
}
@OptIn(ExperimentalTextApi::class)
private val RASTER_HINTING: SkFontHinting = when (FontRasterizationSettings.PlatformDefault.hinting) {
FontHinting.None -> SkFontHinting.NONE
FontHinting.Slight -> SkFontHinting.SLIGHT
FontHinting.Normal -> SkFontHinting.NORMAL
FontHinting.Full -> SkFontHinting.FULL
}
@OptIn(ExperimentalTextApi::class)
private val RASTER_SUBPIXEL: Boolean = FontRasterizationSettings.PlatformDefault.subpixelPositioning
// CDN_TEXT_METRICS=1 — dump the font + paragraph line metrics for each built
// paragraph (PLAN.md §1b confirm-first). Compares the SAME NotoSans bytes across
// backends: on macOS the FontMgr scaler is CoreText, on Linux fontconfig/FreeType,
// on the Windows fork DirectWrite/FreeType — different scalers pick different
// ascent/descent tables, which is the suspected cause of the mac-vs-Windows
// vertical-spacing delta. Acceptance test: Windows-native must match skiko-JVM-Windows.
private val kTextMetricsDebug: Boolean = platform.posix.getenv("CDN_TEXT_METRICS") != null
internal class SkiaParagraphOps(
private val text: String,
private val style: TextStyle,
@@ -51,6 +97,7 @@ internal class SkiaParagraphOps(
private val density: Float,
private val spanStyles: List<AnnotatedString.Range<SpanStyle>>,
private val ellipsize: Boolean,
private val placeholders: List<AnnotatedString.Range<Placeholder>> = emptyList(),
) : NativeParagraphOps {
private val fontPx: Float =
@@ -63,11 +110,48 @@ internal class SkiaParagraphOps(
?: style.fontWeight?.let { listOf(FontVariation.weight(it.weight)) }
private val baseTypeface = SkiaFonts.resolve(baseFamily, baseVariations).first
private val defaultFont = SkFont(baseTypeface ?: SkiaFonts.defaultTypeface, fontPx)
private val layoutWidth: Float =
// Skia's ParagraphStyle.replaceTabCharacters (set below) expands U+0009 to a space
// before shaping (CMP-6589). The Windows skiko fork's flat extern-C surface doesn't
// wire that setter, so a raw tab reaches HarfBuzz, finds no glyph in the font, and
// renders as a .notdef box. Normalise the SHAPED text here instead — this is exactly
// what the flag does internally, so it's upstream-faithful and platform-independent.
// It's length-preserving, so every offset query (getRectsForRange / getCursorRect /
// wordBoundary / lineMetrics / span cut points) keeps operating on the original [text].
private val shapedText: String =
if (text.indexOf('\t') >= 0) text.replace('\t', ' ') else text
// var so relayout() (intrinsics-reuse fast path) can re-break at the final
// width and keep rebuildAndPaint()'s re-layout consistent with it.
private var layoutWidth: Float =
if (widthConstraint.isFinite() && widthConstraint > 0f) widthConstraint else INTRINSIC_WIDTH
/** Resolve the paragraph line height to px for a run of [runSizePx], mirroring
upstream's `lineHeight.toPx(density, fontSize)`: `em` is relative to the run's
font size, `sp` scales by density. Null when unspecified (keep skiko default
line spacing — the pre-existing behaviour, so untouched text doesn't shift). */
private fun resolveLineHeightPx(runSizePx: Float): Float? {
val lh = style.lineHeight
if (!lh.isSpecified) return null
return when {
lh.isEm -> runSizePx * lh.value
lh.isSp -> lh.value * density
else -> null
}
}
private var paragraph: SkParagraph = build(style.color, style.shadow, style.textDecoration)
// The paint attributes the current [paragraph] was built with. Compose paints
// with paint-time color/shadow/decoration; when they match what we already
// laid out (the common case — static text), paint reuses the existing native
// paragraph instead of re-shaping. The constructor above builds with the
// style values, so seed these to match.
private var builtColor: Color = style.color
private var builtShadow: Shadow? = style.shadow
private var builtDecoration: TextDecoration? = style.textDecoration
private var disposed = false
// Reused across color-only repaints (see rebuildAndPaint's fast path).
private var foregroundPaint: SkPaint? = null
// ============
// NativeParagraphOps
@@ -90,11 +174,28 @@ internal class SkiaParagraphOps(
0.0, 0.0, 0.0, a + d, 0),
)
}
// The Windows skiko fork's flat extern-C LineMetrics surface mis-decodes the
// per-line ascent/descent — it returns stale values that don't scale with the
// font size — while baseline, height, width and left stay correct. Lines stack
// contiguously, so the true ascent of a line is (baseline - lineTop); when the
// reported ascent disagrees with that by more than a rounding epsilon, rebuild
// ascent/descent from the reliable baseline + cumulative line height. Official
// skiko is self-consistent (the disagreement is ~0), so this is a no-op there and
// only repairs the fork — keeping caret height, getLineTop/getLineBottom and
// vertical hit-testing correct on Windows.
var lineTop = 0.0
return metrics.map {
var ascent = it.ascent
var descent = it.descent
if (kotlin.math.abs(ascent - (it.baseline - lineTop)) > 0.5) {
ascent = it.baseline - lineTop
descent = it.height - ascent
}
lineTop += it.height
LineMetricData(
startIndex = it.startIndex, endIndex = it.endIndex,
endExcludingWhitespaces = it.endExcludingWhitespaces, endIncludingNewline = it.endIncludingNewline,
isHardBreak = it.isHardBreak, ascent = it.ascent, descent = it.descent, baseline = it.baseline,
isHardBreak = it.isHardBreak, ascent = ascent, descent = descent, baseline = it.baseline,
left = it.left, right = it.left + it.width, width = it.width, height = it.height,
lineNumber = it.lineNumber,
)
@@ -118,51 +219,177 @@ internal class SkiaParagraphOps(
paragraph.getWordBoundary(offset).let { intArrayOf(it.start, it.end) }
override fun rebuildAndPaint(canvas: Canvas, color: Color, shadow: Shadow?, decoration: TextDecoration?) {
val shadowOrDecoChanged = shadow != builtShadow || decoration != builtDecoration
val colorChanged = color != builtColor
// Fast path (mirrors upstream ParagraphLayouter): a color-only change on
// single-style, undecorated text re-applies the foreground paint without
// re-shaping (HarfBuzz + line-break stay cached). Anything else — a
// shadow/decoration change, or color on spanned/decorated text where the
// colour is baked per-run — takes the full rebuild, closing the previous
// native paragraph so it doesn't leak to GC (issue #2).
val canUpdateForeground = colorChanged && !shadowOrDecoChanged &&
spanStyles.isEmpty() && text.isNotEmpty() &&
(decoration == null || decoration == TextDecoration.None)
when {
shadowOrDecoChanged || (colorChanged && !canUpdateForeground) -> {
val previous = paragraph
paragraph = build(color, shadow, decoration)
builtColor = color; builtShadow = shadow; builtDecoration = decoration
previous.close()
}
canUpdateForeground -> {
applyForegroundColor(color)
paragraph.markDirty()
paragraph.layout(layoutWidth)
builtColor = color
}
}
paragraph.paint(canvas.skiaCanvas, 0f, 0f)
}
/** Re-colour the whole (single-style) paragraph in place via skia's
updateForegroundPaint, reusing one Paint instance across repaints. */
private fun applyForegroundColor(color: Color) {
val argb = (if (color.isSpecified) color else Color.Black).toArgb()
val paint = foregroundPaint ?: SkPaint().also { foregroundPaint = it }
paint.reset()
paint.color = argb
paragraph.updateForegroundPaint(0, text.length, paint)
}
// ============
// Compose style/span -> skiko build
private fun build(color: Color, shadow: Shadow?, decoration: TextDecoration?): SkParagraph {
val baseStyle = makeTextStyle(baseFamily, baseVariations, fontPx, color, style.fontStyle, decoration, shadow)
val baseStyle = makeTextStyle(
baseFamily, baseVariations, fontPx, color, style.fontStyle, decoration, shadow,
style.baselineShift, style.background,
)
val pStyle = ParagraphStyle().apply {
// https://youtrack.jetbrains.com/issue/CMP-6589 — tabs expand like upstream.
replaceTabCharacters = true
alignment = style.textAlign.toSkAlignment()
direction = if (style.textDirection == androidx.compose.ui.text.style.TextDirection.Rtl) SkDirection.RTL else SkDirection.LTR
textStyle = baseStyle
// Line-height trim (mirrors upstream ParagraphBuilder.textStyleToParagraphStyle):
// trim-based mode only when lineHeight actually adds leading (> fontSize);
// otherwise DISABLE_ALL, matching upstream's default (with no extra leading
// this leaves single-line metrics at the font's own ascent/descent).
val baseLineHeightPx = resolveLineHeightPx(fontPx)
heightMode = if (baseLineHeightPx != null && baseLineHeightPx > fontPx) {
(style.lineHeightStyle ?: LineHeightStyle.Default).trim.toSkHeightMode()
} else {
SkHeightMode.DISABLE_ALL
}
style.textIndent?.let { ti ->
val em = fontPx
fun tuPx(v: androidx.compose.ui.unit.TextUnit): Float = when {
!v.isSpecified -> 0f
v.isEm -> em * v.value
else -> v.value * density
}
textIndent = SkTextIndent(tuPx(ti.firstLine), tuPx(ti.restLine))
}
if (maxLines != Int.MAX_VALUE) {
maxLinesCount = maxLines
ellipsis = if (ellipsize) "…" else ""
}
}
// The builder is a native resource; close it once the paragraph is built
// (the paragraph owns its own native data and outlives the builder).
val pb = SkParagraphBuilder(pStyle, SkiaFonts.fontCollection)
if (spanStyles.isEmpty()) {
try {
if (spanStyles.isEmpty() && placeholders.isEmpty()) {
pb.pushStyle(baseStyle)
pb.addText(text)
pb.addText(shapedText)
pb.popStyle()
} else {
appendWithSpans(pb, color, shadow, decoration)
}
return pb.build().also { it.layout(layoutWidth) }
return pb.build().also { it.layout(layoutWidth); if (kTextMetricsDebug) dumpMetrics(it) }
} finally {
pb.close()
}
}
/** CDN_TEXT_METRICS diagnostic — see [kTextMetricsDebug]. Prints the font scaler's
ascent/descent/leading and the resulting paragraph line box so the numbers can be
compared native-vs-JVM per platform. */
private fun dumpMetrics(p: SkParagraph) {
val m = defaultFont.metrics
val lm = p.lineMetrics.firstOrNull()
val label = text.take(24).replace('\n', '⏎')
println(
"CDN_TEXT_METRICS text='$label' family='${(baseTypeface ?: SkiaFonts.defaultTypeface)?.familyName}' " +
"fontPx=$fontPx ascent=${m.ascent} descent=${m.descent} leading=${m.leading} " +
"paraHeight=${p.height} line0[ascent=${lm?.ascent} descent=${lm?.descent} " +
"baseline=${lm?.baseline} height=${lm?.height}]"
)
}
override fun relayout(width: Float) {
val newWidth = if (width.isFinite() && width > 0f) width else INTRINSIC_WIDTH
if (newWidth == layoutWidth) return
layoutWidth = newWidth
paragraph.layout(newWidth)
}
override fun dispose() {
if (disposed) return
disposed = true
// All are per-ops native objects (skiko Managed). close() frees them and
// cancels skiko's own GC-driven Cleaner, so nothing double-frees later.
paragraph.close()
defaultFont.close()
foregroundPaint?.close()
}
private fun appendWithSpans(pb: SkParagraphBuilder, color: Color, shadow: Shadow?, decoration: TextDecoration?) {
val len = text.length
val points = buildList {
add(0); add(text.length)
spanStyles.forEach { add(it.start.coerceIn(0, text.length)); add(it.end.coerceIn(0, text.length)) }
add(0); add(len)
spanStyles.forEach { add(it.start.coerceIn(0, len)); add(it.end.coerceIn(0, len)) }
placeholders.forEach { add(it.start.coerceIn(0, len)); add(it.end.coerceIn(0, len)) }
}.distinct().sorted()
for (i in 0 until points.size - 1) {
val segStart = points[i]
val segEnd = points[i + 1]
if (segStart >= segEnd) continue
// A placeholder replaces its text range with a single reserved box:
// emit it once at its start and skip the covered text (upstream
// ParagraphBuilder.makeOps / addPlaceholder). Cut points include every
// placeholder boundary, so a segment is fully inside or fully outside.
val ph = placeholders.firstOrNull { it.start <= segStart && it.end > segStart }
if (ph != null) {
if (ph.start == segStart) pb.addPlaceholder(placeholderStyle(ph.item, segStart))
continue
}
val active = spanStyles.filter { it.start <= segStart && it.end >= segEnd }
pb.pushStyle(segmentStyle(color, shadow, decoration, active))
pb.addText(text.substring(segStart, segEnd))
pb.addText(shapedText.substring(segStart, segEnd))
pb.popStyle()
}
}
/** Map a Compose [Placeholder] to a skiko PlaceholderStyle. Width/height are
resolved against the font size active at [atOffset] (em is relative to it),
mirroring upstream ParagraphBuilder. */
private fun placeholderStyle(p: Placeholder, atOffset: Int): PlaceholderStyle {
val runFontPx = spanStyles
.filter { it.start <= atOffset && it.end > atOffset && it.item.fontSize.isSpecified }
.lastOrNull()?.item?.fontSize?.let { (it.value * density).coerceAtLeast(1f) } ?: fontPx
fun tuPx(v: androidx.compose.ui.unit.TextUnit): Float = when {
!v.isSpecified -> runFontPx
v.isEm -> runFontPx * v.value
else -> v.value * density
}
return PlaceholderStyle(
tuPx(p.width), tuPx(p.height),
p.placeholderVerticalAlign.toSkPlaceholderAlignment(),
BaselineMode.ALPHABETIC, 0f,
)
}
private fun segmentStyle(
color: Color, shadow: Shadow?, decoration: TextDecoration?,
active: List<AnnotatedString.Range<SpanStyle>>,
@@ -173,6 +400,8 @@ internal class SkiaParagraphOps(
var segColor = color
var fontStyle = style.fontStyle
var deco = decoration
var baselineShift = style.baselineShift
var background = style.background
active.forEach { range ->
val sp = range.item
if (sp.color.isSpecified) segColor = sp.color
@@ -182,24 +411,41 @@ internal class SkiaParagraphOps(
sp.textDecoration?.let { deco = it }
sp.fontFamily.projectFontName()?.let { family = it }
sp.fontFamily.projectFontVariations()?.let { variations = it }
sp.baselineShift?.let { baselineShift = it }
if (sp.background.isSpecified) background = sp.background
}
return makeTextStyle(family, variations, size, segColor, fontStyle, deco, shadow)
return makeTextStyle(family, variations, size, segColor, fontStyle, deco, shadow, baselineShift, background)
}
private fun makeTextStyle(
family: String?, variations: List<FontVariation.Setting>?, sizePx: Float,
color: Color, fontStyle: FontStyle?, decoration: TextDecoration?, shadow: Shadow?,
baselineShift: BaselineShift?, background: Color,
): SkTextStyle {
val ts = SkTextStyle()
val argb = (if (color.isSpecified) color else Color.Black).toArgb()
ts.color = argb
ts.fontSize = sizePx
// Match upstream text rasterization (edging / hinting / subpixel) instead
// of skiko's raw defaults — see the RASTER_* defaults above.
ts.fontEdging = RASTER_EDGING
ts.fontHinting = RASTER_HINTING
ts.subpixel = RASTER_SUBPIXEL
// Per-run line height (upstream: res.height = lineHeight / fontSize), set only
// when lineHeight is specified so untouched text keeps skiko's default spacing.
resolveLineHeightPx(sizePx)?.let { ts.height = it / sizePx }
// Span/base background fill behind the run.
if (background.isSpecified) ts.background = SkPaint().also { it.color = background.toArgb() }
// Register + resolve to a provider alias so skiko's shaper maps codepoints
// through the exact typeface (icon fonts would otherwise fall back to
// Noto Sans and render private-use glyphs as tofu).
val (tf, alias) = SkiaFonts.resolve(family, variations)
tf?.let { ts.typeface = it }
ts.fontFamilies = arrayOf(alias)
// Superscript / subscript: shift the baseline by a multiple of the font ascent.
// MUST run after the typeface + fontSize are set — `fontMetrics` is undefined
// without a resolved font, and skiko's setBaselineShift rejects the NaN.
baselineShift?.let { ts.baselineShift = it.multiplier * ts.fontMetrics.ascent }
if (fontStyle == FontStyle.Italic) ts.fontStyle = SkFontStyle.ITALIC
decoration?.takeUnless { it == TextDecoration.None }?.let {
ts.decorationStyle = SkDecorationStyle(
@@ -218,6 +464,25 @@ internal class SkiaParagraphOps(
}
}
private fun PlaceholderVerticalAlign.toSkPlaceholderAlignment(): PlaceholderAlignment = when (this) {
PlaceholderVerticalAlign.AboveBaseline -> PlaceholderAlignment.ABOVE_BASELINE
PlaceholderVerticalAlign.TextTop -> PlaceholderAlignment.TOP
PlaceholderVerticalAlign.TextBottom -> PlaceholderAlignment.BOTTOM
PlaceholderVerticalAlign.TextCenter -> PlaceholderAlignment.MIDDLE
PlaceholderVerticalAlign.Top -> PlaceholderAlignment.TOP
PlaceholderVerticalAlign.Bottom -> PlaceholderAlignment.BOTTOM
PlaceholderVerticalAlign.Center -> PlaceholderAlignment.MIDDLE
else -> PlaceholderAlignment.ABOVE_BASELINE
}
private fun LineHeightStyle.Trim.toSkHeightMode(): SkHeightMode = when (this) {
LineHeightStyle.Trim.Both -> SkHeightMode.DISABLE_ALL
LineHeightStyle.Trim.FirstLineTop -> SkHeightMode.DISABLE_FIRST_ASCENT
LineHeightStyle.Trim.LastLineBottom -> SkHeightMode.DISABLE_LAST_DESCENT
LineHeightStyle.Trim.None -> SkHeightMode.ALL
else -> SkHeightMode.DISABLE_ALL
}
private fun TextAlign.toSkAlignment(): SkAlignment = when (this) {
TextAlign.Left -> SkAlignment.LEFT
TextAlign.Right -> SkAlignment.RIGHT
@@ -241,16 +506,6 @@ internal actual fun buildParagraphOps(
ellipsize: Boolean,
density: Float,
spanStyles: List<AnnotatedString.Range<SpanStyle>>,
): NativeParagraphOps = SkiaParagraphOps(text, style, width, maxLines, density, spanStyles, ellipsize)
placeholders: List<AnnotatedString.Range<Placeholder>>,
): NativeParagraphOps = SkiaParagraphOps(text, style, width, maxLines, density, spanStyles, ellipsize, placeholders)
/** Actual for `expect fun paragraphIntrinsicWidths` — [min, max] from an
* unbounded layout. */
internal actual fun paragraphIntrinsicWidths(
text: String,
style: TextStyle,
density: Float,
spanStyles: List<AnnotatedString.Range<SpanStyle>>,
): FloatArray {
val ops = SkiaParagraphOps(text, style, Float.POSITIVE_INFINITY, Int.MAX_VALUE, density, spanStyles, false)
return floatArrayOf(ops.minIntrinsicWidth, ops.maxIntrinsicWidth)
}
@@ -26,9 +26,18 @@ actual val LocalLifecycleOwner get() = androidx.lifecycle.compose.LocalLifecycle
// a background performance optimization the SDL single-threaded main loop
// doesn't drive).
/** No screen reader integration on the SDL desktop backend (no NSAccessibility /
UIA / AT-SPI bridge). Default to a non-throwing inactive reader so vendored
code that reads this local (e.g. accessibility-gated behaviour) degrades to
"no reader present" instead of crashing the app. */
@OptIn(InternalComposeUiApi::class)
private object InactivePlatformScreenReader : PlatformScreenReader {
override val isActive: Boolean get() = false
}
@InternalComposeUiApi
val LocalPlatformScreenReader = staticCompositionLocalOf<PlatformScreenReader> {
error("CompositionLocal LocalPlatformScreenReader not present")
InactivePlatformScreenReader
}
@InternalComposeUiApi
@@ -27,7 +27,7 @@ import com.compose.sdl.window.PopupOutsideDismiss
/**
Native actuals for the vendored upstream Popup.kt (expect class PopupProperties +
expect fun Popup). This desktop/SDL renderer hosts overlay content at the composition
expect fun Popup). This desktop renderer hosts overlay content at the composition
root via the project's PopupHostState (no OS popup window). Behaviour flags on
PopupProperties are accepted for source-compat; outside-click dismissal / modality are
the caller's responsibility (Dialog draws a scrim, DropdownMenu installs a click-catcher).
@@ -40,7 +40,9 @@ class SDL3Backend(
var pixelWidth: Int = width; private set
var pixelHeight: Int = height; private set
// True when the active renderer paces itself to the display (vsync), so the
// main loop can skip its manual frame delay. Set for the SDL renderer path.
// main loop can skip its manual frame delay. Set per GPU mode in init():
// GL from SDL_GL_SetSwapInterval, Metal always (CAMetalLayer blocks on the
// vertical blank), Software from SDL_SetRenderVSync.
var vsyncEnabled: Boolean = false; private set
fun init(): Boolean {
@@ -93,7 +95,11 @@ class SDL3Backend(
return false
}
SDL_GL_MakeCurrent(window?.reinterpret(), glContext?.reinterpret())
SDL_GL_SetSwapInterval(1)
// Vsync on: SDL_GL_SwapWindow blocks until the vertical blank, so
// the main loop can drop its manual SDL_Delay. If the driver
// refuses (returns false) vsyncEnabled stays false and the loop
// keeps its fallback frame cap.
vsyncEnabled = SDL_GL_SetSwapInterval(1)
}
is GpuMode.Skia.Metal -> {
metalView = SDL_Metal_CreateView(window?.reinterpret())
@@ -101,6 +107,9 @@ class SDL3Backend(
println("SDL_Metal_CreateView failed: ${SDL_GetError()?.toKString()}")
return false
}
// CAMetalLayer presents on the vertical blank — nextDrawable
// blocks — so Metal is vsync-paced and the loop skips its delay.
vsyncEnabled = true
}
is GpuMode.Software -> {
// Skia CPU raster: SkiaSurfaceBridge paints a host buffer that
@@ -23,7 +23,7 @@ sealed class AppEvent {
/** IME preedit / composition (SDL_EVENT_TEXT_EDITING). Empty text = composition
cleared, so it's dispatched even when blank (unlike committed TextInput). */
data class TextEditing(val text: String, val windowId: UInt = 0u) : AppEvent()
data class MouseWheel(val x: Int, val y: Int, val deltaX: Float, val deltaY: Float, val windowId: UInt = 0u) : AppEvent()
data class MouseWheel(val x: Float, val y: Float, val deltaX: Float, val deltaY: Float, val windowId: UInt = 0u) : AppEvent()
/** The pointer left the window (SDL_EVENT_WINDOW_MOUSE_LEAVE). The loop clears
the window's hover state so a widget doesn't stay highlighted after the cursor
exits (SDL then sends no motion, so nothing else would clear it). */
@@ -82,7 +82,7 @@ private fun mapEvent(e: SDL_Event): AppEvent? {
SDL_EVENT_MOUSE_BUTTON_DOWN -> {
val mb = e.button
AppEvent.Pointer(LegacyPointerEvent(
x = mb.x.toInt(), y = mb.y.toInt(),
x = mb.x, y = mb.y,
type = PointerEventType.Press,
button = mapButton(mb.button)
), mb.windowID)
@@ -91,7 +91,7 @@ private fun mapEvent(e: SDL_Event): AppEvent? {
SDL_EVENT_MOUSE_BUTTON_UP -> {
val mb = e.button
AppEvent.Pointer(LegacyPointerEvent(
x = mb.x.toInt(), y = mb.y.toInt(),
x = mb.x, y = mb.y,
type = PointerEventType.Release,
button = mapButton(mb.button)
), mb.windowID)
@@ -100,7 +100,7 @@ private fun mapEvent(e: SDL_Event): AppEvent? {
SDL_EVENT_MOUSE_MOTION -> {
val mm = e.motion
AppEvent.Pointer(LegacyPointerEvent(
x = mm.x.toInt(), y = mm.y.toInt(),
x = mm.x, y = mm.y,
type = PointerEventType.Move
), mm.windowID)
}
@@ -150,8 +150,8 @@ private fun mapEvent(e: SDL_Event): AppEvent? {
SDL_EVENT_MOUSE_WHEEL -> {
val mw = e.wheel
AppEvent.MouseWheel(
x = mw.mouse_x.toInt(),
y = mw.mouse_y.toInt(),
x = mw.mouse_x,
y = mw.mouse_y,
deltaX = mw.x,
deltaY = mw.y,
windowId = mw.windowID,
@@ -17,15 +17,18 @@ import androidx.compose.ui.input.pointer.PointerEventType
ComposeWindow constructs an upstream PointerEvent from this for
delivery to Modifier.pointerInput blocks. */
class LegacyPointerEvent(
val x: Int,
val y: Int,
// Kept as Float: SDL delivers sub-pixel cursor coordinates, and on HiDPI the
// value is multiplied by DPR before dispatch, so truncating to Int here would
// quantize the caret to whole-DPR steps (2px on a 2x display) near glyph edges.
val x: Float,
val y: Float,
val type: PointerEventType,
val button: PointerButton = PointerButton.Primary,
) {
override fun equals(other: Any?): Boolean = other is LegacyPointerEvent &&
other.x == x && other.y == y && other.type == type && other.button == button
override fun hashCode(): Int {
var h = x; h = 31 * h + y; h = 31 * h + type.hashCode(); h = 31 * h + button.hashCode(); return h
var h = x.hashCode(); h = 31 * h + y.hashCode(); h = 31 * h + type.hashCode(); h = 31 * h + button.hashCode(); return h
}
override fun toString(): String =
"LegacyPointerEvent(x=$x, y=$y, type=$type, button=$button)"
@@ -315,10 +315,10 @@ internal class ComposeOwner(
containsControls: Boolean,
): Long = originalTimeoutMillis
}
// Created behind a per-renderer factory seam (B2 / P1.3): SDL uses the project
// record/replay GraphicsContext; the Skia leg swaps in upstream's SkiaGraphicsContext
// at P1.6. SharedTransitionLayout overlays and rememberGraphicsLayer() create layers
// through this context. See com/compose/sdl/graphics/GraphicsContextFactory.kt.
// The Skia renderer uses upstream's SkiaGraphicsContext, created via
// GraphicsContextFactory. SharedTransitionLayout overlays and
// rememberGraphicsLayer() create layers through this context.
// See com/compose/sdl/graphics/GraphicsContextFactory.kt.
override val graphicsContext: GraphicsContext =
com.compose.sdl.graphics.createGraphicsContext()
override val textToolbar: TextToolbar = object : TextToolbar {
@@ -31,9 +31,19 @@ object ImeBridge {
var request: PlatformTextInputMethodRequest? = null
private set
// Invoked when a text-input session becomes active (a field gains focus) or
// inactive (focus lost). The window wires this to place the OS IME candidate
// window at the focused field's rect on focus-gain — otherwise the candidate
// popup is only positioned on the first TEXT_EDITING event, mis-placing it for
// the first keystroke. Repointed per window in installGlobals().
var onSessionActiveChange: ((active: Boolean) -> Unit)? = null
// Set / cleared by ComposeOwner.textInputSession as fields gain / lose focus.
fun setRequest(inRequest: PlatformTextInputMethodRequest?) {
val wasActive = request != null
request = inRequest
val nowActive = inRequest != null
if (wasActive != nowActive) onSessionActiveChange?.invoke(nowActive)
}
// SDL_EVENT_TEXT_INPUT: commit text, replacing any active composition. Returns
@@ -14,7 +14,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
// Foundation / ui.graphics — Brush gradients painted through Modifier.background.
// Exercises the SDL renderer's per-vertex gradient sampler (linear / radial / sweep).
// Exercises the Skia renderer's gradient shaders (linear / radial / sweep).
@Composable
internal fun BrushScreen() {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
@@ -19,9 +19,9 @@ import androidx.compose.ui.unit.sp
import com.compose.sdl.icons.IconFontIcon
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
import com.compose.sdl.scrollbar.HorizontalScrollbar
import com.compose.sdl.scrollbar.VerticalScrollbar
import com.compose.sdl.scrollbar.rememberScrollbarAdapter
import androidx.compose.foundation.HorizontalScrollbar
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.rememberScrollbarAdapter
import com.compose.sdl.text.IconText
// Foundation — AnimatedVisibility, BoxWithConstraints, desktop scrollbars.
@@ -19,13 +19,8 @@ runtime klibs serve every target.
// settings.gradle.kts
pluginManagement {
repositories {
// GitHub Packages requires authentication even for public packages.
maven("https://maven.pkg.github.com/bitsycore/compose-desktop-native") {
credentials {
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
password = providers.gradleProperty("gpr.token").orNull ?: System.getenv("GITHUB_TOKEN")
}
}
// Port artifacts + bridge plugin, no authentication needed.
maven("https://maven.bitsycore.com/releases")
gradlePluginPortal()
mavenCentral()
}
@@ -37,13 +32,31 @@ plugins {
dependencyResolutionManagement {
repositories {
maven("https://maven.pkg.github.com/bitsycore/compose-desktop-native") { credentials { /* same */ } }
maven("https://maven.bitsycore.com/releases") {
content { excludeGroup("com.bitsycore.skiko") }
}
// Windows (mingwX64) renders through the bitsycore skiko fork, published to
// its OWN GitHub Packages repo as com.bitsycore.skiko:skiko / :skiko-mingwx64.
// GitHub Packages requires authentication even for public packages.
// (macOS/Linux use the official org.jetbrains.skiko from Maven Central, so this
// repo is scoped to just the fork group.) Omit only if you build no mingwX64.
maven("https://maven.pkg.github.com/bitsycore/skiko") {
credentials {
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
password = providers.gradleProperty("gpr.token").orNull ?: System.getenv("GITHUB_TOKEN")
}
content { includeGroup("com.bitsycore.skiko") }
}
google()
mavenCentral()
}
}
```
> The port is also published to GitHub Packages
> (`https://maven.pkg.github.com/bitsycore/compose-desktop-native`) as an
> authenticated fallback — same coordinates, needs a PAT with `read:packages`.
```kotlin
// module build.gradle.kts — official coords, everywhere.
// Applying the plugin HERE exposes the `composeDesktopNative` extension, which
@@ -42,6 +42,14 @@ publishing {
password = System.getenv("GITHUB_TOKEN")
}
}
maven {
name = "Bitsycore"
url = uri("https://maven.bitsycore.com/releases")
credentials {
username = System.getenv("BITSYCORE_MAVEN_USER")
password = System.getenv("BITSYCORE_MAVEN_TOKEN")
}
}
}
publications.withType<MavenPublication>().configureEach {
pom {
@@ -27,7 +27,7 @@ import org.gradle.api.tasks.Copy
// rather than re-publishing it from the fork.
private const val SKIKO_MINGW_VERSION_PROPERTY = "skikoMingwVersion"
private const val DEFAULT_SKIKO_MINGW_VERSION = "0.150.1-mingw.1"
private const val DEFAULT_SKIKO_MINGW_VERSION = "0.150.1-mingw.2"
/**
* Provisions skiko-windows-x64.dll AND icudtl.dat next to the mingwX64
+7 -1
View File
@@ -6,7 +6,13 @@ kotlin.code.style=official
kotlin.mpp.enableCInteropCommonization=true
kotlin.native.enableKlibsCrossCompilation=false
kotlin.incremental.native=true
# Per-file native incremental compilation MUST stay off: it fails to emit a
# value class's box/unbox helpers into the declaring file's cache unit when only
# OTHER files box the value, so the final executable link dies with undefined
# symbols (e.g. androidx.compose.material3.NavigationItemIconPosition's box
# constructor, referenced from ShortNavigationBar/WideNavigationRail). Turning it
# on breaks :demo / :apidemo executable linking on all native targets.
kotlin.incremental.native=false
kotlin.native.ignoreDisabledTargets=true
org.jetbrains.compose.experimental.macos.enabled=true
+13 -2
View File
@@ -312,11 +312,22 @@ def buildSdl3(inHost, inCc, inCxx):
"-DSDL_SHARED=OFF", "-DSDL_STATIC=ON",
"-DSDL_TESTS=OFF", "-DSDL_TEST_LIBRARY=OFF", "-DSDL_EXAMPLES=OFF",
"-DSDL_INSTALL_TESTS=OFF",
# Subsystems with zero references in this project (see PLAN.md §3): the
# port uses SDL only for video/window, events, clipboard, file dialogs,
# GL/Metal contexts, the CPU-raster SDL_Render blit, filesystem, cursor,
# locale, theme, and text input/IME. Everything below has no call site
# and nothing depends on it transitively — disabling them shrinks the
# static lib and drops the matching system-framework deps.
"-DSDL_AUDIO=OFF", "-DSDL_JOYSTICK=OFF", "-DSDL_HAPTIC=OFF",
"-DSDL_HIDAPI=OFF", "-DSDL_SENSOR=OFF", "-DSDL_POWER=OFF",
"-DSDL_CAMERA=OFF", "-DSDL_GPU=OFF", "-DSDL_OFFSCREEN=OFF",
"-DSDL_VIRTUAL_JOYSTICK=OFF",
"-DCMAKE_CXX_FLAGS=" + kSizeFlags,
]
if inHost == "windows":
# Kill the D3D12 driver + GPU subsystem (K/N mingw dxgi1_6.h too old).
vExtra += ["-DSDL_RENDER_D3D12=OFF", "-DSDL_GPU=OFF"]
# Kill the D3D12 driver (K/N mingw dxgi1_6.h too old); SDL_GPU is now
# disabled for every host in the shared list above.
vExtra += ["-DSDL_RENDER_D3D12=OFF"]
if inHost == "linux":
# SDL3's CMake feature-detection sees glibc's memfd_create and
# posix_spawn_file_actions_addchdir_np on modern hosts (>= 2.27 /
+1 -1
View File
@@ -20,4 +20,4 @@
# MARK: SDL3 — windowing / input / platform
# ==================
SDL_URL=https://github.com/libsdl-org/SDL.git
SDL_REF=release-3.4.12
SDL_REF=release-3.4.14
@@ -11,13 +11,16 @@ headerFilter = SDL3/**
staticLibraries = libSDL3.a
# Matches the SLIM build's libs/SDL3/lib/pkgconfig/sdl3.pc `Libs:` line (source of
# truth). The audio/gamepad/haptic/camera frameworks (CoreAudio, AudioToolbox,
# AVFoundation, GameController, ForceFeedback, CoreHaptics) dropped out once those
# subsystems were disabled in build-all.py — regenerate the .pc and re-sync this
# list if the SDL feature set ever changes.
linkerOpts.osx = \
-framework CoreMedia -framework CoreVideo -framework Cocoa \
-weak_framework UniformTypeIdentifiers \
-framework IOKit -framework ForceFeedback -framework Carbon \
-framework CoreAudio -framework AudioToolbox -framework AVFoundation \
-framework Foundation -framework GameController -framework Metal \
-framework QuartzCore -weak_framework CoreHaptics \
-framework IOKit -framework Carbon \
-framework Foundation -framework Metal -framework QuartzCore \
-lpthread -lm
# The -L multi-arch dirs + fontconfig/GL/X11 serve the SKIA renderer (font