Compare commits

...
269 Commits
Author SHA1 Message Date
Bitsy c76f629971 feat(bridge): expose the material3-adaptive version to consumers
material3-adaptive rides its own release train upstream (1.3.0-rc01),
like material3 does, so a consumer had no way to express its version
through the plugin - they would have had to hardcode a number and keep
it in sync with the port by hand.

Adds composeDesktopNative.composeMaterial3Adaptive alongside the existing
compose / composeMaterial3 / composeRuntime accessors, stamped into
bridge-version.properties from the version catalog like the others.

The README consumer snippet now shows the adaptive coordinates, and calls
out that window-core has to be declared DIRECTLY when commonMain touches
WindowSizeClass - substituted modules hide their transitives from the
common metadata classpath.
2026-09-22 16:52:25 +02:00
Bitsy c4564b3a10 feat(demo): make the Adaptive screen fold on a live resize
The first version of this screen proved the vendored adaptive modules
render, but it did not really demonstrate adaptation: it left
rememberListDetailPaneScaffoldNavigator on its default directive, which
is the WINDOW size class, and pinned the scaffold to a fixed 420dp.

The window is the wrong input here. The screen does not get the window -
the demo shell spends 190dp on the sidebar plus 48dp of padding - so
defaulting folds ~240dp too late and leaves two panes side by side in a
region already too cramped for them. The directive is now computed from
the width the screen actually gets (BoxWithConstraints), which is what
any pane-hosting app that is not full-window should do; the pane height
tracks the window height instead of being a constant. The header prints
the window size class and the pane-area size class side by side so the
difference is visible while dragging.

Also adds selection highlighting and a Back button that only appears
while folded, so the single-pane mode is actually navigable.

New `--adaptivetest` probe: drives one window wide -> narrow -> wide with
setSize and asserts the scaffold folds and unfolds. This is what proves
the resize chain end to end - SDL resize, the owner's snapshot-backed
containerSize, recomposition, BoxWithConstraints re-measure, a new
PaneScaffoldDirective on the navigator, a new scaffold value. The
--screen=Adaptive --width=N screenshots could only ever prove the fold
was right for the size the window OPENED at, since each run is a fresh
process. The probe reads a small internal seam on the screen
(gAdaptiveTwoPane); it is documented as a probe hook and is not UI.

Verified on mingwX64: folds at 800dp and stays two-pane at 1100/1400dp,
--adaptivetest passes repeatedly, and the JVM parity leg plus commonMain
metadata still compile.
2026-09-22 16:46:57 +02:00
Bitsy fb8e97b74d feat(material3): port material3-adaptive to desktop native
Closes the request in issue #4. org.jetbrains.compose.material3.adaptive:*
1.3.0-rc01 publishes ios_arm64 / ios_simulator_arm64 / macos_arm64 and
nothing else - no linux, no mingw. (It does carry macosArm64, which
answers the reporter's open question.)

All four artifacts are now vendored and built for every desktop-native
target, with ZERO source edits. Two things made it a pure re-target:

- every platform actual the native legs need already exists upstream:
  adaptive/nonAndroidMain (calculatePosture), adaptive-layout/skikoMain
  (~75 l10n tables + Strings + the drag handle) and
  adaptive-layout/nativeMain (identityHashCode, isGraphicsLayerElement)
- androidx.window:window-core - the dependency `adaptive` api-exposes and
  the obvious suspect for the apple-only publication - has shipped
  linux_x64 / linux_arm64 / mingw_x64 / macos_arm64 klibs all along, under
  BOTH the google and jetbrains coordinates

So the artifacts were missing only because upstream never enabled the
targets, not because anything blocked them.

125 files vendored off the existing COMPOSE_CORE_REF pin - no new
SET_REPO. Upstream's skikoMain lands in src/vendor/native/ beside
upstream's own nativeMain; the two actualise disjoint expects so they
share one source set.

Both bridges substitute all four coordinates, so consumers keep declaring
the official org.jetbrains.compose.material3.adaptive:* coords.

Verified: all four compile for mingwX64 + linuxX64 + linuxArm64, API
dumps generated, vendor-clean and audit-exclusions pass, and
`demo --screen=Adaptive` renders a live ListDetailPaneScaffold with the
window size class resolving correctly. adaptive-navigation3 builds and
dumps API but has no demo screen yet, so its nav3 SceneStrategy path is
unproven at runtime.

Two notes for later:
- the new demo screen hit the granular-metadata trap documented in
  CLAUDE.md: because `adaptive` is substituted on native configs, KGP
  drops its transitives from the commonMain metadata classpath, so
  window-core had to be declared DIRECTLY in demo's commonMain
- demo's JVM parity leg moves navigation3-ui 1.2.0-alpha02 -> 1.1.2 to
  follow CMP 1.12.1's navigation3 reset onto the 1.1 line
2026-09-22 16:07:23 +02:00
Bitsy 6c3bfd8679 chore(deps): coil 3.6.2 -> 3.6.3, ktor 3.5.2 -> 3.6.0
Sweep of every pin the project owns. Only these two moved:
- coil: COIL_REF bumped with it and the vendored sources re-synced (no
  local edits needed)
- ktor: plain Maven dep, apidemo's HTTP client

Already latest, left alone: SDL3 (release-3.4.16), koin 4.2.2, okio
3.18.2, coroutines 1.11.0, kotlinx-io-okio 0.9.1. Deliberately not
bumped: kotlinx-serialization (latest is 1.12.0-RC, a prerelease) and
everything under policy 1, which CMP 1.12.1 still pins where we are.

The pulse group rename also lands in the klib dumps here - the unique
name embeds the group, and BCV infers the macOS dumps from the checked-in
files on Windows, so those headers had to be rewritten for apiDump to run.
2026-09-22 15:51:26 +02:00
Bitsy ec2d9b06ad chore(pulse): namespace the republish under the project group
Pulse is the one vendored dependency that is ITSELF a com.bitsycore
library (upstream com.bitsycore.lib:*), so republishing it as a bare
com.bitsycore.pulse:* read like an upstream coordinate and left room for
a real collision if upstream ever moves under that group.

Group becomes com.bitsycore.compose.desktop.native.pulse:*. Nothing
changes for consumers: they still declare the upstream com.bitsycore.lib
coordinate and the bridge plugin substitutes it on native configs.
2026-09-22 15:48:21 +02:00
Bitsy 1d6e5a76de chore: port to Compose Multiplatform 1.12.1
Bumps COMPOSE_CORE_REF / COMPOSE_REF to v1.12.1 and re-syncs all 1760
vendored files. No vendored file needed an edit; the ten manual vendors
were all verified unchanged upstream base..pin and only needed their
VENDOR-BASE ref re-stamped.

skiko stays 0.150.1, so the mingwX64 fork (0.150.1-mingw.2) still matches
its Skia base - no republish.

navigation3 moves BACKWARDS, 1.2.0-alpha04 -> 1.1.7: upstream re-checked
navigation3 out of release/1.11 for this patch (redirectversions.toml), so
the vendored :navigation3:navigation3-ui is now 1.1.7 source and the Maven
runtime has to follow it down. 1.1.7 publishes every target this port needs.

Two upstream-driven API deltas, both matching CMP:
- ui gains LocalSystemTheme, re-exposed upstream as @InternalComposeUiApi
- navigation3-ui loses NavigationBackHandler / rememberNavigationEventState,
  deleted by the 1.1.7 reset

LayerTransformationMatrix.kt's VENDOR-BASE path also corrected - it still
pointed at ui/ui, but upstream moved Matrices.skiko.kt to ui/ui-graphics,
which is why the drift check could not diff it.

Verified on mingwX64: both apps link, full probe suite passes (nav3test,
backtest, multiwintest included), vendor-clean and audit-exclusions clean.
2026-09-22 15:47:19 +02:00
Bitsy eb44db915e refactor: finish the SDL3* -> Sdl3* rename and regenerate the API dumps
Picks up the file/type renames (SDL3Backend -> Sdl3Backend, SDL3FrameClock ->
Sdl3FrameClock, SDL3EventMapper.kt -> AppEvent.kt) and unifies the 15 references
the rename left behind. All were prose or strings, which is why it still
compiled:

  - a user-visible error message ("SDL3Backend received GpuMode.Auto ...")
  - two user-visible demo UI strings ("driven by the SDL3FrameClock ...")
  - the MARK headers in AppEvent.kt and Sdl3Backend.kt
  - KDoc cross-references, including to `SDL3EventMapper.kt` which no longer
    exists - repointed at AppEvent.kt
  - 3 PLAN.md references

The codebase now uses Sdl3* uniformly, matching the Sdl3MainDispatcher /
Sdl3Clipboard / Sdl3DragAndDropOwner / Sdl3ScrollConfig that already followed
that form. `sdl3.*` (the cinterop package) and SDL_* (the C API) are untouched.

API dumps regenerated: a clean 1:1 rename, 41 SDL3Backend -> 41 Sdl3Backend in
:ui and nothing else - the other renamed types were not public. Breaking, but
com.compose.sdl only; no androidx.compose.* / org.jetbrains.compose.* change.

Verified beyond compiling: apiCheck green, both metadata publish gates compile,
nav3test/backtest/multiwintest PASS, raw SDL handles still resolve
(gpu=OpenGL window=true glContext=true), and the Images screen renders.
2026-09-19 00:12:36 +02:00
Bitsy 1120abc488 Update README to remove Infrastructure and add Support
Removed the Infrastructure section and added a Support section with a Discord link.
2026-09-18 23:54:32 +02:00
Bitsy 9d2656f905 refactor: make module-local project declarations internal
162 declarations leave the published API. They were public only because nothing
had ever audited the surface - none is Compose Multiplatform API, and none was
a deliberate native extra.

Removed by package:
  com.compose.sdl.graphics       45   canvas/painter/gradient/path plumbing
  com.compose.sdl.renderer.skia  23   SkiaBridge + SkiaSurfaceBridge
  com.compose.sdl.platform       21   DefaultViewConfiguration
  com.compose.sdl.res            17   ResourcePainter, vector->SVG, resource kinds
  com.compose.sdl.window         11   popup host internals
  com.compose.sdl.icons           7   IconDefaults

Method: inventory each module's public declarations from its klib dump by
DECLARATION OWNER (not by grepping names, which the earlier pass got wrong),
keep those referenced from another module, mark the rest internal, then let the
COMPILER adjudicate. That last step matters - it rejected three candidates that
look module-local but are real API, and I would have got at least one of them
wrong by reasoning alone:

  EncodedImageDecoder  exposed by a public property - it is an install seam
  RawSdlHandles        the return type of rawSdlHandles()
  ApplicationScope     the receiver of nativeComposeApp { Window(...) }

All three stay public.

Compose Multiplatform surface untouched: every removed line is owned by a
com.compose.sdl.* package, zero androidx.compose.* or org.jetbrains.compose.*.

Verified at runtime, since internalising the resource and renderer plumbing is
exactly the kind of change that compiles and then fails to draw: nav3test,
backtest and multiwintest PASS; the Images screen (which drives ResourcePainter,
the vector->SVG path and the image cache) renders 2112 distinct colours; the CPU
raster path still renders.

NOTE what this does NOT do. Kotlin `internal` is per-module and this repo spans
~20 Gradle modules, so anything crossing a module boundary must stay public -
SDL3Backend, ComposeRootHost, AppEvent and the rest of the cross-module plumbing
are still exposed. Narrowing those needs a @RequiresOptIn marker (upstream's
@InternalComposeUiApi pattern); none exists in this project yet.
2026-09-18 22:39:13 +02:00
Bitsy 48cfa5da4d docs(renderer): record why RenderBackend keeps its one-implementation interface
SkiaRenderBackend is the only implementor, which invites 'collapse the
interface, it is dual-backend residue'. It is not, and I tried it: the concrete
type lives in skikoRendererMain, BELOW nativeMain, so :desktop-native-window's
WindowInstance - which holds the renderer in nativeMain - cannot name it.
compileNativeMainKotlinMetadata fails with "Unresolved reference 'renderer'".

Same constraint as the expect fun createRenderBackend that returns it, whose
KDoc already explains its half. Documented on the interface so the next pass
does not spend the same detour.
2026-09-18 22:32:31 +02:00
Bitsy 856cbe5ef3 refactor(renderer): flatten GpuMode - every mode is Skia
GpuMode read as `Software` vs a nested `Skia.*` group, which dated from when
the alternative to Skia was SDL's own renderer. With SDL reduced to windowing
that split is actively misleading: its own KDoc said "Software - SKIA CPU
raster", so all three modes were Skia and the taxonomy implied otherwise.

  Auto / Skia.Metal / Skia.OpenGL / Software
    -> Auto / Metal / OpenGL / CpuRaster

The axis is now what it actually is: how Skia's output reaches the screen.
Added GpuMode.isGpuAccelerated so the GPU-to-CPU fallback in WindowInstance
reads as intent rather than `gpuMode is GpuMode.Skia`.

CLI compatibility kept: --gpu still accepts the retired skia.metal / skia.opengl
/ skia-* spellings alongside the bare metal / opengl / cpu / software / raster
names, so existing scripts and docs do not break.

API break is in com.compose.sdl only - no androidx.compose.* or
org.jetbrains.compose.* declaration changes, so the Compose Multiplatform
surface this port mirrors is untouched.

Verified the renderer actually still works on both paths rather than just
compiling: the resolved default reports gpu=OpenGL, every --gpu alias parses and
renders, and CPU-raster vs OpenGL screenshots of the Buttons screen differ by
0.01% of pixels (antialiasing only) with identical mean RGB.
2026-09-18 22:31:20 +02:00
Bitsy 5c7308c614 refactor(demo,renderer): split the probe suite out of MainNative; drawRoot is abstract
MainNative.kt held the entry point plus 20 probe scenarios in 1240 lines. The
scenarios now live in demo/src/nativeMain/kotlin/probes/, grouped by what they
exercise, and MainNative.kt is 284 lines of argument dispatch:

  InputProbes.kt      click, toggle, key, scroll, cursor, IME
  WindowProbes.kt     multi-window, window info, search-escape, locale
  AnimationProbes.kt  shared transition, AnimatedVisibility, dialog entry
  TextProbes.kt       font resolution, paragraph layout, metrics
  LifecycleProbes.kt  back navigation, Navigation3
  RenderProbes.kt     soak run, encoded-image decode

Behaviour is unchanged - each probe is `internal` so main()'s dispatch still
reaches it - and 134 imports that did not apply to the file they landed in were
pruned. Verified by running them, not just compiling: nav3test, backtest,
multiwintest and winattrtest still PASS, and clicktest/scrolltest (whose
scenarios moved) still report their expected values.

RenderBackend.drawRoot is now abstract rather than carrying an empty default
body. The no-op dated from when a second, non-Skia backend could opt out of it;
with SkiaRenderBackend the only implementation, an unimplemented drawRoot would
silently render nothing instead of failing to compile.

NOT done, and it should not be: removing the expect/actual createRenderBackend
seam. It looks like dual-backend residue but is load-bearing for a different
reason its KDoc already documents - the actual sits in skikoRendererMain, below
nativeMain, so without the expect it is invisible to nativeMain-level consumers.
Tested by deleting it: compileCommonMainKotlinMetadata still passed, but
:desktop-native-window's compileNativeMainKotlinMetadata failed with
"Unresolved reference 'createRenderBackend'" - and that is the compile the
WINDOWS publish job runs to produce the root modules, so it would have broken
publishing rather than local builds.
2026-09-18 22:25:58 +02:00
Bitsy 2fb322776a refactor: rename the f-prefixed member convention to m
500 occurrences across 31 files: mWidth, mSurface, mCache and friends. The `f`
prefix is retired project-wide.

Scope was checked before touching anything, because a blind rename here could
have done real damage:

- VENDORED code is untouched. All 32 src/vendor trees contain ZERO f-prefixed
  names (upstream Compose does not use the convention), so nothing in the
  byte-for-byte copies could be caught by the pattern. check-vendor-clean and
  check-vendor-drift both still pass.
- API-NEUTRAL. No f-prefixed name appears in any published .klib.api dump -
  they were all private - so this changes no consumer-visible surface, and
  apiCheck passes without a re-dump.
- The only matches inside quotes were string TEMPLATES ("...at $mPos",
  "frames=$mFrames"), i.e. code references that had to be renamed with
  everything else, not literal text.

Verified at runtime, not just by compiling: --nav3test, --backtest,
--multiwintest and --winattrtest all pass, and a Buttons screenshot still
renders.

Also corrects CLAUDE.md's Conventions section, which claimed the project used
"no f/in/v prefixes". That had not been true for a long time - the code uses v
for locals, in for parameters and k for file-level constants throughout. It now
documents what the code actually does, including that Composable parameters stay
plain because the name is part of the call-site API.
2026-09-18 22:00:40 +02:00
Bitsy 7c7bb63cae refactor(input): fold LegacyPointerEvent into AppEvent.Pointer
LegacyPointerEvent was a hand-written 4-field carrier (x, y, type, button) with
longhand equals/hashCode/toString - a data class written out by hand. Its whole
life was three lines in SDL3EventMapper that wrapped it and four in
WindowInstance that immediately took it apart again, so the indirection bought
nothing while putting the word "Legacy" on the live input path.

AppEvent.Pointer now carries the four fields directly, which also makes it
consistent with its MouseWheel sibling (already flat). LegacyPointerEvent.kt is
deleted, and com.compose.sdl.input went empty with it.

API: this removes a published declaration, so it is binary-breaking for anyone
who reached into the raw SDL event pipeline. Nothing in-tree did - demo and
apidemo never reference AppEvent or com.compose.sdl.input at all - and this
layer is internal plumbing rather than documented consumer surface. Net effect
on the klib dump is 26 declarations out, 16 in.

Verified pointer dispatch still works rather than assuming: --searchesctest
CLICKS the SearchBar to expand it, and its before/after screenshots differ by
84.87% of pixels, so the click landed and the overlay opened. --nav3test,
--backtest and --multiwintest also pass.
2026-09-18 21:42:00 +02:00
Bitsy 02fb2ddf4e fix(renderer): bind each window's GL context per frame
`demo --multiwintest` exited 139 (SIGSEGV) with no output, so the multi-window
regression probe had silently been asserting nothing.

An OpenGL context is per-WINDOW, but "current" is per-THREAD state, and
SDL_GL_MakeCurrent ran exactly once per window at creation (SDL3Backend.init)
with nothing re-binding it per frame. So with two windows open, whichever
initialised last owned the thread: window A issued its draw and swap against
window B's context. That is silent corruption while both windows live, and a
hard fault in present() the moment B is destroyed and the current context is
left dangling - which is what the probe hit.

SkiaGLBridge now binds its own context at every entry point that touches GL:
init, ensureSize (before the unchanged-size early return, so it is the
per-frame bind covering the draw phase that follows), present, and destroy (a
window frees its GPU objects against its own context, not whichever happens to
be current). Purely additive.

Verified both paths, because the single-window path is the one with the most to
lose:
  single window  Buttons screenshot mean RGB [46.6 44.4 51.5], 167 distinct
                 colours - IDENTICAL to before the change, as expected since
                 SDL short-circuits a redundant bind
  multi window   --multiwintest PASS (was exit 139)
  --nav3test, --backtest, --winattrtest all still pass

Worth recording: multi-window was never correct here, not merely broken at
teardown - two live windows were sharing one context the whole time.
2026-09-18 21:35:25 +02:00
Bitsy 126e83c056 refactor(window): split the window module; add the Compose Desktop Window attributes + a raw SDL escape hatch
ComposeWindow.kt had grown to 985 lines holding four unrelated concerns. Split
by concern, no behaviour change:

  ComposeWindow.kt    337  public surface (ApplicationScope, Window(),
                           nativeComposeApp, nativeComposeWindow) + main loop
  WindowInstance.kt   540  one SDL window: backend, render backend, root host,
                           recomposer/composition, events, frame pump, teardown
  AppRuntime.kt        82  live-window registry + create/destroy lifecycle
  FrameTiming.kt       83  virtual/deterministic frame clocks + probe quiescence
  WindowAttributes.kt  30  the new attribute bundle

Also pruned 25 imports that no longer applied to what stayed behind.

WINDOW ATTRIBUTES. Window() and nativeComposeWindow() now take the same
parameters as Compose Desktop's Window(): undecorated, transparent, resizable,
enabled, focusable, alwaysOnTop, onPreviewKeyEvent and onKeyEvent. The mutable
ones re-apply whenever they change (DisposableEffect keyed on the bundle);
transparent is creation-only because SDL needs SDL_WINDOW_TRANSPARENT at
SDL_CreateWindow and offers no setter, which WindowAttributes documents.

`enabled = false` drops pointer / key / wheel / text / drop events while the
window keeps rendering, matching Compose Desktop. Key dispatch follows
upstream's order: window preview -> focused content -> window onKeyEvent -> app
shortcut handler -> back navigation.

RAW SDL ACCESS. ComposeNativeWindow.rawSdlHandles() returns a RawSdlHandles data
class of the four opaque pointers (window / renderer / glContext / metalView) so
advanced users can call the sdl3.* cinterop directly. Named for what it is
rather than getRawSdlAccessor(): it hands back handles, not an accessor object,
and it is a snapshot - the KDoc spells out that the pointers die with the window
and which ones are non-null per resolved GpuMode.

Verified by a new `demo --winattrtest` probe, which asserts all four behaviours
rather than eyeballing them:
  ATTR      undecorated=true resizable=false alwaysOnTop=true focusable=true
  RAW       gpu=Skia.OpenGL window=true glContext=true renderer=false metalView=false
  KEYCHAIN  preview=2 onKey=2   (both window handlers saw the down/up pair)
  DISABLED  preview=2 onKey=2   (unchanged after injecting with enabled=false)

Existing probes still pass (--nav3test, --backtest, --searchesctest).

Records PLAN.md 7c: `demo --multiwintest` segfaults on mingwX64. Confirmed
PRE-EXISTING by stashing this work and reproducing on the previous commit, so
it is not fallout from the split - the probe is just silent on crash, so it had
gone unnoticed.
2026-09-18 20:54:25 +02:00
Bitsy 0c09109e6e fix(bridge): make the app-icon object a real input of the mingw link
Editing an app-icon PNG rebuilt the .ico and the windres .o and then left the
link UP-TO-DATE, so the .exe silently kept the OLD embedded icon. Reproduced on
:apidemo - generateComposeNativeIco and compileComposeNativeIconResource both
re-ran, linkDebugExecutableMingwX64 reported UP-TO-DATE, and the executable's
md5 did not move.

The link was wired with a bare `dependsOn(windresTask)`, which only ORDERS the
two. The object's path reaches the linker as a `linkerOpts` STRING, so the path
is an input but its CONTENT never is - nothing about a changed .o could
invalidate the link.

Replaced with `inputs.files(windresTask)`, which carries the task dependency AND
puts the object's content into the up-to-date check. Verified: the link now
re-runs and the .exe changes when an icon PNG is edited, in both directions.

The .rgba window-icon blobs were never affected - they go into the data.kres Zip
via `from(dir)`, and a Copy spec is already a declared input.

Also annotates the three icon task types with @DisableCachingByDefault. They
had no cacheability declaration, which failed `validatePlugins` and with it
:compose-desktop-native-bridge:build; that failure predates this change.
2026-09-12 18:44:46 +02:00
Bitsy 5804128e1d fix(bridge): aggregate dependency modules' composeResources into data.kres
In a multi-module project, resources declared in a LIBRARY module never reached
the final data.kres - only the app module's own composeResources were packaged,
so every Res.* accessor pointing at a submodule resource failed at runtime.

The Zip sourced the app's own `generated/compose/resourceGenerator/
preparedResources/<sourceSet>/composeResources`, which is per-module by
construction. Kotlin already solves this: `<target>AggregateResources` merges
this project's own composeResources with those of every dependency module into
`build/kotlin-multiplatform-resources/aggregated-resources/<target>/`, each
under its OWN `composeResources/<package>/` prefix. The plugin now reads that.

Two bugs fixed at once. The old path also forced a SINGLE resolved package onto
everything it copied, which is wrong the moment more than one module
contributes - the aggregate keeps each module's real package instead.

The aggregate directory is already rooted at `composeResources/`, so it is
copied with no `into()` remapping. Kept the old per-module wiring as a fallback
for Kotlin versions that do not register the aggregation tasks.

Verified with a throwaway library module carrying a composeResources file:
  before  data.kres had only the app's resources
  after   composeResources/<app>.generated.resources/files/app_marker.txt
          composeResources/<lib>.generated.resources/files/submodule_marker.txt
Existing single-module apps are unaffected - :demo's resources still land under
`demo.generated.resources` (it sets packageOfResClass explicitly, which the old
code also read), and its Images screen still loads PNG/JPG/SVG/XML at runtime.
2026-09-12 13:10:30 +02:00
Bitsy 04d1df58de docs(plan): record the icon-font centring fix 2026-09-12 12:32:39 +02:00
Bitsy a87ff2d7f6 fix(icons): centre the icon-font glyph so Material Symbols stop rendering low
Material Symbols icons sat 2-4px lower on native than on the JVM reference,
the offset scaling with icon size (~13% of it): eye 11px -> +2, gear/lock 16px
-> +2, download 28px -> +4. Equal top and bottom deltas with identical ink
heights, so it was a pure downward translation, not a sizing problem.

The two stacks positioned the glyph differently. The JVM actual centres it
explicitly (MaterialSymbolsIcon.jvm.kt: y = height/2 - (ascent+descent)/2),
while IconFontIcon just placed IconText in a Box with the DEFAULT TopStart
alignment. The glyph's natural text box is taller than the nominal icon size -
its height is the font's ascent+descent and Material Symbols' em box overshoots
the icon grid - so top-aligning it pushed the glyph down.

Centering the text box is equivalent to the JVM's baseline math, because
IconText sets no lineHeight and its box height therefore IS ascent+descent.

wrapContentSize(unbounded = true) is load-bearing and was the whole reason a
first attempt at this changed nothing: Modifier.size() hands the child FIXED
constraints, so the text measured at exactly `size`, nothing overflowed, and
contentAlignment had nothing to align.

Measured on apidemo, native vs jvm ink boxes:
  gear      +2/+1 -> 0/0
  download  +4/+4 -> 0/0
  lock      +2/+1 -> 0/0
  eye       +2/+2 -> +1/+1   (half-pixel rounding: an odd-height glyph centred
                              in an even box; the JVM draws at a float y where
                              native placement is integer)
Whole-window drift >32/255: 0.29% -> 0.17%.

Found by measuring rather than by eye, off the apidemo --screenshot harness.
2026-09-12 12:32:30 +02:00
Bitsy 10380e5422 fix(text): forward topRatio to skiko so inherited lineHeight centres like upstream
Text with a lineHeight much larger than its fontSize sat visibly LOW on native
compared to the JVM reference - most obvious on apidemo's SourceTag pill, whose
Text sets fontSize = 9.sp but inherits Material 3 bodyLarge's 24.sp lineHeight.

Upstream's ParagraphBuilder.skiko.kt sets `res.topRatio = topRatio` on every
SkTextStyle, sourced from LineHeightStyle.Alignment.topRatio. The port set
`ts.height` and the paragraph `heightMode` but never set topRatio, so skiko fell
back to its own default (-1 = split the extra leading proportionally to the
font's ascent:descent). Proportional splitting puts most of a large leading
ABOVE the glyphs; Material 3's type styles ask for Alignment.Center (0.5).

Fallback is Alignment.Proportional, matching upstream, so text that specifies no
lineHeightStyle keeps its current spacing. topRatio exists on both the official
skiko and the bitsycore mingw fork, so all four targets compile.

Measured on apidemo at 1240x820, native vs jvm over the whole window:
  before  2.94% of pixels differ, 2.14% by >32/255
  after   1.52% of pixels differ, 0.29% by >32/255
The pills are now pixel-identical and the residual is rasterizer AA.

This also resolves the "constant ~2px vertical offset" logged next to it: that
was the same bug compounding through stacked text rows, not a separate issue.
Before the fix, shifting the jvm capture down 2px HALVED the error; now 0px is
strictly best (0.29% vs 2.13% at +1px).

Found via the apidemo --screenshot drift check added in the previous commit.
2026-09-12 01:57:23 +02:00
Bitsy 2fe26cf7f8 docs(plan): root-cause the residual pill mis-centering to leading distribution
Aligning the default font (7a) removed the different-typeface confound - glyph
ink is now 7 rows on both stacks - but the SourceTag pill text still sits low
on native. Measured: jvm line box 24px with 10/9 gaps, native 25px with 13/7.

Decisive test: adding an explicit lineHeight = 9.sp, so there is no extra
leading to distribute, makes the two stacks PIXEL-IDENTICAL (same 14px box,
same ink rows, same 4/3 gaps). The Text only sets fontSize, so it inherits
M3 bodyLarge's 24.sp lineHeight - a line box far taller than the glyphs.

So font metrics, glyph raster and baseline placement are all correct; the
divergence is isolated to the leading-distribution path, which both adds a
pixel to the box and biases the glyphs downward. Pointed at the heightMode
branch in SkiaParagraphEngine and the per-run height multiplier, with an
explicit warning not to paper over it by pinning lineHeight at call sites.
2026-09-12 01:34:55 +02:00
Bitsy 71aab5d502 docs(bridge): correct the stated relationship between the two bridge halves
The comment added with the ecosystem rules claimed the plugin table and the
repo-internal FULL-COMMONIZATION BRIDGE must match each other exactly. They
must not, and a cross-check shows they already do not: animation-graphics,
material-ripple and ui-backhandler have plugin rules but no root rule.

That asymmetry is correct. The plugin table has to cover every coordinate the
port PUBLISHES, since a consumer can declare any of them; the root build only
needs a rule where an IN-REPO module declares a Maven coordinate in commonMain,
and in-repo modules reach those three through project(...) instead. Restated
the real invariant: a published coordinate missing from the PLUGIN table is the
error to look for.

Verified: 32 of 35 published modules are substitutable, and the 3 that are not
(sdl-core, material-symbols, desktop-native-window) are exactly the modules
with no upstream coordinate to substitute from.
2026-09-12 01:29:18 +02:00
Bitsy d19948059b fix(apidemo): align the default font across both stacks; add headless drift capture
The SourceTag pill looked vertically centred on jvm and off-centre on native.
Not a centering bug: the pill is Box(padding(vertical = 1.dp)) { Text(9.sp) }
with no centering logic, so glyph position is decided purely by font metrics -
and the two stacks were drawing it in DIFFERENT TYPEFACES. Native resolves
FontFamily.Default to the bundled NotoSans (SkiaFonts.defaultTypeface is the
fallback for every unresolved family); the jvm leg only ever registered
monoFontFamily, so Default was a system face (Segoe UI on Windows).

That made it a parity-harness defect, not a renderer bug - and a broad one,
since every Text without an explicit fontFamily was being compared against the
wrong reference.

Fixed with a defaultFontFamily expect/actual loading the bundled NotoSans on
both legs, stamped across the M3 Typography (withDefaultFontFamily) because
typography styles carry their own fontFamily and overriding LocalTextStyle
alone would not reach them. This is the same treatment :demo's parity leg has
had all along (its notoSans/notoTypography block says as much); apidemo had
simply never been given it.

Drift capture: :apidemo could not screenshot itself, so both legs now take
--screenshot=<path> and render OFFSCREEN to quiescence - native via a GPU
framebuffer readback, jvm via ImageComposeScene - with a virtual 60fps clock
and infinite animations frozen, mirroring :demo's parity path. Neither needs a
visible window (a screen-scrape approach was tried first and captured whatever
happened to be in front of the app, so it was dropped).

First measured run, 1240x820: 2.9% of pixels differ at all, 2.1% by >32/255.
Two residuals recorded in PLAN.md 7a-bis, neither a text-metrics bug: a
constant ~2px vertical offset that accounts for about half of all remaining
drift, and a Material Symbols glyph missing from the subsetted font in the
session header (tofu on jvm, nothing on native).

Bmp.kt is duplicated from :demo rather than promoted to a library module: it is
app-level tooling and :ui-graphics deliberately does not re-export skiko.
2026-09-12 01:23:34 +02:00
Bitsy 20714b3eeb chore(deps): version sweep against CMP 1.12.0; restructure the version catalog
Applies an explicit policy, now written at the top of libs.versions.toml:
anything Compose Multiplatform integrates is pinned to what CMP ships (for the
androidx mirrors, the JETPACK version CMP's release notes map its
org.jetbrains.androidx.* artifact to, since this port consumes the google
coordinates); anything CMP does not integrate is pinned to the latest release;
deliberate exceptions are allowed but must be commented.

Checked every pin against upstream rather than against memory - the CMP core
catalog at v1.12.0 plus the actual dependency declarations of the vendored
modules:

  kotlin               2.4.0        -> 2.4.20   latest; builds + links clean,
                                                no klib API diff
  okio                 3.17.0       -> 3.18.2   latest
  ktor                 3.5.1        -> 3.5.2    latest
  navigation3-runtime  1.2.0-alpha05 -> alpha04  CMP ships jb 1.2.0-alpha02 ==
                                                Jetpack alpha04; do not drift on
                                                a pre-release line
  collection 1.5.0, graphics-shapes 1.1.0, lifecycle 2.11.0, skiko 0.150.1,
  koin 4.2.2, coil 3.6.2, SDL3 3.4.16           already correct

savedstate stays at 1.5.0 as a DOCUMENTED exception: CMP builds against 1.4.0
and lifecycle 2.11.0 requires 1.4.0, but only as a MINIMUM, so 1.5.0 resolves
with no conflict and hands consumers the newer API. navigationevent stays at
1.1.2, the latest patch of the 1.1 minor CMP ships.

The catalog is now sectioned (toolchain / CMP / skia / androidx / vendored /
infrastructure) with each group carrying the reason it is pinned where it is,
so the next sweep is mechanical. README's pinned-versions tables updated to
match, including the policy statement.
2026-09-12 01:23:15 +02:00
Bitsy d029b48e10 docs(readme): add a pinned-versions table; centralise every androidx pin
The table covers the toolchain (Kotlin, Compose Multiplatform, SDL3, both skiko
builds), the Compose libraries with official-vs-port coordinates side by side,
the vendored ecosystem libraries with the reason each one is vendored, the
androidx artifacts used as-is, and the infrastructure pins.

To make that table a contract rather than prose, every androidx coordinate now
comes from the version catalog instead of being written literally in six build
files. That surfaced a real inconsistency: androidx.navigation3:navigation3-runtime
was pinned at 1.1.4 in :compose:ui:ui and demo's commonMain but 1.2.0-alpha05 in
:navigation3:navigation3-ui and demo's jvmMain. Gradle resolved the conflict to
the highest, so the build was fine, but nothing said which was intended - now
there is one entry.

Also records WHY Kotlin stays on 2.4.0 rather than dropping to the 2.2.20 that
CMP 1.12.0 is built with, and why material3 is 1.12.0-alpha03 at CMP 1.12.0.
2026-09-12 00:42:40 +02:00
Bitsy 47445ab7ab docs: document the vendored ecosystem libraries + the VENDOR-REIMPL convention
feat(bridge): substitute Koin / Coil 3 / Pulse MVI for consumers too

The consumer-facing bridge plugin's table only covered Compose, so a third-party
app declaring io.insert-koin / io.coil-kt.coil3 / com.bitsycore.lib coordinates
would have resolved the upstream artifacts and failed on mingwX64. Added all 15
entries, with a note that the plugin table and the repo-internal
FULL-COMMONIZATION BRIDGE are two halves of the same swap and must stay in sync.

Docs:
- CLAUDE.md: koin/ coil/ pulse/ in the module map (including WHY each is
  vendored and the source-set / skiko-branch shapes), the five pinned upstream
  repos, and the "check BOTH coordinate sets" rule with the duplicate-classes
  hazard that follows from it.
- CLAUDE.md: the VENDOR-REIMPL marker next to VENDOR-BASE in the vendoring
  rules - a reimplementation is deliberately not drift-tracked, only a
  SIGNATURE change matters.
- README.md: a table of what is vendored and why, so the answer to "does X work
  on this port" is visible without reading CLAUDE.md.
- TOOLING.md: audit-exclusions.py alongside the other two guardrails, the
  ecosystem refs in the version map, and the ref-bump runbook generalised off
  Compose-only.
- bridge README: the ecosystem coordinates a consumer can declare, plus the two
  caveats (nav2 is a dead end; pick one lifecycle coordinate set).
- PLAN.md 7b: marked done, with the three findings worth remembering.
2026-09-12 00:39:04 +02:00
Bitsy b965c505ee feat(pulse): vendor the Pulse MVI stack for Kotlin/Native desktop
bitsycore/pulse-mvi targets android / jvm / ios / js / wasm and publishes NO
desktop-native artifact at all, so all four modules are vendored:

  :pulse:pulse             the MVI core (store, state, intents)
  :pulse:pulse-viewmodel   ViewModel binding
  :pulse:pulse-savedstate  SavedStateHandle persistence
  :pulse:pulse-compose     Compose bindings

Vendored VERBATIM @ 0.3.7 (new PULSE_REF pin + manifests); 14 files, commonMain
only, zero local edits. Pinned to the TAG, not main: the three commits past
0.3.7 are docs and CI only, and a tag is the durable ref this repo's vendoring
rules ask for.

GOOGLE lifecycle coordinates again, not upstream's org.jetbrains.androidx.*
mirrors - same duplicate-classes reason as the Koin modules.

COORDINATES: upstream publishes com.bitsycore.lib:*, which this repo must not
republish under the same name. The port publishes com.bitsycore.pulse:* instead
and the bridge substitutes com.bitsycore.lib:* -> the project modules on native
configurations, so consumers keep declaring the upstream coordinate.
2026-09-12 00:35:30 +02:00
Bitsy 05faaa7f7c feat(coil): vendor the Coil 3 stack for Kotlin/Native desktop
Coil 3.6.2 publishes linux + apple klibs for its core/network/svg modules but
NO mingwX64 anywhere, and NO desktop-native artifact at all for the compose
layer. The sources are target-agnostic, so the port re-declares the targets and
rebuilds them:

  :coil:coil-core            coil3.* core (Skia-based decoding)
  :coil:coil                 the singleton ImageLoader facade
  :coil:coil-compose-core    AsyncImage / rememberAsyncImagePainter internals
  :coil:coil-compose         the public compose entry points
  :coil:coil-svg             SVG decoding via skiko SVGDOM
  :coil:coil-network-core    shared network-fetcher plumbing
  :coil:coil-network-ktor3   Ktor 3 fetcher, on the port's existing ktor pin

Vendored VERBATIM from coil-kt/coil @ 3.6.2 (new COIL_REF pin + manifests);
152 files, zero local edits - all of it compiled unmodified.

SOURCE-SET COLLAPSE: the port has no android, jvm or js target, so upstream's
nonAndroidMain / nonJvmCommonMain / nonJsCommonMain all apply to every target
here. They do NOT merge into nativeMain though: several declare `expect`s whose
`actual`s live in upstream nativeMain, and siblings in one source set is a hard
error. So they keep their own level above it.

TWO PARALLEL SKIKO BRANCHES in coil-core / coil-svg / coil-compose-core:
upstream's non-android code uses org.jetbrains.skia.* directly, so the
INTERMEDIATE source set holding it needs skiko on its own compile classpath (a
shared-metadata compilation is a real compile). macOS/Linux take the official
skiko and mingwX64 takes the bitsycore fork, and the two cannot share an
intermediate - so mingw gets a parallel branch over the same srcDirs, the same
shape :compose:ui:ui-graphics already uses. Kotlin emits no metadata
compilation for the single-target fork branch, so the two never collide.

coil-compose-core declares the whole ui / foundation family DIRECTLY rather
than leaning on foundation's transitives - the granular-metadata trap from
CLAUDE.md's "Common pitfalls": a substituted coord's transitives drop off the
commonMain metadata classpath.

Published as com.bitsycore.coil3:<module>; bridge substitution rules added so
consumers keep declaring the official io.coil-kt.coil3 coordinates.
2026-09-12 00:30:01 +02:00
Bitsy 59bb159351 feat(koin): vendor the Koin modules that stop at apple+android upstream
koin-core 4.2.2 already publishes mingwX64 + linux klibs, so it stays a plain
Maven dependency. These four do not, and their sources are platform-agnostic,
so the port rebuilds the same code for its desktop targets:

  :koin:koin-core-viewmodel      org.koin.viewmodel.* + module DSL
  :koin:koin-compose             org.koin.compose.*
  :koin:koin-compose-viewmodel   org.koin.compose.viewmodel.*
  :koin:koin-compose-navigation3 org.koin.compose.navigation3.*

Vendored VERBATIM from InsertKoinIO/koin @ 4.2.2 (new KOIN_REF pin + manifests);
36 files, no local edits needed - everything compiled for mingwX64 unmodified.

NAV3, NOT NAV2: the requested koin-compose-viewmodel-navigation sits on nav2's
navigation-compose, which publishes no mingwX64/linux klibs under EITHER the
androidx.* or org.jetbrains.androidx.* coordinates - it cannot reach this port's
targets without vendoring nav2 too. This port's navigation story is Navigation 3
(:navigation3:navigation3-ui), and koin-compose-navigation3 needs only
koin-compose + navigation3-runtime, both already reachable. Vendored that instead.

GOOGLE lifecycle coordinates (androidx.lifecycle:*), not the
org.jetbrains.androidx.* mirrors upstream koin uses: the port already resolves
the google ones, and mixing both puts the same classes on the path twice.

Published as com.bitsycore.koin:<module>; bridge substitution rules added so
consumers keep declaring the official io.insert-koin coordinates.
2026-09-12 00:19:39 +02:00
Bitsy fcba77b622 docs(plan): record the JVM default-font parity defect + the Koin/Coil vendoring audit
7a: the apidemo SourceTag pill divergence is a font-resolution difference, not a
centering bug - native FontFamily.Default is the bundled NotoSans (SkiaFonts.
defaultTypeface), the JVM leg never registers it so Default is the system font.
Every un-familied Text is being compared against a different typeface.

7b: target-availability table for the requested libraries. navigationevent-compose
needs NO vendoring (both coordinate sets ship mingwX64 + linux + macos, and the
port already consumes it); Koin needs 4 modules, Coil3 needs 7.
2026-09-12 00:05:18 +02:00
Bitsy 82ffd7cf4d feat: migrate to Compose Multiplatform 1.12.0 + SDL3 3.4.16
Upstream baseline
- Pin both vendored refs to the STABLE v1.12.0 tag (core + umbrella); the
  native and JVM-parity legs are back in lockstep, no dev-build skew.
- Catalog: compose 1.12.0, composeRuntime 1.12.0. material3 stays
  1.12.0-alpha03 - that IS the version CMP 1.12.0 ships (its own train).
- Re-synced 1560 vendored files; drift + vendor-clean both clean.
- Retire the SelectionLayout.kt manual vendor: upstream fixed the same
  out-of-bounds at the source (lastSlot = currentSlot, not currentSlot + 1),
  so the fork's index clamps are redundant. Back to verbatim vendoring.
- Re-stamp 10 manual vendors to @ v1.12.0 (upstream unchanged base..pin).
- Regenerate the klib API dumps. These had already drifted on main (the last
  dump predates the scrollbar / IME changes), so the diff covers that too.

SDL3 3.4.16, skiko fork 0.150.1-mingw.2
- Rebuild the static libSDL3.a from release-3.4.16.
- CMP 1.12.0 uses skiko 0.150.1, unchanged, so the fork's Skia base still
  matches; bump to the -mingw.2 build and centralise it as `skikoMingw` in
  the version catalog instead of three hardcoded copies.
- Resolve the fork from the PUBLIC maven.bitsycore.com/releases instead of
  the credentialed GitHub Packages repo: building the Windows target no
  longer needs a GitHub token.

Gradle module paths mirror the directory
- Drop every projectDir redirect: :ui -> :compose:ui:ui,
  :animation-core -> :compose:animation:animation-core, and so on.
- INVARIANT (documented in settings.gradle.kts + CLAUDE.md): the leaf
  directory IS the published artifactId. KMP derives a target publication's
  coordinate as <project.name>-<target>, and a target disabled on the
  building host (all Apple targets on Windows) has no publication object to
  retarget - while the root kotlinMultiplatform metadata, which the WINDOWS
  publish job owns, still emits an available-at for it. A mismatched leaf
  silently ships a root module pointing at a nonexistent <leaf>-macosarm64.
  So compose/desktop/native/window and components/resources/library are
  renamed to their artifactIds. Published coordinates are unchanged.

Manual-vendor audit
- New scripts/compose-fork/audit-exclusions.py: classifies every `!` manifest
  exclusion as manual vendor (VENDOR-BASE, drift-tracked), reimplementation
  (VENDOR-REIMPL, deliberately not tracked), or orphan, and fails on any
  unclassified one.
- 14 project reimplementations were invisible to the drift checker by
  accident; mark them VENDOR-REIMPL. None needed reconciling (all verified
  <= 0.53 similarity to their upstream counterparts, none changed upstream
  between the old pin and v1.12.0).

Fix the JVM apidemo build
- Declare components-resources on jvmMain (decodeToImageBitmap /
  decodeToSvgPainter) and add the two missing cross-package imports. The JVM
  parity target had never compiled.

Verified on Windows: both native executables link and render, apidemo JVM
builds and launches, apiCheck green, common metadata compiles (the Windows
publish gate), publishToMavenLocal emits the same coordinates as before.
2026-09-12 00:03:13 +02:00
Bitsy d3eef74803 build: update Gradle wrapper to version 9.7.1 and add toolchain configuration 2026-09-11 23:29:20 +02:00
Bitsy a07f4634b2 chore: add local.properties to .gitignore 2026-09-11 23:21:23 +02:00
Bitsy 164d2b5e66 style: replace em dashes with plain punctuation 2026-08-09 21:45:57 +02:00
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
Bitsy 275ee97d56 fix(publish): :sdl-core emits a publishable klib + harden retry-cleanup jq
v0.4.0 publish failed on all hosts at :sdl-core:generateMetadataFileFor<Target>Publication
(FileNotFound on sdl-core-<target>Main-<ver>.klib). Root cause: :sdl-core is a
cinterop-only module with ZERO Kotlin sources, so the native main compilation
emits no main klib for maven-publish to consume. Add a minimal internal marker
in nativeMain so every target produces a klib; the sdl3 cinterop klib +.module
metadata ride along (verified via publishMingwX64PublicationToMavenLocal across
all modules).

Also harden cleanup_host_versions' jq: a not-yet-published package returns a
JSON object, not an array, which crashed 'select(.name==$v)' ('Cannot index
string with string') and defeated the retry-cleanup. Guard with type=="array".
2026-07-26 01:04:26 +02:00
Bitsy e5fb04f882 chore(api): refresh klib baselines + exclude sdl-core/material-symbols; add regen to release runbook
Regenerated all tracked klib API dumps (they'd drifted — stale bodies from
before the module split/rename). Exclude :sdl-core (naked SDL cinterop — its
API is the generated sdl3.* bindings, per-OS, not our surface) and
:material-symbols (auto-generated icon codepoint maps; its jvm dump also trips
BCV's ASM on JDK 25, 'class file major version 69') from apiValidation.
macosArm64 is inferred from the linux/mingw ABIs (accurate for the
target-independent Compose surface). Documented 'regenerate baselines before
tagging' in the release runbook; not a hard apiCheck gate until the API
stabilizes. Also fixed two stale com.bitsycore.compose.sdl coord refs in the
TOOLING version map / publish notes (now per-area).
2026-07-26 00:55:04 +02:00
Bitsy 23849330d1 docs(api): regenerate desktop-native-window API dump
The checked-in baseline was stale — generated very early (package
com.compose.desktop.native, only nativeComposeWindow public) and never
regenerated since; the coord rebrand only patched its header. Now that mingw
builds (skiko fork published), apiDump reflects the real surface: nativeComposeApp,
ApplicationScope.Window, AppWindowIcon, windowHasInvalidations, the frame-time
toggles, and the updated nativeComposeWindow signature — all in com.compose.sdl.
2026-07-26 00:45:25 +02:00
Bitsy 4272cec614 refactor(coords): per-area com.bitsycore groups, :window → :desktop-native-window, skiko fork → com.bitsycore.skiko
Rebrand the fork's published coordinates as the 1:1 com.bitsycore mirror of
their org.jetbrains originals, so each module is an obvious fork under the
bitsycore name:
- Per-area groups: com.bitsycore.compose.sdl:<mod> → com.bitsycore.compose.<area>:<mod>
  (ui / foundation / animation / material3 / material / components / navigation3),
  mirroring org.jetbrains.compose.<area>. Project-only modules (:sdl-core,
  :material-symbols) stay com.bitsycore.compose.sdl. Driven by a groupFor() map
  in root build.gradle.kts; the kUseGhPackages substitution + the consumer bridge
  plugin's bridgeTable now emit full per-area coords; publish.yml host_packages
  maps each module to its group (+ adds the split modules to the MODULES list).
- :window renamed to :desktop-native-window (dir compose/sdl/window →
  compose/desktop/native/window; sdl/ now holds only :sdl-core). Published as the
  headline app-shell artifact com.bitsycore.compose:desktop-native-window.
- Consumed skiko mingw fork org.jetbrains.skiko:skiko(+skiko-mingwx64) →
  com.bitsycore.skiko (ui/ui-graphics/ui-text + the DLL provisioner); settings
  repo filters updated. The OFFICIAL skiko (macOS/Linux libs.skiko) and the
  skiko-awt-runtime icudtl source stay org.jetbrains; the fork keeps upstream's
  org.jetbrains.skiko.* PACKAGE names — only the Maven coord is rebranded.
- Docs (CLAUDE/README/TOOLING/RENDERER/bridge README) + stale :window comments
  swept; API dump unique-name headers patched to the new groups + the window dump
  renamed (API is pre-stable; dumps regen once the build can run).

BLOCKED: mingwX64 build + publish need the bitsycore/skiko fork repo to
republish under com.bitsycore.skiko first. Verified locally: bridge plugin
compiles, main build configures, project tree resolves to the new paths.
2026-07-25 23:29:44 +02:00
Bitsy 3f4b3a23be docs+cleanup: refresh module-layout docs and purge dead SDL-renderer references after the :sdl-core / :ui-graphics / :ui-text split
- Regenerate API dumps: ui.klib.api drops the ui.graphics.*/ui.text.* surface
  (now carried by the new ui-graphics/ui-text dumps).
- components-resources: fold the historically-named src/sdlRendererMain dir into
  src/nativeMain (rename *.sdl.kt -> *.native.kt); drop the extra srcDir; fix the
  SDL3_image/IMG_LoadSVG comments (decode is Skia via the :ui-graphics hook).
- Purge stale two-renderer / SDL3_ttf / SDL3_image / FreeType / -Prenderer=sdl3
  references from live comments (RenderBackendFactory, IconFont, IconFontIcon,
  WindowIcon, PlatformClipboard, MaterialSymbolsScreen, sdl3.def, build-all.py,
  make-app-icon.py) and CLAUDE.md rule 4.
- verify-mac.sh: drop the removed SDL-renderer leg from the header prose.
- Keep the accurate 'were removed / no longer built' historical notes.

Comment/doc + build-file-comment changes plus the components-resources dir
rename; no executable logic changed. Verified: :components-resources compile +
metadata gate + :demo/:apidemo mingw build all green.
2026-07-25 22:57:23 +02:00
Bitsy d058f1b6a2 refactor(cleanup): delete dead Project* render cluster + fix bridge for the ui split
- Delete the ~920-line dead cluster superseded by the vendored SkiaBacked* actuals:
  ProjectCanvas/Paint/Path/PathIterator/ImageBitmap, Offscreen (OffscreenRenderer),
  SkiaDrawScope — a self-referential island with zero live references (RENDERER.md
  B6.2's deferred deletion, finally done).
- Full-commonization bridge (root build.gradle + the consumer bridge plugin): the
  official ui-graphics / ui-text coords now substitute to the NEW :ui-graphics /
  :ui-text modules (were still routed to :ui). Added :sdl-core / :ui-graphics /
  :ui-text to kPublishedLibs so they publish + the consumer plugin can map them.

Verified: demo + apidemo compileCommonMainKotlinMetadata + demo link green.
2026-07-25 22:32:33 +02:00
Bitsy 924ebe9776 refactor(ui): split :ui-text out of :ui — completes the upstream ui layout
androidx.compose.ui.text.* + the B6.3 skiko text engine (SkiaParagraph / SkiaParagraphOps
/ SkiaFonts, IconFont, NamedFont, ParagraphFactories, Locale/font actuals) now live in
:ui-text — deps :ui-graphics + skiko, SDL-free. :ui -> :ui-text (api). :ui is now the
Compose core + renderer on top of :ui-graphics / :ui-text / :sdl-core, mirroring upstream.

- compose-fork.txt: the ui-text SET_FOLDER section moved to :ui-text's own manifest;
  re-synced (95 vendored files).
- SystemLocale (SDL) / GenericFonts (resource glue) / ImeBridge stay in :ui;
  Locale/PlatformLocale (text.intl actuals) read the OS locale via a new
  preferredLocaleTagsProvider seam that SystemLocale installs.
- Made cross-module decls public (NoOpPlatformTextInputService); :ui-text needs
  runtime-saveable (Savers).

Verified mingw compile + compileCommonMainKotlinMetadata + demo render (Material Symbols:
variable-font axes + text correct).
2026-07-25 22:10:33 +02:00
Bitsy 5dc4b21867 refactor(ui): split :ui-graphics out of :ui (upstream layout)
androidx.compose.ui.graphics.* + the Skia actuals (SkiaBackedCanvas, SkiaImageCache,
GraphicsLayer/Context, painter/image seams) now live in :ui-graphics — deps skiko +
:ui-geometry/unit/util, SDL-free (image bytes via the composeResourceReader seam).
:ui -> :ui-graphics (api). Mirrors upstream's ui/ui-graphics split: each module is
self-contained (graphics expects + skiko actuals same module), so no cross-module
expect/actual and compileCommonMainKotlinMetadata stays green.

- compose-fork.txt split: the ui-graphics SET_FOLDER section moved to :ui-graphics's
  own manifest; re-synced (99 vendored files).
- Broke the one ui-core coupling: drawNativePainter / SkiaImageCache no longer take
  ContentScale (upstream PainterModifier pre-scales the destination, so the painter
  draws fill-bounds) — Fit/Crop/FillBounds still render correctly.
- Made the cross-module decls public (SkiaImageCache / SkiaLeafDrawer / GraphicsContext /
  readBackBgra / encodedImageDecoder setter / prepareLayerTransformationMatrix).

Verified mingw compile + metadata + demo render.
2026-07-25 22:01:56 +02:00
Bitsy accd74c9ee refactor(sdl): move the sdl3 cinterop into :sdl-core (Phase 1.2a)
New :sdl-core module (sdl/sdl-core) owns the single sdl3 cinterop; :ui exposes it
via api(project(":sdl-core")). Proves cross-module cinterop works: :ui sees
sdl3.*, and SDL3's static lib + linker opts propagate through the klib chain to
the final exe (SDL auto-links just by depending on :ui, unchanged). :sdl-core has
no :ui dependency, so :ui -> :sdl-core introduces no cycle.

Next: move the SDL platform + Skia renderer from :ui into :sdl-core (flips to
:sdl-core -> :ui, makes :ui SDL-free), then split ui-graphics/ui-text. Verified
on mingw (Widgets: controls + text + input all work).
2026-07-25 21:00:14 +02:00
Bitsy 30cc39606a refactor(sdl): platform-env seam — decouple foundation + resources from cinterop (Phase 1.1)
DarkTheme (:foundation, isSystemInDarkTheme) and ResourceEnvironment
(:components-resources, locale/theme qualifiers) called the sdl3 cinterop
directly (SDL_GetSystemTheme / SDL_GetPreferredLocales). Route them through a new
com.compose.sdl.res.{systemThemeIsDarkProvider,preferredLocaleProvider} seam that
SDL3Backend.init() installs. Now only the SDL layer + :window import sdl3 — the
prerequisite for relocating the cinterop into :sdl-core without those modules
needing it. Verified on mingw.
2026-07-25 20:51:24 +02:00
Bitsy 099211b015 refactor(ui): resource-bytes seam — decouple font/image loading from SDL (Phase 0)
SkiaFonts (fonts) + SkiaImageCache (images) read bundled bytes through a new
com.compose.sdl.res.composeResourceReader seam instead of calling
loadComposeResourceBytes (which uses the sdl3 cinterop via SDL_GetBasePath)
directly. SkiaRenderBackend installs the SDL-backed impl at construction. This is
the enabler for splitting ui-graphics/ui-text out of :ui without an SDL cinterop
dependency. Also deletes the throwaway ParagraphSpike + --paraspike demo flag.
Verified on mingw (Images screen: fonts + PNG/JPG/SVG/Android-XML all render).
2026-07-25 20:40:52 +02:00
Bitsy 37a44ffb46 fix(ci): bundle skiko-windows-x64.dll + icudtl.dat in the mingwX64 zips
The release zip step packaged only <app>.exe + data.kres, so the Windows binary
shipped without the two runtime sidecars the bridge plugin provisions next to the
exe (the skiko fork DLL + Skia's ICU data). The mingw exe cannot run without them.
Include both for the exe target (fail loudly if a provision ever regresses);
macOS/Linux unaffected. (v0.3.1 mingw zips need this — patch manually or re-tag.)
2026-07-25 20:09:17 +02:00
Bitsy 045c734eef fix(apidemo): wrappedRowCount via Paragraph.lineCount (currentTextMeasurer removed)
The B6.3 cleanup deleted com.compose.sdl.text.currentTextMeasurer, but apidemo's
Compat.native.kt still used it for wrappedRowCount. Rebuild it on the standard
Paragraph factory (skiko lineCount) — same physical-pixel space (density 1,
fontSize=inFontPx.sp, wrap at inMaxWidthPx). Fixes the v0.3.0 'Build demo +
apidemo release binaries' step, which failed on all targets after the library
publish step had already succeeded.
2026-07-25 19:30:04 +02:00
Bitsy 83c1902b08 refactor(text): delete the hand-rolled text renderer/seam — one skiko path
Now that Compose text draws through the skiko paragraph engine, migrate the last
consumer (IconText / Material Symbols) onto the standard path and delete the
parallel hand-rolled machinery:

- IconText is now a BasicText: its icon font family + variable axes (FILL/wght/
  GRAD/opsz) thread through namedFontFamily(axes=...) -> FontFamily.projectFontVariations(),
  which SkiaFonts applies via Typeface.makeClone. Icons measure + draw through
  skiko skparagraph like any other text.
- SkiaFonts registers each resolved typeface in the TypefaceFontProvider under a
  unique alias and sets fontFamilies=[alias] + typeface (mirrors upstream FontCache) —
  otherwise icon private-use codepoints fall back to Noto Sans and render as tofu.
- Deleted: SkiaTextRenderer (637L), TextDrawModifier (NativeTextCanvas/TextDrawElement/
  TextDrawNode), ColorRun, TextMeasurer (interface + currentTextMeasurer + WrappedText);
  kept currentViewport* in Viewport.kt.
- Trimmed: RenderBackend.textMeasurer, SkiaLeafDrawer.textRenderer,
  SkiaBackedCanvas.drawNativeText, SkiaRenderBackend, ComposeWindow install.
  TextRendererCapabilities.supportsFontVariations set true (skiko always supports it).

Verified on mingwX64: Buttons (text + FAB icon), Material Symbols (wght/FILL/GRAD
sweeps render correctly, no tofu). macOS/Linux + parity still pending.
2026-07-25 18:44:03 +02:00
Bitsy 076d60460c feat(text): render Compose text through skiko skparagraph (B6.3)
Replaces the hand-rolled SdlParagraph measure+draw with skiko's real
org.jetbrains.skia.paragraph engine (HarfBuzz shaping + skunicode bidi +
FontCollection fallback), keeping the port's family/variable-axis font model so
Material Symbols / monospace / custom fonts resolve unchanged.

Paragraph is a sealed interface whose actual is in nativeMain, so implementers
must live there (skikoRendererMain counts as a different module for the sealed
check). The impl therefore splits: SkiaParagraph (nativeMain, skiko-free — the
30 interface methods over plain TextBoxData/LineMetricData) drives a skiko
SkiaParagraphOps (skikoRendererMain) through the NativeParagraphOps seam. Font
resolution (SkiaFonts) + Compose-style->skiko conversion is a reduced local port
of upstream ParagraphBuilder.skiko.kt; cursor/selection/line-metric math is
adapted from upstream SkiaParagraph.skiko.kt.

Verified rendering on mingwX64 (Buttons screen: headers/body/labels/wrapping +
Material Symbol icon all correct). SkiaTextRenderer still backs
currentTextMeasurer for IconText/TextDrawModifier (follow-up). macOS/Linux +
parity verification pending (branch only).
2026-07-25 18:25:05 +02:00
Bitsy 93b7ff28e1 feat(bridge): provision icudtl.dat next to the mingwX64 exe
Skia's skparagraph (used by the upcoming upstream-text migration) loads ICU data
from icudtl.dat next to the binary on Windows at runtime; without it
ParagraphBuilder fatal-aborts. Sourced from the official
skiko-awt-runtime-windows-x64 jar (non-transitive) — same Skia base as the fork,
so no fork re-publish. Extracted + staged by provisionSkikoIcu{Debug,Release}MingwX64,
wired like the DLL provisioning (finalizedBy link, dependsOn run).
2026-07-25 17:56:55 +02:00
Bitsy 08968fe820 spike(text): prove upstream skiko Paragraph on the mingw fork (B6.3)
Isolated --paraspike flag drives org.jetbrains.skia.paragraph.* (ParagraphBuilder
/ FontCollection / TypefaceFontProvider + HarfBuzz shaping) end-to-end, without
touching the port's SdlParagraph engine. Renders 'Hello 123 Åé — wğ' from the
bundled data.kres NotoSans (registered as family 'Noto Sans' — the alias
upstream's FontCache uses), proving both skparagraph AND the font-bridge half
work on the fork DLL.

Finding: Windows needs icudtl.dat (~10.5MB) next to the exe (Skia loads ICU data
at runtime there); without it ParagraphBuilder fatal-aborts. To be provisioned
like skiko-windows-x64.dll. The spike function is present on all native targets,
so it also serves as the macOS/Linux skparagraph check (run with --paraspike).
2026-07-25 17:04:16 +02:00
Bitsy 3a57e96c80 chore(demo): prune the SDL-vs-Skia fidelity probes
With one native renderer, correctness is verified by parity against JVM Compose
Desktop (scripts/parity), so the per-feature SDL-leg fidelity probes are dead
weight. Removes the --dashtest / --tilemodetest / --pointstest / --rotimgtest /
--blendtest / --jointest / --filtertest / --ovaltest / --arctest demo flags,
their functions, and the now-unused twoColorBmp helper (-404 lines). Kept the
behavioural / platform / text probes (click / nav3 / locale / ime / font /
imagebytes / paragraph / metrics / ...).
2026-07-25 16:41:01 +02:00
Bitsy 332ba757fe chore(0.3.0): finish the SDL-renderer removal — docs, scripts, dead code
Completes the Skia-only cleanup for 0.3.0.

- Docs (CLAUDE.md, RENDERER.md, TOOLING.md, TODO.md, README.md): de-dualized
  everything — one Skia renderer (macOS/Linux official Skiko, mingwX64 the
  bitsycore fork). Removed -Prenderer=sdl3 / SDL-leg / SDL3_ttf-image-FreeType /
  two-leg-parity framing. RENDERER.md's "Track A" (real Skia on Windows) is now
  Done, not shelved; TODO's section H (SDL renderer graphics) is deleted.
- scripts/build-sdl: build-all.py builds ONLY SDL3, and its now-dead FreeType /
  SDL3_image / SDL3_ttf builder functions are deleted; build-sdl.properties
  trimmed to SDL3.
- scripts/parity: drop the --renderer=sdl3 / -Prenderer path — parity is
  native-Skia vs JVM now (baseline key is per-target).
- scripts/compose-fork/sync.py: drop the dead sdlMain -> sdlRenderer vendor
  mapping; components-resources manifest comments updated.
- demo ImagesScreen: caption now says Skia's codecs (not "SDL3_image on Windows").

Version 0.3.0 is git-tag-driven (tag v0.3.0 to publish).
2026-07-25 16:32:17 +02:00
Bitsy 09fe92fc95 docs(renderer): note the SDL renderer leg was removed (Skia-only) 2026-07-25 16:15:44 +02:00
Bitsy ec2451aad1 refactor(renderer): remove the SDL renderer leg — Skia everywhere
On this branch every target renders through Skia (mingwX64 via the bitsycore
skiko fork), so the from-scratch SDL renderer (Sdl3Canvas / Sdl3TextRenderer /
FreeTypeIcons / the SDL render nodes, ~3800 lines) and its SDL3_ttf / SDL3_image
/ FreeType cinterops are dead weight. SDL3 itself — windowing / input /
platform — stays, as the sole `sdl3` cinterop.

- Delete compose/ui/ui/src/sdlRendererMain (31 files) + the sdl3_ttf / sdl3_image
  / freetype .def files.
- :ui build: a single sdl3 cinterop; all native targets render through Skia
  (macOS/Linux official Skiko, mingwX64 the fork). Drops -Prenderer /
  -PwindowsSkia and the vSdlRenderer / isSkiaTarget split.
- GpuMode: drop the Sdl3.* modes (Auto / Software / Skia.OpenGL / Skia.Metal
  remain). SDL3Backend keeps the Software (SDL_Renderer), GL and Metal paths.
- :components-resources: its Font/Image actuals are renderer-agnostic project
  code (delegate to :ui) — attach them straight to nativeMain (no skiko dep, no
  renderer source-set split).
- bridge plugin: skiko DLL provisioning is now unconditional (mingw is always
  Skia).
- build-all.py: builds only SDL3 now (the ttf/image/freetype builders are kept
  but unused).

Verified: :ui + :components-resources compile and the demo links + renders on
mingwX64 (GL default).
2026-07-25 16:14:27 +02:00
Bitsy ff32d68a72 refactor(windows): move skiko DLL provisioning into the bridge plugin
The Windows Skia leg's runtime skiko-windows-x64.dll is now provisioned by the
bridge plugin (installWindowsSkiaDll) for every consumer under -PwindowsSkia,
instead of per-app build logic. Kotlin/Native has no runtime native-lib loader
(unlike the JVM's skiko-awt), so the DLL must sit next to the .exe; the plugin
resolves it from the fork's published windows-x64/.dll artifact and copies it
(finalizes link, precedes run). demo drops its inline block; apidemo gets it
automatically too.
2026-07-25 15:56:10 +02:00
Bitsy 1a56b7dcaf feat(windows): consume skiko fork from GitHub Packages + auto-provision DLL
Removes the mavenLocal-only requirement: the mingwX64 skiko klib + runtime DLL
are now published by the fork's CI (bitsycore/skiko) to GitHub Packages, and
this project resolves them from there.

- settings.gradle.kts: add the bitsycore/skiko GitHub Packages repo (scoped to
  org.jetbrains.skiko); the existing repo now excludes that group. mavenLocal
  still takes precedence for local fork builds. (Consuming GitHub Packages
  needs a token with read:packages; it works automatically in CI.)
- :ui: the fork version is overridable via -PskikoMingwVersion (default
  0.150.1-mingw.1, matching the fork CI's published version).
- demo: auto-provision skiko-windows-x64.dll next to the mingwX64 executable
  from the published windows-x64 / .dll artifact (finalizes the link task,
  precedes run) -- no manual copy needed.
2026-07-25 15:47:22 +02:00
Bitsy 7c5abc2705 feat(windows): real Skia renderer on Kotlin/Native mingwX64 (opt-in)
Adds a Skia rendering path for the Windows (mingwX64) target, behind
-PwindowsSkia=true. The default Windows build stays on the SDL renderer.

Backed by a fork of JetBrains/skiko that adds a mingwX64 native target
(Route 1a): skiko + Skia are linked into skiko-windows-x64.dll exposing a
flat extern-C surface, bound from Kotlin/Native via a GNU import library, so
the MSVC<->GNU C++ ABI wall is sidestepped. The fork is consumed as
org.jetbrains.skiko:skiko:0.0.0-SNAPSHOT from mavenLocal -- build & publish
it first (see SKIKO-MINGW-FEASIBILITY.md).

- settings.gradle.kts: mavenLocal() for the fork klibs.
- :ui build: isSkiaTarget(mingwX64)=useWindowsSkia; vSdlRenderer=!isSkiaTarget;
  a separate skikoRendererMingwMain tree on the fork ROOT coord (the platform
  artifact alone does not expose api-elements); SDL renderer skipped for mingw
  when windowsSkia.
- skikoRendererMingwMain/PlatformGpu.mingw.kt: makeMetalBridge=null.
- PlatformGpu.kt: Windows default GPU = Skia.OpenGL (hardware-accelerated via
  the SDL WGL context, reusing the shared SkiaGLBridge); --gpu=software stays.
- ComposeWindow: makeBackend() + one-shot fallback GPU -> CPU raster when a
  Skia GPU context/bridge can't be created (RDP / headless / bad drivers).
  Universal -- also covers a Metal failure on macOS.

Verified on Windows: the demo renders correctly on both the GL GPU path and
CPU raster across Buttons / Brushes / Images / GraphicsLayer / Widgets /
Shadows / LazyColumn / Icons (text, gradients, image+SVG+vector decode, and
graphics-layer transforms).
2026-07-25 14:33:50 +02:00
Bitsy aa96b9eb10 misc: cleanup comments 2026-07-22 22:06:49 +02:00
Bitsy 5a891cd121 refactor(build): commonize app font bundling in buildSrc; demo adopts the bridge plugin
- buildSrc/ComposeFontBundling.kt: registerComposeFontBundling { } shared by
  demo + apidemo — downloadNotoFonts, Material Symbols style detection,
  data.kres font/ entries + JVM classpath staging, hb-subset pipeline.
  Every flag is opt-in (bundleNotoSans / bundleNotoSansMono /
  autoDetectNotoSansMono / bundleMaterialSymbols / enableIconSubsetting);
  no flag = no-op.
- apidemo: the whole Fonts section collapses to the config call.
- demo: applies com.bitsycore.compose-desktop-native.bridge — the plugin's
  package<Variant>ComposeResources<Target> tasks replace the hand-rolled
  copy* Zip loop (now packaging the PREPARED resources), entryPoint moves
  to compose.desktop.native {}.
- root: components-resources substitution hoisted into the
  FULL-COMMONIZATION BRIDGE (was demo-local; matches the consumer plugin's
  bridge table).
- data.kres verified entry-identical for both apps before/after.
2026-07-22 22:02:25 +02:00
Bitsy ad08453859 refactor(apidemo): rename variables for clarity and improve comments 2026-07-22 21:06:54 +02:00
Bitsy 5822061770 style(apidemo): more cleanup 2026-07-21 23:36:13 +02:00
Bitsy 7920587cf5 style(apidemo): reformat apidemo 2026-07-21 23:24:40 +02:00
Bitsy 65aef4e036 docs(style): KDoc doc comments + drop immediate-mode leftovers
Comment pass over PROJECT code only (vendored trees and VENDOR-BASE manual
vendors untouched — those must stay byte-diffable against upstream):

- Every declaration-preceding /* ... */ doc block becomes KDoc /** ... */
  (~693 blocks across ~190 files), content unchanged.
- Stale references from the retired immediate-mode renderer rewritten:
  ProjectOwnedLayer / SkiaCanvas mentions (NativeShadowCanvas,
  ShapeClipCanvas, DeferredRenderNode), the Sdl3Canvas "not yet wired"
  paragraph (everything listed has long been wired), "Phase 4/9 B3-B6"
  plan labels, dead RENDERER.md section pointers, and SdlRenderNode's
  claim that DeferredRenderNode is still the default.
- ComposeWindow: the GC doc sat on kForceRender and a dangling "TEMP
  measurement" note floated above renderFrame — docs moved to their
  actual owners.

Dead code removed:

- ProjectOwnedLayer (~230 lines in ComposeOwner.kt) — the immediate-mode
  OwnedLayer; createLayer has returned upstream's GraphicsLayerOwnerLayer
  since the retained-layer refactor and nothing constructed it.

Also rides along: apidemo VolticThemes "Purple (stock M3)" label shortened
to "Purple" (pending working-tree edit).

Verified: mingwX64 links for demo + apidemo, JVM legs compile, probe smoke
on apidemo + demo Widgets unchanged.
2026-07-21 23:08:57 +02:00
Bitsy 3843d94e29 fix(renderer)+perf: alpha over cached leaves; round-shape mask admission
Alpha correctness (the always-visible hover kebabs):

- drawImageRect blits with BLEND_PREMULTIPLIED, where srcRGB enters the
  blend DIRECTLY (dst = srcRGB + dst*(1-srcA)) — SDL_SetTextureAlphaMod
  alone leaves srcRGB whole, so an "alpha 0" blit degenerated to ADDITIVE
  and alpha(0)-hidden controls rendered fully bright. Fading premultiplied
  content now scales the COLOUR mod together with the alpha mod.
- replayDisplayList re-emitted captured geometry/text/icon runs with their
  capture-time colours — a geo leaf under an enclosing alpha layer replayed
  opaque. The current canvas multiplier now scales replayed vertex alpha
  (replayBatch) and run colours.
- All three render nodes skip drawing entirely at alpha ~0 (upstream's
  invisible-layer skip), AFTER flagging a recording parent so leaf caches
  can't go stale when the child fades back in.

Mask-realization perf (the 30-40fps report — rounded-clip offscreen masks
dominated apidemo's frame):

- admitRoundShapeDraw: drawRoundRect / drawOval / drawCircle prove
  containment against pending rounded clips with ROUND-SHAPE geometry
  (sampled corner arcs, half-pixel tolerance, stroke half-width expansion)
  instead of their AABB — the m3 Surface idiom (clip(shape) +
  background(shape) + border(shape)) could never pass the AABB test, so
  every bordered rounded surface realized a mask every frame. Bails to
  realize under rotation/shear/flip exactly as before.
- drawImageRect goes through admitDraw instead of unconditionally
  realizing — a baked child blit inside a rounded row/card needs no mask.
- Zero-alpha draws return before realization, so hidden hover controls
  no longer force their row's mask.

apidemo steady state (CDN_FORCERENDER): draw 15.5ms -> 7.4ms, masks
24 -> 9, now vsync-locked at 75fps (was ~27fps two commits ago at
35.7ms/67 masks). Verified: hover-reveal kebabs appear only on the
hovered row; demo Shapes / GraphicsLayer (incl. alpha row) / Buttons /
Cards / Widgets / Images all visually unchanged.
2026-07-21 22:39:05 +02:00
Bitsy 73a4f75fa5 fix(renderer): shape-clip bakes composited + private mask pools (missing UI)
55fadc1a's rounded-clip texture cache shipped two bake bugs that left most
small rounded-clip leaves (icon buttons, chips, checkboxes, the apidemo
session header / menu anchors) blitting EMPTY textures:

1. The bake pushed its rounded clip at save-stack depth 0. A realized mask
   is composited by the ENCLOSING save frame's restore(), and realize bumps
   the composite threshold of every frame opened after the push — pushed
   outside any frame, the mask belonged to nobody, nothing composited it,
   and finish() discarded the content. Bakes stayed transparent. Fix:
   bracket the bake (applyClip + block) in an explicit save()/restore() in
   SdlDisplayListRenderNode.recordShapeClipTexture AND SdlRenderNode.record
   (same latent pattern in texture mode).

2. The offscreen bake canvas shared the frame canvas's Sdl3ClipTargets pool.
   The pool destroys ALL targets on any size change, so a mid-frame bake
   requesting bitmap-sized targets released frame-sized targets the main
   canvas was actively compositing into (and the bake's saved restore
   target), corrupting whole subtrees; both canvases also collided on the
   same depth indices. Fix: offscreen canvases now lazily create a PRIVATE
   bitmap-sized pool, released with the render (endOffscreen / finish);
   Sdl3OffscreenRenderer no longer forwards the frame pool (the shadow
   cache stays shared — a pure texture LRU).

Verified: apidemo baseline now pixel-matches the CDN_LAYERCACHE=off
reference (header, gear, chips, checkboxes, tab closers all back), the
method DropdownMenu renders, demo Widgets clean. Steady-state apidemo
draw 35.7ms -> 15.5ms, masks 67 -> 24 vs cache-off.
2026-07-21 22:16:18 +02:00
Bitsy 55fadc1a69 perf(renderer): texture-cache rounded-clip leaves in the geo node
A rounded/generic content clip can't ride the geo geometry cache (its replay
clips only to a rect), so every rounded Surface/Card/tab block-replayed AND
re-realized its offscreen mask every frame. apidemo is wall-to-wall rounded
surfaces -> ~67 mask realizations/frame, ~32ms draw, ~30fps during interaction.

Bake a rounded-clip leaf's clipped content into a layer-local texture once
(record()) and blit it under the transform thereafter -- the SdlRenderNode
pattern, scoped to the one case geo can't cache (strictly non-regressing for
the fast path). A scroll is translation-only and never re-records, so it blits.
Bails to the exact old block-replay on child layers, images, or
scale/rotation/alpha/blend/colorFilter/renderEffect.

Also plumb Sdl3ClipTargets/Sdl3ShadowCache into the offscreen canvas so
clipRoundRect there realizes the rounded mask instead of degrading to a rect
(would otherwise bake square corners into the cached texture).

apidemo: draw 32ms->15.9ms, masks 67->25, ~30fps->~57fps. Verified corners
correct (Cards/Chips/Pickers), Carousel bit-identical to block-replay reference.
2026-07-20 23:58:26 +02:00
Bitsy 3432562567 fix(window): apply frame-clock writes before layout (scroll lag)
State written by withFrameNanos continuations (smooth wheel scrolling) runs
during the per-window pump's sendFrame()/yield(), AFTER the loop's last
sendApplyNotifications(). So the relayout it triggered wasn't registered until
the next frame and the frame drew last frame's positions -- a one-frame trail
that read as 'the clip is a frame late' when scrolling (content briefly past
the viewport edge).

Send apply notifications in renderFrame() right before measureAndLayout().
2026-07-20 23:58:15 +02:00
Bitsy a9a3d42e77 fix(renderer): scale text with a scaled layer
Text was rasterised at its logical size and blit 1:1, so a scaled layer
(menu/dropdown enter animation) grew the geometry while glyphs stayed fixed
size -- text looked mis-sized and 'popped in late' as the box grew past it.

Thread the affine's column-norm scale from Sdl3Canvas into Sdl3TextRenderer
(drawText immediate + span paths, blitRun replay) and stretch the blit by it.
Glyphs still rasterise at logical size (stable hinting, one cache entry);
only the destination rect scales. Strict no-op at scale 1 (snapping preserved),
so crisp static text is unchanged.
2026-07-20 23:58:08 +02:00
Bitsy a71e5dc7e8 fix(renderer): even AA on thin stroked round-rect arcs
The stroked-arc emitters inset the solid band by half the feather on each
edge, so a <=1px stroke (e.g. apidemo's 1dp selected tabs, OutlinedButton)
collapsed to only the triangular feather while the hard, un-feathered
straight edges stayed full width -- corners read washed-out/thinner.

Keep the arc's solid band at the full geometric width [innerR, outerR] and
add the feather strictly outside it, so the corner opacity matches the
straight edges at the tangent. Applies to emitStrokedArc + emitStrokedEllipticArc.
2026-07-20 23:58:00 +02:00
Bitsy 28e4f63447 refactor(build): apidemo dogfoods the bridge plugin; apps own font downloads
- compose-desktop-native-bridge becomes an INCLUDED build
  (pluginManagement.includeBuild) with its own settings + publishing
  config; :apidemo applies the plugin from source
- plugin: substitution gate (composeDesktopNative.substitution=false
  in-repo — the root bridge substitutes to project modules),
  -PcompressResources support in the data.kres zips, and the windres
  icon object + native { entryPoint } now also apply to hand-declared
  binaries.executable {} blocks
- apidemo: python icon tasks + the variant×target data.kres zip loop
  replaced by compose.desktop.native { entryPoint / icon {} }; fonts
  (Noto + Material Symbols subsetting) hook into the plugin's
  package<Variant>ComposeResources<Target> zips; runtime window icon
  switched to the full voltic icon with a dark variant
- downloadNotoFonts removed from :ui — bundling the default font is the
  app's job; demo + apidemo each download into their own build/fonts
- jvm-parity Compose version forcing deduped into the root build
  (kComposeJvmForced, applied to the app modules)
2026-07-20 22:21:00 +02:00
DWA 5eff726f0e misc(apidemo): update icons 2026-07-20 17:52:07 +02:00
DWA da22b1da8a feat(apidemo): selectable Material 3 voltic themes
Add the three voltic palettes (Kotlin Purple, Orange, Purple stock-M3) and run
the app on MaterialTheme driven by the selected one.

- VolticThemes.kt: VolticPalette enum, each a full light/dark M3 ColorScheme +
  extended warning group; appColorsFromScheme() derives the legacy AppColors
  from the active scheme so existing widgets follow without a rewrite.
- App.kt: wrap in MaterialTheme(full scheme) + provide LocalAppColors (derived)
  and LocalVolticExtended; vPalette state persisted as AppState.theme.
- OptionsMenu: palette picker (swatch + check) beside the dark/light toggle.
- Warning colour is now theme-aware (VolticTheme.extended.warning); drop the
  fixed kWarnColor. Shared commonMain, so the JVM parity app themes identically.
2026-07-20 17:21:28 +02:00
DWA 62538d6b59 feat(icon): use the background-less mark for the window icon
The runtime window/taskbar icon reads better as the transparent voltic mark;
the full branded icon (with background) stays in the .exe for Explorer.

- apidemo: window/taskbar + JVM icon -> voltic mark (theme-neutral, single
  variant); .exe keeps the full icon. generateAppIconRgba now purges its output
  dir so a changed icon set leaves no orphan blobs in data.kres.
- bridge plugin: NativeIconSpec gains exeIcon — point light at a transparent
  mark and exeIcon at the full branded icon (falls back to light when unset).
  Same window-vs-exe split apidemo uses.
2026-07-20 17:00:24 +02:00
DWA 83d5448b94 feat(icon): theme-aware window/taskbar icon + Windows .exe icon
Add an app-icon capability and wire the voltic brand into apidemo.

- :window AppWindowIcon(light, dark) + icon param on nativeComposeWindow/Window.
- SDL3Backend.applyThemeIcon(): core-SDL SDL_SetWindowIcon, theme-picked via
  SDL_GetSystemTheme(), re-applied on SDL_EVENT_SYSTEM_THEME_CHANGED. Icons are
  pre-decoded .rgba blobs in data.kres, so no renderer seam / sdl3_image is
  needed and it works on every target.
- Windows .exe icon: multi-size .ico via windres, linked on mingw
  (-PembedWindowsIcon=false to skip).
- scripts/make-app-icon.py (pure stdlib): PNG -> .rgba and PNG -> .ico.
- apidemo: bundle blobs + embed exe icon + JVM parity icon (isSystemInDarkTheme).
- bridge plugin: compose.desktop.native { icon { light/dark } } with a
  self-contained Kotlin PNG/ICO codec (no Python for consumers).
2026-07-20 16:44:57 +02:00
Bitsy e609c0fead docs(todo): remove completed SDL items; add audit findings + needs-review
Move the done SDL fidelity work (dash, tilemode, points, rotated images,
blendmode, joins, colorfilter, elliptical ovals) + FontFamily.Monospace into
Completed; trim section H to only open items. Add section J recording the
2026-07 bug audit: the contained bugs fixed in the same pass, plus the open
items left for review (HiDPI pointer Int-truncation, unfocused-window RESUMED,
IME area timing, synchronous sendApplyNotifications, cosmetic join AA, text/icon
draw-time colorFilter).
2026-07-19 23:23:15 +02:00
Bitsy 4e9bb87975 fix(input): clear hover on pointer-leave; correct Locale.region parsing
- Map SDL_EVENT_WINDOW_MOUSE_LEAVE to AppEvent.PointerExit; the window fires a
  synthetic Exit (pointer bridge inType 3 -> PointerEventType.Exit) at the last
  position and stops the per-frame hover re-dispatch, so a button no longer stays
  highlighted forever after the cursor leaves the window.
- Locale.region: only consider subtags after the language (drop(1)), so
  Locale("en").region is "" not "en" and a 3-letter language ("fil") isn't
  reported as its own region.
2026-07-19 23:23:08 +02:00
Bitsy 495fa27b9a fix(sdl): renderer bugs from audit (crash, arc segment, blend, colorFilter)
- roundRectCore: clamp the corner-radius upper bound >= 0 so an inverted rect
  (negative size) no longer throws IllegalArgumentException from coerceIn.
- emitFilledArc: honor useCenter — false now fills the circular SEGMENT (fan from
  the chord anchor) instead of always a pie sector; rounded-rect corners pass
  useCenter=true so they stay quarter-disc sectors (verified via --arctest).
- sdlBlendFor: BlendMode.Src maps to normal alpha blend, not SDL_BLENDMODE_NONE,
  which wrote the alpha-0 AA fringe verbatim (transparent moat / hard edge).
- cover-fill fast path (tryDrawRectUnderPendingClips) now sets activeColorFilter
  so a filtered fill covering a pending clip isn't rendered unfiltered.
- clamp stroked oval / round-rect inner radius >= 0 (stroke wider than radius).
- hoist the per-vertex ColorMatrix copy out of the sampler loop (colorFilterTransform).
Adds demo --arctest. Verified against the Skia leg.
2026-07-19 23:23:01 +02:00
Bitsy efaf20d943 feat(sdl): stroke non-square ovals as true ellipses
ovalCore/arcCore stroke a non-square oval by offsetting the band along the
ellipse's outward normal (emitStrokedEllipticArc) instead of a circular ring on
the averaged/min radius, which used to bulge on the short axis. Round caps land
on the ellipse endpoint; reduces to the circular path when rx==ry. Adds demo
--ovaltest; matches the Skia leg.
2026-07-18 23:20:36 +02:00
Bitsy 169a51f04d feat(sdl): honor ColorFilter on shapes (tint / colorMatrix / lighting)
Sdl3DrawScope.applyColorFilter runs the paint's ColorFilter per-vertex through
the sampler: tint (SrcIn/Src/Modulate/Plus/SrcOver), colorMatrix (full 4x5 in
0..255 scale, so grayscale/saturation work) and lighting (src*multiply+add).
Sdl3Canvas.withBlend sets activeColorFilter around each geometry emit;
NativeColorFilter round-trips the ColorMatrix for copyColorMatrix(). Adds
demo --filtertest; pixel-identical to the Skia leg (tint->blue, red->(54,54,54)
grayscale, lighting->(192,192,192)). Covers ImageVector icon tinting.
2026-07-18 21:37:26 +02:00
Bitsy 3518521ca2 feat(sdl): stroke joins (miter/bevel/round) + Square cap on the SDL renderer
strokePolyline fills the convex corner wedge at each interior vertex (and a
closed contour's seam) via emitJoin: Miter extends the outer edges to their
apex with a bevel fallback past Stroke.miter, Bevel chamfers, Round discs.
emitCap adds round/square end caps on open polylines; lineCore projects
StrokeCap.Square half a width past each endpoint. Adds demo --jointest;
pixel-identical to the Skia leg for all three joins and Butt vs Square.
2026-07-18 21:23:35 +02:00
Bitsy 774271eb93 feat(sdl): honor BlendMode (Plus/Modulate/Multiply/Src) on the SDL renderer
Map paint.blendMode to the SDL render blend mode around each geometry emit
via Sdl3Canvas.withBlend: Plus->ADD, Modulate->MOD, Multiply->MUL, Src->NONE,
else BLEND (SrcOver). Adds demo --blendtest; verified Plus overlaps brighten
to white and Modulate scales down, matching the Skia leg. Also records a Skia-
leg MULTIPLY anomaly (opaque cyan x yellow renders blue) found during parity.
2026-07-18 21:16:20 +02:00
Bitsy 5df21b1ede feat(sdl): drawPoints/drawRawPoints render (Points/Lines/Polygon)
Both were no-ops, so Canvas point-mode drawing (scatter/charts) rendered nothing
on the SDL leg. Now: Points mode draws a filled strokeWidth square per point,
Lines draws disconnected segments, Polygon draws a connected polyline (reusing the
scope's rect/line primitives, so they capture into the geo node too). drawVertices
(custom meshes) stays unsupported.

Verified demo --pointstest (all three modes render). TODO.md section H.
2026-07-18 20:41:39 +02:00
Bitsy dbcdcc031f feat(sdl): rotated/sheared images render tilted (textured RenderGeometry quad)
drawImageRect mapped only 2 corners then built an axis-aligned dst rect, so images
under a rotated/sheared graphicsLayer stayed upright (SDL_RenderTexture is axis-
aligned). Now, when the matrix has rotation/shear, it maps all 4 corners and submits
a textured SDL_RenderGeometry quad (tint/alpha still via the texture colour-mod);
the cheap SDL_RenderTexture path is kept for the axis-aligned common case.

Verified demo --rotimgtest (a two-colour image rotated 30 renders tilted on the SDL
leg); click/scroll/imagebytes probes PASS. TODO.md section H.
2026-07-18 20:37:52 +02:00
Bitsy e05c5a3016 feat(text): FontFamily.Monospace renders NotoSansMono (generic font families)
FontFamily.Monospace/Serif/Cursive collapsed to the default sans font
(projectFontName returned null for GenericFontFamily). Now:
- NamedFont.projectFontName maps generic families to "generic:<name>"
- com.compose.sdl.text.registerGenericFonts (called from ComposeWindow.installGlobals)
  registers the bundled NotoSansMono under generic:monospace; graceful fallback to
  the default font when not bundled
- the demo Zip task bundles NotoSansMono only when the app references
  FontFamily.Monospace (mirrors the Material Symbols per-style scan)

Verified demo --fonttest on both the Skia and SDL legs (mono renders fixed-width
vs the sans default); paragraph/key/nav3 probes still PASS. Serif/Cursive still
fall back (no bundled font). Also corrected TODO section D: compose-resources
Font(Res.font.x) already works on both legs. TODO.md section D.
2026-07-18 20:20:32 +02:00
Bitsy aa25f0e016 feat(sdl): honour gradient TileMode (Repeated/Mirror/Decal) on the SDL renderer
The linear/radial gradient samplers clamped t to [0,1], so Repeated/Mirror/Decal
all rendered as Clamp. Now they apply the brush's gradientTileMode via tileT:
Repeated wraps, Mirror reflects each period, Decal is transparent outside [0,1];
Clamp is unchanged (default gradients unaffected). Verified demo --tilemodetest
(three rows tile / reflect / cut off correctly); clicktest PASS on the SDL leg.
TODO.md section H.
2026-07-18 18:56:48 +02:00
Bitsy 2104617b82 feat(sdl): dashed strokes (DashPathEffect) on the SDL renderer
dashPathEffect was a no-op, so dashed/dotted strokes rendered solid on the SDL
leg (Windows). Now dashPathEffect carries its interval/phase pattern and
Sdl3DrawScope.dashPolyline splits stroked geometry into the pattern's 'on' runs:
- Sdl3Canvas.drawLine threads paint.pathEffect -> lineCoreDashed
- Sdl3Canvas.styleFor threads paint.pathEffect into the Stroke, so drawPath(Stroke)
  dashes each linearised sub-path in pathCore
Normal strokes (null/non-dash pathEffect) are unchanged. Verified demo --dashtest
(dashed line/diagonal/stroked-rect render as dashes); click/scroll/paragraph probes
PASS on the SDL leg. TODO.md section H.
2026-07-18 18:51:47 +02:00
Bitsy ea22004e6d fix(sdl): createImageBitmap(bytes) decodes via SDL3_image instead of throwing
ByteArray.decodeToImageBitmap() crashed on the SDL leg (Windows) with
UnsupportedOperationException. Route it through the existing decodeEncodedImageBitmap
(the same Sdl3EncodedImageDecoder that Res.painter uses), so apps decoding an image
from bytes work. Verified demo --imagebytestest (2x2 BMP -> 2x2 ImageBitmap) on both
the Skia and SDL legs.
2026-07-18 18:37:15 +02:00
Bitsy acea50d7e9 docs(todo): remove completed items (locale/IME/cursor/windowinfo), re-letter remaining 2026-07-18 18:27:48 +02:00
Bitsy 500c49e41e feat(input): real IME text-input session (composition + commit-replace)
textInputSession was a stub (awaitCancellation), so only committed text worked
via a synthetic-KeyEvent fallback; there was no preedit/composition. Now:
- ComposeOwner.textInputSession establishes a real PlatformTextInputSession that
  registers the focused field's request with com.compose.sdl.text.input.ImeBridge
- SDL3EventMapper maps SDL_EVENT_TEXT_EDITING -> AppEvent.TextEditing (preedit)
- ComposeWindow routes TEXT_INPUT via CommitTextCommand (replaces any composition)
  and TEXT_EDITING via SetComposingTextCommand; falls back to synthetic keys when
  no field is focused (committed typing unchanged)
- SDL_SetTextInputArea positions the OS IME candidate window at the focused field

Verified demo --imetest: composing 'ni' -> text='ni' composition=(0,2); commit 'に'
-> text='に' composition=null (replaced). Full probe suite (keytest/click/scroll/
back/nav3/multiwin/paragraph/cursor/ime) all PASS. Closes TODO.md section B.
2026-07-18 18:22:31 +02:00
Bitsy bad23073f4 feat(window): WindowInfo.isWindowFocused + containerSize report real values
Both were hardcoded (isWindowFocused=true, containerSize/DpSize=Zero). Now
snapshot-backed on ComposeOwner and fed by the window: container size from the
live root constraints (physical px + Dp) in setRootConstraints, focus pushed
from SDL activation via ComposeWindow.onActivationEvent -> ComposeRootHost.setWindowFocused.
LocalWindowInfo readers (popup sizing, focus-reactive UI) now work + recompose.

Verified demo --windowinfotest (0x0 -> 800x600 / 400x300dp @ DPR 2, focused=true);
click/scroll/nav3/multiwin/cursor probes still PASS. TODO.md section I.
2026-07-18 17:55:00 +02:00
Bitsy 1fcc08289a feat(input): Modifier.pointerHoverIcon drives the OS cursor via SDL
The OS cursor never changed: pointerIconService was a no-op and LocalPointerIconService
(read by the vendored HoverIconModifierNode) was never provided, so setIcon was
never called. Now:
- SdlCursors maps the 4 canonical PointerIcons to SDL_SYSTEM_CURSOR_* by identity,
  caches system cursors, applies via SDL_SetCursor (deduped)
- ComposeOwner.pointerIconService stores the desired icon; processPointerInput
  applies it after hover processing
- ComposeRootHost.ProvidePointerIconService seeds the :ui-internal
  LocalPointerIconService (both the local and the service type are internal, so
  :window can't provide it directly)

Verified: demo --cursortest (background=DEFAULT, Text=TEXT, Hand=POINTER);
clicktest/scrolltest/backtest/nav3test/multiwintest all still PASS. Closes TODO.md section D.
2026-07-18 17:29:26 +02:00
Bitsy 5d03711f80 feat(i18n): Locale.current/LocaleList.current read OS locale via SDL
Both were hardcoded to en-US, so Material 3 string translation was stuck in
English despite 40+ vendored l10n tables. Add a shared reader
(com.compose.sdl.text.systemPreferredLocaleTags, backed by SDL_GetPreferredLocales,
cached after first non-empty read) and wire both actuals: Locale.current = first
preferred, LocaleList.current = full ordered list; en-US fallback pre-init.

Verified with a new demo --localetest probe: -AppleLanguages (fr-FR) renders the
DatePicker headline 'Sélectionner une date'; en-US/ja-JP/(de-DE,fr-FR,en) all
reflected correctly. The date/number-format tail (PlatformDateFormat) still needs
CLDR and is unchanged. Closes TODO.md section E flagship items.
2026-07-18 16:50:24 +02:00
Bitsy 30cbbc4be1 fix(demo): mingwX64 compile — currentResidentMb via expect/actual
platform.posix.popen/pclose don't exist on mingw (Windows uses _popen/_pclose),
so the soak-test RSS helper failed :demo:compileKotlinMingwX64 (broke the v0.2.0
publish). Make currentResidentMb() an expect: posix (appleMain/linuxMain) reads
`ps -o rss=`, mingw returns -1 (the soak gate runs on macOS/Linux). Verified
:demo:compileKotlinMacosArm64 green.
2026-07-18 10:21:50 +02:00
Bitsy 4a196c9c23 feat(bridge): expose the Compose versions the port tracks; catalog as single source
The bridge now advertises the official Compose versions its vendored sources
build against, so consumers align their coords without reading compose.properties
by hand (material3 is versioned separately upstream, an easy mismatch).

- version catalog gains composeMaterial3 + composeRuntime alongside compose; the
  three become the single source consumed by the plugin and the JVM parity leg
- plugin stamps version/compose/composeMaterial3/composeRuntime into
  bridge-version.properties and exposes them via a composeDesktopNative { }
  extension (+ project.extra mirror for the settings-apply case)
- demo/apidemo/material-symbols + the ui modules now source runtime + the JVM
  forcing versions from the catalog instead of hardcoding
- fix doc bug: override property is composeDesktopNative.version (was composeSdl.version)
- READMEs updated to use the exposed versions
2026-07-18 01:41:01 +02:00
Bitsy a62a17a7bf docs: add TODO.md — audited no-ops/stubs/hardcodes + what's missing for stable 1.12
Audited project actuals (not vendored) across text input/IME, accessibility,
locale/i18n, cursor, fonts, drag-and-drop, platform info, and the SDL renderer.
Each entry carries file:line + severity. Linked from CLAUDE.md's doc map.
2026-07-18 01:24:49 +02:00
Bitsy b0530cdccb docs: consolidate renderer docs into RENDERER.md; add TOOLING.md + versioning/release runbook; clean README + CLAUDE
- README rewritten in the clean CM-inspired style (concept, quickstart, apps); links the bridge example repo
- new TOOLING.md: build-sdl, vendor sync + drift/clean, parity, probe, profiler, coverage, verify-mac, + version map and ref-bump/release runbooks
- new RENDERER.md consolidates RENDERER_CONVERGE.md + RENDERER_TASKS.md (architecture, decisions, learnings, open work); old planning docs removed
- CLAUDE.md: add a documentation map, trim tooling/build/vendor sections down to pointers
- repoint all code-comment/script references to RENDERER.md
2026-07-18 01:11:26 +02:00
Bitsy e483a4b683 Merge renderer-refactor: upstream-tracking convergence (G1) + leak fix + guardrails
Brings the renderer refactor to main. Highlights (all MAC-VERIFY green, both legs):

- B2/B6 convergence: the Skia leg (macOS/Linux) now draws through upstream Compose's
  OWN engine verbatim — SkiaBackedCanvas + SkiaGraphicsLayer + SkiaGraphicsContext
  (skiko RenderNode). Retired the hand-rolled SkiaCanvas/GraphicsLayer. Bonus perf:
  draw ~1.75ms -> ~0.2ms (real display-list caching). Text stays the port's SdlParagraph
  (skiko-metric-aligned, P3.1 ~2% parity); B6.3 (upstream text) deliberately skipped.

- Guardrails/harness (Phase 0): target-aware parity gate with golden baselines +
  render-to-quiescence; MAC-VERIFY runbook (verify-mac.sh); manual-vendor drift tripwire +
  vendor-clean check. Fixed a real HiDPI parity-harness bug (crop-vs-resize).

- Memory leak FIXED: the port never swept OwnerSnapshotObserver.clearInvalidObservations();
  disposed scopes' observations leaked (worst on ripple draws). Now swept per-frame; soak
  probe added and wired as a verify-mac gate. All-63-screens soak 260->458MB (was leaking)
  -> flat.

- Sync-tax win, proven: O.2 bump beta02 -> beta03+dev4483 needed ZERO manual-vendor
  reconciliation (the upstream graphics/layer files re-sync verbatim now).

- RenderBackend.drawRoot decoupled from ComposeRootHost (removes a ui<->ui-graphics cycle;
  the module split itself is shelved — the sdl3 cinterop is a shared substrate, infeasible
  as CONVERGE §6 specified).

CAVEAT: Windows/mingwX64 is UNVERIFIED — this whole line of work was Mac-only. WIN-SMOKE
(mingw build + scripts/probe + publish dry-run) is still required before shipping.
Stabilization: re-pin to clean v1.12.0 + close the JVM Maven skew when 1.12.0 stable ships.
2026-07-18 00:44:30 +02:00
Bitsy 23eaa5bc8b docs: O.2 second bump (beta03+dev4483) + stabilization plan (clean v1.12.0) 2026-07-18 00:40:22 +02:00
Bitsy 18286f689b chore(vendor): O.2 bump — both pins v1.12.0-beta02 -> v1.12.0-beta03+dev4483
Second real ref-bump through the O.2 runbook. No CLEAN beta03/rc/stable tag exists yet
on either repo, and beta03 isn't on Maven Central, so this is the latest DURABLE
(fetchable-by-name) ref — a beta03 dev snapshot. VENDORED (native) side only; the JVM
parity leg's Maven forcing stays at beta02 / m3 alpha03 (latest published) — a documented
native(beta03+dev) vs JVM(beta02) skew until beta03 publishes to Maven.

Flow: bump compose.properties (both pins) -> sync (1559 files) -> DRIFT-CHECK: all 9
manual vendors' upstream bases UNCHANGED beta02..beta03+dev4483 -> re-stamped their
VENDOR-BASE refs, ZERO hand-reconciliation (the B2/B6 sync-tax win, validated for real:
the newly-upstream Canvas/GraphicsLayer/GraphicsContext re-synced verbatim) -> builds
green (ui both legs + metadata + material3) -> full MAC-VERIFY ALL GREEN both legs
(demo+apidemo, 10 probes incl. soak gate, parity PASS both legs, perf, vendor-clean).

Windows WIN-SMOKE still pending (no Windows host this session).

STABILIZATION PLAN (user): cut a stable compose-desktop-native when Compose 1.12.0
STABLE releases — at that point re-pin both refs to the clean `v1.12.0` tag and bump the
vComposeJvmVersion forcing (demo/apidemo/material-symbols) to match, closing the skew.
2026-07-18 00:39:41 +02:00
Bitsy 319c550af4 docs: module split re-shelved — sdl3 cinterop is a shared substrate (infeasible as §6 specified) 2026-07-18 00:03:33 +02:00
Bitsy c7608b6404 docs: module split un-shelved — cycle finding + staged plan, step 1 (decouple) done 2026-07-17 23:51:07 +02:00
Bitsy c71febbb23 refactor(ui): decouple RenderBackend.drawRoot from ComposeRootHost (module-split step 1)
Prerequisite for extracting :ui-graphics. The render backends (SkiaRenderBackend /
Sdl3RenderBackend, which would move to :ui-graphics) referenced
com.compose.sdl.node.ComposeRootHost (which stays in :ui), while :ui node code
references androidx.compose.ui.graphics.Canvas (:ui-graphics) — a ui<->ui-graphics
dependency cycle §6 didn't account for (it assumed pure relocation).

RenderBackend.drawRoot now takes a (Canvas) -> Unit instead of the host: the backend
builds the frame Canvas and hands it to the callback; ComposeWindow passes
`{ canvas -> host.drawRoot(canvas) }`. Backends + RenderBackend no longer reference the
node/host layer, so they can live in :ui-graphics with no back-edge to :ui.

Behaviour-preserving (inDraw == host::drawRoot). Verified: both legs + metadata compile,
demo builds, nav3/click/scroll probes PASS, skia parity PASS.
2026-07-17 23:50:21 +02:00
Bitsy 5ab3bd6b97 docs+ci: P2.2 done — leak fixed, soak wired into verify-mac gate 2026-07-17 23:34:14 +02:00
Bitsy c59caf7272 fix(ui): sweep invalid snapshot observations each frame — fixes the P2.2 leak
ROOT CAUSE (found via the --soaktest reproducer + macOS heap/vmmap/leaks + a
component bisection): the port never called
`OwnerSnapshotObserver.clearInvalidObservations()`. Upstream RootNodeOwner runs it
after every measure pass; without it, snapshot read-observations whose scope became
invalid on dispose (detached nodes, destroyed layers, disposed DRAW scopes) linger in
the observer forever. Each lingering observation pins its observed object graph via a
K/N ExternalRCRef, so every composition dispose leaked — most visibly on ripple
indication draws (each Button read animatedAlpha/colors in draw, whose layer draw-scope
observation was never swept), pinning the whole button subtree.

FIX: ComposeRootHost.measureAndLayout() now calls
`fOwner.snapshotObserver.clearInvalidObservations()` after the measure/layout pass,
mirroring upstream. (Complements the earlier onDetach `snapshotObserver.clear(node)`.)

RESULT (soak, current RSS which drops after GC): the leak is GONE.
  Counter   123->176MB  =>  113->113MB
  Images    132->199MB  =>  118->119MB
  LazyGrid  133->204MB  =>  113->115MB
  ALL 63 screens x6  260->458MB (FAIL) => 238->240MB (PASS)
Diagnosis narrowed it: not layers/RenderNodes (live counts flat), not nodes
(attach/detach balanced), not per-frame (static flat), not caches (bounded), not true
malloc leaks (`leaks` clean) — the growth was entirely the K/N GC heap (vmmap
Memory Tag 246), pinned by ExternalRCRefs from unswept observations.

MAC-VERIFY ALL GREEN both legs (10/10 probes, parity PASS, perf unchanged —
clearInvalidObservations is a cheap per-frame validity sweep). Soak diagnostics
(--soaktest: current RSS, CDN_SOAK_SCREEN, CDN_SOAK_STATIC, CDN_SOAK_CYCLES) retained.
2026-07-17 23:32:59 +02:00
Bitsy c36f11ab4f docs: P2.2 focused session — onDetach observation-clear fix + residual leak characterized 2026-07-17 22:24:27 +02:00
Bitsy 7189433e5b fix(ui): clear detached node's snapshot observations (partial P2.2)
ComposeOwner.onDetach now calls snapshotObserver.clear(node), exactly as upstream
RootNodeOwner.onDetach does. Without it, every disposed LayoutNode's measure/layout/
draw read-observation scopes accumulated in the OwnerSnapshotObserver forever — an
unbounded (if small) growth over a long session. Matches upstream verbatim; MAC-VERIFY
ALL GREEN both legs (10/10 probes, parity PASS, perf unchanged).

Also lands the P2.2 leak-hunt diagnostics on --soaktest:
  - current RSS (via `ps`, drops after GC) instead of getrusage peak (monotonic) —
    the correct leak metric; verdict now uses it.
  - CDN_SOAK_SCREEN=<name> to bisect one screen; CDN_SOAK_STATIC=1 to isolate
    per-frame vs per-mount.

Leak-hunt status (documented in RENDERER_TASKS.md): the residual leak is real, linear,
~141KB/mount (Counter), referenced (macOS `leaks` shows no malloc leaks in our code),
survives GC, and is NOT nodes/layers/RenderNodes (all balanced by exact count),
per-frame (static flat), or the skia GrContext budget (linear, no plateau). It needs a
skia-aware heap profiler to pin exactly — a follow-up. This observation-clear fix is one
confirmed contributor fixed.
2026-07-17 22:23:56 +02:00
Bitsy dadb9e45d4 docs: P2.2 leak hunt characterized + D2-D6 evaluated (all deferred, rationale) 2026-07-17 21:44:20 +02:00
Bitsy c1fabc5b5c test(demo): P2.2 leak hunt — soak single-screen bisect mode + characterization
Added CDN_SOAK_SCREEN=<name> to the soak (repeat ONE screen 40x/cycle) + key(vShown)
to force a full dispose+recompose each advance, to bisect the leak the soak found.

Leak hunt findings (a real, pre-existing, slow leak — reproducer in --soaktest):
  - Layers are CLEAN: a live-OwnerLayer counter stayed flat (5/7/21/24) while RSS
    climbed — destroy()->releaseGraphicsLayer() runs correctly. (Instrumentation used
    to prove this, then reverted to keep the vendored GraphicsLayerOwnerLayer clean.)
  - Not GC/cleaner lag: Counter over 10 cycles climbs ~linearly 114->176MB (no plateau).
  - Not animation churn: persists with infinite animations disabled.
  - Bisected: a BASELINE composition-machinery leak (~0.15MB/mount, hits even trivial
    Counter) + CONTENT amplification on image/vector/list screens (Images/LazyGrid
    ~0.55MB/mount). Both legs; SDL ~5x worse (offscreen textures).
  - Ruled out: the image decode caches (SkiaImageCache, components-resources
    ResourceCaches) are BOUNDED (keyed by path/id; revisits hit, no growth).
  - Remaining suspects (need heap tooling, out of scope here): composition/snapshot
    observation retention (baseline) + DrawCache/vector offscreen bitmap lifetime
    (content). A precise fix is a dedicated focused session.

Soak stays a standalone diagnostic (not a verify-mac gate) until the leak is fixed.
2026-07-17 21:41:23 +02:00
Bitsy 5def89f182 docs: Phase 2 — P2.1 audit done, P2.3 done via B6.2, P2.2 soak added (found pre-existing leak) 2026-07-17 21:18:00 +02:00
Bitsy 4bd9ca234e test(demo): P2.2 soak probe — cycle all screens x N, track peak RSS
--soaktest cycles through every registered screen kCycles times (CDN_SOAK_CYCLES
override, default 3) in ONE process, disposing each via a changing key() so
composition + layer (skiko RenderNode / SDL node + texture) allocate/release churn
is exercised repeatedly. Disables infinite animations + uses virtual frame time so
RSS reflects lifetime, not animation churn. After each full cycle: GC.collect() then
record peak RSS (getrusage ru_maxrss, MB; macOS-bytes/Linux-KB disambiguated by
magnitude). Verdict: PASS iff last-cycle peak stays within a ceiling of cycle-1 peak.

FINDING (both legs): the probe reveals a real pre-existing memory leak. Peak RSS
climbs ~linearly and does NOT plateau over 6 cycles: skia 264->458MB (~40MB/cycle),
SDL 523->916MB over 3 (~195MB/cycle). SDL (untouched by B6) leaks ~5x worse, so it
is NOT B6-introduced. Persists with animations disabled, so it is composition/layer/
cache teardown, not animation-state churn; SDL's larger delta points at offscreen
textures. The layer release path (destroy -> releaseGraphicsLayer -> close) is wired,
so it's deeper — needs a dedicated heap-profiled hunt. Kept as a standalone diagnostic
(NOT wired into verify-mac's gate) until the leak is fixed.
2026-07-17 21:16:57 +02:00
Bitsy 924229fafe docs: B6.3 skip decision + P1.8 sync-tax measured (~8.5x cut) — B2 convergence complete 2026-07-17 20:57:51 +02:00
Bitsy 2314fca8ee docs: B6.2 done (= P1.4-P1.7) — upstream GraphicsLayer on skia; perf 1.75ms->0.2ms 2026-07-17 20:14:11 +02:00
Bitsy 2210d1fa01 feat(ui): B6.2 — Skia leg uses upstream GraphicsLayer + GraphicsContext
The core B2 convergence (RENDERER_CONVERGE.md §4; = P1.4/P1.5/P1.6/P1.7). Now the
skiko canvas is upstream's SkiaBackedCanvas (B6.1), un-refuse + vendor upstream's
own layer engine VERBATIM: SkiaGraphicsLayer.skiko.kt (actual class GraphicsLayer
backed by org.jetbrains.skiko.node.RenderNode), SkiaGraphicsContext.skiko.kt
(owns a RenderNodeContext), Blur.skiko.kt. ChildLayerDependenciesTracker was already
vendored (commonMain), so SkiaGraphicsLayer compiles verbatim — no manual edit.

Per-renderer wiring (disjoint targets):
  - GraphicsContext seam (P1.3): skiko createGraphicsContext -> SkiaGraphicsContext;
    SDL keeps ProjectGraphicsContext (moved nativeMain -> sdlRendererMain along with
    the createProjectGraphicsLayer factory, now a plain internal fun; the commonMain
    expect is dropped — only SDL uses it).
  - Blur.native.kt (port no-op) moved nativeMain -> sdlRendererMain; skiko gets
    upstream Blur.skiko.
  - Deleted the transient skiko port cluster: GraphicsLayer.native.kt,
    NativeRenderNode.kt, DeferredRenderNode.kt, NativeRenderNode.skia.kt,
    GraphicsLayerFactory.native.kt.

GraphicsLayerOwnerLayer stays SHARED and compiles against BOTH GraphicsLayer actuals
(the API-parity invariant, P1.7 — enforced by the metadata compile). Hit-testing
(shared prepareLayerTransformationMatrix) agrees with the new skiko-RenderNode draw
transform — clicktest/scrolltest/backtest PASS on transformed layers.

MAC-VERIFY ALL GREEN both legs: builds (demo+apidemo), 10/10 probes, parity PASS
(layer screens GraphicsLayer/Shadows/DragAndDrop/Animation byte-stable — the swap is
visually transparent), metadata + drift + vendor-clean pass. PERF WIN: skia draw
1.75ms->0.2ms on LazyColumn/Tabs (skiko RenderNode record-once/replay display-list
caching vs the port's per-frame replay).
2026-07-17 20:12:36 +02:00
Bitsy 746d3cca9c docs: B6.1 done — Skia leg on upstream SkiaBackedCanvas, MAC-VERIFY green 2026-07-17 00:24:21 +02:00
Bitsy 7f5773fc3c feat(ui): B6.1 — Skia leg draws through upstream SkiaBackedCanvas
Full upstream Skia GRAPHICS pipeline on the skiko leg (RENDERER_CONVERGE.md §4,
the enabler for B2). The composition's frame + offscreen drawing now goes through
upstream's SkiaBackedCanvas / SkiaBackedPaint / SkiaShader / SkiaImageAsset /
ImageBitmap.skiko instead of the project SkiaCanvas.

Split the port graphics actuals per-renderer (createRenderBackend disjoint-target
trick): CanvasPaintActuals.native.kt (Paint/Shader/all gradient shaders/
ActualImageBitmap/createImageBitmap/ActualCanvas/NativeCanvas/NativePaint) and
BlendMode.native.kt move nativeMain -> sdlRendererMain (SDL keeps the port flavor);
the skiko leg gets the un-refused upstream files (manifest).

Text/painter/shadow keep the port engine: SkiaBackedCanvas is MANUAL-VENDORED to
also implement NativeTextCanvas/NativePainterCanvas/NativeShadowCanvas/
NativeFinishableCanvas, forwarding to the port SkiaTextRenderer (P3.1 metrics
preserved) + SkiaImageCache via a global skiaLeafDrawer over internalSkiaCanvas.
SkiaImageAsset manual-vendored too (flattened toBitmap/putBytesInto expect/actuals
that would collide in our single native source set). SkiaCanvas.kt deleted; the
offscreen (VectorPainter/DrawCache) now uses upstream's ActualCanvas + the encoded
decoder produces an upstream ImageBitmap.

skiaLeafDrawer is per-WINDOW state re-pointed each frame in drawRoot (+ cleared on
destroy) — mirrors ComposeWindow.installGlobals(); a ctor-set version dangled at a
closed window's destroyed renderer and crashed the surviving window (caught by
multiwintest).

MAC-VERIFY ALL GREEN both legs: builds (demo+apidemo), 10/10 probes, parity PASS
(56/57 byte-identical to baseline; Images within noise), perf within/below baseline,
metadata compile clean, drift + vendor-clean checks pass. B2 (P1.4/1.6) unblocked.
2026-07-17 00:23:31 +02:00
Bitsy 90f56143c4 docs: B6 decision (Option B) + staged plan (B6.1 graphics / B6.2 layer / B6.3 text) 2026-07-16 23:39:40 +02:00
Bitsy ab4b82e492 docs: P1.3 done; P1.4 blocker — SkiaBackedCanvas closure gap + 3 options 2026-07-16 23:27:56 +02:00
Bitsy 4a3d8e5ca2 refactor(ui): P1.3 — fork GraphicsContext behind a per-renderer factory seam
B2 convergence step 2 (RENDERER_CONVERGE.md §4). ComposeOwner.graphicsContext
was an ad-hoc anonymous GraphicsContext calling createProjectGraphicsLayer().
Extracted into createGraphicsContext() — an internal expect in nativeMain with an
actual per renderer source set (disjoint targets, the createRenderBackend trick):

  - ProjectGraphicsContext (shared nativeMain): the project record/replay context,
    layer create/release via the createProjectGraphicsLayer factories.
  - sdl actual -> ProjectGraphicsContext (permanent SDL home).
  - skiko actual -> ProjectGraphicsContext (TRANSIENT; P1.6 swaps in the vendored
    upstream SkiaGraphicsContext + skiko RenderNodeContext).

Behaviour UNCHANGED — both legs return the same ProjectGraphicsContext this step.
MAC-VERIFY ALL GREEN (both legs build, metadata compile clean, 10/10 probes PASS,
parity PASS both legs, perf within baseline).
2026-07-16 23:26:14 +02:00
Bitsy 7f7f57d06d docs: P1.2 done — SDL node cluster relocated, MAC-VERIFY green 2026-07-16 23:12:45 +02:00
Bitsy c45aed105c refactor(ui): P1.2 — relocate SDL node cluster nativeMain -> sdlRendererMain
B2 convergence step 1 (RENDERER_CONVERGE.md §4). The port's actual class
GraphicsLayer lived in shared nativeMain; it cannot coexist there and in
skikoRendererMain, so the whole node cluster moves down into the renderer
source sets (which attach to disjoint targets — the createRenderBackend trick):

  GraphicsLayer.native.kt, NativeRenderNode.kt (interface + context),
  DeferredRenderNode.kt, GraphicsLayerFactory.native.kt (createProjectGraphicsLayer
  actual) -> sdlRendererMain.

The internal expect fun createNativeRenderNode in nativeMain is dropped (its
return type NativeRenderNode is no longer shared); the two per-renderer factories
become plain internal funs. createProjectGraphicsLayer stays a commonMain expect
with an actual now in each renderer set.

Behaviour UNCHANGED: both legs still run the port GraphicsLayer this step — the
Skia leg keeps a TRANSIENT copy of the cluster in skikoRendererMain (deleted in
P1.6 when it switches to upstream SkiaGraphicsLayer + skiko RenderNode).
GraphicsLayerOwnerLayer / ComposeOwner stay SHARED (they only touch GraphicsLayer's
public expect API + createProjectGraphicsLayer).

MAC-VERIFY green: Skia + SDL legs build, metadata compile clean (Windows-publish
trap), 10/10 probes PASS, parity PASS both legs, perf within baseline.
2026-07-16 23:12:15 +02:00
Bitsy 7e829ef87b docs: P1.1 done — skiko:0.150.1 exposes node.RenderNode API (no bump needed) 2026-07-16 22:52:02 +02:00
Bitsy cc9940c705 docs: P0.2 done + first-Mac-session log (P0.3/P0.4 Mac keys, HiDPI harness fix) 2026-07-16 22:49:12 +02:00
Bitsy bb0b5a9a27 test(parity): seed macosArm64/sdl3 golden baselines (57 screens)
Both Mac legs now gate. SDL leg on macOS shows the same ~2% golden-master
median as Skia after the P3.1 text-metric fixes + the HiDPI align fix;
Pickers 19.5 the known TimeInput auto-scroll outlier.
2026-07-16 22:48:17 +02:00
Bitsy 5686e8785f fix(parity): normalize native HiDPI render to JVM reference size before diff
On a Retina Mac the native leg renders at physical density (2000x1400,
DPR 2.0) while the JVM ImageComposeScene reference is fixed at density 1.0
(1000x700). Both represent the same 1000x700-dp viewport, but diff_pair /
side_by_side cropped the top-left min(w,h) instead of resizing, so the
native leg was compared as a 2x-magnified top-left quarter. Invisible on
Windows (both legs 1000x700); on macOS it inflated every screen to 24-78%.

align_native() resizes native down to the JVM size (LANCZOS, no-op at
DPR 1.0). Skia-leg parity collapses to the expected golden-master: Buttons
35->1.96%, Counter 61->0.65%, LazyGrid 78->1.77%; ~2% median.

Seeds macosArm64/skia baselines (57 screens).
2026-07-16 22:38:44 +02:00
Bitsy 3da62b654e docs: P3.1 tracker — post-metrics ranking + h=1.0 boundary log entry 2026-07-16 22:08:05 +02:00
Bitsy b6fb974264 fix(text): lineHeight applies only when STRICTLY greater than the font size
Probe-verified boundary: upstream ignores lineHeight at exactly 1em
(24sp/24lh -> the 33px cell; 24sp/25lh -> 25) — the >= gate applied the
band at h=1.0, off-by-cell on every Text(fontSize = lineHeight) row.
Text screen parity 9.19 -> 3.08; all other screens byte-identical.
Boundary cases added to both metrics probes; baselines re-seeded.
2026-07-16 22:07:32 +02:00
Bitsy 07bfd84810 feat(text): P3.1 — match upstream text metrics exactly; parity median 17% -> 2%
The port's whole 'font-drift baseline' turns out to be line-metric drift,
not typeface difference. Driven by a new matched metrics-probe pair
(demo --metricsprobe native / --metrics JVM), three changes make
SdlParagraph's numbers agree with upstream Paragraph on every probed
config:

1. Font cell = round(hhea_ratio x size), ratios read once per family from
   the face tables via a metrics-only FreeType library (SDL3_ttf only
   exposes grid-fitted CEILED ints — +1px at the M3 body sizes 12/14,
   ~1px per stacked label of accumulating drift). Icon families keep the
   TTF path so icon boxes don't shift.
2. lineHeight application is MODE-dependent: raw styles (lineHeightStyle
   unset) use compat trim — first line keeps the tight font cell,
   lineHeight is the advance between lines; M3 styles (Trim.None, which
   every M3 Typography style sets) use UNIFORM bands — every line exactly
   lineHeight, even compressing below the font cell (probe: 22sp/28lh -> 28).
   Threaded through both renderers' draw paths as inTrimFirstLine.
3. lineHeight smaller than the font size is ignored (probe: 48sp/24lh ->
   65px cell) — upstream skia only applies height multipliers >= 1em.

Parity collapse: Tabs 33.3->1.8, GridsExtra 28.5->1.3, Shadows 24.6->1.5,
Brushes 22.3->2.2, Animation 24.5->3.0; 'gradient ramp' and 'shadow
falloff' were also mostly drift. Remaining outliers: Pickers 19.5
(auto-scroll positioning), Images 16.6, Text 9.2, AnnotatedString 8.1.

Verified on Windows: full 57-screen parity PASS, 9/9 interaction probes
PASS, baselines re-seeded at the collapsed levels. skiko-side mirrors
(SkiaCanvas/SkiaTextRenderer) compile-pend the first Mac run.
2026-07-16 22:02:15 +02:00
Bitsy 2db54e0e19 feat(text): P3.1 fix #1 — honor TextStyle.lineHeight (compat-trim), the top parity gap
SdlParagraph ignored TextStyle.lineHeight entirely (every M3 Typography
style sets one, e.g. bodyMedium 14sp/20sp), so multi-line paragraph
heights diverged from upstream by ~1px per wrapped line — the
accumulating vertical drift that dominates every text-bearing screen's
parity diff.

Implemented with upstream's compat-trim semantics (LineHeightStyle.Trim
of the first line): the first line keeps the tight font cell; lineHeight
is the advance for every following line. Single-line labels are
unchanged — a uniform-band first attempt regressed Counter 2.53->9.18
in the parity gate and was rejected.

The band is threaded as inLineHeightPx through NativeTextCanvas into
both renderers' draw paths (Sdl3Canvas + Sdl3TextRenderer; SkiaCanvas +
SkiaTextRenderer textually — skiko leg compile pends the first Mac run)
so glyph rows stack exactly where layout put the line boxes.

Verified on Windows: full 57-screen parity PASS — LazyExtra 30.00->27.74,
AnnotatedString 24.88->24.59, all 55 other screens byte-identical;
paragraph/key/click/scroll/back probes PASS; baselines re-seeded.
Remaining text-drift term (single-line label cells: TTF integer font
height vs Skia fractional ascent+descent) recorded as the next P3.1
candidate in RENDERER_TASKS.md.
2026-07-16 21:35:58 +02:00
Bitsy a2252a2ede chore(vendor): VENDOR-BASE provenance mandatory + retroactive (all manual vendors)
Per review guidance: every manually-vendored (copy-edited) file must carry
// VENDOR-BASE: <upstream-path> @ <pinned-tag> so it can be diffed against
its base and against later upstream versions. CLAUDE.md vendoring rule 3
now codifies it.

Retro-annotated the 4 manual vendors that predated the convention — both
Synchronization.kt copies (K2 @PublishedApi edit), foundation's
SelectionLayout.kt (OOB clamp fix), components-resources' DomXmlParser.kt
(fresh reimpl; base marks the upstream API it tracks) — each stamped
v1.12.0-beta02 after verifying its upstream base is unchanged
beta01..beta02 in the repo's clone.

check-vendor-drift.py extended: VENDOR-BASE(<VARNAME>) form resolves
non-core pins (COMPOSE_REF -> ../cmp-ref-compose-multiplatform clone),
and a ls-tree existence guard turns a renamed/moved base into 'diff
unavailable' instead of a silent 'unchanged'. 7 files tracked, all OK;
simulated stale umbrella ref fails exit 1 with the correct clone verdict.
2026-07-16 21:22:02 +02:00
Bitsy fcc581d48b chore(vendor): bump both upstream pins to v1.12.0-beta02 (O.2 first run)
compose-multiplatform-core + compose-multiplatform umbrella both pinned to
the v1.12.0-beta02 release tags; Maven side moved in lockstep (catalog
compose=1.12.0-beta02; JVM parity forcing 1.12.0-beta02 core groups /
1.12.0-alpha03 material3 — released pairs confirmed on Maven Central, no
+dev suffix needed anymore).

sync.py fix the bump exposed: fetching a pin that is a TAG created after
the clone only reached FETCH_HEAD, so the checkout failed. Now fetches
refs/tags/x:refs/tags/x (keeping future syncs offline) with a plain-fetch
+ FETCH_HEAD fallback for branches/SHAs.

DRIFT-CHECK: all 3 manual vendors flagged by the P0.6 tripwire; the clone
diff proved each upstream base UNCHANGED base..pin, so their VENDOR-BASE
refs were re-stamped (no reconciliation needed).

Verified on Windows: demo+apidemo build on both legs with zero code
fallout; 8/8 interaction probes PASS; full parity PASS against the
beta01-era baselines with 56/57 screens at their exact percentage (only
GraphicsLayer +0.01pt); baselines re-seeded. Mac legs pend P0.2's first
run. :ui manifest annotation refresh picked up 2 new upstream desktopMain
files as commented gaps (AWT-only; not vendored).
2026-07-16 21:04:44 +02:00
Bitsy ec3bc51346 chore(vendor): O.1 — pin COMPOSE_CORE_REF to the durable tag v1.12.0-beta01+dev4324
Tag verified to dereference to the previously pinned hash 1be9d64 and to
exist on upstream's remote, so any fresh clone can fetch the pin by name
(a bare unreferenced SHA could stop being fetchable if upstream GCs).
VENDOR-BASE headers re-spelled to the tag so check-vendor-drift's
ref-compare stays green. Re-sync at the tag is byte-identical
(check-vendor-clean PASS, 1553 files).
2026-07-16 20:51:48 +02:00
Bitsy 08a14d79cb feat(vendor): P0.7 — vendor-clean check (src/vendor must match the pinned refs)
check-vendor-clean.py hashes every module's src/vendor tree, re-runs
sync.py (which regenerates byte-for-byte from the pinned clones, deleting
stale files), re-hashes and fails exit 1 on any changed/stale/missing
file or manifest-annotation churn — the tree ends up restored to the pin
either way. Wired into verify-mac.sh as step 0 alongside the P0.6
manual-vendor provenance tripwire.

Verified on Windows: clean PASS exit 0 (1553 files / 15 modules / both
upstream repos at their pins); simulated hand-edit + orphan file FAIL
exit 1 listing both, immediate re-run PASS.
2026-07-16 20:48:12 +02:00
Bitsy 812063a320 feat(verify): P0.2 (WIP) — one-command MAC-VERIFY runbook (scripts/verify-mac.sh)
Chains the MAC-VERIFY primitive for both renderer legs on a macOS/Linux
host: build :demo+:apidemo -> nav3/back/click/scroll/multiwin probes
(gated on PASS output) -> parity.py per leg -> CDN_PROFILE draw-ms
spot-check on LazyColumn/Tabs against a self-seeded perf baseline
(build/verify-mac/perf-baseline.txt, +20% regression gate,
--update-perf-baseline to reseed). Non-zero exit on any failure.

Authored + bash -n / parse-logic smoked on Windows; end-to-end run
pending the first Mac session (which also seeds the perf baselines and
the parity macosArm64/{skia,sdl3} baseline keys).
2026-07-16 20:44:55 +02:00
Bitsy 983cafc9b9 feat(parity): P0.5 — render-to-quiescence capture, deterministic on both legs
Native: windowHasInvalidations() (needsFrame + recomposer.hasPendingWork +
both BroadcastFrameClocks' hasAwaiters) queryable from onFrame probes;
disableInfiniteAnimations injects a cancelling InfiniteAnimationPolicy into
each window's Recomposer context (upstream's test mechanism); and
useVirtualFrameTime steps the composition/animation clocks a fixed 16.6ms
per loop iteration — the native mirror of the JVM leg's render(nanos).
Quiescence alone left Pickers drifting ±10px per run (TimeInput focus →
bring-into-view scroll racing real-time frames); virtual time makes the
race resolve identically every run.

JVM: screenshotAllScreens steps a virtual 60fps clock via render(nanos)
until hasInvalidations() clears (cap 300), same cancelling policy through
ImageComposeScene's coroutineContext.

Demo: --screenshot captures after 3 consecutive quiescent frames;
--frames=N is now the quiescence CAP (default 300), not a fixed capture
point.

Verified on Windows: Pickers/Animation/Buttons byte-identical across 3
native runs (Pickers settles at exactly frame 12); all 57 JVM screens
byte-identical across 2 sweeps; 5 interaction probes PASS (flags inert
outside screenshot mode); parity PASSed against the OLD frame-6 baselines
(55/57 identical %), re-seeded mingwX64/sdl3, fresh full run PASS exit 0.
2026-07-16 20:42:09 +02:00
DWA 5e05f0d354 feat(parity): P0.4 — parity.py is now a gate (baselines + non-zero exit)
Per-(target/renderer) golden baselines in scripts/parity/baselines.json, a
--update-baselines seeder, and a non-zero exit when a screen exceeds baseline+max(3pts,25%)
or NATIVE FAILED — so parity can actually gate a change instead of only ranking. Report now
shows baseline + verdict per screen. Seeded a full 57-screen mingwX64/sdl3 baseline.
Output made ASCII-only (Windows cp1252 console crashed on the em-dash/arrow).

Verified on Windows: PASS exit 0, simulated regression exit 1, restore clean.
2026-07-16 17:58:00 +02:00
DWA 73d98a3dc3 feat(parity): P0.3 — font-align the JVM parity leg with the bundled NotoSans
Stage font/NotoSans.ttf onto the demo JVM classpath (jvmProcessResources) and load it in
MainJvm's ScreenHost — applied via an M3 Typography copy (all core styles) + LocalTextStyle
so both Material and BasicText render NotoSans, matching the native leg's default font.

Verified on Windows: staged + loads + applies (no not-aligned warning). Finding: on the SDL
leg parity is dominated NOT by typeface but by an accumulating vertical line-metric delta
(SDL3_ttf NotoSans line height vs Skia) — so the SDL % barely moves; the ~0% golden-master
payoff is Skia-leg (Mac) only. Confirms the CONVERGE §1 layout-engine delta.
2026-07-16 17:52:22 +02:00
DWA 7a78e5e7a6 feat(vendor): P0.6 — manual-vendor drift tripwire
Add machine-readable VENDOR-BASE: <upstream-path> @ <ref> provenance to the three renderer
manual-vendors (GraphicsLayerOwnerLayer, GraphicsLayer.native, LayerTransformationMatrix) +
scripts/compose-fork/check-vendor-drift.py: scans for VENDOR-BASE lines, flags any whose
recorded ref lags the current COMPOSE_CORE_REF pin (optional upstream base..pin diff when a
../cmp-ref clone is present), exits non-zero on staleness so it can gate a ref bump. Also
refreshed the stale RENDERER_REFACTOR.md refs in those headers → RENDERER_CONVERGE.md.
Verified on Windows: clean exit 0, simulated stale exit 1.
2026-07-16 17:42:53 +02:00
DWA f6164e7cf0 feat(parity): P0.1 — make parity.py target-aware (both renderers, any host)
Unhardcode the mingwX64 exe: infer the host's native target by default (Windows→mingwX64
so existing usage is byte-identical), add --target= and --renderer= so macOS/Linux can run
BOTH the Skia leg (default) and the SDL leg (-Prenderer=sdl3). Compute the link task + exe
path (.exe vs .kexe) + default GPU driver from those. Verified on Windows: default run
intact (Buttons 16.47%); macOS legs resolve correctly. RENDERER_TASKS.md P0.1 checked.
2026-07-16 17:37:28 +02:00
DWA 43f88c7866 docs: add RENDERER_TASKS.md (executable checklist) + lock goal to G1 upstream-tracking
RENDERER_CONVERGE.md = study file (why/decisions/analysis); RENDERER_TASKS.md = pure
checkbox task tracker an agent progresses over time (status marks, done-criteria, blocked-by,
Mac-verify primitive, progress log). §0.5 goal locked to G1 (upstream-tracking): B5+B2 lead,
guardrails first, JVM = Windows fidelity tier, B3 capped, Track A shelved, B1 + module split
deferred.
2026-07-16 17:28:15 +02:00
DWA 70602e4c91 docs(renderer-converge): macOS/Linux run BOTH renderers — one box verifies everything
Correct the verification framing: macOS/Linux build the Skia leg (default) AND run the SDL
leg under -Prenderer=sdl3, so the Mac (or a Linux box) is a single machine that verifies
both renderers — not just the Skia leg. Windows is only needed for the shipped mingw target
binary, the Windows-only PrintWindow probe, and the tag-time metadata publish. Updated §5
runbook, the why-it-suffices note, and §8.
2026-07-16 17:22:18 +02:00
DWA 653f32e08c docs(renderer-converge): fold in 5-agent council + switch verification gate to a local Mac runbook (no CI)
Council corrections folded into RENDERER_CONVERGE.md: add §0.5 goal/audience (the root
finding — priority is undefendable without it); restate Rule 3 (bespoke vs vendored-upstream
surface); correct the North Star (Skia-leg text is the shared SdlParagraph, not upstream —
new B6 option / stated axiom); re-rate B2 from file-flip to a source-set MIGRATION (relocate
the SDL node cluster out of nativeMain, fork the ad-hoc GraphicsContext, +Matrices.skiko/
Blur.skiko closure, skiko-version DoD, keep owner layer shared); DECIDE the lifetime model
(shared GC on both legs); enumerate the module-split build fan-out + cohesion cost; make §8
verification concrete (font-aligned golden-master, render-to-quiescence, cross-platform
self-tests, drift tripwire, actual-API-parity invariant); add §9 risks (skiko drift, +dev-ref
fragility, permanent ~3,800-line SDL surface); add §13 council synthesis.

Maintainer opted out of CI (slow); the Phase-0a gate is now a LOCAL MAC VERIFY RUNBOOK
(the Mac builds the Skia leg the Windows box can't) instead of a GitHub CI floor.
2026-07-16 17:19:13 +02:00
DWA a98068ba09 docs: consolidate RENDERER_REFACTOR into RENDERER_CONVERGE (Appendix A), delete the old doc
The retained-layer engine model, the requiresLayer() compositing contract, what
landed (the geo-node story), and the hard-won learnings are preserved as Appendix A
of RENDERER_CONVERGE.md; the commit-by-commit journal is dropped (endpoints kept).
RENDERER_CONVERGE.md is now the single living renderer doc.
2026-07-16 16:32:35 +02:00
DWA b2a23d60b8 docs(renderer-refactor): image-capture investigated + deferred (demo uses drawImageRect, not the capturable drawNativePainter) 2026-07-16 15:51:37 +02:00
DWA 8fddb0a94c docs(renderer): refresh geo-node doc comments for spanned-text + icon capture
The SdlDisplayList / SdlDisplayListRenderNode headers still described the geometry-only
first increment (spanned/icon text listed as deferred). Update them to the current
coverage: GeometryBatch + TextRun (plain AND spanned) + IconRun; unsupported now trips
on image blits, saveLayer, rounded/generic layer clip, compositing, and span
backgrounds. Comment-only.
2026-07-16 15:46:52 +02:00
DWA 15e12ec2b9 docs(renderer-refactor): icon-font glyph capture (Lists/NavRails cache; -23%) 2026-07-16 15:35:50 +02:00
DWA c5945f1a94 feat(renderer): capture icon-font glyphs (Material Symbols) in the geo node
Icon-font glyphs recorded as an IconRun (leaf-local box + params) instead of
deferring the leaf. Replay maps the box through the layer affine and re-draws via
FreeTypeIcons' glyph cache (eviction-safe), with the same SDL_ttf fallback the
immediate path uses for a codepoint absent from the FreeType/subset font.

This unblocks the ubiquitous icon + text list-item / nav-item leaf: Lists text
draws 24 -> 14, NavRails 28 -> 16 (draw -23%). Full 57-screen sweep clean (56 at
0.000% geo-vs-default, Pickers = its settle-timing artifact), nav3test PASS. Icon-only
monolithic grids (Icons / MaterialSymbols) still defer for layer granularity.
2026-07-16 15:35:37 +02:00
DWA d4cb543090 docs(renderer-refactor): spanned-text capture (Tabs -38%) + all flip residuals explained 2026-07-16 15:23:31 +02:00
DWA 63b430873d feat(renderer): capture spanned text runs in the geo node
Styled text (colour / weight / italic / size / decoration spans) now records into
the display list as one TextRun per run — mirroring the plain path — instead of
deferring the whole leaf to block-replay. Replay re-looks-up each segment texture
by params (eviction-safe). A run with a SpanStyle.background still defers (the
background fill isn't a captured command yet; rare — highlight/selection).

Verified: full 57-screen sweep clean (no regression; styled screens Text/Annotated
String/Lists/Chips/Tabs all 0.000% geo-vs-default), and caching now reaches styled
labels where layer granularity allows — Tabs text draws 38 -> 16, draw 4.29 -> 2.65ms
(-38%). Screens whose text sits in one monolithic layer (Text/Lists) still defer for
other reasons in that leaf (child layer / icon) — layer-granularity, as upstream.
2026-07-16 15:22:08 +02:00
DWA 3a584bbe8b docs(renderer-refactor): perf characterization of the geo flip — no regression, win scales with static content 2026-07-16 14:50:20 +02:00
DWA 2873df25ae docs(renderer-refactor): Carousel clip fix + default flipped to geo (-57% draw, sweep clean) 2026-07-16 14:43:06 +02:00
DWA bff6b484e5 feat(renderer): flip SDL default to the cached-geometry node (geo)
CDN_LAYERCACHE default is now SdlDisplayListRenderNode. A full 57-screen sweep of
geo vs the previous block-replay default is clean: 55 at 0.000%, GraphicsLayer 0.122%
and ModShortcuts 0.095% (both the same sub-pixel rotated-edge AA — cosmetic,
deterministic). Former blockers gone: Carousel fixed by the clip change; Pickers was
an entry-animation settle artifact (geo-vs-geo and geo-vs-default both 0.000% once
settled). Perf: LazyColumn steady-state draw 3.55 -> 1.52ms (-57%), text 22 -> 6.

Escape hatches: CDN_LAYERCACHE=off|defer|0 -> DeferredRenderNode (previous default);
=1|texture -> SdlRenderNode (legacy texture cache). nav3test PASS.
2026-07-16 14:42:29 +02:00
DWA 4371335b37 fix(renderer): geo node applies layer clip — Carousel item masks no longer overflow
The geo fast path re-emits raw geometry via SDL_RenderGeometry, which the SDL
canvas clips only to a RECT; its rounded-corner mask is a lazy offscreen realized
from drawRect, which replayBatch bypasses. So a rounded/path LAYER clip went uncut
on the fast path — the Carousel item's morphing rounded mask was ignored and the
coloured card overflowed its silhouette (the deterministic 4.4% geo-vs-default diff).

Fix: apply a rectangular layer clip on the fast path (honoured by SDL_RenderGeometry
via the clip rect), and force rounded/generic-clipped leaves to the block-replay
(which realizes the mask correctly, == DeferredRenderNode). Carousel 4.4% -> 0.000%.
2026-07-16 14:42:29 +02:00
DWA 468c347357 docs(renderer-refactor): Phase 4 text capture landed — -59% draw; flip-blocker analysis 2026-07-16 14:22:41 +02:00
DWA 65503e60b8 feat(renderer): Phase 4 text capture — cache static text runs (geo node)
The real perf win. The geo capture path now records PLAIN text runs (params, not the
texture pointer — mirrors skiko recording a glyph-run into a Picture); replay
re-looks-up via Sdl3TextRenderer's per-run texture LRU (our glyph-atlas analog) and
blits at the transformed origin. Eviction-SAFE: if the LRU dropped the run, the
lookup re-rasterises — no dangling pointer. Spanned / icon text bails (unsupported →
block-replay).

- SdlDisplayList is now an ORDERED command stream (GeometryBatch | TextRun) so
  text↔shape z-order is preserved; replay flushes geometry before each text blit.
- Sdl3TextRenderer: runSink captures the plain run at its blit; blitRun replays it.
- Sdl3Canvas.drawNativeText: capture mode sets the sink for plain text (icons gated).
- SdlDisplayListRenderNode: added cache-leaves-defer-parents (fRecordingStack) — a
  child layer drawn during record defers the parent, and a child's drawInto during a
  parent's record draws NOTHING (no GPU leak into the target-less capture pass). This
  was the fix for the nesting regressions text capture first exposed.

Measured (LazyColumn, forced continuous): draw 3.56ms -> 1.47ms (-59%), text draws
22 -> 6 — static text stops being re-shaped/re-blit every frame.

Verified (geo vs default): Text/LazyColumn/Shapes/Icons/Cards/Chips 0.000%, Buttons
0.006%, GraphicsLayer 0.157% (rotated-square AA). KNOWN =geo issues (flag-gated,
default unaffected): Pickers renders correctly but its scrollable settles to a
different position nondeterministically (capture perturbs the frame timing → scroll
animation) — a timing/app-state sensitivity, not a render bug; Carousel 4.4%
(pre-existing). Resolve both before flipping the default to geo.
2026-07-16 14:13:43 +02:00
DWA a832de5ce1 docs(renderer-refactor): scope text capture + the glyph-texture lifetime constraint 2026-07-16 13:45:52 +02:00
DWA 5caef6b2b3 docs(renderer-refactor): Phase 4 geo node works — pixel-equal + deterministic; text capture next 2026-07-16 13:40:44 +02:00
DWA b50081014e feat(renderer): Phase 4 cached-geometry node — capture + replay (CDN_LAYERCACHE=geo)
The robust retained node. A capture-mode Sdl3Canvas records a leaf's tessellated
geometry ONCE into an SdlDisplayList (layer-local, NO GPU / no render target);
SdlDisplayListRenderNode.drawInto re-emits those cached vertices through the layer
transform via Sdl3DrawScope.replayBatch. Crisp under ANY transform (vertices
re-transformed, not a resampled texture), bit-exact (no texture round-trip), and —
crucially — NO per-frame texture state, so none of the texture node's timing
nondeterminism.

- Sdl3Canvas: capture mode (fCaptureList) routes shapes to the scope's recordingSink
  and gates text/image/clip/saveLayer/shadow to mark the list `unsupported` (that
  leaf falls back to a crisp block-replay — nothing leaks to the GPU). replayGeometryList
  re-emits under the current transform. Sdl3OffscreenRenderer.createCaptureCanvas.
- SdlDisplayListRenderNode behind CDN_LAYERCACHE=geo (1 = texture node, default =
  DeferredRenderNode).

Geometry-only so far: text/image/clip leaves DEFER (correct, not yet cached) — so the
perf win is currently limited to shape-only leaves; extending capture to text/image/
clip is the next increment (the full win). But correctness/robustness is proven:

Verified (geo vs default): Shapes/Text/Buttons/Pickers/Icons/Carousel/LazyColumn/
Canvas/Path ALL 0.000% (incl. every screen the texture node failed — Pickers 13-17%→0,
Carousel 4.4%→0). Pickers DETERMINISTIC (run1-vs-run2 0.000%, vs texture's 0↔17% swing).
nav3test PASS. Only residual: GraphicsLayer 0.134% — deterministic sub-pixel AA fringe
on ROTATED squares (tessellate-then-rotate vs tessellate-rotated), cosmetic.
2026-07-16 13:39:58 +02:00
DWA 5dc3a4525a docs(renderer-refactor): journal Phase 4 start (geometry recording foundation) 2026-07-16 13:23:27 +02:00
DWA 716a005da6 feat(renderer): Phase 4 foundation — geometry recording sink (no behaviour change)
First step of the cached-geometry display list (the robust replacement for texture
caching — no offscreen texture, so no fixed-resolution softness and no timing-
dependent nondeterminism; RENDERER_REFACTOR.md §3/§13).

- SdlDisplayList / GeometryBatch / GeometrySink: capture the tessellated untextured
  triangle batches a layer produces, in layer-local coords (an `unsupported` flag
  trips when a not-yet-captured op — text/image/clip — is hit, so the node can fall
  back to a crisp block-replay; nothing leaks to the GPU).
- Sdl3DrawScope: flush() routes to a `recordingSink` (capture) instead of
  SDL_RenderGeometry when set; `currentDeviceMatrix()` exposes the CTM for replay.
  Recorded with an identity base CTM ⇒ layer-local vertices; replay re-applies the
  layer matrix (crisp under any transform, bit-exact).

Sink defaults null ⇒ zero behaviour change. Next increments: the capture-mode canvas
+ SdlDisplayListRenderNode (record/replay) behind CDN_LAYERCACHE=geo, then text/image/
clip capture. Verified: :ui:compileKotlinMingwX64 BUILD SUCCESSFUL.
2026-07-16 13:22:18 +02:00
DWA 5890dd0fe3 docs(renderer-refactor): Pickers bug — clear fix + nondeterminism finding; Phase 4 is the robust path 2026-07-16 13:13:52 +02:00
DWA 29856d9bea fix(renderer): clear offscreen render targets on begin (reused-texture ghosting)
beginOffscreen set the render target but never cleared it. Fresh SDL target
textures happen to be zeroed (so static leaves were pixel-equal), but a
retained-layer texture (SdlRenderNode) is REUSED across re-records — an un-cleared
re-record ghosted prior content, which showed as multi-frame instability on churny
screens (Pickers: cached changed 16.8% between frame 6 and 60 while default was
stable). Clear to transparent (RenderClear fills with the draw colour ignoring
blend, so 0,0,0,0 = transparent). Shared with the vector-icon offscreen path, which
redraws fully each time, so clearing is correct there too; negligible cost (only on
offscreen begin).

Improves caching robustness (fixed the Pickers instability in controlled tests) but
does NOT fully resolve it: Pickers still renders nondeterministically across launches
(0% or ~17% vs default, even isolated) — a timing-dependent issue in the texture-cache
path on complex screens. Caching stays flag-gated; the robust fix is Phase 4's
cached-geometry list (no texture state → no timing dependence). Static screens remain
pixel-equal.
2026-07-16 13:13:14 +02:00
DWA 5184d3ec24 docs(renderer-refactor): #1 full sweep — DO NOT flip default yet (Pickers/Carousel caching bug) 2026-07-16 12:05:38 +02:00
DWA 0610460ebb docs(renderer-refactor): journal #2 done + #3 (Phase 4) cached-geometry design 2026-07-16 11:46:38 +02:00
DWA 8081fc13f6 fix(renderer): don't texture-cache image/icon leaves (AA-fringe fix)
Content with partial alpha — a decoded image (painterResource), a tinted
vector/icon — does not round-trip bit-exact through the 8-bit premultiplied
offscreen texture (the heart painterResource rendered visibly wrong, Icons
diff 0.36%). Detect it cheaply via a DrawStats.imageBlits delta around the
record block: a leaf that draws any image bails to the crisp block-replay.
Text / shapes / solid fills bump other counters and stay cached (pixel-equal).

Costs almost no perf — icons are blits, not tessellation; the big tessellation
cost is text, which still caches.

Verified (cached vs default): Icons 0.36% -> 0.014% (visible heart gone; residual
is one icon's AA), Buttons 0.006% (disabled semi-transparent fill), Text /
LazyColumn / GraphicsLayer / Shapes still 0.000%. The sub-perceptual residual is
the inherent texture-round-trip rounding floor — removed entirely by Phase 4's
cached-geometry display list (no round-trip).
2026-07-16 11:44:42 +02:00
DWA 3fec62fbc2 docs(renderer-refactor): journal the SDL caching node + measured perf win 2026-07-16 11:19:00 +02:00
DWA 3f76f77e5b feat(renderer): retained texture-cached SdlRenderNode (opt-in CDN_LAYERCACHE=1)
The SDL perf payoff: records a leaf layer's drawing ONCE into an offscreen
texture; replay is a cheap blit, not a re-tessellation.

Correct-by-construction nesting, no upstream divergence: SDL textures composite
children by value (unlike skiko's by-reference Picture), so we CACHE LEAVES,
DEFER PARENTS — a node auto-detects on first record whether its block drew any
child layer (via a recording stack) and, if so, replays the block instead
(children composite their own live textures → no staleness). The texture
fast-path is further restricted to opaque, untransformed, effect-free leaves;
scale/rotation (would bilinear-resample soft) and alpha/blend/colorFilter/
renderEffect (blit can't reproduce offscreen compositing) fall back to a crisp
block-replay — so caching is strictly non-regressing.

Gated behind CDN_LAYERCACHE=1; default stays DeferredRenderNode. Flip the default
after a full parity-harness sweep.

Verified (SDL leg, cached vs default): GraphicsLayer/Shapes/Text/LazyColumn/
Scroll/Brushes/Canvas pixel-equal (0.000%); Buttons 0.006% + Icons 0.36% (AA
fringe from the texture round-trip at rounded-clip/glyph edges — not structural);
nav3test + backtest PASS. Profiler (LazyColumn, forced continuous): per-frame
verts 2958->1158 (-61%), text draws 22->6, draw 4.0->3.4ms — static leaves blit
instead of re-tessellate.
2026-07-16 11:18:08 +02:00
DWA 1f33506c13 docs(renderer-refactor): journal the GraphicsLayer façade + scope the caching node 2026-07-16 10:46:50 +02:00
DWA 44e09aa89f feat(renderer): GraphicsLayer as a façade over NativeRenderNode
Restructure to mirror upstream: GraphicsLayer is now a copy-edit of
SkiaGraphicsLayer.skiko.kt that mirrors every visual property onto a backing
NativeRenderNode and delegates record/replay to it — instead of an ad-hoc
lambda-replay. This is the correct Compose-faithful shape: the per-renderer
caching node (skiko RenderNode on Skia, offscreen/display-list on SDL) will slot
in behind createNativeRenderNode without touching GraphicsLayer.

- NativeRenderNode: reshaped to record(block)/drawInto(canvas)/close() + the full
  skiko-RenderNode property surface (transform/shadow/clip/compositing).
- DeferredRenderNode (nativeMain, renderer-agnostic): the node both renderers use
  today — replay-the-block, UNIFYING the old GraphicsLayer.native lambda-replay
  AND ProjectOwnedLayer's transform/shadow/clip logic in one place. Transform via
  prepareLayerTransformationMatrix + canvas.concat (same matrix as hit-testing, so
  draw and hit-test agree). Behaviour-preserving; no re-tessellation win yet — that
  arrives when createNativeRenderNode returns a caching node (Phase 2a/2b).
- toImageBitmap: implemented (was throwing) — renders the layer into a fresh
  ImageBitmap; flushed via new NativeFinishableCanvas (SDL offscreen must commit
  its texture; Skia finish() is a no-op). Both canvases implement it.

Verified SDL leg: :ui green; demo renders GraphicsLayer (rotation/scale/alpha
identical), Buttons/Shapes correct; nav3test PASS.
2026-07-16 10:45:00 +02:00
DWA 0870cd923a docs(renderer-refactor): journal the navigation crash fix + full flip verification 2026-07-16 10:19:16 +02:00
DWA 49b14337eb fix(renderer): don't shrink dirtyLayers mid-draw (crash on navigation)
The Phase 1 flip crashed with IndexOutOfBoundsException on any navigation
(--nav3test exit 3; sidebar click killed the window). Cause: my hand-written
notifyLayerIsDirty removed a layer from dirtyLayers unconditionally, but
renderRoot iterates `for (i in 0 until dirtyLayers.size)` calling
updateDisplayList(), which sets isDirty=false -> notifyLayerIsDirty(false) ->
removed the layer mid-iteration, so dirtyLayers[i] ran off the end.

Aligned notifyLayerIsDirty + renderRoot to upstream OwnedLayerManagerImpl
verbatim: while isDrawingContent, a clear (isDirty=false) is a no-op — the
dirtyLayers.clear() after the loop drops them all at once, so the list can't
shrink during iteration.

Verified: --nav3test PASS (was exit 3), --backtest PASS, and the sidebar click
navigates to GraphicsLayer correctly (hit-test lands + layer churn, no crash).
2026-07-16 10:18:40 +02:00
DWA 74f186136b docs(renderer-refactor): add progress journal (Phase 0 + Phase 1 flip) 2026-07-16 09:52:29 +02:00
DWA bbaa04354c feat(renderer): flip createLayer to retained GraphicsLayerOwnerLayer
Phase 1 behavioural step: every layout layer now goes through upstream's
dirty-gated record/replay OwnedLayer instead of the immediate-mode
ProjectOwnedLayer stand-in.

- ComposeOwner implements OwnedLayerManager (dirtyLayers + notifyLayerIsDirty
  + recycle + voteFrameRate); createLayer returns GraphicsLayerOwnerLayer;
  invalidate() -> window needsFrame via a callback hook.
- renderRoot(): re-records dirty layers, then walks the tree so clean layers
  replay. ComposeRootHost.drawRoot delegates to it; both backends call it
  instead of rootNode.draw.
- ComposeWindow wires the invalidation callback so a draw-only state change
  still schedules a frame.
- Move ComposeOwner + ComposeRootHost commonMain -> nativeMain (they now touch
  native-only OwnedLayerManager / GraphicsLayerOwnerLayer; :ui commonMain only
  ever compiles to native anyway). The feed*Processor expect/actual collapses
  to plain nativeMain functions.

NOTE: still backed by the existing lambda-replay GraphicsLayer, so this is
behaviour-preserving (no re-tessellation win yet); the storage swap to a
caching NativeRenderNode is the next step. Verified on the SDL leg: demo exe
renders GraphicsLayer (rotation/scale/alpha correct), Buttons (rounded/shape
clip correct), Shapes/Scroll/Text/Counter all non-blank. Skia leg builds on
mac/CI. Hit-testing through the new layer matrix wants a probe pass next.
2026-07-16 09:52:11 +02:00
DWA 86c97cbad1 feat(renderer): vendor GraphicsLayerOwnerLayer (compiles unused, SDL leg)
Phase 1 shared step: bring in upstream's real OwnedLayer (dirty-gated
record/replay over a GraphicsLayer) that will replace the immediate-mode
ProjectOwnedLayer.

- nativeMain: GraphicsLayerOwnerLayer.kt manually vendored from skiko
  (setLightingInfo tail dropped; not wired to createLayer yet).
- nativeMain: prepareLayerTransformationMatrix — the one helper that was
  skikoRenderer-only (Matrices.skiko.kt), lifted to shared nativeMain under a
  distinct name (parent source sets can't see child-set symbols; distinct
  name avoids clashing with the vendored skiko copy on the Skia leg).

Additive — createLayer still returns ProjectOwnedLayer, so no behaviour
change. Verified :ui:compileKotlinMingwX64 BUILD SUCCESSFUL (SDL leg).
2026-07-15 17:38:20 +02:00
DWA 5a05663050 feat(renderer): Phase 0 — NativeRenderNode seam (retained-layer scaffolding)
Introduce the renderer-agnostic retained display-list node the whole
retained-layer engine will hang off (RENDERER_REFACTOR.md §4):

- nativeMain: NativeRenderNode interface + NativeRenderNodeContext +
  createNativeRenderNode expect. Surface mirrors skiko RenderNode /
  SkiaGraphicsLayer (topLeft/size/pivot, transforms, elevation shadow,
  clip rect/rrect/path, layerPaint, beginRecording/endRecording/drawInto/
  close) so the Skia actual can wrap skiko RenderNode / skia Picture and
  the SDL actual can build a command-list + offscreen twin.
- skikoRendererMain / sdlRendererMain: TODO() stub actuals (Phase 1 / 2).

Additive only — nothing constructs a node yet, so no behaviour change.
Verified: :ui:compileKotlinMingwX64 BUILD SUCCESSFUL (SDL leg). Skia leg
compiles on mac/CI (native skiko klibs absent on Windows).
2026-07-15 17:26:54 +02:00
DWA ebe1d67620 docs(renderer-refactor): add minimize-divergence rule for the top renderer layer 2026-07-15 17:19:26 +02:00
DWA 7eaebeb787 docs(renderer-refactor): make scope explicit — whole per-renderer stack, not just retained layers 2026-07-15 17:09:46 +02:00
DWA 2f1f7d5ba5 docs: replace ROADMAP + NEXT-SESSION with RENDERER_REFACTOR plan
Plan to adopt upstream skiko's retained-layer engine (record-once/replay
GraphicsLayer) in place of the current immediate-mode renderer, which
re-tessellates the whole tree every frame.

- Skia leg ~= vendored skiko target (GraphicsLayerOwnerLayer +
  SkiaGraphicsLayer over skiko RenderNode / Picture fallback); SDL leg
  builds a faithful SdlRenderNode twin (display list + offscreen).
- Keep ComposeOwner + SDL loop; wire it as the OwnedLayerManager.
- Adds a feature-parity audit matrix vs skiko (the acceptance gate) and
  Track V: relocate graphics actuals per-renderer, vendor the skiko set,
  delete hand-rolled Skia duplicates.
- Delete ROADMAP.md / NEXT-SESSION.md (superseded; still-valid items
  folded in); repoint CLAUDE.md.
2026-07-15 17:05:20 +02:00
Bitsy 92454ae8a1 fix(window): FPS title shows within ~2 frames instead of needing a page change
The FPS title only updated after a full 1000ms of wall-clock accumulated
since the last update — and the idle-skip branch called resetFpsWindow()
(zeroing the counter + window start) every time the app went idle. Since
the UI renders in short bursts and idles between them, that 1-second
window kept restarting before it ever completed, so the first title
update never fired. A page change was the only interaction whose
transition animation renders continuously long enough (~700ms+) to let
the window complete — hence 'I have to change page for it to work'.

Replace the fixed-window frame counter with an EMA-smoothed
instantaneous rate (from the inter-frame delta), refreshed ~4x/sec. It
shows within ~2 rendered frames of any activity. A dt outside 1..100ms
(first frame / resume-from-idle) isn't sampled, so idle gaps don't tank
the reading and the title holds the last active rate while static.

Verified: demo shows FPS in the title 1.6s after launch, no interaction.
2026-07-14 20:45:36 +02:00
Bitsy aad70f342b fix(sdl): stop re-rasterizing text at device size — popup/dialog text no longer steps size/weight
Regression from 120abbad's glyph device-scaling: drawNativeText scaled
the font size by the layer affine (inFontSize * scaleY, int-quantized)
and re-rasterized. Under an animated layer scale — every Material
DropdownMenu/AlertDialog enter transition scale-animates (Menu.kt
scaleX/scaleY) — text re-rasterized at each integer size every frame, so
it visibly stepped through sizes and (via SDL3_ttf re-hinting) weights
as the popup grew in. User-reported, seen across dropdowns and dialogs.

Revert to rasterising at LOGICAL font size + 1:1 blit at the
affine-mapped position (the pre-120abbad behaviour): the layer scale
reaches text POSITION but not glyph size, so animated-scale popups are
stable. Keeps 120abbad's separate clip-mask AA/overlap fixes. Trade-off:
statically graphicsLayer-scaled text renders at logical size (a rare,
minor parity gap) — smoothly scaling glyphs needs blit-scaling a
logical rasterisation (noted for a future text-renderer pass).

Verified: demo dropdown opens with clean uniform text; text no longer
scales with the layer, so mid-animation == settled by construction.
2026-07-14 20:19:40 +02:00
Bitsy cf597c12e0 cleanup: delete dead modifier scaffolding + fix demo's fake cacheKey claims
ModifierElements.kt (ClipModifier/ClipNode + GraphicsLayerModifier/
GraphicsLayerNode with the never-implemented cacheKey) was entirely
unreferenced — apps use the upstream clip/graphicsLayer; nothing read
these. Deleted the file (compiles clean across :ui and :demo).

The demo GraphicsLayer screen advertised caching that never existed
('Cached subtree (cacheKey)', 'Combined (cache + rotation)', a
'(cached)' label) over plain graphicsLayer() calls. Removed those two
fabricated sections and the cacheKey wording; the honest Rotation
(live) / Scale / Alpha sections already show what graphicsLayer does.
2026-07-14 20:02:30 +02:00
Bitsy cbddf8ca0b docs: RESOLVED — demo-70/apidemo-144 was mixed-refresh monitors, not a renderer bug
User has a 75Hz main + 144Hz second screen; each app's fps is just the
refresh of the monitor its window opened on (SDL vsync follows the
display). Instrumentation already showed both apps at ~3.7ms draw —
well under both the 13.3ms (75Hz) and 6.7ms (144Hz) budgets, so no perf
problem exists. Records that retained-layers/dirty-region/cacheKey are
speculative with no demonstrated need; only the dead-scaffolding cleanup
is worth doing regardless.
2026-07-14 19:54:04 +02:00
Bitsy ad738a643e docs(CLAUDE): profiler now file-output + draw counters + CDN_FORCERENDER; vsync/display caveat
Documents the DrawStats per-frame counters, file output, and
CDN_FORCERENDER added this session, plus the load-bearing caveat: present
is vsync-bound so all timings are capped by the dev display's refresh —
profile on the target rate before diagnosing a frame-rate gap.
2026-07-14 19:53:12 +02:00
Bitsy 8f5c59bd64 feat(window): per-frame DrawStats in profiler + CDN_FORCERENDER; correct the 70fps theory
Extends CDN_PROFILE with per-frame draw counters (DrawStats: geometry
submits, vertices, mask realizations, text/image blits) and a
CDN_FORCERENDER=1 flag to render every frame for steady-state timing on
idle screens.

Used them to test the demo-70fps theory and DISPROVE it: forced-render,
both apps pin at 75 fps (this dev display's refresh), present ~9ms; the
demo's draw (~3.7ms, geo=40, masks=0) is actually LIGHTER than apidemo's
(~2.9ms, geo=146, masks=74). So the demo is NOT draw-bound — the
70-vs-144 split is specific to the user's 144Hz monitor and can't be
reproduced on a 75Hz box. Recorded the correct next step (profile on the
144Hz machine) in NEXT-SESSION.md; noted the vsync/display confound in
CLAUDE.md's profiler docs. Thread A (spurious continuous render) resolved
as a non-issue — the demo idles when static.
2026-07-14 19:52:24 +02:00
Bitsy 914bab840d docs: handoff notes for the renderer-caching session
Captures the demo-70fps investigation for the next session: (A) the
likely-cheaper continuous-render / hover-self-loop thread to chase first,
and (B) the cacheKey/retained-layer work — dead scaffolding today,
blocked on a content-draw-redirect primitive (the 'renderer rewrite')
that also underpins dirty regions. Lists the tooling + existing
infrastructure to build on.
2026-07-14 19:34:39 +02:00
Bitsy 8f96ba9478 docs(roadmap): record demo 70fps cause (draw-bound sidebar re-tessellation)
Investigated the demo-70fps / apidemo-144fps question with CDN_PROFILE:
both apps use vsync(1), so it's NOT a vsync-mode difference. The full
demo is DRAW-bound (draw ~34ms cold / ~14ms steady, present ~1.3ms) —
it re-tessellates the whole always-present sidebar (30+ rows of text +
icons) every rendered frame, capping ~70 fps. apidemo's lighter chrome
fits the 6.9ms/144Hz budget and hits the vsync ceiling. '70≈144/2' is
coincidence. Concrete motivation for retained layers / dirty regions.
2026-07-14 18:43:23 +02:00
Bitsy 5a9e7b0f7f docs(roadmap): mark layer rotation verified-working; warn item-4 list is stale
Layer rotation renders correctly (demo GraphicsLayer Rotation-live +
Combined rows) — like gradients, the claimed gap was already fixed.
Added a note to verify item-4 claims against the parity heatmaps before
acting, since the list predates several fixes.
2026-07-14 18:28:55 +02:00
Bitsy 5dfd9200f8 fix(sdl): grid-mesh gradient fills so radial/sweep aren't flat
The brush samplers (linear/radial/sweep with stops) were fully
implemented, but fills sampled the brush only at the shape's CORNERS and
let SDL interpolate. A square's four corners are equidistant from its
centre → identical radial/sweep t → the fill rendered as ONE flat colour
(the demo Brushes radial was solid green: center==corner pixel). Linear
survived because corners differ along the axis.

Gradient fills (brush !is SolidColor) now tessellate rect and round-rect
bodies into an ~8px-cell grid (emitRectMesh, adaptive 1..24 cells/axis),
so the sampler is evaluated across the interior and SDL interpolates
per-cell. Solid fills keep the single-quad fast path. Round-rect corner
arcs already fan from their centre.

Verified: demo Brushes radial now shows centre→edge falloff matching the
JVM render (was flat); parity Brushes 22.75%→21.72% (swatches are a
small pixel fraction; the rest is font drift).
2026-07-14 18:27:07 +02:00
Bitsy 8c7168a1a9 feat(window): profiler render sub-phases + file output; reprioritize roadmap from data
FrameProfiler is now a named-phase singleton writing to a FILE
(CDN_PROFILE=1 -> cdn_profile.log, or =<path>) not stdout — so
GUI-subsystem apps (the demo links --subsystem,windows) profile too.
renderFrame splits into layout / draw / present sub-phases.

Measured: light screen = 75 Hz, present ~10ms (vsync wait), draw ~1.4ms
(GPU-idle); heavy 84-bubble sheet ~32ms = DRAW, not masks. ROADMAP item
2 re-ordered: dirty-region rendering + retained layers promoted (the
real lever); clip-target right-sizing demoted to memory-only/low.
2026-07-14 18:16:27 +02:00
Bitsy 377f3aa775 docs: consolidated tooling index in CLAUDE.md + simple redirecting table in README
CLAUDE.md gains a '## Tooling — what to reach for' section: a when-to-use
table covering build-sdl, compose-fork sync, compose-coverage,
material-symbols, parity, probe, the CDN_PROFILE profiler, the bridge
plugin and the demo CLI probes — each pointing at its own README. The
parity/profiler/probe entries are expanded inline (parity keeps the
'what the %differ means' detail). README gets a simpler table redirecting
to each tool's README + the bridge/CLAUDE detail.
2026-07-14 17:18:11 +02:00
Bitsy 662975c802 feat(probe): reusable native-window input+capture driver (ROADMAP item 5)
scripts/probe/ — launch a native app, send WINDOW-CLIENT-relative input
(click/hover/hold, fractional coords addressed by process name so it
ignores window position/focus), capture via PrintWindow (works occluded).
The packaged form of the ad-hoc rigs that reproduced the square-on-click
and TLS-chain bugs. Verified: demo --screen=Shapes hover+capture yields
real 1000x700 content.
2026-07-14 17:15:19 +02:00
Bitsy 3b420a7bad docs(CLAUDE): document the parity harness + how to read the %differ
Explains running scripts/parity, the one-process JVM / per-screen native
mechanics, the build/parity outputs, and — the load-bearing part — what
the number means: it's a font-drift-inflated ranking signal, not a
pass/fail score. Ghosted text + dark shapes = normal; a solid bright
block or one-sided shape = regression. Baseline gradient ~2%..32%; known
SDL gaps (solid gradients, effects) inflate specific screens.
2026-07-14 17:09:52 +02:00
Bitsy 2dad6c9bcc fix(parity): use Path.replace so re-runs overwrite prior native PNGs on Windows
Path.rename raised FileExistsError mid-sweep when a screen's
<Name>.native.png already existed from an earlier run (Windows rename
won't overwrite). replace() is an atomic overwrite everywhere.

Full 56-screen sweep now completes: smooth 2%%(Counter)..32%%(Tabs)
distribution, no catastrophic outlier — confirms this session's renderer
changes (lazy clips, batch flush, TLS fix) left no screen blank or
structurally broken. Text density + known SDL parity gaps (solid
gradients, effect differences) explain the top of the ranking.
2026-07-14 17:08:44 +02:00
Bitsy 723f80c8af feat(parity): native-vs-jvm screenshot diff harness (ROADMAP item 5)
A regression net built on the demo's dual-stack design: the same
commonMain screens render on native (SDL/Skia) and on upstream Compose
Desktop (jvm), and scripts/parity/parity.py pixel-diffs them per screen.

- MainJvm gains a headless --screenshot-all=<dir> mode: renders every
  registered screen via ImageComposeScene (density 1, same
  verticalScroll+padding wrapper as MainNative's --screen path) to PNGs
  in ONE jvm process.
- parity.py drives the native exe per screen (--screen/--screenshot),
  diffs against the jvm PNGs with a per-channel tolerance, and writes
  <pct>_<Name>_diff.png (amplified heatmap) + _compare.png (side by
  side) to build/parity/ (gitignored), ranked worst-first by the pct
  filename prefix + report.txt.

The ranking is the signal, not the absolute %: fonts differ between
stacks (text ghosts in the heatmap), so a screen jumping well above its
neighbours/history is the regression. Verified on Buttons/Shapes/Colors
(~16/14/4% — all font drift, structure aligned).
2026-07-14 17:06:08 +02:00
Bitsy 450247175f feat(sdl): deterministic native-resource lifecycle — release queue + bitmap cleaner
ROADMAP.md item 1. SdlImageBitmap textures were NEVER freed (manual SDL
memory the GC nudge can't touch) — the lazy-texture rework made it worse
since the render-target path also leaked. Now:

- NativeReleaseQueue (:ui): thread-safe queue of release actions drained
  on the MAIN thread each frame by the window loop. SDL calls aren't
  thread-safe and both disposal triggers are off-main (a Cleaner runs on
  a GC worker; close() can be reached from other dispatchers), so both
  just ENQUEUE.
- SdlImageBitmap: holds its texture/surface in a by-ref holder with a
  Cleaner that enqueues destruction (backstop) + an explicit close()
  (prompt path). Idempotent — first to run nulls the handles.
- ComposeWindow drains the queue between frames and once at teardown.

This makes the periodic GC.collect() nudge a safeguard rather than the
disposal mechanism (issue #2). Verified end-to-end in the example:
a direct enqueue and a real Cleaner->enqueue->drain cycle both fire.
close()-on-eviction wiring is a documented follow-up (caches hold the
ImageBitmap interface, not the concrete type).
2026-07-14 16:55:33 +02:00
Bitsy 5d6f772a1c fix(sdl): lazily-realized clips use push-time region, not the narrowed current clip
The TLS-chain regression (only the first card + nothing below it, until
a scroll shifted things): realizePendingClips re-intersected each
pending clip's region with the CURRENT fClip. A lazy clip can realize
arbitrarily deep inside descendants whose own clips have since narrowed
fClip — so an OUTER clip (the dialog's rounded Surface) realized with
the innermost descendant's tiny rect as its region. Its offscreen then
composited back only that sliver; every sibling card + the button
drawn into the same offscreen fell outside the blit and vanished.

Each pending clip now realizes with its OWN push-time region
(enclosing-clip-at-push ∩ bbox, already captured in PendingRoundClip),
and the real (already-narrowed) drawing clip is saved across the
realize loop and restored after, so draws into the innermost target
stay correctly clipped.

Reproduced with an outer rounded Surface + height-bounded verticalScroll
of rounded+bordered cards + a button (the TLS dialog's exact shape):
first-card-only before, full list + button after. Demo scroll/card
screens and bubble-wrap unaffected.
2026-07-14 16:37:45 +02:00
Bitsy 575c19f921 feat(window): CDN_PROFILE=1 per-phase frame profiler
Prints avg/max ms per main-loop phase (events / app pump / window pump /
render) every ~2s of rendered frames, via SDL performance counters.
Bubble-wrap sheet under hover: render=32/41ms dominates a 39ms frame —
confirms the renderer, not composition, is the 75Hz bottleneck
(ROADMAP.md item 2).
2026-07-14 16:19:56 +02:00
Bitsy 328663b9e7 docs: renderer roadmap — lifecycle, performance, parity, tooling 2026-07-14 16:14:54 +02:00
Bitsy c9ddd3488d fix(sdl): auto-flush the vertex batch only on triangle boundaries
SDL_RenderGeometry renders floor(count/3) triangles and silently drops
the tail. writeVertex auto-flushed the batch whenever it was full — at
ANY vertex boundary — so a triangle straddling the flush lost its tail
vertices AND left orphan vertices at the head of the next batch,
misaligning every triangle after it: ~350° pac-man circles and glitch
shapes that shifted with every redraw once a frame's geometry outgrew
one batch. Never seen before because clip layers flushed constantly;
bubble-wrap's new unclipped drawBehind sheet (84 bubbles, one giant
batch, ~4 overflows per frame) exposed it — most of the sheet simply
vanished.

Flush now only fires at multiples of 3, and kBatchCapacity drops
8192 → 8190 (a multiple of 3, so the cap lands on a triangle boundary
and the buffer can never overrun).
2026-07-14 16:08:07 +02:00
Bitsy 9958386792 fix(bridge): data.kres zips must not race executable declaration
The resources packaging pre-scanned link-task names in afterEvaluate —
but executables can be declared AFTER that runs, notably by the
bridge's own compose.desktop.native { entryPoint } DSL (also
afterEvaluate, registered later). The scan found no link tasks and
silently packaged nothing: 'data.kres not found' at startup. The old
single-module example masked this with stale build output; a fresh
multi-module layout exposed it.

Zips are now pre-registered lazily for every desktop target/variant and
wired via tasks.matching{}.configureEach — order-independent, and an
unrealised task is free when the target has no executable. (Registering
the zip reactively from inside the link task's configuration callback
is forbidden by Gradle — 'cannot be executed in the current context'.)
Content wiring moved into the zip's own configuration action, which
runs at realisation when the Compose plugin's prepare tasks and the
final compose.resources config exist.

Verified on the restructured multi-module bubble-wrap (shared KMP
library with the new com.android.kotlin.multiplatform.library DSL +
androidApp): packageDebugComposeResourcesMingwX64 runs, data.kres lands
next to shared.exe with the drawable + converted .cvr strings, app
boots with resources loading.
2026-07-14 14:39:29 +02:00
Bitsy f5fae6d32e docs(bridge): document settings-wide vs per-module application
Both worked since the plugin is Plugin<Any> branching on Settings vs
Project, but the module-level path was undocumented and untested —
verified now with the example applied compose-plugin style. Also spells
out that a pluginManagement plugins{} entry only pins the version while
the settings-level plugins{} block is what applies build-wide.
2026-07-14 13:08:39 +02:00
Bitsy b94f1f7bbb feat(bridge): compose.desktop.native { entryPoint } — application{} for native
The native counterpart of compose.desktop { application { mainClass } }:
the bridge grafts a 'native' extension onto the Compose plugin's desktop
extension (extensions are ExtensionAware — no compile-time dependency)
and, in afterEvaluate, declares binaries.executable(entryPoint) on every
Kotlin/Native DESKTOP target (mingw/linux/osx families). Targets that
already declare an executable are skipped, so manual configuration wins.

KGP access is reflective for the same reason as the resources packaging:
settings-applied plugins live in a parent classloader that cannot see
project-plugin types.

Replaces the last piece of consumer boilerplate — bubble-wrap's build
shrinks to targets + dependencies + the two entry points, verified
end-to-end (executable declared by the DSL links, bundles data.kres,
boots).
2026-07-14 13:04:38 +02:00
Bitsy ffbb87eddd perf(sdl): containment-gate text draws under pending clips
Text runs no longer force mask realization unconditionally: like other
draws they pass a containment test against the pending rounded clips
(with a 2px margin for glyph overhang / AA bleed). Small centred labels
inside circle-clipped nodes — a popped bubble's tiny text — previously
cost a full offscreen mask pass each, per frame.

Bubble-wrap probe (84 bubbles, half popped, continuous animation):
23 FPS before the lazy-clip series, 39 after it.
2026-07-14 12:47:18 +02:00
Bitsy 63fad4083f fix(sdl): lazily-realized clip masks belong to the clip's save-frame
The press artifact ('square on click, hover fine'): a press draws TWO
overhanging circles — the state layer, then the ripple animation — each
inside its own inner clipRect{} save-frame. The first one realized the
pending mask INSIDE the ripple's inner frame, whose saved clipLayers
count predated the realization, so that frame's restore() composited
and closed the mask early; the second circle then drew with no pending
and no mask — a faint square filling the clip's rect region. Hover only
draws one circle, hence always looked correct.

A lazily-realized mask is now attributed to the save-frame that PUSHED
the clip: PendingRoundClip records fStack.size at push, and realization
bumps the clipLayers threshold of every save-state entered since, so
only the owning frame's restore composites the layer — matching where
the eager implementation would have opened it.

Reproduced and verified with an automated press-hold rig (locate bubble
by colour, hold, capture mid-hold): square before, round after; eager-
forced realization used as the discriminating experiment.
2026-07-14 12:44:15 +02:00
Bitsy 872d740ce7 perf(sdl): lazy rounded clips — record, prove-safe, or realize on demand
Opening an offscreen mask per rounded clip is the most expensive thing
the SDL canvas does (two render-target switches + clear + corner cut +
feather + composite) — and Compose's dominant idiom
clip(shape).background(color) never needs one. clipRoundRect now only
RECORDS the clip (the rect part applies immediately); each draw either
proves itself safe or realizes the mask on demand:

- full-cover solid rect over the innermost pending clip (the background
  idiom) -> emitted directly as an antialiased rounded-rect fill,
- content whose device aabb passes a 4-corner ellipse containment test
  against every pending outline -> drawn as-is under the rect clip,
- anything genuinely crossing an arc (overhanging text, expanding
  ripples, difference clips, shadows) -> realizes the offscreen masks
  (outermost first) exactly as before.

save/restore tracks pending depth; never-realized pendings pop for
free. Measured on the bubble-wrap sheet (84 circle-clipped bubbles +
highlight dots, continuous animation): 23 -> 37 FPS.

Known open issue under investigation: a press on a bubble can show a
square state-layer/ripple artifact (hover is correct) — suspected
custom-blend fallback in the mask feather pass; follow-up commit.
2026-07-14 12:27:58 +02:00
Bitsy 4c2d46429d fix(publish): Windows owns the root metadata modules — 0.1.15 roots had no mingwX64 variants
The KotlinMultiplatform (root) publications ran on the macOS job, and a
macOS host never declares the mingwX64 target (vHostSupportsMingw) — so
every published root .module carried variants for linux/macos/jvm but
NOT mingw_x64. Windows consumers of the bridge resolved the root fine
and then found 'No matching variant' for every coordinate: v0.1.15 (and
every earlier release) is unusable from the published root modules on
Windows. Local mavenLocal flows never caught it because this repo's dev
box IS Windows, whose roots include all targets.

Two changes:
- createRenderBackend / rendererPreferredGpuMode become a real
  expect/actual seam (:ui nativeMain expect, actuals in each renderer
  source set). They were duplicate no-expect declarations — fine
  per-target, but shared nativeMain METADATA couldn't see them when a
  host's targets span both renderers, which is exactly what blocked
  :window's metadata compilation on Windows hosts.
- The publish workflow moves the KotlinMultiplatform + Jvm + bridge
  plugin publications to the WINDOWS job (the only host declaring every
  target) and updates the delete-then-retry ownership map to match.

Verified on Windows: repo-wide common+native metadata sweep green for
all library modules, full publishKotlinMultiplatformPublicationToMavenLocal
(root .module variants now cover linux_x64/linux_arm64/macos_arm64/
mingw_x64/jvm), demo links + boots, and the bubble-wrap consumer example
resolves and links mingwX64 through the bridge against the local roots.
2026-07-14 10:34:50 +02:00
Bitsy 120abbad93 fix(sdl): glyphs scale with the layer + antialiased, overlap-free clip masks
Text: drawNativeText mapped the pen position through the canvas affine
but passed LOCAL font size and centering boxes to the glyph renderer —
inside graphicsLayer(scale) text rendered full-size and off-centre
(JVM/Skia scales glyphs with the canvas). Font size and boxes now
switch to device units at the drawText boundary (affine column norms);
wrapping stays in local units so line breaks keep matching layout.

Clip masks: the round-rect corner cutter now clamps device radii to the
int-truncated bbox half-size (opposite cuts overlapped by a fraction of
a pixel, flattening N/E/S/W into an octagon look), adapts segment count
to on-screen arc length (~4px/vertex, Sdl3DrawScope density), and adds
a ~1.2px feathered ring (custom dst*=srcAlpha blend) — SDL_RenderGeometry
has no AA, so mask edges were visibly rougher than drawn shapes.

Parity-verified against upstream JVM with OS screenshots of the same
pre-popped bubble-wrap sheet.
2026-07-14 01:30:53 +02:00
Bitsy a39002f1b8 feat(sdl3.def): carry the Linux Skia-stack linker flags in the klib
-L multi-arch dirs + fontconfig/GL/X11 moved from the apps' build files
into sdl3.def's linux linkerOpts — cinterop endorses them into the klib,
so consumers (bridge apps included) need no linkerOpts at all on Linux.
Harmless under -Prenderer=sdl3 (unused -l) and when a -L dir is absent
(LLD skips it). The apps keep their copies until the next publish makes
the klib-carried flags the ones consumers resolve; duplicates are inert.

Windows flags stay app-side ON PURPOSE: --subsystem,windows / -Wl,-s are
policy (no console window, stripped binary), not requirements — a .def
would force them on every consumer binary including debug builds and
test executables.
2026-07-14 01:10:37 +02:00
Bitsy fb470ecf0c fix(sdl): scale round-rect clip radii into device space — scaled circles became lozenges
Sdl3Canvas.clipRoundRect cuts the corner mask in DEVICE space (the bbox
goes through the canvas affine) but used the LOCAL user-space corner
radii. Under a graphicsLayer scale the oversized cuts consumed nearly
the whole shape — a circle clipped at scale 0.55 rendered as a diamond
(bubble-wrap's popped bubbles on the SDL renderer vs perfect circles on
JVM). Scale the radii by the affine's column norms alongside the bbox;
rotation already degrades to AABBs on this path.

Repro: two OS-level screenshots of the same pre-popped sheet, native vs
jvm, before/after.
2026-07-14 01:10:10 +02:00
Bitsy 2b6ac65a1f feat(bridge): auto-package composeResources into data.kres for consumers
With the official org.jetbrains.compose plugin applied, the bridge now
registers a package<Variant>ComposeResources<Target> Zip per native
executable link task: the Compose plugin's PREPARED resources (values
xml already converted to .cvr — zipping the raw source dir would break
stringResource) land in data.kres next to the binary, STORED, under the
composeResources/<res-package>/ prefix the generated accessors carry.
Consumers get the official composeResources UX on the port's targets
with zero build-script code.

Convention-based on purpose: applied from settings, the plugin's
classloader is a PARENT of the project's buildscript loader, so KGP /
Compose-plugin types are structurally invisible (typed compileOnly
access dies with NoClassDefFound at class-init). Link-task names,
binary output dirs, prepare-task names and the prepared-resources dir
are stable conventions; packageOfResClass is read reflectively with the
documented {group}.{module}.generated.resources fallback.

Verified end-to-end with the bubble-wrap example: drawable + strings.xml
resolve through Res.* on mingwX64 (data.kres auto-bundled, app boots,
.cvr conversion intact) while jvm and android keep the official
pipeline.
2026-07-14 00:58:29 +02:00
Bitsy 1b2e83a155 ci(publish): delete-then-retry so a mid-publish network blip can't poison a release
GitHub Packages forbids overwriting files that already exist in a
version, and maven-publish uploads each publication's files sequentially
from the first one. When the v0.1.13 macOS run lost DNS mid-publish, the
retries hit 409 Conflict on the first already-uploaded file of each
publication and aborted before re-attempting the missing tail — the
version could never complete (a 409 is only benign when the publication
had already fully landed, which the 409 alone can't tell you).

Between attempts each host now deletes ITS OWN package versions for the
current release via the API (GITHUB_TOKEN, packages:write) and starts
the upload from a clean slate. Hosts own disjoint package names (roots /
-macosarm64 / -jvm / bridge on macOS, -mingwx64 on Windows, -linuxx64 /
-linuxarm64 on Linux) so parallel jobs can't clobber each other; a 403
on delete degrades to the old blind retry. Last-version deletes (a
package new in this release) fall back to deleting the whole package.
2026-07-13 23:36:12 +02:00
Bitsy 8a6cb868d9 fix(sdl): defer decoded-image texture creation to the first main-thread draw
Sdl3EncodedImageDecoder ran SDL_CreateTextureFromSurface on the
resources pipeline's Dispatchers.Default workers — SDL renderer calls
are not thread-safe, so every painterResource decode on the SDL
renderer was a sporadic-crash risk (same family as #1, SDL flavour).

decode() now does the pure IMG_Load_IO surface decode only (renderer-
free, worker-safe) and hands the surface to SdlImageBitmap, which
creates the texture lazily on first access — createCanvas / the draw
paths, main thread only. Blend modes preserved: straight-alpha BLEND
for decoded surfaces, premultiplied for the vector render-target path.

Verified on Windows: demo --screen=Images renders PNG/JPG/SVG/Android-
vector through the new worker-decode → lazy-texture path.
2026-07-13 22:31:04 +02:00
Bitsy 4a7080b313 fix(window): periodic GC nudge so Cleaner-managed native memory is released
Renderer resources (Skia surfaces / images / typefaces, SDL textures)
free their NATIVE memory through Cleaners that only run when the
Kotlin/Native GC collects — and a Compose app's Kotlin heap is small
enough that the allocation-driven scheduler can starve them for minutes
while the native heap balloons (50MB → 190MB navigating the demo, never
reclaimed). Not a retention leak: layers are plain value objects, the
text/image caches are capped, and the draw paths already close() their
Skia objects eagerly.

The main loop now calls GC.collect() every 10s, only after rendering
activity, scheduled between frames — ~ms pause at these heap sizes,
keeps RSS tracking real usage on both renderers.

Fixes #2
2026-07-13 22:26:35 +02:00
Bitsy 1afb13aae0 fix(skia): register an encoded-image decoder so painterResource works on Skia builds
:components-resources' skikoRenderer variant reuses the SDL actuals,
which decode through :ui's encodedImageDecoder hook — but only the SDL
backend ever registered it. On every macOS/Linux Skia build the first
painterResource() (the demo's Images screen) hit a null decoder and
crashed with 'Image decode failed'. Deterministic, not a race.

Add SkiaEncodedImageDecoder (Image.makeFromEncoded + SVGDOM fallback via
rasterizeSvg, hoisted to file scope from SkiaImageCache) drawn into a
SkiaImageBitmap, registered at SkiaRenderBackend construction — decode
runs on the resources pipeline's Dispatchers.Default workers, which the
CPU-raster path tolerates. Mark the hook @Volatile (main-thread write,
worker-thread reads) and de-SDL the error message.

Fixes #1
2026-07-13 22:26:25 +02:00
Bitsy 3d519bcfcd docs: ui-tooling-preview, apidemo JVM parity target, bridge plugin
README: module tree entries for :ui-tooling-preview and the bridge
plugin, apidemo jvm run command + native-only mTLS note, and a new
"Using it from your own build" section for the bridge.

CLAUDE.md: same module-tree additions, apidemo multiplatform layout
(compat/Compat.kt seams), :apidemo:run parity command, bridge coverage
wording, and a new pitfall: substituted Maven modules hide their
transitives from KGP's granular-metadata visibility check — declare
every artifact commonMain touches directly + a bridge rule each.
2026-07-13 22:06:45 +02:00
Bitsy 72287fc995 feat(bridge): consumer-side Gradle plugin — official CMP coords on every platform
Dependency-substitution rules can't ship inside Maven artifacts, so the
FULL-COMMONIZATION BRIDGE is published as a Gradle plugin instead:
:compose-desktop-native-bridge (id com.bitsycore.compose-desktop-native
.bridge). Consumers apply it once in settings (gradle.lifecycle
.beforeProject, Gradle 8.8+) or per module, declare the OFFICIAL
org.jetbrains.compose coordinates in commonMain, and every native
desktop configuration substitutes the published com.bitsycore.compose
.sdl klibs — android/jvm/iOS/wasm keep resolving the official artifacts.

The substituted version defaults to the plugin's own (stamped into a
resource at build time; plugin and klibs ship from the same tag) and can
be overridden with the composeDesktopNative.version property. The
runtime is deliberately absent from the table — official klibs serve
every target. Published by the macOS publish job (host-independent jar
+ plugin marker).

Verified end-to-end offline: klibs + plugin published to mavenLocal, a
scratch third-party build applying only the plugin compiled a Compose
composable for mingwX64 against official coords — dependencyInsight
shows org.jetbrains.compose.ui:ui -> com.bitsycore.compose.sdl:ui
(selected by rule).
2026-07-13 22:06:45 +02:00
Bitsy 0b4a170910 feat(apidemo): port to JVM — shared commonMain UI + per-stack seams
Same commonization as :demo: the whole UI/logic (App, Sidebar,
ViewerPanel, panels, Model, Http, Persist, SyntaxHighlight, ...) moves
to commonMain compiling against the official Maven compose coords
(native configs substitute the port modules via the root bridge); a
jvm() target runs it on upstream Compose Desktop.

Seams (compat/Compat.kt expects; native actuals are pure delegation to
com.compose.sdl, jvm actuals use AWT + upstream desktop APIs):
file dialogs, appDataDir, revealInFileManager, clipboard text entry,
in-memory response images (Skia decode / loadSvgPainter on jvm),
wrappedRowCount (upstream TextMeasurer at density 1), editorTabWidth,
createApiHttpClient (Curl native / CIO jvm), InstallWindowHooks
(close-intercept + app-wide shortcuts -> Window onCloseRequest/
onPreviewKeyEvent), systemFileSystem (okio SYSTEM isn't in common
metadata), expect/actual mono font. Scrollbar/SplitPane/LayoutExt are
copies of the port's pure-Compose project code (ScrollState adapter
dropped — reads foundation internals; only a LazyColumn is scrollbared).

mTLS (CurlMtls + per-OS ClientCert*) stays native: ChainCert/TlsChain +
expects move to TlsCommon.kt, jvm actuals report the feature as
native-only. kotlinx-coroutines-swing provides Dispatchers.Main on
desktop jvm (App launches on it); jvmProcessResources stages
NotoSansMono + the used Material Symbols fonts.

Verified: common metadata + mingwX64 + jvm compile, native exe links
and boots, jvm app runs (fonts staged, clean exit through the
persist-then-close hook).
2026-07-13 21:40:24 +02:00
Bitsy 72e6f145b3 feat(ui-tooling-preview): common @Preview via vendored module + bridge
The canonical common @Preview is androidx.compose.ui.tooling.preview
.Preview from org.jetbrains.compose.ui:ui-tooling-preview (the umbrella
components-ui-tooling-preview annotation is deprecated). The Maven
artifact ships no mingwX64/linux klibs, so vendor the upstream module
verbatim as :ui-tooling-preview (compose-fork manifest at
COMPOSE_CORE_REF, commonMain + nonJvm actual) and substitute the Maven
coord for it on native configurations via the root bridge.

:demo declares the coord in commonMain; previews render through the jvm
parity target (IntelliJ/AS with the CMP plugin). Example on ColorsScreen.
2026-07-13 21:40:07 +02:00
Bitsy 367ed94c01 refactor(screens): streamline import statements across multiple screen files 2026-07-13 20:46:09 +02:00
Bitsy fa9bd93ada fix(demo): commonize dependencies so commonMain metadata + IDE analysis resolve
demo's commonMain had NO Compose dependencies — shared screens only
compiled because each target folds them in with jvmMain/nativeMain deps,
leaving compileCommonMainKotlinMetadata (and the IDE's common analysis)
broken between the JetBrains artifacts and the local fork.

Apply the :material-symbols pattern: commonMain declares the official
Maven coords for everything the shared screens touch (runtime, ui,
ui-graphics/-text/-unit/-geometry/-util, foundation(+layout),
animation(+core), material3, nav3-runtime, nav3-ui,
lifecycle-viewmodel-navigation3); metadata + jvm resolve them from
Maven, and the root FULL-COMMONIZATION BRIDGE substitutes each for its
port module on native configurations (new rules: ui-text/ui-graphics ->
:ui, ui-unit/-geometry/-util, foundation-layout, animation(+core),
navigation3-ui). Each artifact is declared DIRECTLY because transitives
of a substituted module are invisible to KGP's granular-metadata
visibility check.

nativeMain keeps only :window (the rest now flows from commonMain);
jvmMain keeps only its version bumps (nav3-runtime 1.2.0-alpha05) and
compose.desktop.currentOs.

Verified: :demo compileCommonMainKotlinMetadata + mingwX64 + jvm compile,
mingwX64 link, and a --screenshot runtime smoke test all pass.
2026-07-13 20:43:14 +02:00
Bitsy d1551bc9c1 fix(publish): resolve ui-graphics/ui-unit in material-symbols common metadata
The macOS publish job (the only one running the KotlinMultiplatform
metadata publication) failed in :material-symbols:
compileCommonMainKotlinMetadata — Color/isSpecified/Dp/dp unresolved.
They live in ui-graphics/ui-unit, TRANSITIVES of the Maven ui artifact:
the native substitution (ui -> project(":ui")) hides those transitives
from KGP's granular-metadata visibility check, dropping their metadata
klibs from the commonMain classpath.

Declare both explicitly in commonMain and teach the bridge their
project equivalents (ui-graphics is merged into :ui; ui-unit has its
own module).
2026-07-13 20:34:31 +02:00
510 changed files with 33783 additions and 33272 deletions
+153 -42
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:
@@ -18,10 +21,9 @@ jobs:
strategy:
fail-fast: false
# Each host runs only the publish tasks Gradle actually generated for it.
# macOS also publishes the shared kotlinMultiplatform metadata module.
# No library module declares jvm() today — apps do their own JVM parity
# build against upstream Compose, so JVM isn't part of the publication.
# `target` is the Gradle K/N target token (Capitalised) — one per host,
# WINDOWS also publishes the shared kotlinMultiplatform metadata modules
# (see its matrix entry) + the jvm jar + the bridge plugin.
# `target` is the Gradle K/N target token (Capitalised) - one per host,
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
# skipped for the app build because SDL3 static libs on the ubuntu runner
# are host-arch only; cross-compile would fail at link time.
@@ -31,13 +33,8 @@ jobs:
host: macos
target: MacosArm64
exe_ext: kexe
# jvm publication: only :material-symbols declares a jvm() target
# (its common API renders via upstream Compose on JVM) — publish
# its host-independent jar from the same host as the metadata.
tasks: >-
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
publishMacosArm64PublicationToGitHubPackagesRepository
publishJvmPublicationToGitHubPackagesRepository
- runner: ubuntu-22.04
host: linux
target: LinuxX64
@@ -49,8 +46,18 @@ jobs:
host: windows
target: MingwX64
exe_ext: exe
# WINDOWS owns the root KotlinMultiplatform metadata modules: it is
# the ONLY host that declares every target (vHostSupportsMingw is
# host-gated), so only its generated .module files carry the full
# variant table - the macOS-published roots of v0.1.15 had NO
# mingwX64 variants and Windows consumers could not resolve them.
# The host-independent jvm jar (:material-symbols) and the bridge
# plugin ride along on the same job.
tasks: >-
publishMingwX64PublicationToGitHubPackagesRepository
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
publishJvmPublicationToGitHubPackagesRepository
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
runs-on: ${{ matrix.runner }}
@@ -95,20 +102,17 @@ jobs:
# windows-2022 runners pre-install Chocolatey's mingw-w64 at C:\mingw64.
# SDL3 >= 3.4 needs a C++ compiler for its GameInput backend (K/N's
# bundled mingw is C-only) — this pre-install covers that. The setjmp
# bundled mingw is C-only) - this pre-install covers that. The setjmp
# intrinsic K/N's LLD can't resolve is aliased to msvcrt's _setjmpex at
# link time via --defsym in sdl3.def.
# Build the static native deps from scripts/build-sdl/build-sdl.properties.
# Only Windows ships the SDL3 renderer (no Skiko on mingw), so only it needs
# SDL3_ttf / SDL3_image / FreeType (SDL3_ttf text, SDL3_image decode, FreeType
# icon-axis rasterisation). macOS / Linux publish the Skia renderer — Skiko
# handles text / images / icons — so they only need base SDL3 for the main
# loop (windowing / input / audio / filesystem). Matches the cinterop guard
# in compose/ui/ui/build.gradle.kts, which skips those three cinterops on the
# Skia targets. Cached against the manifest + scripts so version bumps
# invalidate cleanly; bump the `v3` key when the toolchain (runner OS,
# compiler flavour, K/N sysroot) changes without a scripts/build-sdl/ edit.
# Build the static SDL3 lib (scripts/build-sdl/build-sdl.properties). Every
# target renders through Skia now, so all platforms need only base SDL3 for
# the main loop (windowing / input / audio / filesystem) - the from-scratch
# SDL renderer and its SDL3_ttf / SDL3_image / FreeType deps were removed.
# Cached against the manifest + scripts so version bumps invalidate cleanly;
# bump the `v3` key when the toolchain (runner OS, compiler flavour, K/N
# sysroot) changes without a scripts/build-sdl/ edit.
- name: Cache libs/
id: cache-libs
uses: actions/cache@v4
@@ -116,18 +120,12 @@ jobs:
path: libs
key: sdl-libs-v3-${{ matrix.host }}-${{ hashFiles('scripts/build-sdl/**') }}
# Windows: full SDL3 renderer stack. macOS / Linux (Skia): base SDL3 only.
- name: Build static native deps
if: steps.cache-libs.outputs.cache-hit != 'true'
shell: bash
run: |
if [ "${{ matrix.host }}" = "windows" ]; then
python3 scripts/build-sdl/build-all.py
else
python3 scripts/build-sdl/build-all.py sdl3
fi
run: python3 scripts/build-sdl/build-all.py
# src/vendor/ trees are gitignored — a fresh checkout has zero vendored
# src/vendor/ trees are gitignored - a fresh checkout has zero vendored
# androidx.compose.* files. Sync copies them in from the upstream refs
# pinned in scripts/compose-fork/compose.properties. Needs python3 + git;
# both are pre-installed on every runner.
@@ -135,16 +133,84 @@ jobs:
shell: bash
run: bash scripts/compose-fork/sync.sh
# GitHub Packages sometimes returns 409 Conflict on a fresh upload when
# the same coordinate was recently deleted (soft-delete propagation).
# `--continue` lets Gradle finish the rest of the publish tasks past a
# single 409, and the outer retry re-runs the whole thing (Gradle skips
# already-uploaded artifacts). Two attempts is enough for the transient
# cases we've hit; the third pass falls through as a hard failure.
# GitHub Packages FORBIDS overwriting a file that already exists in a
# version, and maven-publish uploads each publication's files
# SEQUENTIALLY from the first one. So when an upload dies mid-publication
# (the v0.1.13 macOS run lost DNS mid-publish), a blind retry hits 409
# Conflict on the first already-uploaded file and aborts BEFORE reaching
# the missing tail files - the version is poisoned and can never
# complete. A 409 is therefore only benign when the publication had
# already completed; you can't tell from the 409 alone. The fix: between
# attempts, DELETE this host's package versions for the current release
# and re-upload from a clean slate. Each host owns DISJOINT package
# names (roots / -mingwx64 / -jvm / bridge on Windows, -macosarm64 on
# macOS, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
# each other's uploads. Uses the run's GITHUB_TOKEN (packages: write);
# a 403 on delete just degrades to the old blind-retry behaviour.
- name: Publish to GitHub Packages
shell: bash
run: |
set -e
VER="${PUBLISH_VERSION#v}"
OWNER="${GITHUB_REPOSITORY%%/*}"
# Keep in sync with the module list in settings.gradle.kts.
MODULES="sdl-core ui ui-graphics ui-text ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple desktop-native-window material-symbols navigation3-ui components-resources"
case "${{ matrix.host }}" in
macos) SUFFIXES="-macosarm64" ;;
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
windows) SUFFIXES="_ROOT_ -mingwx64" ;;
esac
# Published group per module - mirrors the per-area com.bitsycore groups
# in root build.gradle.kts. Project-only modules (sdl-core, material-symbols)
# fall through to com.bitsycore.compose.sdl; the GitHub Packages Maven
# package name is "<group>.<artifact>".
group_of() {
case "$1" in
ui|ui-graphics|ui-text|ui-util|ui-geometry|ui-unit|ui-backhandler|ui-tooling-preview)
echo "com.bitsycore.compose.ui" ;;
foundation|foundation-layout) echo "com.bitsycore.compose.foundation" ;;
animation|animation-core|animation-graphics) echo "com.bitsycore.compose.animation" ;;
material3) echo "com.bitsycore.compose.material3" ;;
material-ripple) echo "com.bitsycore.compose.material" ;;
components-resources) echo "com.bitsycore.compose.components" ;;
navigation3-ui) echo "com.bitsycore.navigation3" ;;
desktop-native-window) echo "com.bitsycore.compose" ;;
*) echo "com.bitsycore.compose.sdl" ;;
esac
}
host_packages() {
for m in $MODULES; do
g=$(group_of "$m")
for s in $SUFFIXES; do
if [ "$s" = "_ROOT_" ]; then echo "$g.$m"; else echo "$g.$m$s"; fi
done
done
if [ "${{ matrix.host }}" = "windows" ]; then
echo "com.bitsycore.compose.sdl.material-symbols-jvm"
echo "com.bitsycore.compose.sdl.compose-desktop-native-bridge"
echo "com.bitsycore.compose-desktop-native.bridge.com.bitsycore.compose-desktop-native.bridge.gradle.plugin"
fi
}
cleanup_host_versions() {
echo "Deleting this host's partially-published $VER package versions"
host_packages | while read -r pkg; do
vid=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/users/$OWNER/packages/maven/$pkg/versions?per_page=100" \
| jq -r --arg v "$VER" 'if type=="array" then ([.[] | select(.name==$v) | .id] | first // empty) else empty end')
[ -z "$vid" ] && continue
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/users/$OWNER/packages/maven/$pkg/versions/$vid")
if [ "$code" != "204" ]; then
# Deleting the LAST version of a package is refused (400) -
# a package new in this release: drop the whole package.
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/users/$OWNER/packages/maven/$pkg")
fi
echo " $pkg@$VER -> HTTP $code"
done
}
for attempt in 1 2 3; do
echo "::group::Publish attempt $attempt"
if ./gradlew ${{ matrix.tasks }} --continue --no-daemon --stacktrace; then
@@ -152,16 +218,48 @@ jobs:
exit 0
fi
echo "::endgroup::"
[ $attempt -lt 3 ] && { echo "::warning::Publish attempt $attempt failed — sleeping 60s and retrying"; sleep 60; }
if [ $attempt -lt 3 ]; then
echo "::warning::Publish attempt $attempt failed - cleaning this host's $VER packages and retrying"
cleanup_host_versions || true
sleep 30
fi
done
echo "::error::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 :demo /
# :apidemo has its linkReleaseExecutable<Target> task wired to auto-run
# the matching copy<Variant>ComposeResources<Target> so data.kres is
# produced next to the .kexe / .exe. On Linux only linuxX64 is built —
# 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 -
# :demo's own copy<Variant>ComposeResources<Target>, :apidemo's
# bridge-plugin package<Variant>ComposeResources<Target> - so data.kres
# is produced next to the .kexe / .exe. On Linux only linuxX64 is built -
# linuxArm64 is skipped since the SDL3 static libs on the ubuntu runner
# are host-arch (x64).
- name: Build demo + apidemo release binaries
@@ -176,7 +274,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
# Lower-case first letter — gradle target tokens are Capitalised but
# Lower-case first letter - gradle target tokens are Capitalised but
# the build/bin dir uses the plain target name (macosArm64 etc).
target_dir=$(echo "${{ matrix.target }}" | awk '{print tolower(substr($0,1,1)) substr($0,2)}')
version="${GITHUB_REF_NAME#v}"
@@ -184,8 +282,21 @@ jobs:
for app in demo apidemo; do
src="$app/build/bin/$target_dir/releaseExecutable"
zip_path="$(pwd)/dist/$app-$version-$target_dir.zip"
files=("$src/$app.${{ matrix.exe_ext }}" "$src/data.kres")
# Windows Skia leg ships two runtime sidecars next to the exe - the
# skiko fork DLL and Skia's ICU data (icudtl.dat) - provisioned by the
# bridge plugin after linkReleaseExecutableMingwX64. The exe cannot run
# without them, so include them (and fail loudly if a provision regressed).
if [ "${{ matrix.exe_ext }}" = "exe" ]; then
for sidecar in skiko-windows-x64.dll icudtl.dat; do
if [ ! -f "$src/$sidecar" ]; then
echo "::error::Missing Windows runtime sidecar: $src/$sidecar"; exit 1
fi
files+=("$src/$sidecar")
done
fi
python3 -c 'import sys,os,zipfile; z=zipfile.ZipFile(sys.argv[1],"w",zipfile.ZIP_DEFLATED,compresslevel=9); [z.write(f,os.path.basename(f)) for f in sys.argv[2:]]; z.close()' \
"$zip_path" "$src/$app.${{ matrix.exe_ext }}" "$src/data.kres"
"$zip_path" "${files[@]}"
done
ls -la dist/
+5 -1
View File
@@ -53,7 +53,7 @@ bin/
### Vendored Compose sources ###
# Byte-for-byte verbatim copies from JetBrains/compose-multiplatform-core,
# regenerated on demand by scripts/compose-fork/sync.sh. Not committed — only the
# regenerated on demand by scripts/compose-fork/sync.sh. Not committed - only the
# sync tooling (script + <module>/compose-fork.txt manifests + pinned ref + README)
# is tracked.
**/src/vendor/
@@ -61,3 +61,7 @@ bin/
### Python ###
__pycache__/
*.pyc
# Probe artifacts written to the repo root by demo --nav3test / --searchesctest / --dialoganimtest
/*.bmp
/local.properties
+508 -228
View File
File diff suppressed because it is too large Load Diff
+966
View File
@@ -0,0 +1,966 @@
# 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.2` (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.2`) 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.2` 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` (`AppEvent.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 - `AppEvent.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).
### 7c. `demo --multiwintest` segfault on mingwX64 - ✅ FIXED
The multi-window probe exited 139 with no output, so it had silently been
asserting nothing. Root cause: an OpenGL context is per-WINDOW but "current" is
per-THREAD, and `SDL_GL_MakeCurrent` ran only once at creation
(`Sdl3Backend.init`) with nothing re-binding it per frame. With two windows open,
whichever initialised last owned the thread - so window A issued its draw and
swap against window B's context (silent corruption while both lived), and
destroying B left the current context dangling, faulting in `present()`.
Fixed in `SkiaGLBridge` by binding the window's own context at every entry point
that touches GL: `init`, `ensureSize` (before its unchanged-size early return -
this is the per-frame bind that covers the draw phase), `present`, and `destroy`
(so a window frees its GPU objects against its own context). Purely additive, 29
lines.
Single-window rendering is bit-identical before and after (same mean RGB
[46.6 44.4 51.5] and 167 distinct colours on the Buttons screenshot), which is
expected: SDL short-circuits a redundant bind. `--multiwintest` now PASSes.
NOTE the multi-window path was never correct, not just at teardown - two live
windows were sharing one context. Anything that looked like flicker or
cross-window corruption before this is explained by it.
## 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).
## 8. material3-adaptive ported to desktop native (2026-09-22) - ✅ LANDED
Asked for in [issue #4](https://github.com/bitsycore/compose-desktop-native/issues/4):
`org.jetbrains.compose.material3.adaptive:*` 1.3.0-rc01 publishes `ios_arm64
ios_simulator_arm64 macos_arm64` and nothing else - no linux, no mingw. (It DOES
carry macosArm64, which answers the reporter's open question.)
**The port needed zero source edits.** Two things made it a pure re-target:
1. Every platform actual the native legs want already exists upstream -
`adaptive/nonAndroidMain` (`calculatePosture`), `adaptive-layout/skikoMain`
(the ~75 l10n tables + `Strings` + the drag handle) and
`adaptive-layout/nativeMain` (`identityHashCode`, `isGraphicsLayerElement`).
2. `androidx.window:window-core` - the dependency `adaptive` api-exposes, and the
obvious suspect for why the artifacts stop at apple - has published
`linux_x64 linux_arm64 mingw_x64 macos_arm64` klibs all along, under BOTH the
google and jetbrains coordinates.
So the artifacts were missing purely because upstream never enabled the targets.
**Landed** as four modules under `compose/material3/adaptive/`
(`com.bitsycore.compose.material3.adaptive:*`), 125 files vendored verbatim off the
existing `COMPOSE_CORE_REF` pin - no new SET_REPO. Upstream's `skikoMain` lands in
`src/vendor/native/` beside upstream's own `nativeMain`; they actualise disjoint
expects so they share one source set. Both bridges substitute all four. `demo --screen=Adaptive` renders a
`ListDetailPaneScaffold`, and `demo --adaptivetest` drives one window
wide -> narrow -> wide and asserts the scaffold folds and unfolds, which is what
actually proves the resize chain end to end (SDL resize -> the owner's
snapshot-backed containerSize -> recomposition -> BoxWithConstraints re-measure ->
a new PaneScaffoldDirective -> a new scaffold value).
The demo screen computes its directive from the width IT gets rather than taking
the `rememberListDetailPaneScaffoldNavigator` default, which is the window size
class: the shell spends 190dp on the sidebar plus 48dp of padding, so defaulting
folds ~240dp too late and leaves two panes in a region already too cramped for
them. Pane-hosting apps that are not full-window should do the same.
One wrinkle worth remembering, and a textbook case of the granular-metadata trap
already documented in CLAUDE.md: because `adaptive` is SUBSTITUTED on native
configs, KGP drops its transitives from the commonMain metadata classpath, so the
shared demo screen could not see `WindowSizeClass` until `window-core` was declared
DIRECTLY in `demo`'s commonMain.
Not yet exercised: `adaptive-navigation3` builds and dumps API but has no demo
screen - the nav3 SceneStrategy path is unproven at runtime.
## 7. Post-1.12.0 open items (opened 2026-09-12)
### 7a. JVM parity reference used a DIFFERENT default font than native - ✅ FIXED
Symptom (apidemo, `SourceTag` in `InheritanceUi.kt`): the "Session" / "Pack" pill is
`Box(padding(horizontal = 5.dp, vertical = 1.dp)) { Text(fontSize = 9.sp) }` - no explicit
vertical centering, so the pill's look is entirely decided by where the glyphs sit inside the
Text node's measured paragraph box. On the JVM leg the label looks vertically centred in the
pill; on native it does not.
**Not a centering bug - a font-resolution divergence.** The two stacks render that label in
two different typefaces, which have different ascent / descent / line-gap, so the glyph run
sits at a different height inside an equally-tall box:
- NATIVE: `FontFamily.Default` resolves to the BUNDLED **NotoSans** -
`SkiaFonts.defaultTypeface` (`compose/ui/ui-text/src/skikoRendererMain/.../SkiaFonts.kt`)
loads `font/NotoSans.ttf` from data.kres and registers it as "the fallback for every
unresolved family".
- JVM: nothing registers NotoSans as the default. `apidemo/src/jvmMain/kotlin/Fonts.jvm.kt`
only defines `monoFontFamily`; `jvmProcessResources` stages the .ttf on the classpath but
never installs it. So `FontFamily.Default` is Skiko's SYSTEM default (Segoe UI on Windows).
This is a defect in the PARITY HARNESS, not (necessarily) in the renderer: every `Text` that
doesn't name a `fontFamily` is being compared against a different typeface, so any such
difference is uninterpretable. It likely masks or manufactures other "port bugs".
**Fixed** by giving apidemo a `defaultFontFamily` expect/actual (`Fonts.kt`) that loads the
bundled `font/NotoSans.ttf` on BOTH legs - native through `IconFont.register` +
`namedFontFamily`, jvm from the classpath copy `jvmProcessResources` already stages - and
stamping it across the M3 `Typography` (`withDefaultFontFamily`), since typography styles
carry their own `fontFamily` and overriding `LocalTextStyle` alone would not reach them.
This is the SAME treatment `:demo`'s parity leg already had (see the `notoSans` /
`notoTypography` block in `demo/src/jvmMain/kotlin/MainJvm.kt`, commented "so the parity %
measures real divergence, not 'different default typeface'"). apidemo had simply never been
given it. Verified: the SourceTag pill is now vertically centred on both stacks.
### 7a-bis. apidemo drift check is now mechanised - and two residuals found
`:apidemo` had no way to capture itself, so drift could only be eyeballed. Both legs now take
`--screenshot=<path>`:
apidemo.exe --screenshot=out.bmp # native: GPU readback
./gradlew :apidemo:run --args=--screenshot=out.png # jvm: ImageComposeScene
Both render OFFSCREEN to quiescence (virtual 60fps clock, infinite animations frozen via
upstream's `InfiniteAnimationPolicy`, capture after 3 quiet frames), mirroring `:demo`'s
parity path. Neither needs a visible window, so the capture is immune to occlusion - a
screen-scrape approach was tried first and grabbed whatever happened to be in front.
First run after the font fix, 1240x820: **2.9% of pixels differ at all, 2.1% by >32/255.**
Two residuals, neither a text-metrics bug:
- **Inherited lineHeight leading distribution - ✅ FIXED.** The port never forwarded
`topRatio` to skiko. Upstream's `ParagraphBuilder.skiko.kt` sets `res.topRatio = topRatio`
on EVERY SkTextStyle, sourced from `LineHeightStyle.Alignment.topRatio`; the port set
`ts.height` and `heightMode` but left `topRatio` alone, so skiko fell back to its own
default (-1 = split the extra leading proportionally to the font's ascent:descent). For a
line box much taller than the glyphs that pushes them visibly DOWN - while Material 3's
type styles ask for `Alignment.Center` (topRatio 0.5).
One line in `SkiaParagraphEngine.kt`'s SkTextStyle builder, with `Alignment.Proportional`
as the fallback exactly like upstream, so text specifying no lineHeightStyle is unchanged.
`topRatio` is present on both the official skiko and the bitsycore mingw fork.
Measured on apidemo (1240x820), whole window, native vs jvm:
| | differ | >32/255 |
|--------------------|--------|---------|
| before | 2.94% | 2.14% |
| after | 1.52% | 0.29% |
The Session/Pack pills are now pixel-identical. This ALSO killed the "constant ~2px
vertical offset" logged alongside it - that was the same bug compounding through stacked
text rows, not an independent issue: shifting the jvm capture now makes the match WORSE
(0.29% at +0px vs 2.13% at +1px), where before +2px halved the error. The residual 0.29%
is rasterizer AA.
- **Icon-font glyphs rendered LOW - ✅ FIXED.** Material Symbols sat 2-4px lower on native
than on jvm, the offset scaling with icon size (~13% of it). Cause: the JVM actual centres
the glyph explicitly (`y = height/2 - (ascent+descent)/2`), while `IconFontIcon` placed
`IconText` in a Box at the DEFAULT TopStart alignment - and the glyph's natural text box is
taller than the nominal icon size. Fixed by centring, with
`wrapContentSize(unbounded = true)` so the text measures at its natural size first:
`Modifier.size()` imposes FIXED constraints, so without it nothing overflows and
contentAlignment is a no-op (a first attempt without it changed literally nothing).
gear/download/lock are now pixel-exact; the eye is 1px off from half-pixel rounding (odd
glyph height centred in an even box; jvm draws at a float y, native placement is integer).
Whole-window >32/255 drift 0.29% -> 0.17%.
- **Missing glyph in the session header.** The leading icon next to "Untitled session"
renders as a `.notdef` TOFU on jvm and as NOTHING on native - so the glyph is absent from
the subsetted Material Symbols font on both, and the two stacks just disagree on how to
draw a missing glyph. The gear icon beside it is fine, so the icon-font path works; this
is almost certainly `enableIconSubsetting` missing a NON-LITERAL `MaterialSymbols.<Name>`
reference that `findMaterialSymbolsUsage` cannot see. Check that call site, and consider
making the native side draw .notdef too so a missing glyph is never silent.
### 7b. Vendor Koin + Coil3 + Pulse MVI (requested 2026-09-12) - ✅ DONE
Target-availability audit against Maven Central (the deciding fact - re-check on every
version bump, and check BOTH the google and jetbrains coordinate sets before concluding an
artifact "has no desktop klibs"):
| Artifact | mingwX64 | linuxX64/arm64 | macosArm64 | Action |
|---|---|---|---|---|
| `androidx.navigationevent:navigationevent-compose` 1.1.2 | YES | YES | YES | **none - already used as-is** |
| `org.jetbrains.androidx.navigationevent:navigationevent-compose` 1.1.0 | YES | YES | YES | **none** |
| `io.insert-koin:koin-core` 4.2.2 | YES | YES | YES | none - Maven as-is |
| `io.insert-koin:koin-core-viewmodel` 4.2.2 | no | no | YES | VENDOR |
| `io.insert-koin:koin-compose` 4.2.2 | no | no | YES | VENDOR |
| `io.insert-koin:koin-compose-viewmodel` 4.2.2 | no | no | YES | VENDOR |
| `io.insert-koin:koin-compose-viewmodel-navigation` 4.2.2 | no | no | YES | VENDOR |
| `io.coil-kt.coil3:coil-core` 3.6.2 | no | YES | YES | VENDOR (mingw gap) |
| `io.coil-kt.coil3:coil` 3.6.2 | no | YES | YES | VENDOR (mingw gap) |
| `io.coil-kt.coil3:coil-network-core` 3.6.2 | no | YES | YES | VENDOR (mingw gap) |
| `io.coil-kt.coil3:coil-network-ktor3` 3.6.2 | no | YES | YES | VENDOR (mingw gap) |
| `io.coil-kt.coil3:coil-svg` 3.6.2 | no | YES | YES | VENDOR (mingw gap) |
| `io.coil-kt.coil3:coil-compose-core` 3.6.2 | no | no | YES | VENDOR |
| `io.coil-kt.coil3:coil-compose` 3.6.2 | no | no | YES | VENDOR |
NOTE the original premise that navigationevent-compose lacks desktop-native artifacts is
WRONG - both coordinate sets publish mingwX64 + linux + macos, and the port already consumes
`androidx.navigationevent:navigationevent-compose:1.1.2` unmodified ("Known Compatible" in
CLAUDE.md, exercised by `demo --backtest`). Nothing to vendor there.
**Landed** as `koin/` (4 modules, `com.bitsycore.koin`), `coil/` (7 modules,
`com.bitsycore.coil3`) and `pulse/` (4 modules, `com.bitsycore.compose.desktop.native.pulse`) - 202 files vendored
verbatim across three new pinned repos (`KOIN_REF=4.2.2`, `COIL_REF=3.6.2`,
`PULSE_REF=0.3.7`), ZERO local source edits. All build for mingwX64 + linuxX64/arm64, are
BCV-dumped, publish, and carry bridge substitution rules so consumers keep declaring the
official coordinates.
Three things worth remembering from doing it:
- **nav2 is a dead end here.** koin-compose-viewmodel-navigation needs nav2's
`navigation-compose`, which has no mingwX64/linux klibs under either coordinate set.
Vendored `koin-compose-navigation3` instead - it needs only koin-compose +
navigation3-runtime, matching this port's nav3 story.
- **Upstream `non*` source sets must stay ABOVE nativeMain, not merge into it.** Several
declare `expect`s whose `actual`s live in upstream nativeMain; collapsing both into one
source set makes them siblings, which is a hard error.
- **A shared-metadata compilation is a real compile.** Coil's nonAndroid code uses
`org.jetbrains.skia.*` directly, so the INTERMEDIATE source set holding it needs skiko on
its own classpath - and since mingwX64 needs the FORK while macOS/Linux need the official
build, the two cannot share an intermediate. coil-core / coil-svg / coil-compose-core
therefore carry two PARALLEL branches over the same srcDirs, the shape
:compose:ui:ui-graphics already used. Kotlin emits no metadata compilation for the
single-target fork branch, so they never collide.
Also hit the granular-metadata trap (CLAUDE.md "Common pitfalls") in coil-compose-core:
every artifact the shared code touches must be declared DIRECTLY and get its own bridge
rule, or `compileCommonMainKotlinMetadata` loses its transitives on the Windows publish job.
REMAINING (deliberate): upstream Koin/Coil/Pulse reference the
`org.jetbrains.androidx.lifecycle:*` mirrors while this port standardises on the GOOGLE
`androidx.lifecycle:*` coords. The build files repoint them, but a consumer that pulls the
jetbrains mirrors in itself will get duplicate `unique_name` klib warnings at metadata
compile. Harmless today; if it ever becomes an error, force the google coords repo-wide.
+290 -191
View File
@@ -1,227 +1,326 @@
[![Kotlin](https://img.shields.io/badge/Kotlin-2.4.0-blue.svg?style=flat&logo=kotlin)](https://kotlinlang.org)
[![Kotlin](https://img.shields.io/badge/Kotlin-2.4.20-blue.svg?style=flat&logo=kotlin)](https://kotlinlang.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md)
# Compose Desktop Native
A Kotlin/Native port of **Compose Multiplatform** running on **SDL3** for
windowing and input — **no JVM**. Compiles to native binaries for macOS
(arm64), Linux (x64/arm64), Windows (mingwX64).
Run [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform)
as a native binary, with no JVM. Compose Desktop Native compiles to a single
executable for macOS (arm64), Linux (x64/arm64), and Windows (mingwX64), using
[SDL3](https://libsdl.org) for windowing and input.
Rendering is pluggable behind one `RenderBackend`:
<img src="screenshots/demo.png" width="100%" alt="Compose Desktop Native demo" />
- **Skia** (via Skiko klibs) on macOS + Linux — Metal / OpenGL / CPU raster.
- **SDL3** (`SDL3_ttf` + `SDL_RenderGeometry`) on Windows, and on macOS/Linux
when `-Prenderer=sdl3` is passed.
## How it works
The Compose **runtime** is the official
`org.jetbrains.compose.runtime:runtime` klibs from Maven — this project
re-implements the layers on top of it (`androidx.compose.ui.*`,
`.foundation.*`, `.animation.*`, `.material3.*`) by **vendoring
upstream Compose Multiplatform verbatim** whenever possible and hand-rolling
project actuals + SDL3 / Skia glue only where needed.
- **The runtime is the real one.** Composition, snapshots, and the recomposer
are the official `org.jetbrains.compose.runtime` klibs from Maven, never
reimplemented.
- **The UI layers are vendored upstream.** `androidx.compose.ui`, `foundation`,
`animation`, and `material3` are copied from Compose Multiplatform verbatim
wherever they compile as is, with project code filling in only the native glue.
The same treatment extends to ecosystem libraries that stop short of
Kotlin/Native desktop - Koin, Coil 3 and Pulse MVI are vendored and rebuilt for
these targets, and you still declare their official coordinates.
- **Rendering is Skia everywhere.** macOS and Linux link the official Skiko
klibs (Metal / OpenGL); Windows links the bitsycore Skiko fork, which ships
Skiko and Skia together in `skiko-windows-x64.dll`.
- **The platform is SDL3.** Windowing, input, audio, filesystem, file dialogs,
and clipboard all go through SDL3, so one code path covers every OS.
- **Lean distributables.** SDL3 is built as a static library and linked in. On
macOS and Linux a distributable is just the executable plus a `data.kres`
resource bundle; on Windows it also ships `skiko-windows-x64.dll` next to the
exe (auto-provisioned by the bridge plugin).
## Module layout
| Platform | Gradle target | Renderer |
|----------|---------------|----------|
| macOS arm64 | `macosArm64` | Skia (Metal) - official Skiko |
| Linux x64 / arm64 | `linuxX64` / `linuxArm64` | Skia (OpenGL) - official Skiko |
| Windows | `mingwX64` | Skia - bitsycore Skiko fork |
One Gradle module per upstream Compose artifact — the directory tree mirrors
upstream's `compose/` layout, gradle paths stay short via `projectDir`
redirects.
## Quickstart
```
compose/
├── ui/
│ ├── ui/ → :ui (androidx.compose.ui.* + renderers + cinterops)
│ ├── ui-util/ → :ui-util
│ ├── ui-geometry/ → :ui-geometry
│ ├── ui-unit/ → :ui-unit
│ └── ui-backhandler/ → :ui-backhandler
├── animation/
│ ├── animation-core/ → :animation-core
│ ├── animation/ → :animation
│ └── animation-graphics/ → :animation-graphics
├── foundation/
│ ├── foundation/ → :foundation
│ └── foundation-layout/ → :foundation-layout
├── material3/material3/ → :material3
├── material/material-ripple/ → :material-ripple
└── sdl/ (project modules — the SDL integration layer)
└── window/ → :window (nativeComposeApp { Window(...) } + SDL3 main loop,
per-window Lifecycle/ViewModelStore/SavedState owners)
utils/
└── material-symbols/ → :material-symbols (Material Symbols: 4200+ codepoints + Outlined/Rounded/
Sharp composables with the variable-font axes as
parameters (FILL/wght/GRAD/opsz). Common API — native
draws via the port's icon-font engine, the jvm target
via Skiko, so shared app code uses it on both stacks)
components/
└── resources/library/ → :components-resources (vendored official compose resources runtime —
painterResource/stringResource/Res codegen; the Maven
artifact ships no mingwX64/linux klibs)
navigation3/
└── navigation3-ui/ → :navigation3-ui (vendored NavDisplay + scene machinery — the one
Navigation 3 layer without a K/N desktop artifact)
demo/ → :demo (showcase + CLI probe suite; multiplatform — the same screens
also build for stock JVM Compose Desktop as a parity reference)
apidemo/ → :apidemo (Postman-style REST client)
```
## demo — widget & feature showcase
<img src="screenshots/demo.png" width="100%" alt="ComposeNativeSDL3 demo" />
`:demo` is a full tour of the re-implemented Compose + Material 3 surface —
30+ sidebar screens covering buttons, text fields, layout, modifiers, shapes,
images, state & recomposition, scrolling & lazy lists, dialogs, icons, canvas,
graphics layers, custom layout, animation and gestures.
```bash
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (SDL3)
./gradlew :demo:run # stock JVM Compose Desktop (parity reference)
```
The same shared screens drive both stacks — the JVM target runs them on
upstream Compose Desktop unchanged, so any visual or behavioural difference
against the native build is a porting bug, not a demo artifact.
CLI: `--gpu=…`, `--screen=<Name>` (one screen, no sidebar),
`--screenshot=out.bmp --frames=N`, `--width=W --height=H`.
## apidemo — API Manager
<img src="screenshots/apidemo.png" width="100%" alt="ComposeNativeSDL3 API Manager" />
`:apidemo` is a Postman-style REST client built entirely on the library:
request collections (**packs**, nested sub-packs, linked copies), a
**Session → Pack → Request** inheritance ladder for variables / headers /
query params / client certs, syntax-highlighted JSON / XML / YAML / HTML body
editors, a response viewer with timing, size and TLS-chain inspection, mTLS
client certificates, drag-and-drop tree management, request history and
file-based sessions.
```bash
./gradlew :apidemo:runDebugExecutableMacosArm64
./gradlew :apidemo:runDebugExecutableLinuxX64
gradlew.bat :apidemo:runDebugExecutableMingwX64
```
Add `-Prenderer=sdl3` on macOS/Linux to drop Skiko and use the pure-SDL3
renderer everywhere.
## Minimal app
### A window
```kotlin
import androidx.compose.material3.Text
import com.compose.sdl.nativeComposeWindow
fun main() = nativeComposeWindow(title = "Hello") {
Text("Hello from ComposeNativeSDL3")
Text("Hello from Compose Desktop Native")
}
```
The lambda runs with a `ComposeWindowScope` receiver exposing
`window: ComposeNativeWindow` (`setTitle` / `setSize` / `minimize` /
`maximize` / `setFullscreen` / `close` / …); the same handle is reachable
from any nested composable via `LocalComposeNativeWindow.current`.
The content lambda runs with a `ComposeWindowScope` receiver that exposes
`window: ComposeNativeWindow` (`setTitle`, `setSize`, `minimize`, `maximize`,
`setFullscreen`, `close`). The same handle is reachable from any nested
composable via `LocalComposeNativeWindow.current`.
Add these to your module's `commonMain.dependencies`:
For multiple windows, wrap them in `nativeComposeApp`, where the set of
`Window(...)` calls is state driven (Compose Desktop style):
```kotlin
implementation(project(":window")) // window + main loop
implementation(project(":material3")) // Material 3 widgets
implementation(project(":material-symbols")) // icon-font composables (optional)
fun main() = nativeComposeApp {
Window(title = "Main", onCloseRequest = ::exitApplication) { /* ... */ }
if (showInspector) {
Window(title = "Inspector", onCloseRequest = { showInspector = false }) { /* ... */ }
}
}
```
Each window gets its own `Lifecycle`, `ViewModelStore`, and `SavedStateRegistry`
owners, driven by real SDL focus and visibility events, so ViewModels and
saved state behave as they do on Android.
`Window()` takes the same attributes as Compose Desktop's - `undecorated`,
`transparent`, `resizable`, `enabled`, `focusable`, `alwaysOnTop`,
`onPreviewKeyEvent` and `onKeyEvent` - and re-applies them when they change.
(`transparent` is the one exception: SDL needs it at window-creation time, so it
is fixed for the window's life.)
When the Compose-level API doesn't reach far enough, drop to SDL directly:
```kotlin
val handles = window.rawSdlHandles() // SDL_Window* / SDL_Renderer* / GL / Metal
handles.window?.let { sdl3.SDL_FlashWindow(it.reinterpret(), SDL_FLASH_BRIEFLY) }
```
The pointers are a snapshot valid only while the window lives, and which ones
are non-null depends on the resolved renderer (`glContext` for Skia OpenGL,
`metalView` for Metal, `renderer` for CPU raster).
### Building in this repo
```kotlin
commonMain.dependencies {
implementation(project(":compose:desktop:native:desktop-native-window")) // window shell + main loop
implementation(project(":compose:material3:material3")) // Material 3 widgets
implementation(project(":utils:material-symbols")) // icon-font composables (optional)
}
```
### Building from your own project: the bridge plugin
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.
```kotlin
// build.gradle.kts, official coordinates everywhere
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
id("com.bitsycore.compose-desktop-native.bridge") version "<release>"
}
commonMain.dependencies {
// The plugin exposes the exact Compose versions the port tracks, so you
// never hand-match them (material3 and material3-adaptive each ride their
// own release train upstream).
implementation("org.jetbrains.compose.runtime:runtime:${composeDesktopNative.composeRuntime}")
implementation("org.jetbrains.compose.ui:ui:${composeDesktopNative.compose}")
implementation("org.jetbrains.compose.foundation:foundation:${composeDesktopNative.compose}")
implementation("org.jetbrains.compose.material3:material3:${composeDesktopNative.composeMaterial3}")
// material3-adaptive, which upstream publishes for apple targets only.
// Note adaptive api-exposes androidx.window:window-core - declare it
// DIRECTLY if your commonMain touches WindowSizeClass, since substituted
// modules hide their transitives from the common metadata classpath.
val vAdaptive = composeDesktopNative.composeMaterial3Adaptive
implementation("org.jetbrains.compose.material3.adaptive:adaptive:$vAdaptive")
implementation("org.jetbrains.compose.material3.adaptive:adaptive-layout:$vAdaptive")
implementation("androidx.window:window-core:1.5.0")
}
```
**One repository, no credentials.** `https://maven.bitsycore.com/releases`
carries both the port's artifacts and the bitsycore skiko fork
(`com.bitsycore.skiko`), which the Windows (mingwX64) target renders through;
macOS/Linux pull the official skiko from Maven Central instead. The exact
`repositories {}` snippet 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
Android, JVM, and native desktop, see the example repo:
[bitsycore/compose-desktop-native-bridge-example](https://github.com/bitsycore/compose-desktop-native-bridge-example).
## Sample apps
Both live in `commonMain` and also build for stock JVM Compose Desktop, which
serves as the visual and behavioural reference: any difference against the
native build is a porting bug.
**`:demo`** is a tour of the re-implemented Compose and Material 3 surface,
30-plus screens covering text, layout, shapes, images, state, lazy lists,
dialogs, canvas, graphics layers, animation, and gestures.
```bash
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (Skia / Skiko fork)
./gradlew :demo:run # JVM Compose Desktop (reference)
```
**`:apidemo`** is a Postman-style REST client built entirely on the library:
request collections, a session inheritance ladder, syntax-highlighted body
editors, a response viewer with timing and TLS-chain inspection, and mTLS
client certificates.
<img src="screenshots/apidemo.png" width="100%" alt="Compose Desktop Native API Manager" />
```bash
./gradlew :apidemo:runDebugExecutableMacosArm64
./gradlew :apidemo:run # JVM Compose Desktop (reference)
```
## Modules
One Gradle module per upstream artifact, and the Gradle path mirrors the
directory (`:compose:ui:ui`, `:compose:foundation:foundation`, …). The renderer
lives in `:compose:ui:ui` and the `sdl3` cinterop in `:sdl:sdl-core`;
`:compose:desktop:native:desktop-native-window` is the SDL integration layer.
Most of the androidx architecture stack (lifecycle, viewmodel, navigation3,
savedstate, navigationevent) ships real Kotlin/Native desktop klibs and runs on
the port unmodified. Where an ecosystem library stops short of Kotlin/Native
desktop, this repo vendors it verbatim from upstream and rebuilds it for these
targets, so you keep writing against the official coordinates:
| Vendored | Why | Published as |
|----------|-----|--------------|
| Compose `ui` / `foundation` / `animation` / `material3` | the port itself | `com.bitsycore.compose.*` |
| `components-resources`, `navigation3-ui` | no mingwX64 / linux klibs | `com.bitsycore.compose.components`, `com.bitsycore.navigation3` |
| **Koin** viewmodel + compose modules | apple + android only upstream (`koin-core` itself is fine) | `com.bitsycore.koin` |
| **Coil 3** (whole stack) | no mingwX64 anywhere; no desktop native at all for its compose layer | `com.bitsycore.coil3` |
| **Pulse MVI** | no desktop-native artifact upstream | `com.bitsycore.compose.desktop.native.pulse` |
| **material3-adaptive** (all four) | ios + macosArm64 only upstream - no linux, no mingw | `com.bitsycore.compose.material3.adaptive` |
The bridge plugin substitutes each of these on native desktop configurations, so
app code declares `io.insert-koin:koin-compose`, `io.coil-kt.coil3:coil-compose`
and so on exactly as it would anywhere else. The full module map, dependency
graph, and the list of compatible artifacts are in [CLAUDE.md](CLAUDE.md).
## Pinned versions
Everything below is pinned in exactly two files -
[`gradle/libs.versions.toml`](gradle/libs.versions.toml) (Maven coordinates) and
[`scripts/compose-fork/compose.properties`](scripts/compose-fork/compose.properties)
(the upstream git refs the vendored sources are copied from) - plus
[`scripts/build-sdl/build-sdl.properties`](scripts/build-sdl/build-sdl.properties)
for SDL3. A release tracks one Compose Multiplatform version; the table is the
contract.
**Version policy.** Anything Compose Multiplatform integrates is pinned to what
CMP ships - for the androidx mirrors, the *Jetpack* version CMP's release notes
map its `org.jetbrains.androidx.*` artifact to, since this port consumes the
google coordinates. Anything CMP does not integrate is pinned to the latest
release. Deliberate exceptions are commented in the catalog; there is currently
one (`savedstate`, below).
### Toolchain
| | Version | Notes |
|---|---|---|
| Kotlin | **2.4.20** | Newer than the 2.3.20 CMP 1.12.1 is built with. Kotlin/Native consumes older klibs fine, and the whole port builds clean on 2.4.20 with no klib API diff, so there is no reason to hold back. |
| Compose Multiplatform | **1.12.1** | The vendored sources are the `v1.12.1` tag of both upstream repos, and the JVM parity leg forces the same version - no dev-build skew. skiko is unchanged at 0.150.1, so the mingwX64 fork still matches its Skia base. |
| SDL3 | **release-3.4.16** | Built from source as a static lib per host (`scripts/build-sdl/build-all.py`), linked into the executable. |
| Skiko | **0.150.1** | macOS / Linux use the official `org.jetbrains.skiko`. |
| Skiko (Windows fork) | **0.150.1-mingw.2** | `com.bitsycore.skiko:skiko` - Skiko + Skia in `skiko-windows-x64.dll`. Same Skia base as the official build. Public repo, no auth. |
### Compose libraries
The port republishes each vendored artifact under a `com.bitsycore` group that
mirrors the upstream one, so the fork of any given coordinate is obvious. Apply
the bridge plugin and you keep declaring the **official** coordinate.
| Library | Official coordinate | Port coordinate |
|---|---|---|
| Runtime | `org.jetbrains.compose.runtime:runtime*:1.12.1` | *not forked - the official klibs serve every target* |
| UI | `org.jetbrains.compose.ui:ui*:1.12.1` | `com.bitsycore.compose.ui:ui*` |
| Foundation | `org.jetbrains.compose.foundation:foundation*:1.12.1` | `com.bitsycore.compose.foundation:foundation*` |
| Animation | `org.jetbrains.compose.animation:animation*:1.12.1` | `com.bitsycore.compose.animation:animation*` |
| Material Ripple | `org.jetbrains.compose.material:material-ripple:1.12.1` | `com.bitsycore.compose.material:material-ripple` |
| Material3 | `org.jetbrains.compose.material3:material3:1.12.0-alpha03` | `com.bitsycore.compose.material3:material3` |
| Material3 Adaptive | `org.jetbrains.compose.material3.adaptive:adaptive*:1.3.0-rc01` | `com.bitsycore.compose.material3.adaptive:adaptive*` |
| Resources | `org.jetbrains.compose.components:components-resources:1.12.1` | `com.bitsycore.compose.components:components-resources` |
| Navigation3 UI | `org.jetbrains.androidx.navigation3:navigation3-ui` | `com.bitsycore.navigation3:navigation3-ui` |
| Window shell | *no upstream equivalent* | `com.bitsycore.compose:desktop-native-window` |
| SDL layer | *no upstream equivalent* | `com.bitsycore.compose.sdl:sdl-core` |
Material3 rides its own release train upstream: `1.12.0-alpha03` **is** the
version Compose Multiplatform 1.12.1 ships (Jetpack Material3 1.5.0-alpha22).
material3-adaptive rides a third train again.
**material3-adaptive** (`adaptive`, `adaptive-layout`, `adaptive-navigation`,
`adaptive-navigation3`) is published upstream for ios and macosArm64 only - no
linux, no mingw. This port ships all four for every desktop-native target. It
needed no source changes at all: every platform actual the native legs want is
already in upstream's `nonAndroidMain` / `skikoMain` / `nativeMain`, and
`androidx.window:window-core` - the one dependency involved - has published
linux, mingw and macos klibs the whole time. The artifacts were missing only
because upstream never turned the targets on. `demo --screen=Adaptive` renders
a live `ListDetailPaneScaffold`.
### Ecosystem libraries
Vendored because upstream stops short of Kotlin/Native desktop. The bridge
substitutes them on native desktop targets only.
| Library | Official coordinate | Port coordinate | Why |
|---|---|---|---|
| Koin | `io.insert-koin:koin-compose*:4.2.2` | `com.bitsycore.koin:*` | apple + android only upstream |
| Koin core | `io.insert-koin:koin-core:4.2.2` | *not forked* | already ships mingwX64 + linux |
| Coil 3 | `io.coil-kt.coil3:coil*:3.6.3` | `com.bitsycore.coil3:*` | no mingwX64 anywhere; no desktop native at all for the compose layer |
| Pulse MVI | `com.bitsycore.lib:pulse*:0.3.7` | `com.bitsycore.compose.desktop.native.pulse:*` | no desktop-native artifact upstream - and Pulse is itself a `com.bitsycore` library, so the republish is namespaced under this project |
Koin's `koin-compose-viewmodel-navigation` is deliberately **not** provided: it
needs Navigation 2's `navigation-compose`, which has no mingwX64 or linux klibs
under either coordinate set. Use `koin-compose-navigation3`.
### AndroidX, used as-is
These publish real Kotlin/Native desktop klibs and run on the port unmodified -
nothing is vendored or substituted. The port standardises on the **google**
`androidx.*` coordinates; do not also pull the `org.jetbrains.androidx.*`
mirrors or you will have every class twice.
| Library | Coordinate |
|---|---|
| Lifecycle / ViewModel | `androidx.lifecycle:lifecycle-*:2.11.0` |
| SavedState | `androidx.savedstate:savedstate*:1.5.0` (CMP builds against 1.4.0; lifecycle 2.11.0 requires it only as a *minimum*, so 1.5.0 resolves cleanly and gives consumers the newer API) |
| Navigation3 runtime | `androidx.navigation3:navigation3-runtime:1.1.7` |
| Navigation Event | `androidx.navigationevent:navigationevent-compose:1.1.2` |
| Collection | `androidx.collection:collection:1.5.0` |
| Graphics Shapes | `androidx.graphics:graphics-shapes:1.1.0` |
## Building
All four native dependencies — **SDL3**, **SDL3_ttf** (the in-house
variable-font-axes fork), **SDL3_image** (+ vendored PNG/WEBP codecs) and
**FreeType** — are built from source as **static** libraries by one script,
on every OS, and linked straight into the executable. No `brew install`, no
`apt install libsdl3-dev`, no runtime `.dll` / `.so` / `.dylib` next to the
binary — a distributable is just `<app>` + `data.kres`.
Build the native libraries once per machine, then build any app target:
```bash
python3 scripts/build-sdl/build-all.py # freetype → sdl3 → sdl3-image → sdl3-ttf
python3 scripts/build-sdl/build-all.py sdl3-ttf # rebuild a subset
python3 scripts/build-sdl/build-all.py # SDL3 (static)
./gradlew :demo:runDebugExecutableMacosArm64
```
Library versions / URLs are pinned in `scripts/build-sdl/build-sdl.properties`;
output lands in the gitignored in-repo `libs/`. Required on every host: `git`,
`cmake`, Python 3 (`ninja` is fetched automatically when absent). Per host:
Every host needs `git`, `cmake`, and Python 3. Per host: macOS needs the Xcode
command line tools; Linux needs gcc/g++ and the X11 / Wayland / audio dev
headers; Windows needs a mingw-w64 g++ on PATH. See [TOOLING.md](TOOLING.md) for
the full build and verification workflow.
- **macOS** — Xcode command line tools. Skia is the default renderer; Skiko
klibs come from Maven.
- **Linux** — gcc/g++ plus the X11 / Wayland / audio dev headers SDL3's
configure detects (the exact apt list is in
`.github/workflows/publish.yml`).
- **Windows** — a mingw-w64 g++ on PATH (the C side compiles with
Kotlin/Native's bundled mingw).
## Support
Then build any app target — see the demo / apidemo commands above.
[Bitsycore's Discord](https://discord.gg/kGnraVAtDR)
## Vendoring
## Documentation
The bulk of the `androidx.compose.*` code is vendored byte-for-byte from
`JetBrains/compose-multiplatform-core` and lives under
`<module>/src/vendor/` (gitignored — you re-sync on demand). Each module
carries a `compose-fork.txt` manifest listing which upstream files it pulls
in.
```bash
scripts/compose-fork/sync.sh # sync every module
scripts/compose-fork/sync.sh compose/ui/compose-fork.txt # one module
```
Upstream ref pinned in `scripts/compose-fork/compose.properties`; manifests can
pin additional upstream repos inline (`SET_REPO=<url>@<ref>` — the resources
runtime vendors from the compose-multiplatform umbrella repo this way).
How much of upstream's public API the port actually covers is measured, not
guessed — `scripts/compose-coverage.py` diffs this repo's klib ABI dumps
against upstream's, per vendored module:
```bash
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
python3 scripts/compose-coverage.py --missing ui-text # list the exact uncovered decls
```
See [CLAUDE.md](CLAUDE.md) for the full architecture, source-set hierarchy,
vendoring rules, density flow (physical-pixel Option B), and per-area file
map.
## Known Compatible — official artifacts that just work
A surprising amount of the androidx architecture stack ships real
Kotlin/Native desktop klibs (mingwX64, linuxX64/arm64, macosArm64) and runs
on this port **unmodified** — no reimplementation, no vendoring. Sometimes
the Google coordinates (`androidx.*`) carry the K/N variant, sometimes the
JetBrains ones (`org.jetbrains.*`) — check both.
Verified working in-tree:
| Artifact | Notes |
|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| `org.jetbrains.compose.runtime:runtime`, `runtime-saveable` | the Compose runtime itself — never reimplemented |
| `androidx.compose.runtime:runtime-retain` | google coordinates |
| `androidx.lifecycle:lifecycle-runtime-compose` | `LocalLifecycleOwner`, `rememberLifecycleOwner`, `repeatOnLifecycle` |
| `androidx.lifecycle:lifecycle-viewmodel(-compose, -savedstate)` | full ViewModel + SavedStateHandle stack |
| `androidx.lifecycle:lifecycle-viewmodel-navigation3` | per-entry ViewModel scoping for Navigation 3 |
| `androidx.savedstate:savedstate`, `savedstate-compose` | |
| `androidx.navigation3:navigation3-runtime` | backstack / NavEntry / decorators (only the UI layer needed vendoring → `:navigation3-ui`) |
| `androidx.navigationevent:navigationevent-compose` | predictive back / BackHandler plumbing |
| `androidx.collection:collection` | |
The window layer supplies what these expect from a host: per-window
`LifecycleOwner` (driven by real SDL focus/visibility events — focused →
RESUMED, unfocused → STARTED, minimized → CREATED), `ViewModelStoreOwner`
(the `activityViewModels()` analog, `SavedStateHandle`-enabled) and
`SavedStateRegistryOwner`, plus a `Dispatchers.Main` with a true `immediate`.
ViewModels scope per nav entry, per window, or anywhere in between — same
semantics as Android.
- [TOOLING.md](TOOLING.md): building the native libs, vendoring, and verification.
- [CLAUDE.md](CLAUDE.md): architecture, source-set hierarchy, vendoring rules,
density flow, and a per-area file map.
- [gradle-plugin/compose-desktop-native-bridge](gradle-plugin/compose-desktop-native-bridge/README.md):
consuming the port from another build.
## License
+258
View File
@@ -0,0 +1,258 @@
# Tooling
Helper scripts and workflows used to build, vendor, and verify Compose Desktop
Native. Each has a focused job; the deeper ones link to their own README.
For architecture, source-set layout, and vendoring rules, see
[CLAUDE.md](CLAUDE.md).
## Native libraries
SDL3 is built from source as a static library and linked straight into the
executable - the windowing, input, and platform-integration layer. One script
does it on every OS. Output lands in the gitignored `libs/`; the version is
pinned in `scripts/build-sdl/build-sdl.properties`.
```bash
python3 scripts/build-sdl/build-all.py # sdl3
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
`JetBrains/compose-multiplatform-core` into each module's gitignored
`src/vendor/`. A `compose-fork.txt` manifest per module lists which upstream
files it pulls; the pinned ref lives in `scripts/compose-fork/compose.properties`.
```bash
scripts/compose-fork/sync.sh # re-sync every module
scripts/compose-fork/sync.sh compose/ui/ui/compose-fork.txt # one module
```
Reach for this after a fresh checkout or a ref bump. Details:
[scripts/compose-fork/README.md](scripts/compose-fork/README.md).
Guardrails that keep the vendor tree honest:
```bash
python3 scripts/compose-fork/check-vendor-clean.py # src/vendor matches the pinned refs
python3 scripts/compose-fork/check-vendor-drift.py # hand-edited "manual vendors" match their pin
python3 scripts/compose-fork/audit-exclusions.py # every `!` exclusion is classified
```
`check-vendor-clean` fails if a hand-edit ever leaks into `src/vendor/`.
`check-vendor-drift` reads the `// VENDOR-BASE:` header on every edited copy and,
using the local upstream clone, reports whether the base actually changed since
the pin (needs reconciling) or is merely stale (safe to re-stamp).
`audit-exclusions` covers the other half: a manifest `!` line takes a file OUT of
the verbatim sync, and the local replacement is either a derived copy
(`// VENDOR-BASE:`, drift-tracked) or a fresh reimplementation
(`// VENDOR-REIMPL:`, deliberately not tracked - only a SIGNATURE change matters).
It classifies each one, flags any local counterpart carrying NEITHER marker
(invisible to the drift check, silently rotting) and any copy that has become
identical to upstream (drop it and re-vendor), and exits non-zero on an
unclassified file. Run all three on every ref bump.
### Coverage against upstream
How much of upstream's public API the port covers is measured, not guessed.
```bash
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
python3 scripts/compose-coverage.py --missing ui-text # list uncovered declarations
```
## Verification
### One-command gate
`verify-mac.sh` is the runbook to run after any renderer or layout change. On
macOS or Linux it exercises the Skia renderer and exits non-zero on any
failure:
```bash
scripts/verify-mac.sh
```
It runs: the vendor drift and clean checks, a build of `:demo` and `:apidemo`,
the interaction probes, the parity sweep, a memory soak, and a frame-time spot
check. The Windows target is verified separately (see below).
### Parity: native vs JVM
`:demo` renders the same shared screens on two stacks: this port, and stock JVM
Compose Desktop. `parity.py` screenshots every screen on both and pixel-diffs
them, so a screen that diverges is a porting bug. It gates against per-screen
golden baselines.
```bash
python3 scripts/parity/parity.py # all screens
python3 scripts/parity/parity.py Buttons Shapes # a subset
python3 scripts/parity/parity.py --no-build # reuse the last renders
```
How to read the diff and the percentage: [scripts/parity/README.md](scripts/parity/README.md).
### Interaction probe
`probe.py` launches a native window, sends window-relative input (click, hover,
hold), and captures the client area. It packages the rigs used to reproduce
interaction bugs. Windows-only. Details:
[scripts/probe/README.md](scripts/probe/README.md).
### Frame profiler
Set `CDN_PROFILE=1` (or `CDN_PROFILE=<path>`) and run any app. Every couple of
seconds it writes per-phase main-loop timings (events, app pump, render, with
draw sub-phases and per-frame draw counters) to a file. `CDN_FORCERENDER=1`
renders every frame so steady-state timings read on an otherwise idle screen.
```bash
CDN_PROFILE=1 CDN_FORCERENDER=1 ./demo/build/bin/macosArm64/debugExecutable/demo.kexe
```
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
verify runbook and in isolation:
```bash
demo.kexe --nav3test # Navigation 3 + ViewModel + lifecycle
demo.kexe --backtest # predictive-back / BackHandler
demo.kexe --clicktest # pointer -> upstream clickable
demo.kexe --scrolltest # wheel -> scrollable
demo.kexe --multiwintest # multi-window lifecycle
demo.kexe --adaptivetest # material3-adaptive folds/unfolds on a LIVE window resize
demo.kexe --soaktest # memory soak (CDN_SOAK_SCREEN, CDN_SOAK_STATIC, CDN_SOAK_CYCLES)
```
### Windows smoke
The Mac runbook cannot cover the Windows target. Before shipping, on a Windows
host: build the mingwX64 executables, run `scripts/probe/`, and run a publish
dry run (only Windows compiles the full common-metadata variant table).
```bat
gradlew.bat :demo:runDebugExecutableMingwX64
gradlew.bat :apidemo:runDebugExecutableMingwX64
```
mingwX64 renders through Skia via the **bitsycore skiko fork**, consumed from
the PUBLIC `https://maven.bitsycore.com/releases` (no credentials) as
`com.bitsycore.skiko:skiko:0.150.1-mingw.2` (macOS/Linux use official Skiko). The runtime `skiko-windows-x64.dll` is auto-provisioned next
to the executable by the bridge plugin - no manual copy. The fork itself is
published by a separate GitHub Actions workflow in the fork repo, out of band
from this repo's release flow.
## Consuming the port
To build a third-party app against the published klibs, apply the bridge Gradle
plugin and declare official Compose Multiplatform coordinates; the plugin swaps
in the port's klibs on native desktop targets. Setup:
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
## Versioning and releasing
### Version map
Versions live in a few places, some of which move in lockstep. There is no
single file to edit; know which axis you are changing.
| Version | Where | Notes |
|---------|-------|-------|
| Project release version | The git tag `vX.Y.Z`. `PUBLISH_VERSION` (from the tag) feeds `vPublishVersion` in `build.gradle.kts`, which strips the leading `v`. Groups mirror upstream per area (`com.bitsycore.compose.<area>:<module>`, e.g. `com.bitsycore.compose.ui:ui`); project-only modules use `com.bitsycore.compose.sdl` and `:desktop-native-window` is `com.bitsycore.compose` - see `groupFor()` in the root build. | Set by the tag, not edited by hand. A non-publish build is `0.0.0-SNAPSHOT`. |
| Vendored Compose (native side) | `COMPOSE_CORE_REF` and `COMPOSE_REF` in `scripts/compose-fork/compose.properties`, plus `compose` in `gradle/libs.versions.toml`. | Pin to a durable tag (not a `+dev` commit upstream may GC). Re-sync after changing. |
| Vendored ecosystem libs | `KOIN_REF`, `COIL_REF`, `PULSE_REF` in the same file, plus `koin` / `coil` in `gradle/libs.versions.toml`. | Same rule: durable tags. Each repo gets its own sibling sparse clone (`../cmp-ref-koin`, `../cmp-ref-coil`, `../cmp-ref-pulse-mvi`). Re-sync, then `apiDump` - these modules are BCV-validated like the rest. |
| JVM parity forcing | `compose` / `composeMaterial3` / `composeRuntime` in `gradle/libs.versions.toml` (read by `demo`, `apidemo`, `material-symbols` and the root build's forcing map). | Must be a version PUBLISHED to Maven Central. It may lag the vendored native ref (a documented skew) until the matching version is published; at v1.12.1 they are in lockstep. |
| Skiko | `skiko` (official) and `skikoMingw` (the fork) in `gradle/libs.versions.toml`; keep the fork's `-mingw.N` base equal to `skiko`. | macOS/Linux use official Skiko (`org.jetbrains.skiko`); mingwX64 uses the bitsycore fork (`com.bitsycore.skiko:skiko:0.150.1-mingw.2` from the public maven.bitsycore.com), published out of band by the fork repo's own workflow. Must expose the `org.jetbrains.skiko.node` `RenderNode` / `GraphicsContext` API the vendored compose-core uses (the fork keeps upstream's `org.jetbrains.skiko.*` package names; only the Maven coord is rebranded). Verify with a throwaway `skikoRendererMain` compile if unsure. |
| SDL3 | `scripts/build-sdl/build-sdl.properties`. | Rebuild `libs/` with `build-all.py` after any change. |
| Bridge substituted version | Defaults to the bridge plugin's own published version; consumers override with the `composeDesktopNative.version` Gradle property. | The plugin publishes with the release tag, so a consumer on the matching plugin version resolves the right klibs automatically. |
### Bump the upstream Compose ref
Run this on each upstream bump; it is the flow that keeps the sync tax low.
1. Edit the relevant `*_REF` in `compose.properties` - `COMPOSE_CORE_REF` /
`COMPOSE_REF` for Compose, `KOIN_REF` / `COIL_REF` / `PULSE_REF` for the
ecosystem libs (and the matching entry in `libs.versions.toml` if the
coordinate version moved).
2. `scripts/compose-fork/sync.sh`
3. `python3 scripts/compose-fork/check-vendor-drift.py`. For any manual vendor
whose upstream base actually changed, reconcile by hand; otherwise re-stamp
its `// VENDOR-BASE:` header to the new ref. Then
`python3 scripts/compose-fork/audit-exclusions.py` - a newly-identical copy
should go back to verbatim vendoring, and an unclassified one needs a marker.
Finish with `./gradlew apiDump` and review the klib API diff.
4. Build and fix any fallout.
5. `scripts/verify-mac.sh` (green, including the soak and parity gates).
6. If a matching Compose version is now published to Maven, bump
`vComposeJvmVersion` in demo/apidemo/material-symbols to close the skew.
7. Run WIN-SMOKE on a Windows host.
8. Re-seed parity baselines only if metrics legitimately moved
(`parity.py --update-baselines`), and record why.
### Cut a release
1. Complete the ref-bump flow above and confirm it is green. For a STABLE
release, `vComposeJvmVersion` should match the vendored ref (no skew).
2. On a Windows host, run `gradlew :desktop-native-window:compileCommonMainKotlinMetadata`.
Only Windows compiles the full common-metadata variant table, and the root
KotlinMultiplatform publications live there; a macOS-only publish leaves the
roots without mingwX64 variants (this bit v0.1.15).
3. Refresh the klib API baselines: on a Windows host (JDK 21, matching CI) run
`./gradlew apiDump` and commit any diff. Not a hard gate yet - the API is
pre-stable and `apiCheck` isn't wired - but regenerating each release keeps
the baselines honest so the eventual gate is a no-op. `macosArm64` is inferred
from the linux/mingw ABIs (accurate for the target-independent Compose
surface); `:sdl-core` (cinterop) and `:material-symbols` (generated icon maps)
are excluded via `apiValidation.ignoredProjects`. Best host: Windows - it
builds the mingw slice (the fork's unique surface) for real and only infers
macos; the macos ABI is target-independent for the tracked pure-Kotlin modules.
4. `git tag vX.Y.Z && git push origin vX.Y.Z`. This triggers `.github/workflows/publish.yml`.
What the publish job does, so you can read a failure:
- Each host publishes only its own target's publications to GitHub Packages
under their per-area `com.bitsycore.compose.*` coords. Windows additionally publishes the root
KotlinMultiplatform metadata, the `jvm` publication, and the bridge plugin
(only Windows declares every target).
- The `MODULES` list in the workflow must stay in sync with the modules in
`settings.gradle.kts`.
- A partial publish (for example a dropped connection mid-upload) deletes this
host's half-published versions for the tag and retries, so a rerun is safe.
- The demo and apidemo release binaries are zipped and attached to the GitHub
Release for the tag.
4. Verify a consumer build against the new version resolves through the bridge.
+104 -234
View File
@@ -1,76 +1,75 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType
// :apidemo — a Postman-style REST API manager built on the compose-desktop-native
// stack. Uses Ktor with the Curl engine on every desktop target (bundled
// libcurl: Schannel on Windows, OpenSSL on macOS/Linux),
// kotlinx.serialization for the savable "packs", and okio for file I/O.
import com.bitsycore.compose.sdl.build.registerComposeFontBundling
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.plugin.compose)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.kotlin.plugin.serialization)
id("com.bitsycore.compose-desktop-native.bridge")
}
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
val vHostSupportsMingw: Boolean by rootProject.extra
val isHostMingwCompatible = rootProject.extra["vHostSupportsMingw"] as Boolean
// ==================
// MARK: Targets
// ==================
kotlin {
linuxArm64()
linuxX64()
macosArm64()
if (vHostSupportsMingw) mingwX64()
jvm()
targets.withType<KotlinNativeTarget>().all {
val isMingw = name == "mingwX64"
val isLinux = name.startsWith("linux")
val isApple = name.startsWith("macos") || name.startsWith("ios")
binaries.executable {
if (buildType == NativeBuildType.RELEASE) {
// --gc-sections is a GNU ld/lld flag; Apple's ld64 rejects it
// (it dead-strips at -O anyway). Only pass it on GNU-ld targets.
if (!isApple) linkerOpts("-Wl,--gc-sections")
compilerOptions {
freeCompilerArgs.add("-opt")
buildList {
add(linuxArm64())
add(linuxX64())
add(macosArm64())
if (isHostMingwCompatible) add(mingwX64())
}.forEach {
it.binaries {
executable {
when {
target.name == "mingwX64" -> linkerOpts(
// crypt32: client-cert (mTLS) import into the Windows cert store.
"-lcrypt32",
"-Wl,--gc-sections", "-Wl,-s",
// GUI subsystem (no console window), keeping the C `main` entry.
"-Wl,--subsystem,windows", "-Wl,-e,mainCRTStartup",
)
target.name.startsWith("linux") -> linkerOpts(
"-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib/aarch64-linux-gnu",
"-lfontconfig", "-lGL", "-lX11",
)
}
}
entryPoint = "apidemo.main"
// SDL3 + SDL3_ttf + SDL3_image + FreeType + image codecs come from
// :ui's cinterop klibs (staticLibraries directive in each .def); the
// system libs they need are in sdl3.def's linkerOpts.<target>. Only
// shell-level flags and app-specific extras (crypt32 for mTLS) live
// here. Ktor's Curl engine bundles its own libcurl + TLS stack.
if (isMingw) linkerOpts(
// crypt32: client-cert (mTLS) import into the Windows cert store
// (CertOpenStore / PFXImportCertStore / CertAddCertificateContextToStore…).
"-lcrypt32",
// Code-shrink: drop unused sections and strip symbols.
"-Wl,--gc-sections", "-Wl,-s",
// GUI subsystem so launching the .exe pops no console window.
// Keep the standard C `main` entry — ld would otherwise default
// a GUI-subsystem PE to WinMainCRTStartup (needs WinMain) and
// fail to link, since Kotlin/Native emits `main`.
"-Wl,--subsystem,windows", "-Wl,-e,mainCRTStartup",
)
// Skia (default renderer) references the system graphics stack.
// K/N's LLD sysroot on linuxX64 doesn't include the host's multi-arch
// dir, so add the -L for both x86_64 and aarch64 (harmless if either
// is absent — LLD skips missing dirs).
if (isLinux) linkerOpts(
"-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib/aarch64-linux-gnu",
"-lfontconfig", "-lGL", "-lX11",
)
}
}
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
dependencies {
implementation(project(":window"))
implementation(project(":material3"))
implementation(project(":material-symbols"))
val vComposeJvmVersion = libs.versions.compose.get()
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
// Official Maven coords for everything the shared UI touches; native
// configurations substitute them for the port modules automatically
// (root bridge in-repo, the bridge plugin for consumers).
implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
implementation("org.jetbrains.compose.ui:ui:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-graphics:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-text:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-unit:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-geometry:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-util:$vComposeJvmVersion")
implementation("org.jetbrains.compose.foundation:foundation:$vComposeJvmVersion")
implementation("org.jetbrains.compose.foundation:foundation-layout:$vComposeJvmVersion")
implementation("org.jetbrains.compose.animation:animation:$vComposeJvmVersion")
implementation("org.jetbrains.compose.animation:animation-core:$vComposeJvmVersion")
implementation("org.jetbrains.compose.material3:material3:$vComposeM3JvmVersion")
// Common Material Symbols Utility
implementation(project(":utils:material-symbols"))
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
@@ -80,17 +79,23 @@ kotlin {
implementation(libs.kotlinx.datetime)
}
}
// Single HTTP engine on every desktop target: Ktor's Curl engine. It
// bundles a static libcurl per target (Schannel on Windows, OpenSSL on
// macOS/Linux) embedded in its cinterop klib — no runtime DLL, one copy.
// We use Curl everywhere (not WinHttp/Darwin) so that an upcoming
// client-certificate (mTLS) path — which calls the same bundled libcurl
// directly, since Ktor's engines expose no client-cert API — shares the
// exact same TLS stack as ordinary requests.
if (vHostSupportsMingw) getByName("mingwX64Main").dependencies { implementation(libs.ktor.client.curl) }
getByName("macosArm64Main").dependencies { implementation(libs.ktor.client.curl) }
getByName("linuxX64Main").dependencies { implementation(libs.ktor.client.curl) }
getByName("linuxArm64Main").dependencies { implementation(libs.ktor.client.curl) }
nativeMain {
dependencies {
implementation(project(":compose:desktop:native:desktop-native-window"))
implementation(libs.ktor.client.curl)
}
}
jvmMain {
dependencies {
implementation(compose.desktop.currentOs)
// decodeToImageBitmap / decodeToSvgPainter (window icon + in-memory
// image viewer). JVM-only: the native side goes through the port's
// own EncodedImageDecoder seam, so this stays out of commonMain.
implementation("org.jetbrains.compose.components:components-resources:${libs.versions.compose.get()}")
implementation(libs.ktor.client.cio)
implementation(libs.kotlinx.coroutines.swing)
}
}
}
compilerOptions {
@@ -99,181 +104,46 @@ kotlin {
}
}
// SDL3 / SDL3_ttf / SDL3_image / FreeType are linked statically into the
// executable (see the mingw linkerOpts above), so there are no runtime DLLs to
// bundle — the distributable is just <app>.exe + data.kres.
val variants = listOf("debug", "release")
// ==================
// MARK: Bundle the default font (data.kres) next to each native executable
// ==================
// Default UI font (Noto Sans) + monospace body font (Noto Sans Mono) are
// downloaded by :core and exposed through these generic "extra" handles (the
// same handshake the :material-symbols:* modules use). The renderers load
// NotoSans (variable) as the default; apidemo registers NotoSansMono (variable)
// with IconFont under "noto-mono" for the body editor.
val nativeTargets = listOf("macosArm64", "linuxX64", "linuxArm64", "mingwX64")
// :core downloads the variable Noto fonts into its build/fonts. Reference them
// by layout (lazy providers, no evaluation of :core needed) and depend on the
// download task by path so ordering doesn't rely on :core being configured first.
val coreBuildDir = rootProject.project(":ui").layout.buildDirectory
val notoSansFile = coreBuildDir.file("fonts/NotoSans.ttf")
val notoMonoFile = coreBuildDir.file("fonts/NotoSansMono.ttf")
// Which Material Symbols style(s) this app uses = which style call sites appear
// in its Kotlin sources. `MaterialSymbolsOutlined(...)` → outlined font,
// `MaterialSymbolsRounded(...)` → rounded, `MaterialSymbolsSharp(...)` → sharp.
// The Zip task below downloads + bundles ONLY the fonts a style actually
// referenced — an app that only ever calls MaterialSymbolsOutlined never pays
// for Rounded/Sharp .ttf bytes (each is ~600KB uncompressed).
val kAllStyles = listOf(
"Outlined" to Regex("\\bMaterialSymbolsOutlined\\b"),
"Rounded" to Regex("\\bMaterialSymbolsRounded\\b"),
"Sharp" to Regex("\\bMaterialSymbolsSharp\\b"),
)
fun detectUsedStyles(): List<String> {
val vSrcRoot = layout.projectDirectory.dir("src").asFile
val vUsed = mutableSetOf<String>()
if (!vSrcRoot.exists()) return emptyList()
vSrcRoot.walk().filter { it.isFile && it.extension == "kt" }.forEach { vFile ->
val vText = vFile.readText()
for ((vStyle, vRegex) in kAllStyles) {
if (vStyle !in vUsed && vRegex.containsMatchIn(vText)) vUsed.add(vStyle)
}
compose.desktop {
application {
mainClass = "apidemo.MainJvmKt"
}
return vUsed.toList()
}
val vUsedStyles: List<String> = detectUsedStyles()
// ==================
// MARK: Material Symbols subsetting (-PsubsetIcons=true)
// ==================
// scripts/subset-material-symbols.py scans this module's Kotlin sources for
// MaterialSymbols.<Name> references and writes build/icons/usage-codepoint.txt.
// When -PsubsetIcons=true is passed, each icon-font module's downloaded TTF
// is hb-subset'd against that file and the Zip below bundles the trimmed
// font instead of the full one — typically 80-95% smaller.
//
// Needs: python3 + hb-subset on PATH (brew install harfbuzz /
// apt install harfbuzz-utils).
val subsetIcons = (findProperty("subsetIcons") as? String)?.toBoolean() ?: false
val iconsBuildDir = layout.buildDirectory.dir("icons")
val findMaterialSymbolsUsage = tasks.register<Exec>("findMaterialSymbolsUsage") {
description = "Scan src/ for MaterialSymbols.<Name> usages → usage-codepoint.txt."
val vScript = rootProject.layout.projectDirectory.file("scripts/subset-material-symbols.py").asFile
val vConstants = rootProject.project(":material-symbols").layout.projectDirectory
.file("src/commonMain/kotlin/com/compose/sdl/icons/MaterialSymbols.kt").asFile
val vUsageFile = iconsBuildDir.get().file("usage-codepoint.txt").asFile
inputs.files(fileTree("src") { include("**/*.kt") })
inputs.file(vConstants)
outputs.file(vUsageFile)
commandLine(
"python3", vScript.absolutePath,
"--src", layout.projectDirectory.dir("src").asFile.absolutePath,
"--constants", vConstants.absolutePath,
"--out", vUsageFile.absolutePath,
)
}
/* Register `subsetMaterialSymbols<Style>` for a style — hb-subsets the style's
downloaded TTF (owned by :material-symbols) to only the codepoints in
usage-codepoint.txt and writes the output under this app's build/icons/ dir. */
fun registerSubsetTask(inStyle: String): TaskProvider<*> {
val vSymbolsProject = rootProject.project(":material-symbols")
return tasks.register("subsetMaterialSymbols$inStyle") {
description = "hb-subset the $inStyle Material Symbols font to icons actually used."
@Suppress("UNCHECKED_CAST")
val vInputProvider = vSymbolsProject.extra["iconFontFile$inStyle"] as org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$inStyle"] as TaskProvider<*>
val vOut = iconsBuildDir.get().file("MaterialSymbols$inStyle.subset.ttf").asFile
val vUsage = iconsBuildDir.get().file("usage-codepoint.txt").asFile
inputs.file(vInputProvider)
inputs.file(vUsage)
outputs.file(vOut)
dependsOn(findMaterialSymbolsUsage)
dependsOn(vDownloadTask)
doLast {
val vCodepoints = vUsage.readLines()
.filter { it.isNotBlank() && !it.startsWith("#") && it.contains("=") }
.map { it.substringAfter("=").trim().removePrefix("0x").removePrefix("0X") }
if (vCodepoints.isEmpty()) throw GradleException(
"usage-codepoint.txt has no entries — refusing to subset to an empty font.")
val vUnicodes = vCodepoints.joinToString(",") { "U+$it" }
vOut.parentFile.mkdirs()
val vInputFile = vInputProvider.get().asFile
val vBefore = vInputFile.length()
// ProcessBuilder rather than project.exec — the latter isn't
// available inside doLast on Gradle 9 (only ExecOperations via
// an injected service is, which would mean a buildSrc plugin).
// hb-subset (HarfBuzz) is optional: if it isn't on PATH — common on
// Windows — bundle the full font instead of failing the build.
val vProc = try {
ProcessBuilder(
"hb-subset",
vInputFile.absolutePath,
"-o", vOut.absolutePath,
"--unicodes=$vUnicodes",
).redirectErrorStream(true).start()
} catch (e: java.io.IOException) {
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).")
return@doLast
compose.desktop.native {
entryPoint = "apidemo.main"
icon {
light.from(
layout.projectDirectory.file("icons/voltic-icon-32.png"),
layout.projectDirectory.file("icons/voltic-icon-128.png"),
)
dark.from(
layout.projectDirectory.file("icons/voltic-icon-dark-32.png"),
layout.projectDirectory.file("icons/voltic-icon-dark-128.png"),
)
exeIcon.from(
listOf(16, 32, 48, 64, 128, 256).map {
layout.projectDirectory.file("icons/voltic-icon-$it.png")
}
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")
}
)
embedWindowsIcon = providers.gradleProperty("embedWindowsIcon").map { it.toBoolean() }
}
}
// Pre-create subset tasks per USED style when enabled; the Zip below uses
// them as the font source. Built outside the variant×target loop so each
// style is subset once and reused across all bundle variants.
val subsetTasksByStyle: Map<String, TaskProvider<*>> =
if (subsetIcons) vUsedStyles.associateWith { registerSubsetTask(it) } else emptyMap()
// ==================
// MARK: Fonts (shared pipeline - buildSrc ComposeFontBundling.kt)
// ==================
for (variant in variants) {
for (target in nativeTargets) {
val variantCap = variant.replaceFirstChar { it.uppercase() }
val targetCap = target.replaceFirstChar { it.uppercase() }
val outDir = layout.buildDirectory.dir("bin/$target/${variant}Executable")
val copyTask = tasks.register<Zip>("copy${variantCap}ComposeResources${targetCap}") {
archiveFileName.set("data.kres")
destinationDirectory.set(outDir)
entryCompression = if ((findProperty("compressResources") as? String)?.toBoolean() == true) org.gradle.api.tasks.bundling.ZipEntryCompression.DEFLATED else org.gradle.api.tasks.bundling.ZipEntryCompression.STORED
from(notoSansFile) { into("font") }
from(notoMonoFile) { into("font") }
dependsOn(":ui:downloadNotoFonts")
val vSymbolsProject = rootProject.project(":material-symbols")
for (vStyle in vUsedStyles) {
@Suppress("UNCHECKED_CAST")
val vFontFile = vSymbolsProject.extra["iconFontFile$vStyle"] as org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$vStyle"] as TaskProvider<*>
val vSubsetTask = subsetTasksByStyle[vStyle]
if (vSubsetTask != null) {
// Trimmed font: depend on the subset task and bundle its output
// with the original filename so the runtime IconFont registration
// doesn't need to know the bundle was subset.
val vOriginalName = vFontFile.get().asFile.name
from(vSubsetTask.get().outputs.files) {
into("font")
rename { vOriginalName }
}
dependsOn(vSubsetTask)
} else {
from(vFontFile) { into("font") }
}
dependsOn(vDownloadTask)
}
}
listOf("link${variantCap}Executable${targetCap}", "run${variantCap}Executable${targetCap}").forEach { taskName ->
tasks.matching { it.name == taskName }.configureEach { dependsOn(copyTask) }
}
}
registerComposeFontBundling {
bundleNotoSans = true
// The mono body font is loaded through the app's own seam (Fonts.kt), not via
// FontFamily.Monospace call sites - auto-detection can't see it, bundle explicitly.
bundleNotoSansMono = true
bundleMaterialSymbols = true
enableIconSubsetting = true
}
// Parity-window icon (cosmetic : the harness compares the canvas, not chrome).
tasks.named<ProcessResources>("jvmProcessResources") {
from(layout.projectDirectory.file("icons/voltic-icon-256.png")) { into("icon") }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+130 -129
View File
@@ -2,181 +2,182 @@ package apidemo
import kotlinx.cinterop.*
import platform.CoreFoundation.*
import platform.Foundation.*
import platform.Foundation.CFBridgingRelease
import platform.Foundation.NSString
import platform.Security.*
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
// macOS: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
// certificate and key files directly — no certificate-store dance needed.
// certificate and key files directly - no certificate-store dance needed.
/* Point libcurl straight at the certificate / key files. */
/** Point libcurl straight at the certificate / key files. */
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
PreparedCert(
sslCert = inReq.certPath,
sslCertType = inReq.certFormat.curlName,
sslKey = inReq.keyPath.ifBlank { null },
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
keyPassword = inReq.certPassword.ifEmpty { null },
cleanup = {},
)
PreparedCert(
sslCert = inReq.certPath,
sslCertType = inReq.certFormat.curlName,
sslKey = inReq.keyPath.ifBlank { null },
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
keyPassword = inReq.certPassword.ifEmpty { null },
cleanup = {},
)
/* No temporary certificate store off Windows. */
/** No temporary certificate store off Windows. */
actual fun sweepTempClientCerts() {}
/* Continue the server's chain with intermediates / roots pulled from the
macOS Keychain — same UX as the Windows path does via the trust store.
Falls back to the name-only placeholder if Apple's APIs can't resolve
the chain (e.g. the server's leaf doesn't parse, or its issuer isn't
in any keychain). */
/** Continue the server's chain with intermediates / roots pulled from the
macOS Keychain - same UX as the Windows path does via the trust store.
Falls back to the name-only placeholder if Apple's APIs can't resolve
the chain (e.g. the server's leaf doesn't parse, or its issuer isn't
in any keychain). */
@OptIn(ExperimentalForeignApi::class)
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> {
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
?: return serverChainWithIssuerName(inServerCerts)
val vOs = runCatching { osChainApple(vLeafPem, inServerCerts.size, inServerCerts) }.getOrNull()
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
?: return serverChainWithIssuerName(inServerCerts)
val vOs = runCatching { osChainApple(vLeafPem, inServerCerts.size, inServerCerts) }.getOrNull()
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
}
// ============
// Chain walk via Security.framework
// ============
/* Build the chain by handing the leaf to SecTrust + evaluating against
an SSL policy — Apple then walks Keychain, pulling intermediates and
the root in. Returns the resolved chain with fromServer=true for the
first inServerCount entries (those came down on the wire) and false
for anything the OS added. */
/** Build the chain by handing the leaf to SecTrust + evaluating against
an SSL policy - Apple then walks Keychain, pulling intermediates and
the root in. Returns the resolved chain with fromServer=true for the
first inServerCount entries (those came down on the wire) and false
for anything the OS added. */
@OptIn(ExperimentalForeignApi::class)
private fun osChainApple(
inLeafPem: String,
inServerCount: Int,
inServerCerts: List<List<Pair<String, String>>>,
inLeafPem: String,
inServerCount: Int,
inServerCerts: List<List<Pair<String, String>>>,
): List<ChainCert> {
// Parse every cert the server presented, not just the leaf — Apple's
// chain builder uses them as candidate intermediates when stitching to
// a root, instead of relying solely on AIA-fetch which is off by default.
val vServerCerts = inServerCerts.mapNotNull {
certFieldOf(it, "Cert")?.let { vPem -> derFromPem(vPem) }
}
if (vServerCerts.isEmpty()) return emptyList()
// Parse every cert the server presented, not just the leaf - Apple's
// chain builder uses them as candidate intermediates when stitching to
// a root, instead of relying solely on AIA-fetch which is off by default.
val vServerCerts = inServerCerts.mapNotNull {
certFieldOf(it, "Cert")?.let { vPem -> derFromPem(vPem) }
}
if (vServerCerts.isEmpty()) return emptyList()
val vCertRefs = vServerCerts.map { createSecCertificate(it) ?: return emptyList() }
val vCertArray = createCFArray(vCertRefs)
val vCertRefs = vServerCerts.map { createSecCertificate(it) ?: return emptyList() }
val vCertArray = createCFArray(vCertRefs)
// SSL policy lets evaluateTrust pull a root out of Keychain.
val vPolicy = SecPolicyCreateSSL(true, null) ?: run {
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
return emptyList()
}
// SSL policy lets evaluateTrust pull a root out of Keychain.
val vPolicy = SecPolicyCreateSSL(true, null) ?: run {
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
return emptyList()
}
val vTrustVar = nativeHeap.alloc<SecTrustRefVar>()
val vCreateStatus = SecTrustCreateWithCertificates(vCertArray, vPolicy, vTrustVar.ptr)
CFRelease(vPolicy)
if (vCreateStatus != errSecSuccess || vTrustVar.value == null) {
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
nativeHeap.free(vTrustVar)
return emptyList()
}
val vTrust = vTrustVar.value!!
val vTrustVar = nativeHeap.alloc<SecTrustRefVar>()
val vCreateStatus = SecTrustCreateWithCertificates(vCertArray, vPolicy, vTrustVar.ptr)
CFRelease(vPolicy)
if (vCreateStatus != errSecSuccess || vTrustVar.value == null) {
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
nativeHeap.free(vTrustVar)
return emptyList()
}
val vTrust = vTrustVar.value!!
// Evaluate even if it fails — we still want the partial chain back so
// the UI can show what it could resolve. SecTrustEvaluateWithError
// stops at the highest cert it found.
val vErr = nativeHeap.alloc<CFErrorRefVar>()
SecTrustEvaluateWithError(vTrust, vErr.ptr)
vErr.value?.let { CFRelease(it) }
nativeHeap.free(vErr)
// Evaluate even if it fails - we still want the partial chain back so
// the UI can show what it could resolve. SecTrustEvaluateWithError
// stops at the highest cert it found.
val vErr = nativeHeap.alloc<CFErrorRefVar>()
SecTrustEvaluateWithError(vTrust, vErr.ptr)
vErr.value?.let { CFRelease(it) }
nativeHeap.free(vErr)
val vCount = SecTrustGetCertificateCount(vTrust).toInt()
val vResolved = ArrayList<ChainCert>(vCount)
val vSubjects = ArrayList<String>(vCount)
val vCount = SecTrustGetCertificateCount(vTrust).toInt()
val vResolved = ArrayList<ChainCert>(vCount)
val vSubjects = ArrayList<String>(vCount)
for (vI in 0 until vCount) {
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
val vSubject = secSubjectSummary(vCert)
vSubjects.add(vSubject ?: "")
}
for (vI in 0 until vCount) {
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
val vSubject = vSubjects[vI].ifBlank { null }
// The issuer is the next cert in the chain's subject (or this
// cert's own subject if it's the self-signed root).
val vIssuer = when {
vI + 1 < vCount -> vSubjects[vI + 1].ifBlank { null }
vSubject != null -> vSubject // root: self-signed
else -> null
}
val vPem = secCertPem(vCert)
val vFields = buildList {
vSubject?.let { add("Subject" to "CN=$it") }
vIssuer?.let { add("Issuer" to "CN=$it") }
vPem?.let { add("Cert" to it) }
}
vResolved.add(ChainCert(vFields, vI < inServerCount))
}
for (vI in 0 until vCount) {
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
val vSubject = secSubjectSummary(vCert)
vSubjects.add(vSubject ?: "")
}
for (vI in 0 until vCount) {
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
val vSubject = vSubjects[vI].ifBlank { null }
// The issuer is the next cert in the chain's subject (or this
// cert's own subject if it's the self-signed root).
val vIssuer = when {
vI + 1 < vCount -> vSubjects[vI + 1].ifBlank { null }
vSubject != null -> vSubject // root: self-signed
else -> null
}
val vPem = secCertPem(vCert)
val vFields = buildList {
vSubject?.let { add("Subject" to "CN=$it") }
vIssuer?.let { add("Issuer" to "CN=$it") }
vPem?.let { add("Cert" to it) }
}
vResolved.add(ChainCert(vFields, vI < inServerCount))
}
CFRelease(vTrust)
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
nativeHeap.free(vTrustVar)
return vResolved
CFRelease(vTrust)
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
nativeHeap.free(vTrustVar)
return vResolved
}
/* SecCertificate from DER bytes. Returns null if the bytes aren't a
valid X.509 cert. */
/** SecCertificate from DER bytes. Returns null if the bytes aren't a
valid X.509 cert. */
@OptIn(ExperimentalForeignApi::class)
private fun createSecCertificate(inDer: ByteArray): SecCertificateRef? {
val vData = inDer.usePinned { vPin ->
CFDataCreate(null, vPin.addressOf(0).reinterpret(), inDer.size.convert())
} ?: return null
val vCert = SecCertificateCreateWithData(null, vData)
CFRelease(vData)
return vCert
val vData = inDer.usePinned { vPin ->
CFDataCreate(null, vPin.addressOf(0).reinterpret(), inDer.size.convert())
} ?: return null
val vCert = SecCertificateCreateWithData(null, vData)
CFRelease(vData)
return vCert
}
/* CFArray of SecCertificateRef — the input to SecTrustCreateWithCertificates. */
/** CFArray of SecCertificateRef - the input to SecTrustCreateWithCertificates. */
@OptIn(ExperimentalForeignApi::class)
private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
for (vI in inCerts.indices) vPtrs[vI] = inCerts[vI]
val vArr = CFArrayCreate(null, vPtrs, inCerts.size.convert(), kCFTypeArrayCallBacks.ptr)!!
nativeHeap.free(vPtrs)
return vArr
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
for (vI in inCerts.indices) vPtrs[vI] = inCerts[vI]
val vArr = CFArrayCreate(null, vPtrs, inCerts.size.convert(), kCFTypeArrayCallBacks.ptr)!!
nativeHeap.free(vPtrs)
return vArr
}
/* Apple's "subject summary" — basically the CN, but falls back to the
organisation when no CN is present (matches what Keychain Access
displays in its cert list). */
/** Apple's "subject summary" - basically the CN, but falls back to the
organisation when no CN is present (matches what Keychain Access
displays in its cert list). */
@OptIn(ExperimentalForeignApi::class)
private fun secSubjectSummary(inCert: SecCertificateRef): String? {
val vCf = SecCertificateCopySubjectSummary(inCert) ?: return null
val vNs = CFBridgingRelease(vCf) as? NSString
return vNs?.toString()
val vCf = SecCertificateCopySubjectSummary(inCert) ?: return null
val vNs = CFBridgingRelease(vCf) as? NSString
return vNs?.toString()
}
/* PEM string for a SecCertificate — base64 of the DER bytes wrapped
with -----BEGIN CERTIFICATE-----/-----END----- headers. */
/** PEM string for a SecCertificate - base64 of the DER bytes wrapped
with -----BEGIN CERTIFICATE-----/-----END----- headers. */
@OptIn(ExperimentalForeignApi::class, ExperimentalEncodingApi::class)
private fun secCertPem(inCert: SecCertificateRef): String? {
val vData = SecCertificateCopyData(inCert) ?: return null
val vLen = CFDataGetLength(vData).toInt()
val vBytes = ByteArray(vLen)
val vPtr = CFDataGetBytePtr(vData)
if (vPtr != null) for (vI in 0 until vLen) vBytes[vI] = vPtr[vI].toByte()
CFRelease(vData)
val vB64 = Base64.Default.encode(vBytes)
val vWrapped = vB64.chunked(64).joinToString("\n")
return "-----BEGIN CERTIFICATE-----\n$vWrapped\n-----END CERTIFICATE-----"
val vData = SecCertificateCopyData(inCert) ?: return null
val vLen = CFDataGetLength(vData).toInt()
val vBytes = ByteArray(vLen)
val vPtr = CFDataGetBytePtr(vData)
if (vPtr != null) for (vI in 0 until vLen) vBytes[vI] = vPtr[vI].toByte()
CFRelease(vData)
val vB64 = Base64.encode(vBytes)
val vWrapped = vB64.chunked(64).joinToString("\n")
return "-----BEGIN CERTIFICATE-----\n$vWrapped\n-----END CERTIFICATE-----"
}
/* Decode a PEM block to DER bytes, or pass DER bytes through. */
/** Decode a PEM block to DER bytes, or pass DER bytes through. */
@OptIn(ExperimentalEncodingApi::class)
private fun derFromPem(inPem: String): ByteArray? {
if (!inPem.contains("-----BEGIN")) return inPem.encodeToByteArray()
val vBody = inPem.lineSequence()
.filterNot { it.startsWith("-----") }
.joinToString("") { it.trim() }
return runCatching { Base64.Default.decode(vBody) }.getOrNull()
if (!inPem.contains("-----BEGIN")) return inPem.encodeToByteArray()
val vBody = inPem.lineSequence()
.filterNot { it.startsWith("-----") }
.joinToString("") { it.trim() }
return runCatching { Base64.decode(vBody) }.getOrNull()
}
File diff suppressed because it is too large Load Diff
@@ -6,17 +6,6 @@ import androidx.compose.ui.graphics.Color
// MARK: App-wide constants + pure color / asset mappers
// ==================
// Amber used to flag undefined {{variables}} and unsaved-changes warnings.
internal val kWarnColor = Color(0xFFFFAB00)
// SDL scancodes used for app keyboard shortcuts.
internal const val kScS = 22
internal const val kScN = 17
internal const val kScW = 26
internal const val kScEnter = 40
internal const val kScKpEnter = 88
internal const val kScEscape = 41
internal fun methodColor(inM: ReqMethod): Color = when (inM) {
ReqMethod.GET -> Color(0xFF4C9AFF)
ReqMethod.POST -> Color(0xFF36B37E)
@@ -35,11 +24,11 @@ internal val kPackColors: List<Long> = listOf(
0xFFFF7043, 0xFF8D6E63, 0xFF78909C, 0xFFBDBDBD, 0xFF5C7CFA,
)
/* The palette colour for a 1-based pack-colour index, or null when unset. */
/** The palette colour for a 1-based pack-colour index, or null when unset. */
internal fun packColor(inIndex: Int): Color? =
if (inIndex in 1..kPackColors.size) Color(kPackColors[inIndex - 1]) else null
/* Default save name for an image response, by content type. */
/** Default save name for an image response, by content type. */
internal fun imageFileName(inContentType: String?): String = when {
inContentType == null -> "image.bin"
inContentType.contains("png", ignoreCase = true) -> "image.png"
@@ -51,9 +40,9 @@ internal fun imageFileName(inContentType: String?): String = when {
}
internal fun statusColor(inStatus: Int): Color = when (inStatus) {
in 200..299 -> Color(0xFF36B37E) // success — green
in 300..399 -> Color(0xFF4C9AFF) // redirect — blue
in 400..499 -> Color(0xFFFF991F) // client error — orange (warning)
in 500..599 -> Color(0xFFFF5630) // server error — red
else -> Color(0xFFFF991F) // unknown / pending — orange
in 200..299 -> Color(0xFF36B37E) // success - green
in 300..399 -> Color(0xFF4C9AFF) // redirect - blue
in 400..499 -> Color(0xFFFF991F) // client error - orange (warning)
in 500..599 -> Color(0xFFFF5630) // server error - red
else -> Color(0xFFFF991F) // unknown / pending - orange
}
@@ -1,21 +1,23 @@
package apidemo
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import kotlinx.coroutines.Job
// ==================
// MARK: Working state (session / pack / request / tabs / drag)
// ==================
internal class HistoryEntry(val method: ReqMethod, val url: String, val status: Int, val timeMs: Long, val request: ApiRequest)
internal class HistoryEntry(
val method: ReqMethod,
val url: String,
val status: Int,
val timeMs: Long,
val request: ApiRequest
)
/* One request plus its live session state — response, in-flight job (for
cancel) and which sub-tabs show. Stable identity lets the sidebar and the
open-tab strip reference the same request without index juggling. */
/** One request plus its live session state - response, in-flight job (for
cancel) and which sub-tabs show. Stable identity lets the sidebar and the
open-tab strip reference the same request without index juggling. */
internal class ReqState(inInitial: ApiRequest) {
var req by mutableStateOf(inInitial)
var response by mutableStateOf<ApiResponse?>(null)
@@ -26,6 +28,7 @@ internal class ReqState(inInitial: ApiRequest) {
var preview by mutableStateOf(false) // panel 4 showing the resolved, not-yet-sent request
var sentReq by mutableStateOf<ApiRequest?>(null) // resolved request actually sent (Request tab)
var imageKey: String? = null // memory-resource key when the response is an image
// null = auto-detect from Content-Type each frame; non-null = user-pinned override.
var respFormatOverride by mutableStateOf<BodyFormat?>(null)
var bodyWrap by mutableStateOf(true) // body view: true = soft-wrap, false = no wrap + horizontal scroll
@@ -35,77 +38,98 @@ internal class ReqState(inInitial: ApiRequest) {
var chainUrl by mutableStateOf("") // resolved URL the chain was fetched from
}
/* One open pack: its file path (null = never saved), a dirty flag (edits since
last file save), and its own requests, variables and open-tab strip. Switching
packs swaps the whole working set. */
internal class PackState(inPack: Pack, inPath: String?, inDirty: Boolean, inOpenTabs: List<Int> = emptyList(), inActive: Int = -1) {
/** One open pack: its file path (null = never saved), a dirty flag (edits since
last file save), and its own requests, variables and open-tab strip. Switching
packs swaps the whole working set. */
internal class PackState(
inPack: Pack,
inPath: String?,
inDirty: Boolean,
inOpenTabs: List<Int> = emptyList(),
inActive: Int = -1
) {
var name by mutableStateOf(inPack.name)
var path by mutableStateOf(inPath)
var dirty by mutableStateOf(inDirty)
var color by mutableStateOf(inPack.color)
// A linked copy mirrors its source's requests read-only; non-linked packs own
// their requests. `requests` resolves to whichever applies.
var linkedSource by mutableStateOf<PackState?>(null)
private val fOwnRequests = mutableStateListOf<ReqState>().apply {
private val mOwnRequests = mutableStateListOf<ReqState>().apply {
inPack.requests.forEach { add(ReqState(it)) } // packs (and the loose root) may be empty
}
val requests: androidx.compose.runtime.snapshots.SnapshotStateList<ReqState>
get() = linkedSource?.requests ?: fOwnRequests
get() = linkedSource?.requests ?: mOwnRequests
val isLinked: Boolean get() = linkedSource != null
var parent: PackState? = null // enclosing pack (null = top-level / root)
var parent: PackState? =
null // enclosing pack (null = top-level / root)
val subPacks = mutableStateListOf<PackState>().apply {
inPack.subPacks.forEach { add(PackState(it, null, false).also { vSp -> vSp.parent = this@PackState }) }
}
val variables = mutableStateListOf<KeyVal>().apply { addAll(inPack.variables) }
val headers = mutableStateListOf<KeyVal>().apply { addAll(inPack.headers) } // pack-level, inherited by requests
val params = mutableStateListOf<KeyVal>().apply { addAll(inPack.params) } // pack-level query params (inherited)
val params =
mutableStateListOf<KeyVal>().apply { addAll(inPack.params) } // pack-level query params (inherited)
var cert by mutableStateOf(inPack.cert) // pack-level client cert (inherited)
val id: String = inPack.id.ifBlank { newPackId() } // stable id (linked copies reference it)
val isRoot: Boolean = inPack.isRoot // the session-root "pack" (loose requests)
val id: String =
inPack.id.ifBlank { newPackId() } // stable id (linked copies reference it)
val isRoot: Boolean =
inPack.isRoot // the session-root "pack" (loose requests)
// No auto-open: tabs start empty and are restored from persisted state only.
val openTabs = mutableStateListOf<ReqState>().apply {
inOpenTabs.forEach { vIdx -> requests.getOrNull(vIdx)?.let { add(it) } }
}
var active by mutableStateOf(requests.getOrNull(inActive)?.takeIf { it in openTabs } ?: openTabs.firstOrNull())
var expanded by mutableStateOf(true) // sidebar fold state (transient — not part of the pack file)
var expanded by mutableStateOf(true) // sidebar fold state (transient - not part of the pack file)
var envOpen by mutableStateOf(false) // whether this pack's env tab is open in the strip (transient)
fun toPack(): Pack = Pack(
name = name,
requests = if (isLinked) emptyList() else requests.map { it.req }, // linked: requests live in the source
variables = variables.toList(), color = color, headers = headers.toList(), params = params.toList(), cert = cert,
id = id, linkedTo = linkedSource?.id, isRoot = isRoot,
variables = variables.toList(),
color = color,
headers = headers.toList(),
params = params.toList(),
cert = cert,
id = id,
linkedTo = linkedSource?.id,
isRoot = isRoot,
subPacks = subPacks.map { it.toPack() },
)
}
/* A fresh random id for a pack (used to wire linked-copy packs to their source
across save / load). */
/** A fresh random id for a pack (used to wire linked-copy packs to their source
across save / load). */
internal fun newPackId(): String = "pk-" + kotlin.random.Random.nextLong().toULong().toString(16)
/* One entry in the unified tab strip: a request tab, a pack's settings tab when
req == null, or the session settings tab when isSession (pack == null). The
underlying ReqState / PackState (or the session key) is the stable identity. */
/** One entry in the unified tab strip: a request tab, a pack's settings tab when
req == null, or the session settings tab when isSession (pack == null). The
underlying ReqState / PackState (or the session key) is the stable identity. */
internal const val kSessionTabKey = "session-settings"
internal class StripTab(val pack: PackState?, val req: ReqState?, val isSession: Boolean = false)
/* Identity of a strip tab. A linked pack shares the source's ReqState objects,
so the key must include the pack — otherwise the source's and linked's tabs
for the same request collide (double selection, duplicate key()). Data class →
structural equality, so compare keys with == (not ===). */
/** Identity of a strip tab. A linked pack shares the source's ReqState objects,
so the key must include the pack - otherwise the source's and linked's tabs
for the same request collide (double selection, duplicate key()). Data class →
structural equality, so compare keys with == (not ===). */
internal data class TabKey(val pack: PackState?, val req: ReqState?)
internal val StripTab.tabKey: Any get() = if (isSession) kSessionTabKey else TabKey(pack, req)
// ==================
// MARK: Cross-pack drag (sidebar tree)
// ==================
/* Shared state for dragging within and across packs in the sidebar tree. One
instance is hoisted to App so any PackSection can be the drag source or the
drop target. Two drag kinds: a request row (dragReq, owned by dragReqOwner) or
a whole pack header (dragPack). Every rendered row / header registers its
absolute window position + height here so onDrag can hit-test across packs,
even into collapsed or empty ones. The resolved drop target is published back
so the target section draws the indicator. */
/** Shared state for dragging within and across packs in the sidebar tree. One
instance is hoisted to App so any PackSection can be the drag source or the
drop target. Two drag kinds: a request row (dragReq, owned by dragReqOwner) or
a whole pack header (dragPack). Every rendered row / header registers its
absolute window position + height here so onDrag can hit-test across packs,
even into collapsed or empty ones. The resolved drop target is published back
so the target section draws the indicator. */
internal class TreeDrag {
// What's being dragged (at most one is non-null while a drag is live).
var dragReq by mutableStateOf<ReqState?>(null)
@@ -113,7 +137,8 @@ internal class TreeDrag {
var dragPack by mutableStateOf<PackState?>(null)
var dy by mutableStateOf(0f) // draw-only follow offset for the grabbed element
var pressRel by mutableStateOf(0) // relY at capture (cursor = grabbed-element top + relY)
// A press doesn't become a drag until the pointer moves past kDragSlop — so a
// A press doesn't become a drag until the pointer moves past kDragSlop - so a
// plain click (which carries a pixel or two of jitter) still selects / opens.
var engaged by mutableStateOf(false)
+195
View File
@@ -0,0 +1,195 @@
package apidemo
import kotlin.io.encoding.Base64
// ==================
// MARK: Minimal X.509 / DER parser
// ==================
// Some TLS backends (notably Schannel on Windows) expose only Subject / Issuer /
// the PEM through CURLINFO_CERTINFO - not the parsed fields (validity, serial,
// SAN, algorithms). Since every backend gives us the PEM, we parse it ourselves
// to fill in the detail. Just enough ASN.1 to walk a Certificate; anything odd
// is swallowed and the field is simply omitted.
/** Parsed certificate detail, all nullable/empty when not found or unparseable. */
class CertDetails(
val version: Int?, // X.509 version (1/2/3)
val serial: String?, // colon-separated hex
val sigAlg: String?, // signature algorithm (friendly name or OID)
val keyAlg: String?, // public-key algorithm (friendly name or OID)
val notBefore: String?, // "YYYY-MM-DD HH:MM:SS UTC"
val notAfter: String?,
val sans: List<String>, // dNSName SANs
)
// ============
// DER reader: each next() returns [tag, contentStart, contentEnd] and advances.
private class DerReader(val b: ByteArray, var pos: Int, val end: Int) {
fun hasMore() = pos < end
fun next(): IntArray {
val vTag = b[pos++].toInt() and 0xFF
var vLen = b[pos++].toInt() and 0xFF
if (vLen and 0x80 != 0) {
val vN = vLen and 0x7F
vLen = 0
repeat(vN) { vLen = (vLen shl 8) or (b[pos++].toInt() and 0xFF) }
}
val vStart = pos
pos += vLen
return intArrayOf(vTag, vStart, vStart + vLen)
}
}
/** Parse the PEM into the fields the chain dialog shows. Returns null on any
structural surprise (the UI just falls back to whatever the backend gave). */
fun parseCertDetails(inPem: String): CertDetails? {
val vDer = pemToDer(inPem) ?: return null
return try {
val vCert = DerReader(vDer, 0, vDer.size).next() // Certificate SEQUENCE
val vCertInner = DerReader(vDer, vCert[1], vCert[2])
val vTbs = vCertInner.next() // tbsCertificate
val vSigAlg = firstOidName(vDer, vCertInner.next()) // signatureAlgorithm
val vT = DerReader(vDer, vTbs[1], vTbs[2])
var vE = vT.next()
var vVersion: Int? = null
if (vE[0] == 0xA0) // [0] EXPLICIT version
{
val vVi = DerReader(vDer, vE[1], vE[2]).next()
if (vVi[0] == 0x02 && vVi[2] > vVi[1]) vVersion = (vDer[vVi[1]].toInt() and 0xFF) + 1
vE = vT.next()
}
val vSerial = if (vE[0] == 0x02) bytesToHex(vDer, vE[1], vE[2]) else null
vT.next() // signature
vT.next() // issuer
val vValidity = vT.next() // validity SEQUENCE
val vVr = DerReader(vDer, vValidity[1], vValidity[2])
val vNotBefore = parseAsn1Time(vDer, vVr.next())
val vNotAfter = parseAsn1Time(vDer, vVr.next())
vT.next() // subject
val vSpki = vT.next() // subjectPublicKeyInfo SEQUENCE
// SPKI = SEQUENCE { algorithm AlgorithmIdentifier SEQUENCE { OID … }, key BIT STRING }
val vKeyAlg = firstOidName(vDer, DerReader(vDer, vSpki[1], vSpki[2]).next())
val vSans = ArrayList<String>()
while (vT.hasMore()) {
val vX = vT.next()
if (vX[0] == 0xA3) parseSans(vDer, vX, vSans) // extensions [3]
}
CertDetails(vVersion, vSerial, vSigAlg, vKeyAlg, vNotBefore, vNotAfter, vSans)
} catch (_: Throwable) {
null
}
}
// ============
// Helpers
private fun pemToDer(inPem: String): ByteArray? {
val vBody = inPem.lineSequence().filterNot { it.contains("-----") }.joinToString("") { it.trim() }
if (vBody.isBlank()) return null
return try {
Base64.decode(vBody)
} catch (_: Throwable) {
null
}
}
/** The friendly name (or dotted OID) of the first child OID of a SEQUENCE - used
for signatureAlgorithm and subjectPublicKeyInfo's AlgorithmIdentifier. */
private fun firstOidName(inB: ByteArray, inSeq: IntArray): String? {
val vR = DerReader(inB, inSeq[1], inSeq[2])
val vOid = vR.next()
if (vOid[0] != 0x06) return null
return oidName(oidToString(inB, vOid[1], vOid[2]))
}
/** Pull dNSName entries out of the SubjectAltName extension under [3]. */
private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<String>) {
val vSeq = DerReader(inB, inExt3[1], inExt3[2]).next() // SEQUENCE OF Extension
val vEs = DerReader(inB, vSeq[1], vSeq[2])
while (vEs.hasMore()) {
val vExtn = vEs.next()
val vEr = DerReader(inB, vExtn[1], vExtn[2])
val vOid = vEr.next() // extnID
// SAN OID 2.5.29.17 == bytes 55 1D 11
val vIsSan = vOid[2] - vOid[1] == 3 &&
(inB[vOid[1]].toInt() and 0xFF) == 0x55 &&
(inB[vOid[1] + 1].toInt() and 0xFF) == 0x1D &&
(inB[vOid[1] + 2].toInt() and 0xFF) == 0x11
var vVal = vEr.next()
if (vVal[0] == 0x01) vVal = vEr.next() // skip critical BOOLEAN
if (vIsSan && vVal[0] == 0x04) // OCTET STRING wrapping GeneralNames
{
val vGnSeq = DerReader(inB, vVal[1], vVal[2]).next()
val vGn = DerReader(inB, vGnSeq[1], vGnSeq[2])
while (vGn.hasMore()) {
val vName = vGn.next()
if (vName[0] == 0x82) inOut.add(inB.copyOfRange(vName[1], vName[2]).decodeToString()) // [2] dNSName
}
}
}
}
/** ASN.1 UTCTime (YYMMDD…) / GeneralizedTime (YYYYMMDD…) → readable UTC string. */
private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String? {
val vS = inB.copyOfRange(inTlv[1], inTlv[2]).decodeToString()
return try {
val vYear: String
val vRest: String
if (inTlv[0] == 0x18) // GeneralizedTime
{
vYear = vS.substring(0, 4); vRest = vS.substring(4)
} else // UTCTime - RFC 5280 pivot at 50
{
val vYy = vS.substring(0, 2).toInt()
vYear = (if (vYy >= 50) "19" else "20") + vS.substring(0, 2)
vRest = vS.substring(2)
}
val vMo = vRest.substring(0, 2)
val vDa = vRest.substring(2, 4)
val vHh = vRest.substring(4, 6)
val vMi = vRest.substring(6, 8)
val vSs = if (vRest.length >= 10 && vRest[8].isDigit()) vRest.substring(8, 10) else "00"
"$vYear-$vMo-$vDa $vHh:$vMi:$vSs UTC"
} catch (_: Throwable) {
vS
}
}
/** Colon-separated hex of a byte range (serial number rendering). */
private fun bytesToHex(inB: ByteArray, inFrom: Int, inTo: Int): String =
(inFrom until inTo).joinToString(":") { (inB[it].toInt() and 0xFF).toString(16).padStart(2, '0') }
/** Decode an OID's content bytes to dotted-decimal. */
private fun oidToString(inB: ByteArray, inFrom: Int, inTo: Int): String {
if (inTo <= inFrom) return ""
val vSb = StringBuilder()
val vFirst = inB[inFrom].toInt() and 0xFF
vSb.append(vFirst / 40).append('.').append(vFirst % 40)
var vAcc = 0L
for (vI in (inFrom + 1) until inTo) {
val vByte = inB[vI].toInt() and 0xFF
vAcc = (vAcc shl 7) or (vByte and 0x7F).toLong()
if (vByte and 0x80 == 0) {
vSb.append('.').append(vAcc); vAcc = 0
}
}
return vSb.toString()
}
/** Friendly name for the common signature / public-key OIDs, else the OID. */
private fun oidName(inOid: String): String = when (inOid) {
"1.2.840.113549.1.1.1" -> "RSA"
"1.2.840.113549.1.1.5" -> "SHA1withRSA"
"1.2.840.113549.1.1.11" -> "SHA256withRSA"
"1.2.840.113549.1.1.12" -> "SHA384withRSA"
"1.2.840.113549.1.1.13" -> "SHA512withRSA"
"1.2.840.113549.1.1.10" -> "RSASSA-PSS"
"1.2.840.10045.2.1" -> "EC"
"1.2.840.10045.4.3.2" -> "ECDSAwithSHA256"
"1.2.840.10045.4.3.3" -> "ECDSAwithSHA384"
"1.2.840.10045.4.3.4" -> "ECDSAwithSHA512"
"1.3.101.112" -> "Ed25519"
"1.3.101.113" -> "Ed448"
else -> inOid
}
+68
View File
@@ -0,0 +1,68 @@
package apidemo
import androidx.compose.material3.Typography
import androidx.compose.ui.text.font.FontFamily
// ==================
// MARK: Monospace body font (seam)
// ==================
// Family name the body editor passes for monospace text. Resolves to the
// bundled Noto Sans Mono once registered; null means the font isn't shipped
// (data.kres on native, classpath font/ on jvm), in which case the body falls
// back to the default proportional font.
const val kMonoFamily = "noto-mono"
/** Family name the bundled proportional font registers under. */
const val kDefaultFamily = "noto-sans"
/** The raw family name string - used by wrappedRowCount, which measures via the
platform text pipeline (accepts a name string). Null when not bundled. */
expect val monoFontFamilyName: String?
/** The material3 Text / BasicTextField-shaped FontFamily for the mono family.
Null when not bundled. */
expect val monoFontFamily: FontFamily?
// ==================
// MARK: Default proportional font (seam)
// ==================
// PARITY: the native side resolves FontFamily.Default to the BUNDLED NotoSans
// (SkiaFonts.defaultTypeface is the fallback for every unresolved family), while
// stock Compose Desktop on the JVM resolves it to a SYSTEM font (Segoe UI on
// Windows, Helvetica on macOS). Different typefaces mean different ascent /
// descent / line-gap, so identical layout code puts glyphs at a different height
// inside an identically-sized text box - which is what made the SourceTag pill
// look vertically centred on JVM and off-centre on native.
//
// The two stacks must rasterise the SAME face for the comparison to mean
// anything, so the app pins its default family explicitly rather than inheriting
// whatever the platform picks. Null = the bundled font isn't available, in which
// case we fall back to the platform default and the skew returns.
/** The bundled proportional font, applied app-wide as the default text family.
Null when NotoSans isn't shipped (data.kres on native, classpath font/ on jvm). */
expect val defaultFontFamily: FontFamily?
/** Re-stamps every style in a Material 3 [Typography] with [inFamily]. M3 seeds
LocalTextStyle from the typography, so this reaches every `Text` that doesn't name
a family of its own. A null family leaves the typography untouched. */
fun Typography.withDefaultFontFamily(inFamily: FontFamily?): Typography {
if (inFamily == null) return this
return copy(
displayLarge = displayLarge.copy(fontFamily = inFamily),
displayMedium = displayMedium.copy(fontFamily = inFamily),
displaySmall = displaySmall.copy(fontFamily = inFamily),
headlineLarge = headlineLarge.copy(fontFamily = inFamily),
headlineMedium = headlineMedium.copy(fontFamily = inFamily),
headlineSmall = headlineSmall.copy(fontFamily = inFamily),
titleLarge = titleLarge.copy(fontFamily = inFamily),
titleMedium = titleMedium.copy(fontFamily = inFamily),
titleSmall = titleSmall.copy(fontFamily = inFamily),
bodyLarge = bodyLarge.copy(fontFamily = inFamily),
bodyMedium = bodyMedium.copy(fontFamily = inFamily),
bodySmall = bodySmall.copy(fontFamily = inFamily),
labelLarge = labelLarge.copy(fontFamily = inFamily),
labelMedium = labelMedium.copy(fontFamily = inFamily),
labelSmall = labelSmall.copy(fontFamily = inFamily),
)
}
@@ -11,7 +11,7 @@ import kotlinx.serialization.json.JsonElement
// respects the editor's "tab size" (TextLayoutConfig.tabWidth), exactly like
// typed tabs. Lenient so slightly-off JSON (single quotes, trailing commas)
// still reformats instead of failing.
private val fPrettyJson = Json {
private val mPrettyJson = Json {
prettyPrint = true
prettyPrintIndent = "\t"
isLenient = true
@@ -23,15 +23,16 @@ private val fPrettyJson = Json {
fun formatBody(inText: String, inFormat: BodyFormat): String =
when (inFormat) {
BodyFormat.JSON -> runCatching {
fPrettyJson.encodeToString(JsonElement.serializer(), fPrettyJson.parseToJsonElement(inText))
mPrettyJson.encodeToString(JsonElement.serializer(), mPrettyJson.parseToJsonElement(inText))
}.getOrDefault(inText)
BodyFormat.XML -> runCatching { formatXml(inText) }.getOrDefault(inText)
else -> inText
}
// Minimal XML re-indenter: a newline between adjacent tags, one tab per
// nesting level (rendered at the editor's tab size). Declarations (<?…?>), comments / doctype (<!…>), self-closing tags and
// single-line <a>text</a> elements don't change depth. Best-effort — content
// single-line <a>text</a> elements don't change depth. Best-effort - content
// with a literal '>' inside attributes or CDATA may not round-trip, hence the
// runCatching guard at the call site.
private fun formatXml(inText: String): String {
@@ -1,29 +1,28 @@
package apidemo
import io.ktor.client.*
import io.ktor.client.engine.curl.Curl
import apidemo.compat.createApiHttpClient
import apidemo.compat.systemFileSystem
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.CancellationException
import okio.Buffer
import okio.FileSystem
import okio.GzipSource
import okio.Path.Companion.toPath
import okio.buffer
import kotlin.time.TimeSource
// ==================
// MARK: HttpRunner — executes an ApiRequest through Ktor
// MARK: HttpRunner - executes an ApiRequest through Ktor
// ==================
/* Holds one Ktor HttpClient for the app's lifetime. One engine on every desktop
target: Ktor's Curl engine (bundled libcurl — Schannel on Windows, OpenSSL on
macOS/Linux). Same TLS stack as the client-cert path in CurlMtls.kt.
run() is a suspend fun — call it off the UI dispatcher. */
/** Holds one Ktor HttpClient for the app's lifetime. One engine on every desktop
target: Ktor's Curl engine (bundled libcurl - Schannel on Windows, OpenSSL on
macOS/Linux). Same TLS stack as the client-cert path in CurlMtls.kt.
run() is a suspend fun - call it off the UI dispatcher. */
class HttpRunner {
private val fClient = HttpClient(Curl)
private val mClient = createApiHttpClient()
init {
// Clear any temporary client certs a prior crash left in the Windows
@@ -33,11 +32,11 @@ class HttpRunner {
suspend fun run(inReq: ApiRequest): ApiResponse {
// Client-certificate requests bypass Ktor (no engine exposes a cert API)
// and go straight through libcurl — same bundled TLS stack.
// and go straight through libcurl - same bundled TLS stack.
if (inReq.hasClientCert) return curlSendWithClientCert(inReq)
val vMark = TimeSource.Monotonic.markNow()
return try {
val vResp: HttpResponse = fClient.request(inReq.url.trim()) {
val vResp: HttpResponse = mClient.request(inReq.url.trim()) {
method = HttpMethod.parse(inReq.method.name)
inReq.params
.filter { it.enabled && it.key.isNotBlank() }
@@ -45,7 +44,7 @@ class HttpRunner {
inReq.headers
.filter { it.enabled && it.key.isNotBlank() }
.forEach { header(it.key, it.value) }
if (inReq.method.allowsBody && inReq.bodyType != BodyType.NONE) {
if (inReq.bodyType != BodyType.NONE) {
// Content-Type comes from the body type / text format, unless the
// request already carries an explicit Content-Type header.
val vCt = inReq.bodyContentType()
@@ -73,7 +72,6 @@ class HttpRunner {
// so show a short note instead and let the user Save the raw bytes.
val vBinary = !vIsImage && isBinaryBody(vContentType, vBody)
ApiResponse(
ok = true,
status = vResp.status.value,
statusText = vResp.status.description,
timeMs = vMark.elapsedNow().inWholeMilliseconds,
@@ -83,7 +81,7 @@ class HttpRunner {
.sortedBy { it.first.lowercase() },
body = when {
vIsImage -> ""
vBinary -> "(${vContentType ?: "binary"} · ${vBody.size} bytes — not shown; use Save as…)"
vBinary -> "(${vContentType ?: "binary"} · ${vBody.size} bytes - not shown; use Save as…)"
else -> vBody.decodeToString()
},
bytes = vBody,
@@ -99,14 +97,13 @@ class HttpRunner {
httpVersion = vResp.version.toString(),
)
} catch (e: CancellationException) {
// The caller cancelled (Cancel button) — let it propagate so the
// The caller cancelled (Cancel button) - let it propagate so the
// request is dropped rather than reported as a failed response.
throw e
} catch (e: Throwable) {
ApiResponse(
ok = false,
status = 0,
statusText = "—",
statusText = "-",
timeMs = vMark.elapsedNow().inWholeMilliseconds,
sizeBytes = 0,
headers = emptyList(),
@@ -116,33 +113,33 @@ class HttpRunner {
}
}
fun close() = fClient.close()
fun close() = mClient.close()
}
/* Heuristic for a non-text body: textual content types (text, json, xml, html,
javascript, csv, form) are shown as text; everything else, or bytes with
embedded NULs, is treated as binary. */
/** Heuristic for a non-text body: textual content types (text, json, xml, html,
javascript, csv, form) are shown as text; everything else, or bytes with
embedded NULs, is treated as binary. */
internal fun isBinaryBody(inContentType: String?, inBytes: ByteArray): Boolean {
val vCt = inContentType?.lowercase() ?: ""
val vTextual = vCt.startsWith("text/") ||
vCt.contains("json") || vCt.contains("xml") || vCt.contains("html") ||
vCt.contains("javascript") || vCt.contains("csv") || vCt.contains("x-www-form-urlencoded")
vCt.contains("json") || vCt.contains("xml") || vCt.contains("html") ||
vCt.contains("javascript") || vCt.contains("csv") || vCt.contains("x-www-form-urlencoded")
if (vTextual) return false
if (vCt.isNotEmpty()) return true // a non-textual content type → binary
return inBytes.take(1024).any { it.toInt() == 0 } // no type: sniff for NUL bytes
}
/* True when the bytes are gzip — either the response says so or they carry the
gzip magic number (1f 8b). */
/** True when the bytes are gzip - either the response says so or they carry the
gzip magic number (1f 8b). */
private fun isGzip(inEncoding: String?, inBytes: ByteArray): Boolean =
inEncoding?.contains("gzip", ignoreCase = true) == true ||
(inBytes.size >= 2 && inBytes[0] == 0x1f.toByte() && inBytes[1] == 0x8b.toByte())
(inBytes.size >= 2 && inBytes[0] == 0x1f.toByte() && inBytes[1] == 0x8b.toByte())
/* Read a file's raw bytes (used for a FILE request body). */
/** Read a file's raw bytes (used for a FILE request body). */
internal fun readFileBytes(inPath: String): ByteArray =
FileSystem.SYSTEM.read(inPath.trim().toPath()) { readByteArray() }
systemFileSystem.read(inPath.trim().toPath()) { readByteArray() }
/* Inflate a gzip stream to its original bytes via okio's GzipSource. */
/** Inflate a gzip stream to its original bytes via okio's GzipSource. */
private fun gunzip(inBytes: ByteArray): ByteArray {
val vGz = GzipSource(Buffer().write(inBytes)).buffer()
val vResult = vGz.readByteArray()
@@ -1,5 +1,3 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package apidemo
import androidx.compose.foundation.background
@@ -21,10 +19,10 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// MARK: Headers tab (inherited read-only + own editable)
// ==================
/* A request tab pairing an inherited (read-only, source-tagged) list with the
request's own editable list: Override copies an inherited entry down, and own
rows that shadow an inherited one get an OverrideMark. Used by Query / Var /
Headers — the only differences are the key case-sensitivity and the labels. */
/** A request tab pairing an inherited (read-only, source-tagged) list with the
request's own editable list: Override copies an inherited entry down, and own
rows that shadow an inherited one get an OverrideMark. Used by Query / Var /
Headers - the only differences are the key case-sensitivity and the labels. */
@Composable
internal fun InheritedEditableTab(
inInherited: List<InheritedKv>,
@@ -40,8 +38,10 @@ internal fun InheritedEditableTab(
fun norm(inKey: String) = if (inCaseInsensitive) inKey.lowercase() else inKey
val vOwnKeys = inOwn.filter { it.key.isNotBlank() }.map { norm(it.key) }.toSet()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
InheritedKvSection(inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
inOnOverride = if (inReadOnly) null else inOnOverride)
InheritedKvSection(
inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
inOnOverride = if (inReadOnly) null else inOnOverride
)
if (inInherited.any { it.kv.key.isNotBlank() }) {
HorizontalDivider(color = c.border)
Text(inOwnTitle, color = c.dim, fontSize = 11.sp)
@@ -52,26 +52,52 @@ internal fun InheritedEditableTab(
}
}
/* Request Headers tab: inherited headers (case-insensitive) + the request's own. */
/** Request Headers tab: inherited headers (case-insensitive) + the request's own. */
@Composable
internal fun HeadersTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
InheritedEditableTab(inInherited, inReq.headers, inCaseInsensitive = true, inReadOnly = inReadOnly, inOwnTitle = "Request headers",
internal fun HeadersTab(
inReq: ApiRequest,
inInherited: List<InheritedKv>,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) =
InheritedEditableTab(
inInherited, inReq.headers, inCaseInsensitive = true, inReadOnly = inReadOnly, inOwnTitle = "Request headers",
inOnOverride = { vKv -> inEdit { it.copy(headers = it.headers + vKv) } },
inOnChange = { v -> inEdit { it.copy(headers = v) } })
/* Request Query tab: inherited query params (case-sensitive) + the request's own. */
/** Request Query tab: inherited query params (case-sensitive) + the request's own. */
@Composable
internal fun QueryTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
InheritedEditableTab(inInherited, inReq.params, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request query params",
internal fun QueryTab(
inReq: ApiRequest,
inInherited: List<InheritedKv>,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) =
InheritedEditableTab(
inInherited,
inReq.params,
inCaseInsensitive = false,
inReadOnly = inReadOnly,
inOwnTitle = "Request query params",
inOnOverride = { vKv -> inEdit { it.copy(params = it.params + vKv) } },
inOnChange = { v -> inEdit { it.copy(params = v) } })
/* Request Var tab: inherited variables (case-sensitive {{name}}) + the request's
own. A request's own variable overrides the same name inherited from a pack /
the session when the request is sent. */
/** Request Var tab: inherited variables (case-sensitive {{name}}) + the request's
own. A request's own variable overrides the same name inherited from a pack /
the session when the request is sent. */
@Composable
internal fun VarTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
InheritedEditableTab(inInherited, inReq.variables, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request variables",
internal fun VarTab(
inReq: ApiRequest,
inInherited: List<InheritedKv>,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) =
InheritedEditableTab(
inInherited,
inReq.variables,
inCaseInsensitive = false,
inReadOnly = inReadOnly,
inOwnTitle = "Request variables",
inOnOverride = { vKv -> inEdit { it.copy(variables = it.variables + vKv) } },
inOnChange = { v -> inEdit { it.copy(variables = v) } })
@@ -79,14 +105,18 @@ internal fun VarTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnl
// MARK: Inheritance UI (source tags / override markers / inherited lists)
// ==================
/* A small pill naming the kind of scope an inherited value comes from ("Session"
/ "Pack"); hover shows the full path (e.g. "Methods / Nested"). The path tooltip
is skipped when it would just repeat the label (the session). */
/** A small pill naming the kind of scope an inherited value comes from ("Session"
/ "Pack"); hover shows the full path (e.g. "Methods / Nested"). The path tooltip
is skipped when it would just repeat the label (the session). */
@Composable
internal fun SourceTag(inLabel: String, inPath: String) {
val c = LocalAppColors.current
val vPill = @Composable {
Box(modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(c.accent.copy(alpha = 0.16f), RoundedCornerShape(4.dp)).padding(horizontal = 5.dp, vertical = 1.dp)) {
Box(
modifier = Modifier.clip(RoundedCornerShape(4.dp))
.background(c.accent.copy(alpha = 0.16f), RoundedCornerShape(4.dp))
.padding(horizontal = 5.dp, vertical = 1.dp)
) {
Text(inLabel, color = c.accent, fontSize = 9.sp)
}
}
@@ -101,8 +131,8 @@ internal fun SourceTag(inLabel: String, inPath: String) {
}
}
/* The tiny marker shown on an own value that shadows an inherited one — hover for
"Overrides <key> from <path>". */
/** The tiny marker shown on an own value that shadows an inherited one - hover for
"Overrides <key> from <path>". */
@Composable
internal fun OverrideMark(inKey: String, inPath: String) {
val c = LocalAppColors.current
@@ -120,9 +150,9 @@ internal fun OverrideMark(inKey: String, inPath: String) {
}
}
/* Read-only list of inherited key/values, each tagged with its source. When not
already overridden in this scope (own key absent) and inOnOverride != null, a
row offers a one-click Override that copies it into this scope's own list. */
/** Read-only list of inherited key/values, each tagged with its source. When not
already overridden in this scope (own key absent) and inOnOverride != null, a
row offers a one-click Override that copies it into this scope's own list. */
@Composable
internal fun InheritedKvSection(
inTitle: String,
@@ -139,15 +169,26 @@ internal fun InheritedKvSection(
vShown.forEach { vIt ->
val vKey = if (inCaseInsensitive) vIt.kv.key.lowercase() else vIt.kv.key
val vOverridden = vKey in inOwnKeys
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SourceTag(vIt.source, vIt.path)
Text(vIt.kv.key, color = c.dim, fontSize = 12.sp, modifier = Modifier.weight(0.42f))
Text(vIt.kv.value, color = c.dim.copy(alpha = if (vOverridden) 0.4f else 1f), fontSize = 12.sp, modifier = Modifier.weight(0.58f))
Text(
vIt.kv.value,
color = c.dim.copy(alpha = if (vOverridden) 0.4f else 1f),
fontSize = 12.sp,
modifier = Modifier.weight(0.58f)
)
when {
vOverridden -> Text("overridden", color = c.dim.copy(alpha = 0.6f), fontSize = 10.sp)
inOnOverride != null -> Box(
modifier = Modifier.clip(RoundedCornerShape(6.dp)).border(1.dp, c.border, RoundedCornerShape(6.dp))
.clickable { inOnOverride(KeyVal(vIt.kv.key, vIt.kv.value)) }.padding(horizontal = 8.dp, vertical = 3.dp),
modifier = Modifier.clip(RoundedCornerShape(6.dp))
.border(1.dp, c.border, RoundedCornerShape(6.dp))
.clickable { inOnOverride(KeyVal(vIt.kv.key, vIt.kv.value)) }
.padding(horizontal = 8.dp, vertical = 3.dp),
) { Text("Override", color = c.accent, fontSize = 11.sp) }
}
}
@@ -159,11 +200,15 @@ internal fun InheritedKvSection(
// MARK: Key/value editor
// ==================
/* inOverrideInfo maps an own row to the source label it shadows (or null) so the
editor can flag overriding rows with an OverrideMark. It sits before inOnChange
so existing trailing-lambda calls (KeyValEditor(rows) { … }) still bind to onChange. */
/** inOverrideInfo maps an own row to the source label it shadows (or null) so the
editor can flag overriding rows with an OverrideMark. It sits before inOnChange
so existing trailing-lambda calls (KeyValEditor(rows) { … }) still bind to onChange. */
@Composable
internal fun KeyValEditor(inRows: List<KeyVal>, inOverrideInfo: (KeyVal) -> String? = { null }, inOnChange: (List<KeyVal>) -> Unit) {
internal fun KeyValEditor(
inRows: List<KeyVal>,
inOverrideInfo: (KeyVal) -> String? = { null },
inOnChange: (List<KeyVal>) -> Unit
) {
val c = LocalAppColors.current
val vAnyOverride = inRows.any { inOverrideInfo(it) != null }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -188,15 +233,33 @@ internal fun KeyValEditor(inRows: List<KeyVal>, inOverrideInfo: (KeyVal) -> Stri
Checkbox(
checked = vKv.enabled,
onCheckedChange = null,
colors = CheckboxDefaults.colors(checkedColor = c.accent, uncheckedColor = c.dim, checkmarkColor = c.onAccent),
colors = CheckboxDefaults.colors(
checkedColor = c.accent,
uncheckedColor = c.dim,
checkmarkColor = c.onAccent
),
)
}
if (vAnyOverride) Box(modifier = Modifier.width(18.dp), contentAlignment = Alignment.Center) {
inOverrideInfo(vKv)?.let { vSrc -> OverrideMark(vKv.key, vSrc) }
}
ThinField(vKv.key, { v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(key = v) else vR }) }, inModifier = Modifier.weight(1f), inPlaceholder = "key")
ThinField(vKv.value, { v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(value = v) else vR }) }, inModifier = Modifier.weight(1.4f), inPlaceholder = "value")
IconBtn(MaterialSymbols.Close, "Remove", inSize = 16.dp) { inOnChange(inRows.filterIndexed { vJ, _ -> vJ != vI }) }
ThinField(
vKv.key,
{ v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(key = v) else vR }) },
inModifier = Modifier.weight(1f),
inPlaceholder = "key"
)
ThinField(
vKv.value,
{ v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(value = v) else vR }) },
inModifier = Modifier.weight(1.4f),
inPlaceholder = "value"
)
IconBtn(
MaterialSymbols.Close,
"Remove",
inSize = 16.dp
) { inOnChange(inRows.filterIndexed { vJ, _ -> vJ != vI }) }
}
}
OutlinedAction(MaterialSymbols.Add, "Add row") { inOnChange(inRows + KeyVal()) }
@@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
/* Icon + label row used inside nearly every DropdownMenuItem. */
/** Icon + label row used inside nearly every DropdownMenuItem. */
@Composable
internal fun MenuRow(inIcon: Int, inLabel: String, inColor: Color? = null) {
val c = LocalAppColors.current
+325
View File
@@ -0,0 +1,325 @@
package apidemo
import kotlinx.serialization.Serializable
// ==================
// MARK: Pack model (serialized to / from .json)
// ==================
/** A "pack" is a savable collection of requests plus its environment variables
- the export/import unit. Variables are referenced as {{name}} in any URL,
query value, header or body and substituted just before the request is sent
(see resolveVars in Tools.kt). Packs may be empty; the rich starter set lives
in defaultSession(). */
@Serializable
data class Pack(
val name: String = "My Pack",
val requests: List<ApiRequest> = emptyList(),
val variables: List<KeyVal> = emptyList(),
val color: Int = 0, // 1-based index into the pack-colour palette; 0 = none
val headers: List<KeyVal> = emptyList(), // pack-level headers, inherited by requests
val params: List<KeyVal> = emptyList(), // pack-level query params, inherited by requests
val cert: CertConfig? = null, // pack-level client cert, inherited by requests
val id: String = "", // stable id (used by linked-copy packs)
val linkedTo: String? = null, // id of the source pack when this is a linked copy
val isRoot: Boolean = false, // the hidden session-root "pack" holding loose requests
val subPacks: List<Pack> = emptyList(), // nested packs (a pack tree)
)
/** A client-certificate (mutual TLS) configuration. Lives on a request today and
- with the pack tree - on the session / packs too (inherited downward). Maps
to libcurl's CURLOPT_SSLCERT / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. */
@Serializable
data class CertConfig(
val certPath: String = "",
val certFormat: CertFormat = CertFormat.PEM,
val keyPath: String = "",
val keyFormat: CertFormat = CertFormat.PEM,
val certPassword: String = "",
)
/** The starter SESSION loaded on first launch (and on demand from the session
menu) - a guided tour of every feature against httpbin.org: the inheritance
ladder (session → pack → sub-pack → request, for variables / query params /
headers / client cert, innermost wins), loose root requests, a nested sub-pack,
per-request overrides, a client-cert pack, and a linked-copy pack. Each /get or
/anything endpoint echoes the args + headers it received, so the merged,
inherited result is visible in the response. Loaded as an unsaved session. */
fun defaultSession(): Session = Session(
activePack = 0,
// ── Session level - the base of every inheritance ladder ──
globalEnv = listOf(
KeyVal("baseUrl", "https://httpbin.org"),
KeyVal("token", "session-token-abc"),
KeyVal("user", "session-user"),
KeyVal("password", "passwd"),
KeyVal("apiVer", "v1"), // overridden by Methods (v2) then Nested (v3)
),
globalHeaders = listOf(KeyVal("X-Client", "compose-apidemo")), // every request sends it (echoed by /headers)
globalParams = listOf(
KeyVal(
"trace",
"session"
)
), // every request gets ?trace=session (echoed in /get args)
// No session-level cert: it would route every request through mTLS and fail
// without a real cert file. Client certs are shown on the "Secure (mTLS)" pack.
// ── Loose requests at the session root (in no pack - inherit session only) ──
root = Pack(
isRoot = true, name = "", requests = listOf(
ApiRequest(name = "Ping (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
ApiRequest(name = "Headers echo (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/headers"),
)
),
packs = listOf(
// ── Methods - pack header/param/var inherited by its requests, plus a
// nested sub-pack and per-request overrides of each kind. ──
SavedPack(
pack = Pack(
name = "Methods", id = "pk-methods", color = 1,
headers = listOf(KeyVal("Accept", "application/json")), // pack header, inherited by its requests
params = listOf(
KeyVal(
"source",
"methods"
)
), // pack query param, inherited (echoed in /get args)
variables = listOf(
KeyVal(
"apiVer",
"v2"
)
), // overrides the session's apiVer for this pack
requests = listOf(
ApiRequest(
name = "GET - echoes inherited", method = ReqMethod.GET, url = "{{baseUrl}}/get",
params = listOf(KeyVal("q", "compose"))
), // own q + inherited trace=session + source=methods
ApiRequest(
name = "GET - overrides query param", method = ReqMethod.GET, url = "{{baseUrl}}/get",
params = listOf(KeyVal("trace", "request"))
), // request's trace wins over the session's
ApiRequest(
name = "POST - overrides header",
method = ReqMethod.POST,
url = "{{baseUrl}}/post",
headers = listOf(KeyVal("Accept", "text/plain")), // request's Accept wins over the pack's
bodyType = BodyType.TEXT,
bodyFormat = BodyFormat.JSON,
body = "{\n \"user\": \"{{user}}\",\n \"api\": \"{{apiVer}}\"\n}"
),
ApiRequest(
name = "POST - overrides {{user}} var", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
variables = listOf(
KeyVal(
"user",
"request-user"
)
), // request var beats session/pack - see body echo
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"who\": \"{{user}}\" }"
),
ApiRequest(
name = "PUT - replace",
method = ReqMethod.PUT,
url = "{{baseUrl}}/put",
bodyType = BodyType.TEXT,
bodyFormat = BodyFormat.JSON,
body = "{ \"id\": 1, \"name\": \"updated\" }"
),
ApiRequest(name = "DELETE", method = ReqMethod.DELETE, url = "{{baseUrl}}/delete"),
ApiRequest(
name = "Anything echo", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"hello\": \"world\" }"
),
),
// Nested sub-pack - inherits Session → Methods → here (apiVer becomes v3,
// plus its own X-Nested header; trace / source / Accept still flow down).
subPacks = listOf(
Pack(
name = "Nested", color = 4,
variables = listOf(KeyVal("apiVer", "v3")),
headers = listOf(KeyVal("X-Nested", "true")),
requests = listOf(
ApiRequest(
name = "GET - deep inheritance",
method = ReqMethod.GET,
url = "{{baseUrl}}/anything/{{apiVer}}"
),
)
),
)
)
),
// ── Auth & status - uses the inherited {{token}} / {{user}} / {{password}} ──
SavedPack(
pack = Pack(
name = "Auth & status", color = 2, requests = listOf(
ApiRequest(
name = "Bearer auth", method = ReqMethod.GET, url = "{{baseUrl}}/bearer",
headers = listOf(KeyVal("Authorization", "Bearer {{token}}"))
),
ApiRequest(
name = "Basic auth",
method = ReqMethod.GET,
url = "{{baseUrl}}/basic-auth/{{user}}/{{password}}"
),
ApiRequest(name = "Status 200", method = ReqMethod.GET, url = "{{baseUrl}}/status/200"),
ApiRequest(name = "Status 500", method = ReqMethod.GET, url = "{{baseUrl}}/status/500"),
ApiRequest(name = "Redirect x3", method = ReqMethod.GET, url = "{{baseUrl}}/redirect/3"),
ApiRequest(name = "Delay 3s (try Cancel)", method = ReqMethod.GET, url = "{{baseUrl}}/delay/3"),
)
)
),
// ── Formats - response viewer (JSON / XML / HTML / image) ──
SavedPack(
pack = Pack(
name = "Formats", color = 3, requests = listOf(
ApiRequest(name = "JSON", method = ReqMethod.GET, url = "{{baseUrl}}/json"),
ApiRequest(name = "XML", method = ReqMethod.GET, url = "{{baseUrl}}/xml"),
ApiRequest(name = "HTML", method = ReqMethod.GET, url = "{{baseUrl}}/html"),
ApiRequest(name = "UTF-8 text", method = ReqMethod.GET, url = "{{baseUrl}}/encoding/utf8"),
ApiRequest(name = "PNG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/png"),
ApiRequest(name = "SVG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/svg"),
)
)
),
// ── Secure (mTLS) - a PACK-LEVEL client cert, inherited by its requests.
// Open a request's Cert tab to see the inherited cert (source pill +
// Override). Set real cert paths to actually send; the example paths
// won't load. The second request overrides with its own cert. ──
SavedPack(
pack = Pack(
name = "Secure (mTLS)", color = 5,
cert = CertConfig(
certPath = "/path/to/client.p12",
certFormat = CertFormat.PKCS12,
certPassword = "changeit"
),
requests = listOf(
ApiRequest(name = "Inherits the pack cert", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
ApiRequest(
name = "Overrides with own cert",
method = ReqMethod.GET,
url = "{{baseUrl}}/get",
certPath = "/path/to/other-client.pem",
certFormat = CertFormat.PEM,
keyPath = "/path/to/other-key.pem"
),
)
)
),
// ── Linked copy of "Methods": a read-only mirror of its requests, but with
// its own baseUrl var → the same calls run against the httpbingo sibling.
// Showcases linked packs + per-pack variable override. ──
SavedPack(
pack = Pack(
name = "Methods · httpbingo", color = 6, requests = emptyList(),
variables = listOf(KeyVal("baseUrl", "https://httpbingo.org")), linkedTo = "pk-methods"
)
),
),
)
@Serializable
data class ApiRequest(
val name: String = "New request",
val method: ReqMethod = ReqMethod.GET,
val url: String = "https://",
val params: List<KeyVal> = emptyList(),
val variables: List<KeyVal> = emptyList(), // request-level vars ({{name}}); override inherited ones
val headers: List<KeyVal> = emptyList(),
val bodyType: BodyType = BodyType.NONE,
val body: String = "", // TEXT content, or the file path when bodyType == FILE
val bodyFormat: BodyFormat = BodyFormat.RAW, // for a TEXT body: syntax-highlight type + sent Content-Type
val form: List<KeyVal> = emptyList(), // key/value fields when bodyType == FORM
// Client certificate (mutual TLS). When certPath is set, the request is sent
// through the libcurl path (CurlMtls.kt) with these mapped to CURLOPT_SSLCERT
// / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. A PKCS#12 file bundles its
// own key, so keyPath is left blank for it.
val certPath: String = "",
val certFormat: CertFormat = CertFormat.PEM,
val keyPath: String = "",
val keyFormat: CertFormat = CertFormat.PEM,
val certPassword: String = "",
)
/** Whether this request carries a client certificate (and so is sent via the
libcurl mTLS path rather than the default engine). */
val ApiRequest.hasClientCert: Boolean
get() = certPath.isNotBlank()
/** This request's cert fields as a CertConfig (certPath may be blank = none). */
fun ApiRequest.certConfig(): CertConfig = CertConfig(certPath, certFormat, keyPath, keyFormat, certPassword)
/** This request with its cert fields replaced by inCert's. */
fun ApiRequest.withCert(inCert: CertConfig): ApiRequest =
copy(
certPath = inCert.certPath, certFormat = inCert.certFormat,
keyPath = inCert.keyPath, keyFormat = inCert.keyFormat, certPassword = inCert.certPassword
)
/** True when this config actually selects a certificate. */
val CertConfig.isSet: Boolean
get() = certPath.isNotBlank()
/** Certificate / private-key encodings, mapped to libcurl's CURLOPT_SSLCERTTYPE
/ CURLOPT_SSLKEYTYPE string values and to `curl --cert-type` / `--key-type`.
Runtime support depends on the TLS backend: OpenSSL (macOS/Linux) handles all
three; Schannel (Windows) effectively supports P12. */
@Serializable
enum class CertFormat(val curlName: String, val label: String) {
PEM("PEM", "PEM"),
DER("DER", "DER"),
PKCS12("P12", "PKCS#12"),
}
/** A toggleable key/value row, used for both query params and headers. */
@Serializable
data class KeyVal(val key: String = "", val value: String = "", val enabled: Boolean = true)
@Serializable
enum class ReqMethod { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS }
@Serializable
enum class BodyType { NONE, TEXT, FORM, FILE }
/** Title-case label for the body-type picker. */
val BodyType.label: String
get() = when (this) {
BodyType.NONE -> "None"
BodyType.TEXT -> "Text"
BodyType.FORM -> "Form"
BodyType.FILE -> "File"
}
/** The Content-Type the request body implies (null when there's no body). For a
TEXT body it comes from the chosen format (Text→text/plain, JSON→application/
json, …); FORM / FILE are fixed. Sent unless the request already sets one. */
fun ApiRequest.bodyContentType(): String? = when (bodyType) {
BodyType.NONE -> null
BodyType.TEXT -> bodyFormat.contentType
BodyType.FORM -> "application/x-www-form-urlencoded"
BodyType.FILE -> "application/octet-stream"
}
// ==================
// MARK: Response (runtime only - not part of a pack)
// ==================
class ApiResponse(
val status: Int,
val statusText: String,
val timeMs: Long,
val sizeBytes: Long,
val headers: List<Pair<String, String>>,
val body: String,
val bytes: ByteArray = ByteArray(0),
val contentType: String? = null,
val requestHeaders: List<Pair<String, String>> = emptyList(), // headers actually sent
val httpVersion: String = "HTTP/1.1", // from Ktor's response.version, e.g. "HTTP/1.1" or "HTTP/2"
val error: String? = null,
)
/** True when the response carries an image payload (rendered, not shown as text). */
val ApiResponse.isImage: Boolean
get() = contentType?.startsWith("image/", ignoreCase = true) == true
@@ -1,91 +1,91 @@
package apidemo
import apidemo.compat.systemFileSystem
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okio.FileSystem
import okio.Path.Companion.toPath
// ==================
// MARK: Pack persistence (okio + kotlinx.serialization)
// ==================
private val fJson = Json {
private val mJson = Json {
prettyPrint = true
prettyPrintIndent = " "
ignoreUnknownKeys = true
encodeDefaults = true
}
/* Write the pack as pretty JSON to inPath. Returns null on success, else the
error message. */
/** Write the pack as pretty JSON to inPath. Returns null on success, else the
error message. */
fun exportPack(inPack: Pack, inPath: String): String? = try {
FileSystem.SYSTEM.write(inPath.trim().toPath()) {
writeUtf8(fJson.encodeToString(inPack))
systemFileSystem.write(inPath.trim().toPath()) {
writeUtf8(mJson.encodeToString(inPack))
}
null
} catch (e: Throwable) {
e.message ?: e.toString()
}
/* Read a pack back from inPath. */
/** Read a pack back from inPath. */
fun importPack(inPath: String): Result<Pack> = try {
val vText = FileSystem.SYSTEM.read(inPath.trim().toPath()) { readUtf8() }
Result.success(fJson.decodeFromString<Pack>(vText))
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
Result.success(mJson.decodeFromString<Pack>(vText))
} catch (e: Throwable) {
Result.failure(e)
}
/* Write a whole self-contained session to inPath. Returns null on success. */
/** Write a whole self-contained session to inPath. Returns null on success. */
fun exportSession(inSession: Session, inPath: String): String? = try {
FileSystem.SYSTEM.write(inPath.trim().toPath()) {
writeUtf8(fJson.encodeToString(inSession))
systemFileSystem.write(inPath.trim().toPath()) {
writeUtf8(mJson.encodeToString(inSession))
}
null
} catch (e: Throwable) {
e.message ?: e.toString()
}
/* Read a session back from inPath. */
/** Read a session back from inPath. */
fun importSession(inPath: String): Result<Session> = try {
val vText = FileSystem.SYSTEM.read(inPath.trim().toPath()) { readUtf8() }
Result.success(fJson.decodeFromString<Session>(vText))
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
Result.success(mJson.decodeFromString<Session>(vText))
} catch (e: Throwable) {
Result.failure(e)
}
/* Rename inPath to inNewName within the same directory (adding a .json
extension when the new name has none), returning the new path on success. */
/** Rename inPath to inNewName within the same directory (adding a .json
extension when the new name has none), returning the new path on success. */
fun renameFile(inPath: String, inNewName: String): Result<String> = try {
val vSrc = inPath.trim().toPath()
val vLeaf = inNewName.trim().let { if (it.contains('.')) it else "$it.json" }
val vDst = vSrc.parent?.div(vLeaf) ?: vLeaf.toPath()
FileSystem.SYSTEM.atomicMove(vSrc, vDst)
systemFileSystem.atomicMove(vSrc, vDst)
Result.success(vDst.toString())
} catch (e: Throwable) {
Result.failure(e)
}
/* Write arbitrary text (a response body / headers dump) to inPath. Returns null
on success, else the error message. */
/** Write arbitrary text (a response body / headers dump) to inPath. Returns null
on success, else the error message. */
fun writeTextFile(inPath: String, inText: String): String? = try {
FileSystem.SYSTEM.write(inPath.trim().toPath()) { writeUtf8(inText) }
systemFileSystem.write(inPath.trim().toPath()) { writeUtf8(inText) }
null
} catch (e: Throwable) {
e.message ?: e.toString()
}
/* Write raw bytes (a binary response body, e.g. an image) to inPath. */
/** Write raw bytes (a binary response body, e.g. an image) to inPath. */
fun writeBytesFile(inPath: String, inBytes: ByteArray): String? = try {
FileSystem.SYSTEM.write(inPath.trim().toPath()) { write(inBytes) }
systemFileSystem.write(inPath.trim().toPath()) { write(inBytes) }
null
} catch (e: Throwable) {
e.message ?: e.toString()
}
/* Re-indent a JSON response for display; returns the input unchanged if it
isn't valid JSON (so plain-text / HTML responses still show). */
/** Re-indent a JSON response for display; returns the input unchanged if it
isn't valid JSON (so plain-text / HTML responses still show). */
fun prettyJsonOrRaw(inText: String): String = try {
fJson.encodeToString(JsonElement.serializer(), fJson.parseToJsonElement(inText))
} catch (e: Throwable) {
mJson.encodeToString(JsonElement.serializer(), mJson.parseToJsonElement(inText))
} catch (_: Throwable) {
inText
}
@@ -1,24 +1,25 @@
package apidemo
import com.compose.sdl.appDataDir
import com.compose.sdl.revealInFileManager
import apidemo.compat.appDataDir
import apidemo.compat.revealInFileManager
import apidemo.compat.systemFileSystem
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okio.FileSystem
import okio.Path.Companion.toPath
// ==================
// MARK: Persisted app state
// ==================
/* Everything the app restores on relaunch: the theme, the global override env,
every open pack (with its file path + dirty flag so unsaved work survives a
quit) and which pack was active. `launched` flips to true after the very
first run so the httpbin starter pack is only auto-loaded once. */
/** Everything the app restores on relaunch: the theme, the global override env,
every open pack (with its file path + dirty flag so unsaved work survives a
quit) and which pack was active. `launched` flips to true after the very
first run so the httpbin starter pack is only auto-loaded once. */
@Serializable
data class AppState(
val launched: Boolean = false,
val dark: Boolean = true,
val theme: String = "purple", // selected voltic palette id (VolticPalette.id)
val globalEnv: List<KeyVal> = emptyList(), // session-level variables (base of the inheritance ladder)
val globalHeaders: List<KeyVal> = emptyList(), // session-level headers (inherited by every request)
val globalParams: List<KeyVal> = emptyList(), // session-level query params (inherited by every request)
@@ -36,8 +37,8 @@ data class AppState(
val activeReq: Int = -1,
)
/* A pack as stored in the app state: the pack content plus where it was last
saved (null = never saved to a file) and whether it has unsaved edits. */
/** A pack as stored in the app state: the pack content plus where it was last
saved (null = never saved to a file) and whether it has unsaved edits. */
@Serializable
data class SavedPack(
val path: String? = null,
@@ -45,9 +46,9 @@ data class SavedPack(
val pack: Pack = Pack(),
)
/* A whole session, the export/import unit for the working set: every open pack
(self-contained — full content embedded) plus the shared global env and which
pack was active. Only one session is open at a time. */
/** A whole session, the export/import unit for the working set: every open pack
(self-contained - full content embedded) plus the shared global env and which
pack was active. Only one session is open at a time. */
@Serializable
data class Session(
val packs: List<SavedPack> = emptyList(),
@@ -63,7 +64,7 @@ data class Session(
// MARK: Load / save (app-data dir via okio)
// ==================
private val fStateJson = Json {
private val mStateJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
prettyPrint = true
@@ -74,40 +75,40 @@ private const val kOrg = "ComposeDesktopNative"
private const val kApp = "ApiManager"
private const val kStateFile = "state.json"
/* Absolute path to the state file in the per-user app-data dir (created on
demand by SDL_GetPrefPath). Null if no writable dir is available. */
/** Absolute path to the state file in the per-user app-data dir (created on
demand by SDL_GetPrefPath). Null if no writable dir is available. */
private fun stateFilePath(): String? {
val vDir = appDataDir(kOrg, kApp) ?: return null // ends with a separator
return vDir + kStateFile
}
/* Read the persisted app state, or a fresh (first-launch) AppState if there's
nothing saved yet or it can't be read. */
/** Read the persisted app state, or a fresh (first-launch) AppState if there's
nothing saved yet or it can't be read. */
fun loadAppState(): AppState {
val vPath = stateFilePath() ?: return AppState()
return try {
val vText = FileSystem.SYSTEM.read(vPath.toPath()) { readUtf8() }
fStateJson.decodeFromString<AppState>(vText)
} catch (e: Throwable) {
val vText = systemFileSystem.read(vPath.toPath()) { readUtf8() }
mStateJson.decodeFromString<AppState>(vText)
} catch (_: Throwable) {
AppState()
}
}
/* Write the app state. Best-effort — failures are swallowed (it's a cache of
the session, not the user's exported .json packs). */
/** Write the app state. Best-effort - failures are swallowed (it's a cache of
the session, not the user's exported .json packs). */
fun saveAppState(inState: AppState) {
val vPath = stateFilePath() ?: return
try {
FileSystem.SYSTEM.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
} catch (e: Throwable) {
// ignore — nothing the user can act on, and packs can still be exported
systemFileSystem.write(vPath.toPath()) { writeUtf8(mStateJson.encodeToString(inState)) }
} catch (_: Throwable) {
// ignore - nothing the user can act on, and packs can still be exported
}
}
/* Open the per-user app-data folder where the state file lives in the OS file
manager. Revealing the state file opens its containing folder, which SDL has
already created even when nothing's been saved yet. Fire-and-forget — the
reveal launches off the main thread (see revealInFileManager). */
/** Open the per-user app-data folder where the state file lives in the OS file
manager. Revealing the state file opens its containing folder, which SDL has
already created even when nothing's been saved yet. Fire-and-forget - the
reveal launches off the main thread (see revealInFileManager). */
fun openSettingsFolder() {
val vPath = stateFilePath() ?: return
revealInFileManager(vPath)
@@ -1,5 +1,3 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package apidemo
import androidx.compose.foundation.background
@@ -32,12 +30,18 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
internal fun OptionsMenu(
inDark: Boolean,
inOnToggleTheme: () -> Unit,
inPalette: VolticPalette,
inOnPalette: (VolticPalette) -> Unit,
) {
val c = LocalAppColors.current
var vOpen by remember { mutableStateOf(false) }
Box {
IconBtn(MaterialSymbols.Settings, "Options") { vOpen = true }
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }, modifier = Modifier.widthIn(min = 200.dp)) {
DropdownMenu(
expanded = vOpen,
onDismissRequest = { vOpen = false },
modifier = Modifier.widthIn(min = 200.dp)
) {
DropdownMenuItem(text = {
Text("Dark mode", color = if (inDark) c.accent else c.text, fontSize = 13.sp)
}, onClick = { if (!inDark) inOnToggleTheme(); vOpen = false })
@@ -45,6 +49,32 @@ internal fun OptionsMenu(
Text("Light mode", color = if (!inDark) c.accent else c.text, fontSize = 13.sp)
}, onClick = { if (inDark) inOnToggleTheme(); vOpen = false })
HorizontalDivider(color = c.border)
// Palette picker - a swatch of each theme's primary + a check on the active one.
Box(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 2.dp)) {
Text("Theme", color = c.dim, fontSize = 11.sp)
}
VolticPalette.entries.forEach { vPal ->
val vSel = vPal == inPalette
DropdownMenuItem(text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier.size(12.dp).clip(RoundedCornerShape(3.dp))
.background(vPal.scheme.light.primary)
)
Text(
vPal.label,
color = if (vSel) c.accent else c.text,
fontSize = 13.sp,
modifier = Modifier.weight(1f)
)
if (vSel) MaterialSymbolsOutlined(MaterialSymbols.Check, tint = c.accent, size = 16.dp)
}
}, onClick = { inOnPalette(vPal); vOpen = false })
}
HorizontalDivider(color = c.border)
DropdownMenuItem(text = {
MenuRow(MaterialSymbols.Folder, "Open settings folder")
}, onClick = { vOpen = false; openSettingsFolder() })
@@ -56,8 +86,8 @@ internal fun OptionsMenu(
// MARK: Method picker (dropdown)
// ==================
/* Panel 2 — one unified bar: a method dropdown (coloured label + unfold arrows),
a borderless URL field that melts into the bar, and Send (or Cancel). */
/** Panel 2 - one unified bar: a method dropdown (coloured label + unfold arrows),
a borderless URL field that melts into the bar, and Send (or Cancel). */
@Composable
internal fun UrlBar(
inReq: ApiRequest,
@@ -96,19 +126,21 @@ internal fun UrlBar(
}
}
// Borderless URL field — no box, so it reads as part of the bar.
// Borderless URL field - no box, so it reads as part of the bar.
Box(
modifier = Modifier.weight(1f).alpha(if (inReadOnly) 0.55f else 1f).onKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) { inOnSend(); true } else false
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) {
inOnSend(); true
} else false
},
) {
if (inReq.url.isEmpty()) Text("https://example.com/path", color = c.dim, fontSize = 14.sp)
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
),
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
),
) {
BasicTextField(
value = inReq.url,
@@ -122,8 +154,8 @@ internal fun UrlBar(
}
// Inspect the server's TLS certificate chain (handshake-only probe).
// During the probe the lock glyph swaps to a spinner IN PLACE — same
// box, padding and size — so the bar doesn't shift.
// During the probe the lock glyph swaps to a spinner IN PLACE - same
// box, padding and size - so the bar doesn't shift.
val vLockHoverSrc = remember { MutableInteractionSource() }
val vLockHover by vLockHoverSrc.collectIsHoveredAsState()
TooltipBox(
@@ -133,7 +165,10 @@ internal fun UrlBar(
) {
Box(
modifier = Modifier.clip(RoundedCornerShape(6.dp))
.background(if (vLockHover) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(6.dp))
.background(
if (vLockHover) c.accent.copy(alpha = 0.18f) else Color.Transparent,
RoundedCornerShape(6.dp)
)
.hoverable(vLockHoverSrc)
.clickable(onClick = inOnInspectChain)
.padding(6.dp),
@@ -141,23 +176,28 @@ internal fun UrlBar(
if (inChainLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = c.accent, strokeWidth = 2.dp)
} else {
MaterialSymbolsOutlined(MaterialSymbols.Lock, contentDescription = "Inspect TLS chain",
tint = if (vLockHover) c.accent else c.dim, size = 18.dp)
MaterialSymbolsOutlined(
MaterialSymbols.Lock, contentDescription = "Inspect TLS chain",
tint = if (vLockHover) c.accent else c.dim, size = 18.dp
)
}
}
}
// Send and Cancel are the same Material Button (same MinHeight + padding)
// so the bar never changes height when toggling — Cancel is just red.
// so the bar never changes height when toggling - Cancel is just red.
if (inLoading) Button(
onClick = inOnCancel,
colors = ButtonDefaults.buttonColors(containerColor = methodColor(ReqMethod.DELETE), contentColor = Color.White),
colors = ButtonDefaults.buttonColors(
containerColor = methodColor(ReqMethod.DELETE),
contentColor = Color.White
),
) { BtnContent(MaterialSymbols.Stop, "Cancel", Color.White) }
else Button(onClick = inOnSend) { BtnContent(MaterialSymbols.Send, "Send", c.onAccent) }
}
}
/* Bottom-of-panel-3 dropdown choosing the body type (None/Text/Form/File). */
/** Bottom-of-panel-3 dropdown choosing the body type (None/Text/Form/File). */
@Composable
internal fun BodyTypeMenu(inType: BodyType, inEnabled: Boolean, inOnPick: (BodyType) -> Unit) {
val c = LocalAppColors.current
@@ -1,5 +1,3 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package apidemo
import androidx.compose.foundation.*
@@ -17,17 +15,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.compose.sdl.TextLayoutConfig
import apidemo.compat.editorTabWidth
import apidemo.compat.showOpenFileDialog
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
import com.compose.sdl.showOpenFileDialog
// ==================
// MARK: Panel 3 — request builder (Query / Headers / Body)
// MARK: Panel 3 - request builder (Query / Headers / Body)
// ==================
/* Tabs to edit query params, headers and the body, a Preview toggle top-right,
and a body-type dropdown pinned at the bottom. */
/** Tabs to edit query params, headers and the body, a Preview toggle top-right,
and a body-type dropdown pinned at the bottom. */
@Composable
internal fun RequestBuilder(
inReq: ApiRequest,
@@ -42,7 +40,7 @@ internal fun RequestBuilder(
inEdit: ((ApiRequest) -> ApiRequest) -> Unit,
) {
val c = LocalAppColors.current
val vBodySet = inReq.method.allowsBody && when (inReq.bodyType) {
val vBodySet = true && when (inReq.bodyType) {
BodyType.NONE -> false
BodyType.FORM -> inReq.form.any { it.enabled && it.key.isNotBlank() }
else -> inReq.body.isNotBlank()
@@ -55,17 +53,17 @@ internal fun RequestBuilder(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MaterialSymbolsOutlined(MaterialSymbols.Warning, tint = kWarnColor, size = 15.dp)
MaterialSymbolsOutlined(MaterialSymbols.Warning, tint = VolticTheme.extended.warning, size = 15.dp)
Text(
"Undefined: ${inUnresolved.joinToString(", ") { "{{$it}}" }}",
color = kWarnColor,
color = VolticTheme.extended.warning,
fontSize = 11.sp
)
}
}
// Tabs, with the status message and a Preview toggle (icon only) on the
// same line — the toggle no longer needs its own row.
// same line - the toggle no longer needs its own row.
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 10.dp, top = 6.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -100,7 +98,7 @@ internal fun RequestBuilder(
}
HorizontalDivider(color = c.border)
// Tab content — scrolls, except a Text body which fills the whole panel
// Tab content - scrolls, except a Text body which fills the whole panel
// (so it stays one editable surface you can click anywhere in). Greyed when
// read-only (a linked-copy request).
val vScroll = rememberScrollState()
@@ -119,7 +117,7 @@ internal fun RequestBuilder(
}
}
// Body-type selector — only on the Body tab, pinned at the bottom. For a
// Body-type selector - only on the Body tab, pinned at the bottom. For a
// Text body the format/type picker sits beside it; that type drives both the
// syntax colours and the sent Content-Type.
if (inRs.reqTab == 3) {
@@ -147,8 +145,8 @@ internal fun RequestBuilder(
}
}
/* The body editing area for the current body type (driven by the bottom menu). A
Text body is highlighted per its chosen format (bodyFormat). */
/** The body editing area for the current body type (driven by the bottom menu). A
Text body is highlighted per its chosen format (bodyFormat). */
@Composable
internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
when (inReq.bodyType) {
@@ -159,21 +157,22 @@ internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest)
inOnChange = { v -> inEdit { it.copy(body = v) } },
inFormat = inReq.bodyFormat,
)
BodyType.FORM -> KeyValEditor(inReq.form) { v -> inEdit { it.copy(form = v) } }
BodyType.FILE -> FileBody(inReq) { v -> inEdit(v) }
}
}
/* Magic-wand button that pretty-prints the current body in place (JSON /
XML). Lives at the right end of the body type/format bar. */
/** Magic-wand button that pretty-prints the current body in place (JSON /
XML). Lives at the right end of the body type/format bar. */
@Composable
internal fun FormatButton(inOnClick: () -> Unit) {
HoverIconBtn(MaterialSymbols.AutoFixHigh, "Format (pretty-print)", inOnClick)
}
/* Icon button with the toolbar's standard hover treatment — accent-tinted
background + accent icon on hover (matching the TLS chain button). inActive
keeps it lit, for toggles such as the preview eye. */
/** Icon button with the toolbar's standard hover treatment - accent-tinted
background + accent icon on hover (matching the TLS chain button). inActive
keeps it lit, for toggles such as the preview eye. */
@Composable
internal fun HoverIconBtn(
inIcon: Int,
@@ -188,8 +187,8 @@ internal fun HoverIconBtn(
val vHover by vHoverSrc.collectIsHoveredAsState()
val vBg = when {
inActive -> c.accent.copy(alpha = 0.20f)
vHover -> c.accent.copy(alpha = 0.18f)
else -> Color.Transparent
vHover -> c.accent.copy(alpha = 0.18f)
else -> Color.Transparent
}
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
@@ -204,22 +203,24 @@ internal fun HoverIconBtn(
.clickable(onClick = inOnClick)
.padding(6.dp),
) {
MaterialSymbolsOutlined(inIcon, contentDescription = inTooltip,
tint = if (inActive || vHover) c.accent else c.dim, size = inSize)
MaterialSymbolsOutlined(
inIcon, contentDescription = inTooltip,
tint = if (inActive || vHover) c.accent else c.dim, size = inSize
)
}
}
}
/* Tab-size picker (2 / 4 / 8), shown left of the format button. Sets the global
editor tab width via TextLayoutConfig — how wide a typed '\t' renders AND how
deep the formatter indents. Snapshot-backed, so the change is live. */
/** Tab-size picker (2 / 4 / 8), shown left of the format button. Sets the global
editor tab width via TextLayoutConfig - how wide a typed '\t' renders AND how
deep the formatter indents. Snapshot-backed, so the change is live. */
@Composable
internal fun TabSizeSelector() {
val c = LocalAppColors.current
var vOpen by remember { mutableStateOf(false) }
val vHoverSrc = remember { MutableInteractionSource() }
val vHover by vHoverSrc.collectIsHoveredAsState()
val vSize = TextLayoutConfig.tabWidth
val vSize = editorTabWidth
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { PlainTooltip { Text("Editor tab size") } },
@@ -229,7 +230,10 @@ internal fun TabSizeSelector() {
Row(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(6.dp))
.background(
if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent,
RoundedCornerShape(6.dp)
)
.hoverable(vHoverSrc)
.clickable { vOpen = true }
.padding(horizontal = 8.dp, vertical = 5.dp),
@@ -237,20 +241,24 @@ internal fun TabSizeSelector() {
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Text("Tab $vSize", color = if (vHover) c.accent else c.dim, fontSize = 12.sp)
MaterialSymbolsOutlined(MaterialSymbols.UnfoldMore, tint = if (vHover) c.accent else c.dim, size = 14.dp)
MaterialSymbolsOutlined(
MaterialSymbols.UnfoldMore,
tint = if (vHover) c.accent else c.dim,
size = 14.dp
)
}
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
for (vN in listOf(2, 4, 8)) {
DropdownMenuItem(text = {
Text("$vN spaces", color = if (vN == vSize) c.accent else c.text)
}, onClick = { TextLayoutConfig.tabWidth = vN; vOpen = false })
}, onClick = { editorTabWidth = vN; vOpen = false })
}
}
}
}
}
/* FILE body — pick a file; its path is stored in body and sent as raw bytes. */
/** FILE body - pick a file; its path is stored in body and sent as raw bytes. */
@Composable
internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
val c = LocalAppColors.current
@@ -263,12 +271,17 @@ internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) ->
}
}
/* Client-certificate (mTLS) editor — certificate + optional separate key +
passphrase. PEM/DER are used directly by OpenSSL on macOS/Linux; on Windows
they're imported into the certificate store for the request then removed
(see CurlMtls). PKCS#12 bundles its own private key. */
/** Client-certificate (mTLS) editor - certificate + optional separate key +
passphrase. PEM/DER are used directly by OpenSSL on macOS/Linux; on Windows
they're imported into the certificate store for the request then removed
(see CurlMtls). PKCS#12 bundles its own private key. */
@Composable
internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client certificate (mTLS)", inOverrideSource: String? = null, inOnChange: (CertConfig) -> Unit) {
internal fun CertConfigEditor(
inCert: CertConfig,
inHeading: String = "Client certificate (mTLS)",
inOverrideSource: String? = null,
inOnChange: (CertConfig) -> Unit
) {
val c = LocalAppColors.current
val vHasCert = inCert.certPath.isNotBlank()
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -293,7 +306,11 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
inOnChange(CertConfig())
}
}
Text(if (vHasCert) inCert.certPath else "No certificate selected.", color = if (vHasCert) c.text else c.dim, fontSize = 12.sp)
Text(
if (vHasCert) inCert.certPath else "No certificate selected.",
color = if (vHasCert) c.text else c.dim,
fontSize = 12.sp
)
// ============
// Private key (PKCS#12 carries its own key)
@@ -304,10 +321,17 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
showOpenFileDialog { vPath -> if (vPath != null) inOnChange(inCert.copy(keyPath = vPath)) }
}
CertFormatMenu(inCert.keyFormat) { vF -> inOnChange(inCert.copy(keyFormat = vF)) }
if (inCert.keyPath.isNotBlank()) IconBtn(MaterialSymbols.Close, "Clear key") { inOnChange(inCert.copy(keyPath = "", keyFormat = CertFormat.PEM)) }
if (inCert.keyPath.isNotBlank()) IconBtn(MaterialSymbols.Close, "Clear key") {
inOnChange(
inCert.copy(
keyPath = "",
keyFormat = CertFormat.PEM
)
)
}
}
Text(
if (inCert.keyPath.isNotBlank()) inCert.keyPath else "Optional — only if the key is in a separate file.",
inCert.keyPath.ifBlank { "Optional - only if the key is in a separate file." },
color = if (inCert.keyPath.isNotBlank()) c.text else c.dim, fontSize = 12.sp,
)
}
@@ -315,17 +339,27 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
// ============
// Passphrase
Text("Passphrase", color = c.dim, fontSize = 12.sp)
ThinField(inCert.certPassword, { v -> inOnChange(inCert.copy(certPassword = v)) }, inPlaceholder = "Key / PKCS#12 password (optional)")
ThinField(
inCert.certPassword,
{ v -> inOnChange(inCert.copy(certPassword = v)) },
inPlaceholder = "Key / PKCS#12 password (optional)"
)
}
}
/* A read-only card for an inherited client cert: its source pill + path, plus an
Override action (copy it into this scope) when this scope hasn't set its own. */
/** A read-only card for an inherited client cert: its source pill + path, plus an
Override action (copy it into this scope) when this scope hasn't set its own. */
@Composable
internal fun InheritedCertCard(inInherited: InheritedCert, inOwnSet: Boolean, inReadOnly: Boolean, inOnOverride: () -> Unit) {
internal fun InheritedCertCard(
inInherited: InheritedCert,
inOwnSet: Boolean,
inReadOnly: Boolean,
inOnOverride: () -> Unit
) {
val c = LocalAppColors.current
Column(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).border(1.dp, c.border, RoundedCornerShape(8.dp)).padding(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
.border(1.dp, c.border, RoundedCornerShape(8.dp)).padding(10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -343,23 +377,35 @@ internal fun InheritedCertCard(inInherited: InheritedCert, inOwnSet: Boolean, in
}
}
/* The request's Cert tab: the inherited cert (from pack / session) shown read-only
with its source + an Override action when the request has none, then the
request's own cert editor (its own cert overrides the inherited one). */
/** The request's Cert tab: the inherited cert (from pack / session) shown read-only
with its source + an Override action when the request has none, then the
request's own cert editor (its own cert overrides the inherited one). */
@Composable
internal fun RequestCertTab(inReq: ApiRequest, inInheritedCert: InheritedCert?, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) {
internal fun RequestCertTab(
inReq: ApiRequest,
inInheritedCert: InheritedCert?,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (inInheritedCert != null) InheritedCertCard(inInheritedCert, inOwnSet = inReq.hasClientCert, inReadOnly = inReadOnly) {
if (inInheritedCert != null) InheritedCertCard(
inInheritedCert,
inOwnSet = inReq.hasClientCert,
inReadOnly = inReadOnly
) {
inEdit { it.withCert(inInheritedCert.cert) }
}
CertConfigEditor(inReq.certConfig(), inOverrideSource = if (inReq.hasClientCert) inInheritedCert?.path else null) { vCc -> inEdit { it.withCert(vCc) } }
CertConfigEditor(
inReq.certConfig(),
inOverrideSource = if (inReq.hasClientCert) inInheritedCert?.path else null
) { vCc -> inEdit { it.withCert(vCc) } }
}
}
/* Scope settings editor (Variables / Query / Headers / Cert sub-tabs) — shared by
the session settings tab and each pack's settings tab. Each sub-tab shows what
the scope inherits from above (source-tagged, with Override) over its own
editable list, mirroring the request panels. */
/** Scope settings editor (Variables / Query / Headers / Cert sub-tabs) - shared by
the session settings tab and each pack's settings tab. Each sub-tab shows what
the scope inherits from above (source-tagged, with Override) over its own
editable list, mirroring the request panels. */
@Composable
internal fun ScopeSettings(
inTab: Int,
@@ -369,7 +415,7 @@ internal fun ScopeSettings(
inParams: List<KeyVal>, inOnParams: (List<KeyVal>) -> Unit, inParamHelp: String,
inHeaders: List<KeyVal>, inOnHeaders: (List<KeyVal>) -> Unit, inHeaderHelp: String,
inCert: CertConfig?, inOnCert: (CertConfig?) -> Unit, inCertHelp: String, inCertHeading: String,
// What this scope inherits from above (empty for the session — it's the top).
// What this scope inherits from above (empty for the session - it's the top).
inInheritedVars: List<InheritedKv> = emptyList(),
inInheritedParams: List<InheritedKv> = emptyList(),
inInheritedHeaders: List<InheritedKv> = emptyList(),
@@ -390,33 +436,50 @@ internal fun ScopeSettings(
when (inTab) {
0 -> {
Text(inVarHelp, color = c.dim, fontSize = 12.sp)
InheritedEditableTab(inInheritedVars, inVars, inCaseInsensitive = false, inReadOnly = false,
InheritedEditableTab(
inInheritedVars, inVars, inCaseInsensitive = false, inReadOnly = false,
inOwnTitle = "This scope's variables", inInheritedTitle = "Inherited variables",
inOnOverride = { vKv -> inOnVars(inVars + vKv) }, inOnChange = inOnVars)
inOnOverride = { vKv -> inOnVars(inVars + vKv) }, inOnChange = inOnVars
)
}
1 -> {
Text(inParamHelp, color = c.dim, fontSize = 12.sp)
InheritedEditableTab(inInheritedParams, inParams, inCaseInsensitive = false, inReadOnly = false,
InheritedEditableTab(
inInheritedParams, inParams, inCaseInsensitive = false, inReadOnly = false,
inOwnTitle = "This scope's query params", inInheritedTitle = "Inherited query params",
inOnOverride = { vKv -> inOnParams(inParams + vKv) }, inOnChange = inOnParams)
inOnOverride = { vKv -> inOnParams(inParams + vKv) }, inOnChange = inOnParams
)
}
2 -> {
Text(inHeaderHelp, color = c.dim, fontSize = 12.sp)
InheritedEditableTab(inInheritedHeaders, inHeaders, inCaseInsensitive = true, inReadOnly = false,
InheritedEditableTab(
inInheritedHeaders, inHeaders, inCaseInsensitive = true, inReadOnly = false,
inOwnTitle = "This scope's headers", inInheritedTitle = "Inherited headers",
inOnOverride = { vKv -> inOnHeaders(inHeaders + vKv) }, inOnChange = inOnHeaders)
inOnOverride = { vKv -> inOnHeaders(inHeaders + vKv) }, inOnChange = inOnHeaders
)
}
else -> {
Text(inCertHelp, color = c.dim, fontSize = 12.sp)
val vOwnSet = inCert?.isSet == true
if (inInheritedCert != null) InheritedCertCard(inInheritedCert, inOwnSet = vOwnSet, inReadOnly = false) { inOnCert(inInheritedCert.cert) }
CertConfigEditor(inCert ?: CertConfig(), inCertHeading, inOverrideSource = if (vOwnSet) inInheritedCert?.path else null) { vCc -> inOnCert(vCc.takeIf { it.isSet }) }
if (inInheritedCert != null) InheritedCertCard(
inInheritedCert,
inOwnSet = vOwnSet,
inReadOnly = false
) { inOnCert(inInheritedCert.cert) }
CertConfigEditor(
inCert ?: CertConfig(),
inCertHeading,
inOverrideSource = if (vOwnSet) inInheritedCert?.path else null
) { vCc -> inOnCert(vCc.takeIf { it.isSet }) }
}
}
}
}
/* Certificate / key encoding dropdown (PEM / DER / PKCS#12). */
/** Certificate / key encoding dropdown (PEM / DER / PKCS#12). */
@Composable
internal fun CertFormatMenu(inFormat: CertFormat, inOnPick: (CertFormat) -> Unit) {
val c = LocalAppColors.current
@@ -0,0 +1,295 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package apidemo
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import apidemo.compat.x
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// ==================
// MARK: Request tab strip (open requests; drag a tab to reorder)
// ==================
/** Strip of open requests above the editor. Each tab is clickable to select
and drag-reorderable. While dragging, the grabbed tab lifts (zIndex),
fades, and follows the cursor via a graphicsLayer translation - its
neighbours don't shuffle; the reorder commits on drop, to the slot whose
neighbours' centres the cursor passed. key(tab) keeps each tab's
LayoutNode stable so the drag session survives the reorder. */
@Composable
internal fun RequestTabStrip(
inTabs: List<StripTab>,
inActiveKey: Any?,
inOnSelect: (StripTab) -> Unit,
inOnClose: (StripTab) -> Unit,
inOnCloseOthers: (StripTab) -> Unit,
inOnCloseAll: () -> Unit,
inOnReorder: (Int, Int) -> Unit,
) {
val c = LocalAppColors.current
val vScroll = rememberScrollState() // strip's horizontal scroll
val vLeft = remember { mutableStateMapOf<Any, Int>() } // window-x of each tab's left edge, by tab key
val vWidth = remember { mutableStateMapOf<Any, Int>() } // measured tab width, by tab key
var vDragKey by remember { mutableStateOf<Any?>(null) }
var vPressRelX by remember { mutableStateOf(0) }
var vDragDx by remember { mutableStateOf(0f) }
var vDragTarget by remember { mutableStateOf(-1) }
// One shared context menu, anchored to the tab that opened it (m3 DropdownMenu
// is parent-anchored, not cursor-anchored - the old x/y capture is gone).
var vMenu by remember { mutableStateOf(false) }
var vMenuTab by remember { mutableStateOf<StripTab?>(null) }
var vListOpen by remember { mutableStateOf(false) } // overflow list
val vShowBar = vDragKey != null && vDragDx != 0f
val vOthers = if (vShowBar) inTabs.filter { it.tabKey != vDragKey } else emptyList()
val vBarBeforeKey = if (vShowBar) vOthers.getOrNull(vDragTarget)?.tabKey else null
val vBarAtEnd = vShowBar && vDragTarget >= vOthers.size
Row(
modifier = Modifier.fillMaxWidth().background(c.panel),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f)
.horizontalScroll(vScroll).padding(horizontal = 6.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
inTabs.forEach { vTab ->
val vKey = vTab.tabKey
if (vKey == vBarBeforeKey) DropBar()
key(vKey) {
val vSel = vKey == inActiveKey
val vDragged = vKey == vDragKey
var vMod = Modifier
.onGloballyPositioned { vLeft[vKey] = it.x }
.onSizeChanged { vWidth[vKey] = it.width }
// NOTE: `alpha` and `translationX` MUST be on the same graphicsLayer.
// `Modifier.alpha` expands to `graphicsLayer(alpha, clip = true)` - clip is
// applied in the ALPHA layer's local coord space (before any child layer's
// translation), so `.alpha().graphicsLayer(translationX = X)` clips the drag
// ghost inside its original bounds and the ghost disappears as it slides. One
// layer with both properties = clip stays false, alpha modulates via layerPaint.
if (vDragged) vMod =
vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = vDragDx, translationY = 0f)
vMod = vMod
.clip(RoundedCornerShape(7.dp))
.background(if (vSel) c.accent.copy(alpha = 0.20f) else c.field, RoundedCornerShape(7.dp))
.border(1.dp, if (vSel || vDragged) c.accent else c.border, RoundedCornerShape(7.dp))
.pointerInput(vKey) {
// Accumulate the per-frame delta rather than reading change.position.
// The tab wears a graphicsLayer(translationX = vDragDx) while dragging,
// which transforms the modifier's local pointer frame - so position.x
// would compensate for the translation each frame and the tab would
// trail the mouse by roughly half its offset. dragAmount is the
// root-frame delta and doesn't feed back through the layer transform.
detectDragGestures(
onDragStart = { offset ->
vDragKey = vKey
vPressRelX = offset.x.toInt()
vDragDx = 0f
vDragTarget = inTabs.indexOfFirst { it.tabKey == vKey }
},
onDrag = { _, dragAmount ->
val vd = vDragKey ?: return@detectDragGestures
vDragDx += dragAmount.x
val vRelX = vPressRelX + vDragDx.toInt()
val vCursorX = (vLeft[vd] ?: 0) + vRelX
var vCount = 0
inTabs.forEach { vT ->
if (vT.tabKey != vd) {
val vCenter = (vLeft[vT.tabKey] ?: 0) + (vWidth[vT.tabKey] ?: 0) / 2
if (vCursorX > vCenter) vCount++
}
}
vDragTarget = vCount
},
onDragEnd = {
val vd = vDragKey
if (vd != null) {
val vFrom = inTabs.indexOfFirst { it.tabKey == vd }
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(
vFrom,
vDragTarget
)
}
vDragKey = null; vDragDx = 0f; vDragTarget = -1
},
onDragCancel = { vDragKey = null; vDragDx = 0f; vDragTarget = -1 },
)
}
// Middle-click closes the tab; right-click opens the tab menu. Compose
// has no first-class match for either - inline pointerInput checks the
// pressed PointerButton at each Press.
.pointerInput(vTab) {
awaitPointerEventScope {
while (true) {
val vEv = awaitPointerEvent()
if (vEv.type != PointerEventType.Press) continue
when (vEv.button) {
PointerButton.Tertiary -> {
vEv.changes.firstOrNull()?.consume()
inOnClose(vTab)
}
PointerButton.Secondary -> {
vEv.changes.firstOrNull()?.consume()
vMenuTab = vTab; vMenu = true
}
}
}
}
}
.clickable { inOnSelect(vTab) }
.padding(start = 9.dp, top = 6.dp, bottom = 6.dp, end = 3.dp)
Row(
modifier = vMod,
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val vRs = vTab.req
if (vRs == null) {
// Session / pack settings tab.
MaterialSymbolsOutlined(
MaterialSymbols.Tune,
tint = if (vSel) c.accent else c.dim,
size = 13.dp
)
Text(
if (vTab.isSession) "Session" else (vTab.pack?.name ?: ""),
color = if (vSel) c.text else c.dim,
fontSize = 13.sp
)
} else {
Box(
modifier = Modifier.size(7.dp)
.background(methodColor(vRs.req.method), RoundedCornerShape(4.dp))
)
Text(vRs.req.name, color = if (vSel) c.text else c.dim, fontSize = 13.sp)
if (vRs.loading) CircularProgressIndicator(
modifier = Modifier.size(12.dp),
color = c.accent,
strokeWidth = 1.5.dp
)
}
IconBtn(MaterialSymbols.Close, "Close", inSize = 13.dp) { inOnClose(vTab) }
}
}
}
if (vBarAtEnd) DropBar()
// Shared right-click menu, opened at the cursor over the clicked tab.
val vMTab = vMenuTab
if (vMenu && vMTab != null) {
DropdownMenu(
expanded = true,
onDismissRequest = { vMenu = false },
modifier = Modifier.widthIn(min = 188.dp),
) {
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Close, "Close tab") },
onClick = { vMenu = false; inOnClose(vMTab) })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Clear, "Close other tabs") },
onClick = { vMenu = false; inOnCloseOthers(vMTab) })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Clear, "Close all tabs") },
onClick = { vMenu = false; inOnCloseAll() })
}
}
}
// Overflow → chevron opens a scrollable list of every open tab.
if (vScroll.maxValue > 0) {
Box(modifier = Modifier.padding(end = 4.dp)) {
IconBtn(MaterialSymbols.ExpandMore, "All tabs") { vListOpen = true }
DropdownMenu(
expanded = vListOpen,
onDismissRequest = { vListOpen = false },
modifier = Modifier.widthIn(min = 240.dp)
) {
Column(modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState())) {
inTabs.forEach { vTab ->
DropdownMenuItem(text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val vSel = vTab.tabKey == inActiveKey
val vRs = vTab.req
if (vRs == null) {
MaterialSymbolsOutlined(
MaterialSymbols.Tune,
tint = if (vSel) c.accent else c.dim,
size = 13.dp
)
Text(
if (vTab.isSession) "Session" else "${vTab.pack?.name} · var",
color = if (vSel) c.accent else c.text,
fontSize = 13.sp,
modifier = Modifier.weight(1f)
)
} else {
Box(
modifier = Modifier.size(7.dp)
.background(methodColor(vRs.req.method), RoundedCornerShape(4.dp))
)
Text(
vRs.req.name,
color = if (vSel) c.accent else c.text,
fontSize = 13.sp,
modifier = Modifier.weight(1f)
)
if (vRs.loading) CircularProgressIndicator(
modifier = Modifier.size(12.dp),
color = c.accent,
strokeWidth = 1.5.dp
)
}
}
}, onClick = { vListOpen = false; inOnSelect(vTab) })
}
}
}
}
}
}
}
/** Thin accent bar marking where a dragged tab will be inserted. Rendered in-flow
between tabs so the Row centres it vertically and reserves a little space. */
@Composable
internal fun DropBar() {
val c = LocalAppColors.current
Box(
modifier = Modifier.width(3.dp).height(24.dp).clip(RoundedCornerShape(2.dp))
.background(c.accent, RoundedCornerShape(2.dp))
)
}
@@ -1,5 +1,4 @@
@file:OptIn(
androidx.compose.material3.ExperimentalMaterial3Api::class,
androidx.compose.ui.ExperimentalComposeUiApi::class,
)
@@ -31,18 +30,18 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.compose.sdl.fileManagerName
import apidemo.compat.fileManagerName
import apidemo.compat.revealInFileManager
import apidemo.compat.y
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
import com.compose.sdl.layout.y
import com.compose.sdl.revealInFileManager
// ==================
// MARK: Pack colour (palette dot + picker)
// ==================
/* A small rounded square in the pack's palette colour (neutral when unset).
The pack's visual identity in the sidebar's foldable pack header. */
/** A small rounded square in the pack's palette colour (neutral when unset).
The pack's visual identity in the sidebar's foldable pack header. */
@Composable
internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
val c = LocalAppColors.current
@@ -50,8 +49,8 @@ internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
Box(Modifier.size(inSize).background(vCol, RoundedCornerShape(3.dp)))
}
/* A 5×4 grid of swatches; tapping one sets the pack's colour (1-based index).
The current colour gets a ring. Sized to fit inside the (fixed-width) ⋮ menu. */
/** A 5×4 grid of swatches; tapping one sets the pack's colour (1-based index).
The current colour gets a ring. Sized to fit inside the (fixed-width) ⋮ menu. */
@Composable
internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
val c = LocalAppColors.current
@@ -80,15 +79,19 @@ internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
// MARK: Add-pack menu (header '+')
// ==================
/* The header '+' — a small menu to add a pack to the open session, either blank
or imported from a .json file. Importing a pack always lands it in the session. */
/** The header '+' - a small menu to add a pack to the open session, either blank
or imported from a .json file. Importing a pack always lands it in the session. */
@Composable
internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnImport: () -> Unit) {
val c = LocalAppColors.current
var vOpen by remember { mutableStateOf(false) }
Box {
IconBtn(MaterialSymbols.Add, "Add", inSize = 18.dp) { vOpen = true }
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }, modifier = Modifier.widthIn(min = 200.dp)) {
DropdownMenu(
expanded = vOpen,
onDismissRequest = { vOpen = false },
modifier = Modifier.widthIn(min = 200.dp)
) {
DropdownMenuItem(text = {
MenuRow(
MaterialSymbols.Send,
@@ -96,7 +99,9 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
)
}, onClick = { vOpen = false; inOnNewRequest() })
HorizontalDivider(color = c.border)
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Add, "New pack") }, onClick = { vOpen = false; inOnNew() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Add, "New pack") },
onClick = { vOpen = false; inOnNew() })
DropdownMenuItem(text = {
MenuRow(
MaterialSymbols.Download,
@@ -111,13 +116,13 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
// MARK: Session menu (save / open / new / recent)
// ==================
/* The last path segment of inPath (the file name), tolerant of either slash. */
/** The last path segment of inPath (the file name), tolerant of either slash. */
internal fun fileLeaf(inPath: String): String =
inPath.trimEnd('/', '\\').substringAfterLast('/').substringAfterLast('\\')
/* Shortens a long path to inMax chars keeping the start and end (so the root
and file stay visible), joined by an ellipsis. The menu has no text-overflow
primitive, so we trim the string ourselves. */
/** Shortens a long path to inMax chars keeping the start and end (so the root
and file stay visible), joined by an ellipsis. The menu has no text-overflow
primitive, so we trim the string ourselves. */
internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
if (inText.length <= inMax) return inText
val vKeep = inMax - 1
@@ -125,10 +130,10 @@ internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
return inText.take(vHead) + "…" + inText.takeLast(vKeep - vHead)
}
/* Header dropdown for the one open session. Shows its file name with the full
path underneath when it has a file (so you can see it's saved — file-backed
sessions auto-save), or "Untitled session" with a dot when it has no file yet.
Menu: Save / Save as… / Rename / Reveal / Open… / New + a recent list. */
/** Header dropdown for the one open session. Shows its file name with the full
path underneath when it has a file (so you can see it's saved - file-backed
sessions auto-save), or "Untitled session" with a dot when it has no file yet.
Menu: Save / Save as… / Rename / Reveal / Open… / New + a recent list. */
@Composable
internal fun SessionMenu(
inPath: String?,
@@ -155,7 +160,10 @@ internal fun SessionMenu(
// Highlight on hover and stay highlighted while the menu is open.
Row(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(6.dp))
.background(if (vHover || vOpen) c.accent.copy(alpha = 0.16f) else Color.Transparent, RoundedCornerShape(6.dp))
.background(
if (vHover || vOpen) c.accent.copy(alpha = 0.16f) else Color.Transparent,
RoundedCornerShape(6.dp)
)
.hoverable(vHoverSrc)
.clickable { vOpen = true }.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -169,7 +177,11 @@ internal fun SessionMenu(
// No file yet → a dot; once it has a file it auto-saves, so no dot.
if (!vSaved) Box(Modifier.size(6.dp).background(c.accent, RoundedCornerShape(3.dp)))
}
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }, modifier = Modifier.widthIn(min = 248.dp)) {
DropdownMenu(
expanded = vOpen,
onDismissRequest = { vOpen = false },
modifier = Modifier.widthIn(min = 248.dp)
) {
DropdownMenuItem(text = {
MenuRow(
MaterialSymbols.Tune,
@@ -177,7 +189,9 @@ internal fun SessionMenu(
)
}, onClick = { vOpen = false; inOnSettings() })
HorizontalDivider(color = c.border)
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Save, "Save session") }, onClick = { vOpen = false; inOnSave() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Save, "Save session") },
onClick = { vOpen = false; inOnSave() })
DropdownMenuItem(text = {
MenuRow(
MaterialSymbols.Download,
@@ -193,7 +207,8 @@ internal fun SessionMenu(
)
},
onClick = { vOpen = false; inOnRename() },
enabled = vSaved)
enabled = vSaved
)
DropdownMenuItem(
text = {
MenuRow(
@@ -203,7 +218,8 @@ internal fun SessionMenu(
)
},
onClick = { vOpen = false; inOnReveal() },
enabled = vSaved)
enabled = vSaved
)
HorizontalDivider(color = c.border)
DropdownMenuItem(text = {
MenuRow(
@@ -211,7 +227,9 @@ internal fun SessionMenu(
"Default session"
)
}, onClick = { vOpen = false; inOnDefault() })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Add, "New session") }, onClick = { vOpen = false; inOnNew() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Add, "New session") },
onClick = { vOpen = false; inOnNew() })
DropdownMenuItem(text = {
MenuRow(
MaterialSymbols.Folder,
@@ -222,7 +240,7 @@ internal fun SessionMenu(
HorizontalDivider(color = c.border)
// Aligned to the icon column (the DropdownMenuItem's 16dp pad), so
// it lines up with the recent items' file icon. Padding goes on a
// wrapping Box — a leaf Text's own start padding isn't applied to its
// wrapping Box - a leaf Text's own start padding isn't applied to its
// draw position by this layout engine.
Box(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 2.dp)) {
Text("Recent", color = c.dim, fontSize = 11.sp)
@@ -245,10 +263,12 @@ internal fun SessionMenu(
Text(fileLeaf(vPath), color = c.text, fontSize = 12.sp)
Text(ellipsizeMiddle(vPath, 32), color = c.dim, fontSize = 10.sp, softWrap = false)
}
// Reveal this session's folder — its own hover + tooltip so it
// Reveal this session's folder - its own hover + tooltip so it
// reads as a separate action from "open the session".
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above
),
tooltip = { PlainTooltip { Text("Reveal in ${fileManagerName()}") } },
state = rememberTooltipState(),
) {
@@ -283,8 +303,8 @@ internal fun SessionMenu(
// MARK: Pack tree (recursive sidebar rendering)
// ==================
/* The per-pack operations a PackSection needs, bundled so PackTree can bind them
recursively for sub-packs while PackSection itself stays callback-driven. */
/** The per-pack operations a PackSection needs, bundled so PackTree can bind them
recursively for sub-packs while PackSection itself stays callback-driven. */
internal class PackOps(
val onEnv: (PackState) -> Unit,
val onToggle: (PackState) -> Unit,
@@ -310,11 +330,18 @@ internal class PackOps(
val packDropEnd: () -> Unit,
)
/* Renders a pack then its sub-packs recursively, each indented by its depth.
inSiblings is the list this pack belongs to (top-level vPacks, or a parent's
subPacks) — used to position the pack-reorder drop bars by index. */
/** Renders a pack then its sub-packs recursively, each indented by its depth.
inSiblings is the list this pack belongs to (top-level vPacks, or a parent's
subPacks) - used to position the pack-reorder drop bars by index. */
@Composable
internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: Int, inOps: PackOps, inActive: PackState?, inEnvActive: Boolean) {
internal fun PackTree(
inPack: PackState,
inSiblings: List<PackState>,
inDepth: Int,
inOps: PackOps,
inActive: PackState?,
inEnvActive: Boolean
) {
val vDrag = inOps.drag
val vMyIndex = inSiblings.indexOf(inPack)
val vMoving = vDrag.draggingPack && vDrag.engaged && vDrag.dropInto == null
@@ -322,7 +349,7 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
val vPad = Modifier.padding(start = (inDepth * 14).dp)
val vBarBefore = vMoving && vDrag.dropParent === inPack.parent && vDrag.dropSibIndex == vMyIndex
val vBarAfter = vMoving && vDrag.dropParent === inPack.parent &&
vMyIndex == inSiblings.size - 1 && vDrag.dropSibIndex == inSiblings.size
vMyIndex == inSiblings.size - 1 && vDrag.dropSibIndex == inSiblings.size
key(inPack) {
if (vBarBefore) Box(modifier = vPad) { RowDropBar() }
Box(modifier = vPad) {
@@ -354,7 +381,16 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
)
}
}
if (inPack.expanded) inPack.subPacks.forEach { PackTree(it, inPack.subPacks, inDepth + 1, inOps, inActive, inEnvActive) }
if (inPack.expanded) inPack.subPacks.forEach {
PackTree(
it,
inPack.subPacks,
inDepth + 1,
inOps,
inActive,
inEnvActive
)
}
if (vBarAfter) Box(modifier = vPad) { RowDropBar() }
}
@@ -362,11 +398,11 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
// MARK: Pack section (foldable pack in the sidebar)
// ==================
/* One open pack rendered as a foldable section: a header (fold chevron, colour
box, name, dirty dot, request count, ⋮ menu) over the pack's request list
when expanded. Clicking the header body selects the pack (so the Env tab and
main panel follow it); the chevron only folds. The request list drags to
reorder, scoped to this pack. Right-click the header for the pack menu. */
/** One open pack rendered as a foldable section: a header (fold chevron, colour
box, name, dirty dot, request count, ⋮ menu) over the pack's request list
when expanded. Clicking the header body selects the pack (so the Env tab and
main panel follow it); the chevron only folds. The request list drags to
reorder, scoped to this pack. Right-click the header for the pack menu. */
@Composable
internal fun PackSection(
inPack: PackState,
@@ -407,13 +443,14 @@ internal fun PackSection(
// to reorder / reparent; highlighted when it's the "drop inside" target.
val vHeaderDragged = inDrag.dragPack === inPack && vMoving
val vIntoHi = vMoving && ((inDrag.draggingPack && inDrag.dropInto === inPack) ||
(inDrag.draggingReq && inDrag.dropPack === inPack && !inPack.expanded))
(inDrag.draggingReq && inDrag.dropPack === inPack && !inPack.expanded))
var vHeadMod = Modifier.fillMaxWidth()
.onGloballyPositioned { inDrag.headTop[inPack] = it.y }
.onSizeChanged { inDrag.headH[inPack] = it.height }
// See RequestTabStrip note: alpha + translation on the SAME graphicsLayer so
// clip=false and the drag ghost isn't clipped to its original bounds.
if (vHeaderDragged) vHeadMod = vHeadMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
if (vHeaderDragged) vHeadMod =
vHeadMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
vHeadMod = vHeadMod.clip(RoundedCornerShape(6.dp))
.background(
when {
@@ -465,9 +502,15 @@ internal fun PackSection(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
IconBtn(if (inPack.expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight, "Fold", inSize = 18.dp, inPadding = 3.dp) { inOnToggle() }
IconBtn(
if (inPack.expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight,
"Fold",
inSize = 18.dp,
inPadding = 3.dp
) { inOnToggle() }
Row(
modifier = Modifier.weight(1f).clip(RoundedCornerShape(5.dp)).clickable { inOnSelect() }.padding(vertical = 4.dp),
modifier = Modifier.weight(1f).clip(RoundedCornerShape(5.dp)).clickable { inOnSelect() }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
) {
@@ -476,11 +519,19 @@ internal fun PackSection(
Text(inPack.name, color = c.text, fontSize = 14.sp, modifier = Modifier.weight(1f))
Text("${inPack.requests.size}", color = c.dim, fontSize = 11.sp)
}
// + (new request) and ⋮ (pack menu) — both reveal on hover only, so the
// + (new request) and ⋮ (pack menu) - both reveal on hover only, so the
// header width never shifts (alpha, not conditional layout). A linked
// copy mirrors its source's requests, so it has no "new request".
Row(modifier = Modifier.alpha(if (vHover || vMenu) 1f else 0f), verticalAlignment = Alignment.CenterVertically) {
if (!inPack.isLinked) IconBtn(MaterialSymbols.Add, "New request", inSize = 16.dp, inPadding = 4.dp) { inOnNewRequest() }
Row(
modifier = Modifier.alpha(if (vHover || vMenu) 1f else 0f),
verticalAlignment = Alignment.CenterVertically
) {
if (!inPack.isLinked) IconBtn(
MaterialSymbols.Add,
"New request",
inSize = 16.dp,
inPadding = 4.dp
) { inOnNewRequest() }
Box {
IconBtn(MaterialSymbols.MoreHoriz, "Pack menu", inSize = 16.dp, inPadding = 4.dp) { vMenu = true }
DropdownMenu(
@@ -488,23 +539,41 @@ internal fun PackSection(
onDismissRequest = { vMenu = false },
modifier = Modifier.widthIn(min = 220.dp),
) {
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Edit, "Rename pack") }, onClick = { vMenu = false; inOnRenamePack() })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate pack") }, onClick = { vMenu = false; inOnDuplicatePack() })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Add, "New sub-pack") }, onClick = { vMenu = false; inOnNewSubPack() })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Share, "Linked copy") }, onClick = { vMenu = false; inOnLinkedCopy() })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Save, "Export pack") }, onClick = { vMenu = false; inOnSavePack() })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Folder, "Export pack as…") }, onClick = { vMenu = false; inOnSaveAsPack() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Edit, "Rename pack") },
onClick = { vMenu = false; inOnRenamePack() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate pack") },
onClick = { vMenu = false; inOnDuplicatePack() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Add, "New sub-pack") },
onClick = { vMenu = false; inOnNewSubPack() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Share, "Linked copy") },
onClick = { vMenu = false; inOnLinkedCopy() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Save, "Export pack") },
onClick = { vMenu = false; inOnSavePack() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Folder, "Export pack as…") },
onClick = { vMenu = false; inOnSaveAsPack() })
HorizontalDivider(color = c.border)
PackColorPicker(inPack.color) { inOnSetColor(it) }
HorizontalDivider(color = c.border)
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Delete, "Remove pack", methodColor(ReqMethod.DELETE)) }, onClick = { vMenu = false; inOnRemovePack() })
DropdownMenuItem(text = {
MenuRow(
MaterialSymbols.Delete,
"Remove pack",
methodColor(ReqMethod.DELETE)
)
}, onClick = { vMenu = false; inOnRemovePack() })
}
}
}
}
// ============
// Pack body — request list (expanded, or always for the loose root).
// Pack body - request list (expanded, or always for the loose root).
// Rows drag within and across packs via the shared controller; the drop
// bar shows here whenever this pack is the resolved target.
if (inPack.expanded || inHeaderless) {
@@ -515,7 +584,7 @@ internal fun PackSection(
val vBarBefore = if (vIsTarget) vOthers.getOrNull(inDrag.dropIndex) else null
val vBarAtEnd = vIsTarget && inDrag.dropIndex >= vOthers.size
// The loose root has no header, so register the body box as its drop
// anchor — that gives an empty root a region to target while dragging.
// anchor - that gives an empty root a region to target while dragging.
var vBodyMod = Modifier.fillMaxWidth().padding(start = 10.dp, top = 2.dp, bottom = 4.dp)
if (inHeaderless) vBodyMod = vBodyMod
.onGloballyPositioned { inDrag.headTop[inPack] = it.y }
@@ -525,13 +594,18 @@ internal fun PackSection(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (inHeaderless && vReqs.isEmpty())
Text("Drop a request here to make it loose", color = c.dim, fontSize = 11.sp, modifier = Modifier.padding(vertical = 4.dp))
Text(
"Drop a request here to make it loose",
color = c.dim,
fontSize = 11.sp,
modifier = Modifier.padding(vertical = 4.dp)
)
vReqs.forEach { vRs ->
if (vRs === vBarBefore) RowDropBar()
key(vRs) {
val vDragged = vRs === inDrag.dragReq && vMoving
var vMod: Modifier = Modifier
// Linked copies mirror the source's requests — read-only, so
// Linked copies mirror the source's requests - read-only, so
// their rows neither register geometry (would clobber the
// source's) nor drag.
if (!inPack.isLinked) vMod = vMod
@@ -539,28 +613,30 @@ internal fun PackSection(
.onSizeChanged { inDrag.rowH[vRs] = it.height }
// translate is draw-only (doesn't shift absoluteY), so the
// cursor offset below stays correct while dragging.
if (vDragged) vMod = vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
if (vDragged) vMod =
vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
if (!inPack.isLinked) vMod = vMod.pointerInput(vRs) {
// Same graphicsLayer feedback concern as the pack drag —
// accumulate dragAmount instead of reading change.position.
detectDragGestures(
onDragStart = { offset ->
inDrag.clear()
inDrag.dragReq = vRs; inDrag.dragReqOwner = inPack
inDrag.pressRel = offset.y.toInt(); inDrag.dy = 0f
inDrag.dropPack = inPack; inDrag.dropIndex = vReqs.indexOf(vRs)
},
onDrag = { _, dragAmount ->
val vd = inDrag.dragReq ?: return@detectDragGestures
inDrag.dy += dragAmount.y
val vRelY = inDrag.pressRel + inDrag.dy.toInt()
if (!inDrag.engaged && kotlin.math.abs(inDrag.dy) >= kDragSlop) inDrag.engaged = true
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
},
onDragEnd = { inOnReqDropEnd() },
onDragCancel = { inOnReqDropEnd() },
)
}
// Same graphicsLayer feedback concern as the pack drag -
// accumulate dragAmount instead of reading change.position.
detectDragGestures(
onDragStart = { offset ->
inDrag.clear()
inDrag.dragReq = vRs; inDrag.dragReqOwner = inPack
inDrag.pressRel = offset.y.toInt(); inDrag.dy = 0f
inDrag.dropPack = inPack; inDrag.dropIndex = vReqs.indexOf(vRs)
},
onDrag = { _, dragAmount ->
val vd = inDrag.dragReq ?: return@detectDragGestures
inDrag.dy += dragAmount.y
val vRelY = inDrag.pressRel + inDrag.dy.toInt()
if (!inDrag.engaged && kotlin.math.abs(inDrag.dy) >= kDragSlop) inDrag.engaged =
true
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
},
onDragEnd = { inOnReqDropEnd() },
onDragCancel = { inOnReqDropEnd() },
)
}
Box(modifier = vMod) {
RequestRow(
inRs = vRs,
@@ -630,7 +706,7 @@ internal fun RequestRow(
) {
MethodTag(vReq.method)
Text(vReq.name, color = c.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
// Overflow (vertical-dots) menu — always laid out so the row width
// Overflow (vertical-dots) menu - always laid out so the row width
// never changes; only its opacity toggles on hover / while open. Delete
// lives inside this menu (no separate button on the row).
Box(modifier = Modifier.alpha(if (vHover || vMenu) 1f else 0f)) {
@@ -640,10 +716,22 @@ internal fun RequestRow(
onDismissRequest = { vMenu = false },
modifier = Modifier.widthIn(min = 168.dp),
) {
if (!inReadOnly) DropdownMenuItem(text = { MenuRow(MaterialSymbols.Edit, "Rename") }, onClick = { vMenu = false; inOnRename() })
if (!inReadOnly) DropdownMenuItem(text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate") }, onClick = { vMenu = false; inOnDuplicate() })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Terminal, "Copy as cURL") }, onClick = { vMenu = false; inOnCopyCurl() })
if (!inReadOnly) DropdownMenuItem(text = { MenuRow(MaterialSymbols.Delete, "Delete", methodColor(ReqMethod.DELETE)) }, onClick = { vMenu = false; inOnDelete() })
if (!inReadOnly) DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Edit, "Rename") },
onClick = { vMenu = false; inOnRename() })
if (!inReadOnly) DropdownMenuItem(
text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate") },
onClick = { vMenu = false; inOnDuplicate() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Terminal, "Copy as cURL") },
onClick = { vMenu = false; inOnCopyCurl() })
if (!inReadOnly) DropdownMenuItem(text = {
MenuRow(
MaterialSymbols.Delete,
"Delete",
methodColor(ReqMethod.DELETE)
)
}, onClick = { vMenu = false; inOnDelete() })
}
}
}
@@ -0,0 +1,397 @@
package apidemo
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import kotlinx.serialization.Serializable
// ==================
// MARK: BodyFormat - viewer/highlighter selection
// ==================
/** Format of a body payload as shown in the viewer, and (for a TEXT request body)
the type the user picks on the right of the Body tab - it drives both syntax
highlighting and the sent Content-Type. RAW disables highlighting and falls back
to the selectable BasicTextField; other values pick a tokeniser. */
@Serializable
enum class BodyFormat { RAW, JSON, XML, YAML, HTML }
/** Short label for the format / "type" picker (RAW = plain text, no highlighting). */
val BodyFormat.label: String
get() = when (this) {
BodyFormat.RAW -> "RAW"
BodyFormat.JSON -> "JSON"
BodyFormat.XML -> "XML"
BodyFormat.YAML -> "YAML"
BodyFormat.HTML -> "HTML"
}
/** The Content-Type a TEXT body of this format is sent with. */
val BodyFormat.contentType: String
get() = when (this) {
BodyFormat.RAW -> "text/plain"
BodyFormat.JSON -> "application/json"
BodyFormat.XML -> "application/xml"
BodyFormat.YAML -> "application/yaml"
BodyFormat.HTML -> "text/html"
}
/** Pick a format from a Content-Type header. JSON / XML / YAML / HTML
match their canonical types and common variants; anything else
(image types, text/plain, …) falls back to RAW. */
fun autoFormatFor(inContentType: String?): BodyFormat {
val vCt = inContentType?.substringBefore(';')?.trim()?.lowercase() ?: return BodyFormat.RAW
return when {
vCt.endsWith("+json") || vCt == "application/json" -> BodyFormat.JSON
vCt.endsWith("+xml") || vCt == "application/xml" || vCt == "text/xml" -> BodyFormat.XML
vCt == "application/yaml" || vCt == "text/yaml" || vCt == "application/x-yaml" -> BodyFormat.YAML
vCt == "text/html" -> BodyFormat.HTML
else -> BodyFormat.RAW
}
}
// ==================
// MARK: Highlight palette
// ==================
/** Per-token colours. Two preset themes: a dark-on-dark and a dark-on-
light variant matching VS Code's defaults. Caller picks based on
surrounding theme; auto-pick available via SyntaxPalette.forDark(). */
data class SyntaxPalette(
val key: Color,
val string: Color,
val number: Color,
val keyword: Color,
val tag: Color,
val comment: Color,
val punct: Color,
) {
companion object {
/** Light-on-dark - VS Code Dark+. */
val Dark = SyntaxPalette(
key = Color(0xFF9CDCFE),
string = Color(0xFFCE9178),
number = Color(0xFFB5CEA8),
keyword = Color(0xFFC586C0),
tag = Color(0xFF569CD6),
comment = Color(0xFF6A9955),
punct = Color(0xFFCCCCCC),
)
/** Dark-on-light - VS Code Light+. Higher contrast on white. */
val Light = SyntaxPalette(
key = Color(0xFF0451A5),
string = Color(0xFFA31515),
number = Color(0xFF098658),
keyword = Color(0xFF0000FF),
tag = Color(0xFF800000),
comment = Color(0xFF008000),
punct = Color(0xFF333333),
)
/** Pick a palette by the host theme's background brightness. */
fun forDark(inIsDark: Boolean): SyntaxPalette = if (inIsDark) Dark else Light
}
}
// ==================
// MARK: Entry point
// ==================
/** Render the given text with per-token colour spans for the requested
format. RAW returns an unstyled AnnotatedString. */
fun highlight(
inText: String,
inFormat: BodyFormat,
inPalette: SyntaxPalette = SyntaxPalette.Dark,
): AnnotatedString = when (inFormat) {
BodyFormat.RAW -> AnnotatedString(inText)
BodyFormat.JSON -> highlightJson(inText, inPalette)
BodyFormat.XML, BodyFormat.HTML -> highlightXml(inText, inPalette)
BodyFormat.YAML -> highlightYaml(inText, inPalette)
}
// ==================
// MARK: JSON tokeniser - single-pass, error-tolerant
// ==================
/** Tokeniser for JSON. Walks the string once and emits style spans for
strings, numbers, booleans, null, punctuation. Distinguishes "keys"
(strings followed by ':') from string values by peeking after each
string. Error-tolerant - malformed JSON still highlights as best
effort rather than throwing. */
private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
val vN = inText.length
var vI = 0
while (vI < vN) {
val vC = inText[vI]
when {
vC == '"' -> {
// Find closing quote, honouring backslash-escaped chars so an
// escaped \" doesn't terminate the string early.
val vStart = vI
var vJ = vI + 1
while (vJ < vN) {
if (inText[vJ] == '\\' && vJ + 1 < vN) {
vJ += 2; continue
}
if (inText[vJ] == '"') {
vJ++; break
}
vJ++
}
// Peek for ':' (skipping whitespace) → it's a key, not a value.
var vK = vJ
while (vK < vN && inText[vK].isWhitespace()) vK++
val vIsKey = vK < vN && inText[vK] == ':'
val vColor = if (vIsKey) inP.key else inP.string
pushStyle(SpanStyle(color = vColor))
append(inText.substring(vStart, vJ))
pop()
vI = vJ
}
vC.isDigit() || (vC == '-' && vI + 1 < vN && inText[vI + 1].isDigit()) -> {
val vStart = vI
if (vC == '-') vI++
while (vI < vN && (inText[vI].isDigit() || inText[vI] in ".eE+-")) vI++
pushStyle(SpanStyle(color = inP.number))
append(inText.substring(vStart, vI))
pop()
}
vC.isLetter() -> {
val vStart = vI
while (vI < vN && inText[vI].isLetter()) vI++
val vWord = inText.substring(vStart, vI)
if (vWord == "true" || vWord == "false" || vWord == "null") {
pushStyle(SpanStyle(color = inP.keyword))
append(vWord)
pop()
} else {
append(vWord)
}
}
vC in "{}[],:" -> {
pushStyle(SpanStyle(color = inP.punct))
append(vC)
pop()
vI++
}
else -> {
append(vC); vI++
}
}
}
}
// ==================
// MARK: XML / HTML tokeniser
// ==================
/** Tokeniser for XML / HTML. Distinguishes tag names, attribute names,
attribute values (string), and comments. Inside tags, attr=value pairs
are recognised; outside tags text is left in the default colour. */
private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
val vN = inText.length
var vI = 0
while (vI < vN) {
when (val vC = inText[vI]) {
'<' if inText.startsWith("<!--", vI) -> {
val vEnd = inText.indexOf("-->", vI + 4).let { if (it < 0) vN else it + 3 }
pushStyle(SpanStyle(color = inP.comment))
append(inText.substring(vI, vEnd))
pop()
vI = vEnd
}
'<' -> {
// Tag open: <[/]name attr=val attr2="val2">
val vGt = inText.indexOf('>', vI).let { if (it < 0) vN else it + 1 }
pushStyle(SpanStyle(color = inP.punct)); append('<'); pop()
var vJ = vI + 1
if (vJ < vN && inText[vJ] == '/') {
pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++
}
// Tag name.
val vTagStart = vJ
while (vJ < vN && !inText[vJ].isWhitespace() && inText[vJ] != '>' && inText[vJ] != '/') vJ++
if (vJ > vTagStart) {
pushStyle(SpanStyle(color = inP.tag))
append(inText.substring(vTagStart, vJ))
pop()
}
// Attributes - name="value" pairs.
while (vJ < vGt - 1) {
val vC2 = inText[vJ]
when {
vC2.isWhitespace() -> {
append(vC2); vJ++
}
vC2 == '/' -> {
pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++
}
vC2 == '=' -> {
pushStyle(SpanStyle(color = inP.punct)); append('='); pop(); vJ++
}
vC2 == '"' || vC2 == '\'' -> {
val vEnd = inText.indexOf(vC2, vJ + 1).let { if (it < 0) vGt - 1 else it + 1 }
pushStyle(SpanStyle(color = inP.string))
append(inText.substring(vJ, vEnd))
pop()
vJ = vEnd
}
else -> {
val vAttrStart = vJ
while (vJ < vGt - 1 && !inText[vJ].isWhitespace() && inText[vJ] != '=' && inText[vJ] != '>' && inText[vJ] != '/') vJ++
pushStyle(SpanStyle(color = inP.key))
append(inText.substring(vAttrStart, vJ))
pop()
}
}
}
if (vGt > vI && inText.getOrNull(vGt - 1) == '>') {
pushStyle(SpanStyle(color = inP.punct)); append('>'); pop()
}
vI = vGt
}
else -> {
append(vC); vI++
}
}
}
}
// ==================
// MARK: YAML tokeniser - line-based
// ==================
/** Tokeniser for YAML. Walks line by line because YAML's grammar is
indent-sensitive and most of the interesting colour decisions
(key vs value, list item vs scalar) depend on position within a
line. Comments (#), keys (text before ':'), quoted strings, numbers
and booleans are coloured; everything else stays default. */
private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
val vLines = inText.split('\n')
for ((vIdx, vLineRaw) in vLines.withIndex()) {
val vLine = vLineRaw
// Comment: rest of the line after a # (not inside a quoted string).
val vCommentAt = findUnquotedHash(vLine)
val vCodePart = if (vCommentAt >= 0) vLine.substring(0, vCommentAt) else vLine
val vCommentPart = if (vCommentAt >= 0) vLine.substring(vCommentAt) else ""
// Find a key: indent + (optional "- ") + key + ':' + (space|eol).
val vColon = indexOfKeyColon(vCodePart)
if (vColon > 0) {
val vPrefix = vCodePart.substring(0, vColon)
val vRest = vCodePart.substring(vColon + 1)
// Lead whitespace + optional list marker, then key.
var vK = 0
while (vK < vPrefix.length && vPrefix[vK].isWhitespace()) {
append(vPrefix[vK]); vK++
}
if (vK + 1 < vPrefix.length && vPrefix[vK] == '-' && vPrefix[vK + 1].isWhitespace()) {
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
append(vPrefix[vK + 1])
vK += 2
}
pushStyle(SpanStyle(color = inP.key))
append(vPrefix.substring(vK))
pop()
pushStyle(SpanStyle(color = inP.punct)); append(':'); pop()
appendYamlValue(vRest, inP)
} else {
appendYamlValue(vCodePart, inP)
}
if (vCommentPart.isNotEmpty()) {
pushStyle(SpanStyle(color = inP.comment))
append(vCommentPart)
pop()
}
if (vIdx < vLines.size - 1) append('\n')
}
}
/** Render the value portion of a YAML line - applies string / number /
keyword / list-marker colours where applicable. */
private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPalette) {
val vTrimmed = inSeg.trimStart()
val vPad = inSeg.substring(0, inSeg.length - vTrimmed.length)
append(vPad)
when {
vTrimmed.isEmpty() -> { /* nothing */
}
vTrimmed.startsWith('-') && (vTrimmed.length == 1 || vTrimmed[1].isWhitespace()) -> {
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
appendYamlValue(vTrimmed.substring(1), inP)
}
vTrimmed.startsWith('"') || vTrimmed.startsWith('\'') -> {
val vQ = vTrimmed[0]
val vEnd = vTrimmed.indexOf(vQ, 1).let { if (it < 0) vTrimmed.length else it + 1 }
pushStyle(SpanStyle(color = inP.string))
append(vTrimmed.substring(0, vEnd))
pop()
append(vTrimmed.substring(vEnd))
}
vTrimmed == "true" || vTrimmed == "false" || vTrimmed == "null" || vTrimmed == "~" -> {
pushStyle(SpanStyle(color = inP.keyword)); append(vTrimmed); pop()
}
vTrimmed.first().let { it.isDigit() || it == '-' && vTrimmed.length > 1 && vTrimmed[1].isDigit() } -> {
var vJ = 0
if (vTrimmed[0] == '-') vJ++
while (vJ < vTrimmed.length && (vTrimmed[vJ].isDigit() || vTrimmed[vJ] in ".eE+-")) vJ++
pushStyle(SpanStyle(color = inP.number))
append(vTrimmed.substring(0, vJ))
pop()
append(vTrimmed.substring(vJ))
}
else -> append(vTrimmed)
}
}
/** Find the index of the first '#' that isn't inside a quoted string,
or -1 if there is none. Used to split a YAML line into code + comment. */
private fun findUnquotedHash(inLine: String): Int {
var vInSingle = false
var vInDouble = false
for (vI in inLine.indices) {
when (inLine[vI]) {
'"' if !vInSingle -> vInDouble = !vInDouble
'\'' if !vInDouble -> vInSingle = !vInSingle
'#' if !vInSingle && !vInDouble -> return vI
}
}
return -1
}
/** Locate the colon that separates a YAML key from its value at the
current indent level - the FIRST unquoted ':' that's followed by
either whitespace or end-of-line. Returns -1 if the line has no key. */
private fun indexOfKeyColon(inLine: String): Int {
var vInSingle = false
var vInDouble = false
for (vI in inLine.indices) {
when (inLine[vI]) {
'"' if !vInSingle -> vInDouble = !vInDouble
'\'' if !vInDouble -> vInSingle = !vInSingle
':' if !vInSingle && !vInDouble -> {
val vNext = if (vI + 1 < inLine.length) inLine[vI + 1] else ' '
if (vNext.isWhitespace() || vI + 1 == inLine.length) return vI
}
}
}
return -1
}
+19
View File
@@ -0,0 +1,19 @@
package apidemo
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// ==================
// MARK: Theme - the app's legacy colour slots, derived from the active palette
// ==================
// AppColors is the app's own colour vocabulary (used by the custom widgets). It
// is now DERIVED from the selected Voltic M3 ColorScheme via appColorsFromScheme
// (VolticThemes.kt) and provided through LocalAppColors by App(). The default
// below (purple dark) only applies before that provider is installed.
class AppColors(
val bg: Color, val panel: Color, val field: Color, val border: Color,
val accent: Color, val text: Color, val dim: Color, val onAccent: Color,
)
internal val LocalAppColors = staticCompositionLocalOf { appColorsFromScheme(VolticPalette.Purple.scheme.dark) }
@@ -15,41 +15,54 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.Popup
import apidemo.compat.clipEntryOfText
import apidemo.compat.x
import apidemo.compat.y
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
import com.compose.sdl.layout.x
import com.compose.sdl.layout.y
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
// ==================
// MARK: TLS certificate-chain dialog + cert cards
// ==================
/* Dialog showing the server's TLS certificate chain (one card per cert, plus a
Copy PEM action). Fetched on demand by the lock button in the URL bar. */
/** Dialog showing the server's TLS certificate chain (one card per cert, plus a
Copy PEM action). Fetched on demand by the lock button in the URL bar. */
@Composable
internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -> Unit) {
val c = LocalAppColors.current
Dialog(onDismissRequest = inOnDismiss) {
Surface(color = c.panel, shape = RoundedCornerShape(10.dp), modifier = Modifier.width(540.dp)) {
Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
MaterialSymbolsOutlined(MaterialSymbols.Lock, tint = c.accent, size = 20.dp)
Text("TLS certificate chain", color = c.text, fontSize = 16.sp)
}
val vCerts = inChain?.certs.orEmpty()
when {
inChain == null -> Text("Probing…", color = c.dim, fontSize = 13.sp)
vCerts.isEmpty() -> Text(inChain.error ?: "No chain reported.", color = kWarnColor, fontSize = 13.sp)
vCerts.isEmpty() -> Text(
inChain.error ?: "No chain reported.",
color = VolticTheme.extended.warning,
fontSize = 13.sp
)
else -> {
Text("${vCerts.size} certificate(s) presented by ${hostOf(inUrl)}", color = c.dim, fontSize = 12.sp)
Text(
"${vCerts.size} certificate(s) presented by ${hostOf(inUrl)}",
color = c.dim,
fontSize = 12.sp
)
Column(
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
@@ -57,7 +70,10 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
val vChainPem = vCerts.mapNotNull { certField(it.fields, "Cert") }.joinToString("\n")
if (vChainPem.isNotEmpty()) CopyButton(vChainPem, "Copy chain")
OutlinedButton(onClick = inOnDismiss) { Text("Close", color = c.text) }
@@ -68,10 +84,10 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
}
}
/* One certificate's summary card (subject / issuer / validity). Server-presented
certs get a solid border; derived ones (issuer pulled from the OS store, or a
name-only placeholder) get an accent-coloured border + a label. A self-signed
cert shows "Self-signed" in green instead of repeating its issuer. */
/** One certificate's summary card (subject / issuer / validity). Server-presented
certs get a solid border; derived ones (issuer pulled from the OS store, or a
name-only placeholder) get an accent-coloured border + a label. A self-signed
cert shows "Self-signed" in green instead of repeating its issuer. */
@Composable
internal fun CertCard(inIndex: Int, inCert: ChainCert) {
val c = LocalAppColors.current
@@ -88,20 +104,28 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
Column(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
.background(c.field.copy(alpha = if (inCert.fromServer) 1f else 0.4f), RoundedCornerShape(8.dp))
.border(if (inCert.fromServer) 1.dp else 1.5.dp, if (inCert.fromServer) c.border else c.accent, RoundedCornerShape(8.dp))
.border(
if (inCert.fromServer) 1.dp else 1.5.dp,
if (inCert.fromServer) c.border else c.accent,
RoundedCornerShape(8.dp)
)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
val vTitle = vSubject?.let { cnOf(it) } ?: if (inIndex == 0) "Leaf certificate" else "Issuer #$inIndex"
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(vTitle, color = c.accent, fontSize = 12.sp, modifier = Modifier.weight(1f))
if (!inCert.fromServer) Text(if (vPem != null) "from OS store" else "not presented", color = c.dim, fontSize = 10.sp)
if (!inCert.fromServer) Text(
if (vPem != null) "from OS store" else "not presented",
color = c.dim,
fontSize = 10.sp
)
if (vPem != null) CopyButton(vPem)
}
vSubject?.let { CertLine("Subject", it) }
if (vSelfSigned) CertLine("Issuer", "Self-signed", kSelfSignedColor)
else vIssuer?.let { CertLine("Issuer", it) }
// Hosts this cert is valid for — the field that actually has to match the URL.
// Hosts this cert is valid for - the field that actually has to match the URL.
(certField(vFields, "X509v3 Subject Alternative Name")
?: certField(vFields, "Subject Alternative Name")
?: vParsed?.sans?.takeIf { it.isNotEmpty() }?.joinToString(", "))
@@ -117,10 +141,10 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
internal val kSelfSignedColor = Color(0xFF3FB950L)
/* Copy button — rounded, hover overlay, real click that copies inText and shows
a green check. Icon-only (per cert) flashes a small floating "Copied" bubble
for 2s; the labelled variant (Copy chain) flips its label to "Copied" instead.
Uses a non-catching Popup so it never dismisses the dialog or eats clicks. */
/** Copy button - rounded, hover overlay, real click that copies inText and shows
a green check. Icon-only (per cert) flashes a small floating "Copied" bubble
for 2s; the labelled variant (Copy chain) flips its label to "Copied" instead.
Uses a non-catching Popup so it never dismisses the dialog or eats clicks. */
@Composable
internal fun CopyButton(inText: String, inLabel: String? = null) {
val c = LocalAppColors.current
@@ -132,7 +156,11 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
var vHeight by remember { mutableStateOf(0) }
val vClipboard = LocalClipboard.current
val vScope = rememberCoroutineScope()
LaunchedEffect(vCopied) { if (vCopied) { delay(2000); vCopied = false } }
LaunchedEffect(vCopied) {
if (vCopied) {
delay(2000.milliseconds); vCopied = false
}
}
Row(
modifier = Modifier
.onGloballyPositioned { vX = it.x; vY = it.y }
@@ -141,7 +169,7 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
.background(if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(7.dp))
.hoverable(vHoverSrc)
.clickable {
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(inText)) }
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(inText)) }
vCopied = true
}
.padding(horizontal = if (inLabel != null) 10.dp else 5.dp, vertical = 5.dp),
@@ -154,7 +182,11 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
tint = if (vCopied) kSelfSignedColor else c.dim,
size = 15.dp,
)
if (inLabel != null) Text(if (vCopied) "Copied" else inLabel, color = if (vCopied) kSelfSignedColor else c.text, fontSize = 12.sp)
if (inLabel != null) Text(
if (vCopied) "Copied" else inLabel,
color = if (vCopied) kSelfSignedColor else c.text,
fontSize = 12.sp
)
}
// Icon-only: float a tiny "Copied" bubble (non-catching popup → no dismiss).
if (vCopied && inLabel == null) {
@@ -177,23 +209,23 @@ internal fun CertLine(inLabel: String, inValue: String, inValueColor: Color? = n
}
}
/* Look up a CURLINFO_CERTINFO field by name (case-insensitive). */
/** Look up a CURLINFO_CERTINFO field by name (case-insensitive). */
internal fun certField(inFields: List<Pair<String, String>>, inName: String): String? =
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
/* The CN= value out of a distinguished-name string. Handles every
format the platform backends emit:
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
"/C=US/O=Let's Encrypt/CN=R3" (OpenSSL X509_NAME_oneline)
"CN = R3, O = Let's Encrypt, C = US" (OpenSSL X509_NAME_print_ex
default, with spaces — what
libcurl on macOS emits)
Null when there's no CN. */
/** The CN= value out of a distinguished-name string. Handles every
format the platform backends emit:
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
"/C=US/O=Let's Encrypt/CN=R3" (OpenSSL X509_NAME_oneline)
"CN = R3, O = Let's Encrypt, C = US" (OpenSSL X509_NAME_print_ex
default, with spaces - what
libcurl on macOS emits)
Null when there's no CN. */
internal fun cnOf(inDn: String): String? {
val vMatch = Regex("""(?i)\bCN\s*=\s*([^,/]+)""").find(inDn) ?: return null
return vMatch.groupValues[1].trim().ifBlank { null }
}
/* The host portion of a URL (for the chain dialog header). */
/** The host portion of a URL (for the chain dialog header). */
internal fun hostOf(inUrl: String): String =
inUrl.substringAfter("://", inUrl).substringBefore("/").substringBefore("?").ifBlank { inUrl }
@@ -0,0 +1,29 @@
package apidemo
// ==================
// MARK: TLS chain / client-certificate seam
// ==================
// The real implementations drive the bundled libcurl directly (CurlMtls.kt,
// nativeMain) - Ktor's engines expose no client-cert / CERTINFO API. The jvm
// parity app has no bundled libcurl, so its actuals report the feature as
// native-only instead.
/** One certificate in the chain: its CURLINFO_CERTINFO-style fields (Subject /
Issuer / the PEM under "Cert" / dates …) and whether the server actually
presented it. Derived certs - an issuer resolved from the OS store, or a
name-only placeholder - have fromServer = false and are drawn dotted. */
class ChainCert(val fields: List<Pair<String, String>>, val fromServer: Boolean)
/** The server's TLS certificate chain. error is set instead when it couldn't be
fetched; fields vary by TLS backend. */
class TlsChain(val certs: List<ChainCert>, val error: String?)
/** Fetch the TLS certificate chain the request's server presents. */
expect fun inspectTlsChain(inReq: ApiRequest): TlsChain
/** Send the request with its client certificate attached (mTLS). */
expect fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
/** Remove temp client certs a previous crashed run left behind. Call once at
startup; no-op where the OS store isn't used. */
expect fun sweepTempClientCerts()
@@ -4,13 +4,13 @@ package apidemo
// MARK: Variable substitution ({{name}})
// ==================
/* Matches a {{ name }} token. Names may contain letters, digits, _, . and -.
Surrounding whitespace inside the braces is tolerated. */
/** Matches a {{ name }} token. Names may contain letters, digits, _, . and -.
Surrounding whitespace inside the braces is tolerated. */
private val kVarRegex = Regex("""\{\{\s*([A-Za-z0-9_.\-]+)\s*\}\}""")
/* Replace every {{name}} token in inText with the matching enabled variable's
value. Unknown / disabled names are left untouched so the user can spot the
ones that won't resolve. */
/** Replace every {{name}} token in inText with the matching enabled variable's
value. Unknown / disabled names are left untouched so the user can spot the
ones that won't resolve. */
fun substituteVars(inText: String, inVars: List<KeyVal>): String {
if (inText.isEmpty()) return inText
val vMap = inVars
@@ -20,26 +20,41 @@ fun substituteVars(inText: String, inVars: List<KeyVal>): String {
return kVarRegex.replace(inText) { vMatch -> vMap[vMatch.groupValues[1]] ?: vMatch.value }
}
/* Apply variable substitution across every field that actually gets sent — URL,
query params, headers and body — returning a fully-resolved request. The
original (template) request is left untouched. */
/** Apply variable substitution across every field that actually gets sent - URL,
query params, headers and body - returning a fully-resolved request. The
original (template) request is left untouched. */
fun resolveVars(inReq: ApiRequest, inVars: List<KeyVal>): ApiRequest {
val vActive = inVars.any { it.enabled && it.key.isNotBlank() }
if (!vActive) return inReq
return inReq.copy(
url = substituteVars(inReq.url, inVars),
params = inReq.params.map { it.copy(key = substituteVars(it.key, inVars), value = substituteVars(it.value, inVars)) },
headers = inReq.headers.map { it.copy(key = substituteVars(it.key, inVars), value = substituteVars(it.value, inVars)) },
params = inReq.params.map {
it.copy(
key = substituteVars(it.key, inVars),
value = substituteVars(it.value, inVars)
)
},
headers = inReq.headers.map {
it.copy(
key = substituteVars(it.key, inVars),
value = substituteVars(it.value, inVars)
)
},
body = substituteVars(inReq.body, inVars),
form = inReq.form.map { it.copy(key = substituteVars(it.key, inVars), value = substituteVars(it.value, inVars)) },
form = inReq.form.map {
it.copy(
key = substituteVars(it.key, inVars),
value = substituteVars(it.value, inVars)
)
},
certPath = substituteVars(inReq.certPath, inVars),
keyPath = substituteVars(inReq.keyPath, inVars),
certPassword = substituteVars(inReq.certPassword, inVars),
)
}
/* The distinct {{names}} referenced anywhere in the request that have no enabled
variable to fill them — surfaced as a warning in the editor. */
/** The distinct {{names}} referenced anywhere in the request that have no enabled
variable to fill them - surfaced as a warning in the editor. */
fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
val vDefined = inVars
.filter { it.enabled && it.key.isNotBlank() }
@@ -49,7 +64,7 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
append(inReq.url).append('\n')
inReq.params.forEach { append(it.key).append('\n').append(it.value).append('\n') }
inReq.headers.forEach { append(it.key).append('\n').append(it.value).append('\n') }
if (inReq.method.allowsBody) {
if (true) {
when (inReq.bodyType) {
BodyType.FORM -> inReq.form.forEach { append(it.key).append('\n').append(it.value).append('\n') }
else -> append(inReq.body)
@@ -67,10 +82,10 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
// MARK: Copy as cURL
// ==================
/* Render inReq as a runnable, multi-line curl command (pass an already-resolved
request so the output has real values, not {{tokens}}). Enabled query params
are folded into the URL; a Content-Type header is added for the body type
unless the request already sets one. */
/** Render inReq as a runnable, multi-line curl command (pass an already-resolved
request so the output has real values, not {{tokens}}). Enabled query params
are folded into the URL; a Content-Type header is added for the body type
unless the request already sets one. */
fun toCurl(inReq: ApiRequest): String {
val vSb = StringBuilder()
vSb.append("curl -X ").append(inReq.method.name)
@@ -91,27 +106,31 @@ fun toCurl(inReq: ApiRequest): String {
if (inReq.certPassword.isNotEmpty()) vSb.append(" \\\n --pass ").append(shellQuote(inReq.certPassword))
}
if (inReq.method.allowsBody && inReq.bodyType != BodyType.NONE) {
if (true && inReq.bodyType != BodyType.NONE) {
val vContentType = inReq.bodyContentType()
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
if (vContentType != null && !vHasCt) {
vSb.append(" \\\n -H ").append(shellQuote("Content-Type: $vContentType"))
}
when (inReq.bodyType) {
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ").append(shellQuote(formEncode(inReq.form)))
BodyType.FILE -> if (inReq.body.isNotBlank()) vSb.append(" \\\n --data-binary ").append(shellQuote("@${inReq.body}"))
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ")
.append(shellQuote(formEncode(inReq.form)))
BodyType.FILE -> if (inReq.body.isNotBlank()) vSb.append(" \\\n --data-binary ")
.append(shellQuote("@${inReq.body}"))
else -> if (inReq.body.isNotEmpty()) vSb.append(" \\\n --data ").append(shellQuote(inReq.body))
}
}
return vSb.toString()
}
/* URL-encode enabled form fields into an application/x-www-form-urlencoded body. */
/** URL-encode enabled form fields into an application/x-www-form-urlencoded body. */
fun formEncode(inForm: List<KeyVal>): String =
inForm.filter { it.enabled && it.key.isNotBlank() }
.joinToString("&") { "${urlEncode(it.key)}=${urlEncode(it.value)}" }
/* The request URL with its enabled query params appended (percent-encoded). */
/** The request URL with its enabled query params appended (percent-encoded). */
internal fun urlWithParams(inReq: ApiRequest): String {
val vTrimmed = inReq.url.trim()
val vEnabled = inReq.params.filter { it.enabled && it.key.isNotBlank() }
@@ -121,10 +140,10 @@ internal fun urlWithParams(inReq: ApiRequest): String {
return vTrimmed + vSep + vQuery
}
/* Single-quote a string for a POSIX shell, escaping any embedded quotes. */
/** Single-quote a string for a POSIX shell, escaping any embedded quotes. */
private fun shellQuote(inS: String): String = "'" + inS.replace("'", "'\\''") + "'"
/* Minimal RFC-3986 percent-encoding for query keys / values. */
/** Minimal RFC-3986 percent-encoding for query keys / values. */
private fun urlEncode(inS: String): String = buildString {
for (vByte in inS.encodeToByteArray()) {
val vCode = vByte.toInt() and 0xFF
@@ -30,12 +30,12 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// MARK: Small UI atoms (shared across panels / dialogs / sidebar)
// ==================
/* Method as a compact rounded badge (soft colour fill + coloured label), fixed
width so request names line up. */
/** Method as a compact rounded badge (soft colour fill + coloured label), fixed
width so request names line up. */
@Composable
internal fun MethodTag(inMethod: ReqMethod) {
val vCol = methodColor(inMethod)
// No background — just the coloured method name, fixed width so the request
// No background - just the coloured method name, fixed width so the request
// names still line up in the sidebar list.
Box(
modifier = Modifier.width(46.dp).padding(vertical = 2.dp),
@@ -45,16 +45,16 @@ internal fun MethodTag(inMethod: ReqMethod) {
}
}
/* Thin horizontal accent line showing where a dragged request row will drop
(the open-tab strip uses the vertical DropBar). */
/** Thin horizontal accent line showing where a dragged request row will drop
(the open-tab strip uses the vertical DropBar). */
@Composable
internal fun RowDropBar() {
val c = LocalAppColors.current
Box(modifier = Modifier.fillMaxWidth().height(2.dp).background(c.accent, RoundedCornerShape(1.dp)))
}
/* Tab indices listed in inDots get a small accent dot after their label — used
to flag a tab that holds content (e.g. a non-empty request body). */
/** Tab indices listed in inDots get a small accent dot after their label - used
to flag a tab that holds content (e.g. a non-empty request body). */
@Composable
internal fun TabBar(inTabs: List<String>, inSelected: Int, inDots: Set<Int> = emptySet(), inOnSelect: (Int) -> Unit) {
val c = LocalAppColors.current
@@ -90,10 +90,17 @@ internal fun TogglePill(inLabel: String, inSelected: Boolean, inOnClick: () -> U
) { Text(inLabel, color = if (inSelected) c.onAccent else c.dim, fontSize = 12.sp) }
}
/* Compact single-line (or fixed-height multi-line) input — a BasicTextField in
a slim bordered box, much shorter than the 56 dp Material OutlinedTextField. */
/** Compact single-line (or fixed-height multi-line) input - a BasicTextField in
a slim bordered box, much shorter than the 56 dp Material OutlinedTextField. */
@Composable
internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier: Modifier = Modifier, inPlaceholder: String = "", inSingleLine: Boolean = true, inOnEnter: (() -> Unit)? = null) {
internal fun ThinField(
inValue: String,
inOnChange: (String) -> Unit,
inModifier: Modifier = Modifier,
inPlaceholder: String = "",
inSingleLine: Boolean = true,
inOnEnter: (() -> Unit)? = null
) {
val c = LocalAppColors.current
// Box is an ancestor of the field, so it sees Enter the single-line field
// leaves unconsumed (it propagates up the focus → root chain).
@@ -102,7 +109,9 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
.border(1.dp, c.border, RoundedCornerShape(6.dp))
.padding(horizontal = 10.dp, vertical = 9.dp)
if (inOnEnter != null) vBoxMod = vBoxMod.onKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) { inOnEnter(); true } else false
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) {
inOnEnter(); true
} else false
}
Box(
modifier = vBoxMod,
@@ -111,10 +120,10 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
if (inValue.isEmpty() && inPlaceholder.isNotEmpty()) Text(inPlaceholder, color = c.dim, fontSize = 13.sp)
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
),
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
),
) {
BasicTextField(
value = inValue,
@@ -128,10 +137,17 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
}
}
/* Small icon-only button (burger / pack / options / save / close). inPadding
tightens the tap area for slimmer rows. */
/** Small icon-only button (burger / pack / options / save / close). inPadding
tightens the tap area for slimmer rows. */
@Composable
internal fun IconBtn(inIcon: Int, inDesc: String, inModifier: Modifier = Modifier, inSize: Dp = 18.dp, inPadding: Dp = 6.dp, inOnClick: () -> Unit) {
internal fun IconBtn(
inIcon: Int,
inDesc: String,
inModifier: Modifier = Modifier,
inSize: Dp = 18.dp,
inPadding: Dp = 6.dp,
inOnClick: () -> Unit
) {
val c = LocalAppColors.current
val vHoverSrc = remember { MutableInteractionSource() }
val vHover by vHoverSrc.collectIsHoveredAsState()
@@ -152,18 +168,18 @@ internal fun IconBtn(inIcon: Int, inDesc: String, inModifier: Modifier = Modifie
}
}
/* Outlined icon+label action (New request / Add row / pack actions). */
/** Outlined icon+label action (New request / Add row / pack actions). */
@Composable
internal fun OutlinedAction(inIcon: Int, inLabel: String, inOnClick: () -> Unit) {
val c = LocalAppColors.current
OutlinedButton(onClick = inOnClick) { BtnContent(inIcon, inLabel, c.accent) }
}
/* Filled red icon+label button for destructive / stop actions (delete, cancel, quit).
Uses the m3 Button so its size + ripple + interaction match every other
Button in the app — previously handrolled Box + background + clickable + padding
gave it a different height / horizontal padding, and the Discard button sat
next to a Button ("Save first…") looking noticeably smaller in dialogs. */
/** Filled red icon+label button for destructive / stop actions (delete, cancel, quit).
Uses the m3 Button so its size + ripple + interaction match every other
Button in the app - previously handrolled Box + background + clickable + padding
gave it a different height / horizontal padding, and the Discard button sat
next to a Button ("Save first…") looking noticeably smaller in dialogs. */
@Composable
internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
val vRed = methodColor(ReqMethod.DELETE)
@@ -173,17 +189,7 @@ internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
) { BtnContent(inIcon, inLabel, Color.White) }
}
/* Bordered icon+label chip (Copy / Save as / Cancel). */
@Composable
internal fun IconLabelChip(inIcon: Int, inLabel: String, inOnClick: () -> Unit) {
val c = LocalAppColors.current
Box(
modifier = Modifier.clip(RoundedCornerShape(6.dp)).border(1.dp, c.border, RoundedCornerShape(6.dp))
.clickable(onClick = inOnClick).padding(horizontal = 8.dp, vertical = 5.dp),
) { BtnContent(inIcon, inLabel, c.dim, 14.dp) }
}
/* Icon + label row used inside buttons. */
/** Icon + label row used inside buttons. */
@Composable
internal fun BtnContent(inIcon: Int, inLabel: String, inColor: Color, inSize: Dp = 16.dp) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
@@ -0,0 +1,212 @@
package apidemo
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// ==================
// MARK: Voltic themes - selectable Material 3 palettes
// ==================
// The three "voltic" brand palettes (Kotlin purple, orange, and a stock-M3
// purple), each a full light + dark M3 ColorScheme plus an EXTENDED group that
// M3 has no slot for (warning). The app runs on MaterialTheme(colorScheme = …)
// driven by the selected palette; the legacy AppColors abstraction is DERIVED
// from the active scheme (appColorsFromScheme) so existing widgets follow along.
/** Colors M3's ColorScheme has no slot for. Warning is the amber "unsaved /
undefined variable" accent; read it with VolticTheme.extended.warning. */
@Immutable
data class VolticExtendedColors(
val warning: Color,
val onWarning: Color,
val warningContainer: Color,
val onWarningContainer: Color,
)
/** One palette's four schemes: the M3 light/dark ColorSchemes + the matching
extended groups. */
@Immutable
class VolticScheme(
val light: ColorScheme,
val dark: ColorScheme,
val lightExtended: VolticExtendedColors,
val darkExtended: VolticExtendedColors,
)
/** The palettes the user can pick from. `id` is the stable persistence key. */
enum class VolticPalette(val id: String, val label: String) {
Purple("purple", "Kotlin Purple"),
Orange("orange", "Voltic Orange"),
PurpleStock("purple-stock", "Purple");
val scheme: VolticScheme
get() = when (this) {
Purple -> kPurpleScheme
Orange -> kOrangeScheme
PurpleStock -> kPurpleStockScheme
}
companion object {
fun fromId(inId: String?): VolticPalette = entries.firstOrNull { it.id == inId } ?: Purple
}
}
// Usage: MaterialTheme.colorScheme.primary, VolticTheme.extended.warning
val LocalVolticExtended = staticCompositionLocalOf { kPurpleScheme.darkExtended }
object VolticTheme {
val extended: VolticExtendedColors
@Composable get() = LocalVolticExtended.current
}
/** Maps the active M3 ColorScheme onto the app's legacy AppColors slots, so the
~50 existing `LocalAppColors.current` call sites theme with the palette:
panel = surface, field = surfaceVariant, border = the subtle outlineVariant,
dim = onSurfaceVariant, accent = primary. */
internal fun appColorsFromScheme(inScheme: ColorScheme): AppColors = AppColors(
bg = inScheme.background,
panel = inScheme.surface,
field = inScheme.surfaceVariant,
border = inScheme.outlineVariant,
accent = inScheme.primary,
text = inScheme.onSurface,
dim = inScheme.onSurfaceVariant,
onAccent = inScheme.onPrimary,
)
// ==================
// MARK: Palette definitions (from voltic-pack/theme)
// ==================
private val kPurpleScheme = VolticScheme(
light = lightColorScheme(
primary = Color(0xFF7F52FF), onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFE3D4FF), onPrimaryContainer = Color(0xFF2A1065),
secondary = Color(0xFF5F5873), onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFE5DFF3), onSecondaryContainer = Color(0xFF1B1626),
tertiary = Color(0xFFF5B437), onTertiary = Color(0xFF3F2E00),
tertiaryContainer = Color(0xFFFFE8B8), onTertiaryContainer = Color(0xFF2F2000),
error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002),
background = Color(0xFFFAF8FF), onBackground = Color(0xFF1B1626),
surface = Color(0xFFFFFFFF), onSurface = Color(0xFF1B1626),
surfaceVariant = Color(0xFFECE6F6), onSurfaceVariant = Color(0xFF453D5C),
outline = Color(0xFF6A5F8A), outlineVariant = Color(0xFFD7CDEB),
inverseSurface = Color(0xFF2E2840), inverseOnSurface = Color(0xFFF3EFFB),
inversePrimary = Color(0xFF9D7BFF), surfaceTint = Color(0xFF7F52FF), scrim = Color(0xFF000000),
),
dark = darkColorScheme(
primary = Color(0xFF9D7BFF), onPrimary = Color(0xFF1F0068),
primaryContainer = Color(0xFF4A22C4), onPrimaryContainer = Color(0xFFE3D4FF),
secondary = Color(0xFFC9C1DC), onSecondary = Color(0xFF2E2840),
secondaryContainer = Color(0xFF453D5C), onSecondaryContainer = Color(0xFFE5DFF3),
tertiary = Color(0xFFF5B437), onTertiary = Color(0xFF3F2E00),
tertiaryContainer = Color(0xFF6D5000), onTertiaryContainer = Color(0xFFFFE8B8),
error = Color(0xFFFFB4AB), onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF0F0D17), onBackground = Color(0xFFE6E1F2),
surface = Color(0xFF171423), onSurface = Color(0xFFE6E1F2),
surfaceVariant = Color(0xFF37304A), onSurfaceVariant = Color(0xFFCFC6E4),
outline = Color(0xFF8F86A8), outlineVariant = Color(0xFF37304A),
inverseSurface = Color(0xFFE6E1F2), inverseOnSurface = Color(0xFF171423),
inversePrimary = Color(0xFF7F52FF), surfaceTint = Color(0xFF9D7BFF), scrim = Color(0xFF000000),
),
lightExtended = VolticExtendedColors(
warning = Color(0xFFB26A00), onWarning = Color(0xFFFFFFFF),
warningContainer = Color(0xFFFFDF9E), onWarningContainer = Color(0xFF2A1800),
),
darkExtended = VolticExtendedColors(
warning = Color(0xFFFFC960), onWarning = Color(0xFF3F2E00),
warningContainer = Color(0xFF5C4300), onWarningContainer = Color(0xFFFFDF9E),
),
)
private val kOrangeScheme = VolticScheme(
light = lightColorScheme(
primary = Color(0xFFE86A33), onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFFFDBCB), onPrimaryContainer = Color(0xFF380D00),
secondary = Color(0xFF9C4E2A), onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFFFE0D2), onSecondaryContainer = Color(0xFF3A0F00),
tertiary = Color(0xFF8F6A00), onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFFBE08A), onTertiaryContainer = Color(0xFF2D2000),
error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFF8F5), onBackground = Color(0xFF221A15),
surface = Color(0xFFFFF8F5), onSurface = Color(0xFF221A15),
surfaceVariant = Color(0xFFF4DED4), onSurfaceVariant = Color(0xFF52443C),
outline = Color(0xFF85736B), outlineVariant = Color(0xFFD7C2B8),
inverseSurface = Color(0xFF382E29), inverseOnSurface = Color(0xFFFFEDE5),
inversePrimary = Color(0xFFFFB598), surfaceTint = Color(0xFFE86A33), scrim = Color(0xFF000000),
),
dark = darkColorScheme(
primary = Color(0xFFFFB598), onPrimary = Color(0xFF5B1B00),
primaryContainer = Color(0xFFC1531F), onPrimaryContainer = Color(0xFFFFDBCB),
secondary = Color(0xFFFFB599), onSecondary = Color(0xFF5E1A00),
secondaryContainer = Color(0xFF7D3715), onSecondaryContainer = Color(0xFFFFE0D2),
tertiary = Color(0xFFF5B437), onTertiary = Color(0xFF3F2E00),
tertiaryContainer = Color(0xFF6D5000), onTertiaryContainer = Color(0xFFFBE08A),
error = Color(0xFFFFB4AB), onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF101B28), onBackground = Color(0xFFE3E7ED),
surface = Color(0xFF16283A), onSurface = Color(0xFFE3E7ED),
surfaceVariant = Color(0xFF33414F), onSurfaceVariant = Color(0xFFC9D3DD),
outline = Color(0xFF7C8A97), outlineVariant = Color(0xFF33414F),
inverseSurface = Color(0xFFE3E7ED), inverseOnSurface = Color(0xFF16283A),
inversePrimary = Color(0xFFE86A33), surfaceTint = Color(0xFFFFB598), scrim = Color(0xFF000000),
),
lightExtended = VolticExtendedColors(
warning = Color(0xFF7D5800), onWarning = Color(0xFFFFFFFF),
warningContainer = Color(0xFFFFDF9E), onWarningContainer = Color(0xFF271900),
),
darkExtended = VolticExtendedColors(
warning = Color(0xFFF2C13D), onWarning = Color(0xFF3E2E00),
warningContainer = Color(0xFF5E4600), onWarningContainer = Color(0xFFFFDF9E),
),
)
private val kPurpleStockScheme = VolticScheme(
light = lightColorScheme(
primary = Color(0xFF7F52FF), onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFE9DDFF), onPrimaryContainer = Color(0xFF22005D),
secondary = Color(0xFF615B71), onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFE7DEF8), onSecondaryContainer = Color(0xFF1D192B),
tertiary = Color(0xFF9A25AE), onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFFFD6FE), onTertiaryContainer = Color(0xFF35003F),
error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002),
background = Color(0xFFFDF7FF), onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFDF7FF), onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFE7E0EB), onSurfaceVariant = Color(0xFF49454E),
outline = Color(0xFF7A757F), outlineVariant = Color(0xFFCBC4CF),
inverseSurface = Color(0xFF313035), inverseOnSurface = Color(0xFFF4EFF4),
inversePrimary = Color(0xFFCFBCFF), surfaceTint = Color(0xFF7F52FF), scrim = Color(0xFF000000),
),
dark = darkColorScheme(
primary = Color(0xFFCFBCFF), onPrimary = Color(0xFF3A0AA5),
primaryContainer = Color(0xFF6236E0), onPrimaryContainer = Color(0xFFE9DDFF),
secondary = Color(0xFFCBC2DB), onSecondary = Color(0xFF332D41),
secondaryContainer = Color(0xFF494458), onSecondaryContainer = Color(0xFFE7DEF8),
tertiary = Color(0xFFF9ABFF), onTertiary = Color(0xFF570066),
tertiaryContainer = Color(0xFF7B0E8F), onTertiaryContainer = Color(0xFFFFD6FE),
error = Color(0xFFFFB4AB), onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF141220), onBackground = Color(0xFFE6E1E9),
surface = Color(0xFF1D1A2C), onSurface = Color(0xFFE6E1E9),
surfaceVariant = Color(0xFF3B3548), onSurfaceVariant = Color(0xFFCBC4CF),
outline = Color(0xFF948F99), outlineVariant = Color(0xFF3B3548),
inverseSurface = Color(0xFFE6E1E9), inverseOnSurface = Color(0xFF1D1A2C),
inversePrimary = Color(0xFF7F52FF), surfaceTint = Color(0xFFCFBCFF), scrim = Color(0xFF000000),
),
lightExtended = VolticExtendedColors(
warning = Color(0xFFB26A00), onWarning = Color(0xFFFFFFFF),
warningContainer = Color(0xFFFFDF9E), onWarningContainer = Color(0xFF2A1800),
),
darkExtended = VolticExtendedColors(
warning = Color(0xFFFFC960), onWarning = Color(0xFF3F2E00),
warningContainer = Color(0xFF5C4300), onWarningContainer = Color(0xFFFFDF9E),
),
)
@@ -0,0 +1,77 @@
package apidemo.compat
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.platform.ClipEntry
import io.ktor.client.*
import okio.FileSystem
// ==================
// MARK: Platform seams (native = com.compose.sdl / SDL3, jvm = AWT + upstream desktop)
// ==================
// The port modules are native-only, so shared code can't call com.compose.sdl
// directly - every SDL-backed API the app uses goes through these expects.
// Signatures mirror the native originals so the native actuals are pure
// delegation (same pattern as :demo's demo.shim package).
/** Per-user app-data dir (created on demand, ends with a separator) or null. */
expect fun appDataDir(inOrg: String, inApp: String): String?
/** Open the OS file manager showing the given path. Fire-and-forget. */
expect fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)? = null)
/** "Explorer" / "Finder" / "Files" - for menu labels. */
expect fun fileManagerName(): String
/** Async save-file dialog; the callback gets the chosen absolute path or null. */
expect fun showSaveFileDialog(inDefaultName: String? = null, inOnResult: (String?) -> Unit)
/** Async open-file dialog; the callback gets the chosen absolute path or null. */
expect fun showOpenFileDialog(inOnResult: (String?) -> Unit)
/** A plain-text clipboard entry (ClipEntry construction differs per stack). */
expect fun clipEntryOfText(inText: String): ClipEntry
// ============
// In-memory response images
/** Register/replace decoded-image source bytes under a key (resp-image://N). */
expect fun registerMemoryImage(inKey: String, inBytes: ByteArray)
/** Drop a registered image's bytes. */
expect fun removeMemoryImage(inKey: String)
/** Painter for a registered image key (raster or SVG). */
@Composable
expect fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter
// ============
// Text layout
/** Rows the given line occupies when soft-wrapped to inMaxWidthPx at
inFontPx (already density-scaled). Used by the body gutter so its line
numbers stay aligned with the wrapped body text. */
expect fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int
/** Editor tab width in spaces - how wide a typed '\t' renders. On the native
stack this drives the project text pipeline (TextLayoutConfig); the jvm
parity app only stores the preference. */
expect var editorTabWidth: Int
// ============
// Infrastructure
/** okio's FileSystem.SYSTEM is declared per-platform, not in its common
metadata - surface it through a seam. */
internal expect val systemFileSystem: FileSystem
/** The app's Ktor client: Curl on native (bundled libcurl - same TLS stack as
the mTLS path), CIO on the jvm parity target. */
expect fun createApiHttpClient(): HttpClient
/** Window-shell integration: an intercepting close handler (return true to
allow the close) and an app-wide key-shortcut handler; both cleared when
the composition leaves. */
@Composable
expect fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean)
@@ -0,0 +1,16 @@
package apidemo.compat
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.positionInWindow
// COPY of the port's com.compose.sdl.layout LayoutCoordinates sugar so the
// same helpers resolve on the jvm parity target (the port modules are
// native-only). Guards `isAttached`: a menu/tooltip anchor can be read while
// its node is mid-recycle, and positionInWindow() throws on a detached
// coordinator.
/** Logical-point X of this layout in the window. */
val LayoutCoordinates.x: Int get() = if (isAttached) positionInWindow().x.toInt() else 0
/** Logical-point Y of this layout in the window. */
val LayoutCoordinates.y: Int get() = if (isAttached) positionInWindow().y.toInt() else 0
@@ -1,38 +1,34 @@
package com.compose.sdl.scrollbar
// COPY of the port's Scrollbar (package com.compose.sdl.scrollbar) so the SAME
// implementation runs on the jvm parity target - the port modules are
// native-only, so apidemo's commonMain cannot reference them directly.
// Keep in sync with the original when it changes.
package apidemo.compat
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.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.hoverable
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.lazy.LazyListState
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.runtime.*
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.input.pointer.pointerInput
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
@@ -42,24 +38,24 @@ 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:
/** 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(),
)
}
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. */
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(
@@ -71,8 +67,8 @@ class ScrollbarStyle(
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. */
/** 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,
@@ -88,9 +84,9 @@ 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). */
/** 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
@@ -98,29 +94,16 @@ interface ScrollbarAdapter {
suspend fun scrollTo(scrollOffset: Double)
}
@Composable
fun rememberScrollbarAdapter(scrollState: ScrollState): ScrollbarAdapter =
remember(scrollState) { ScrollStateScrollbarAdapter(scrollState) }
// (The port also ships a ScrollState-based adapter; it reads foundation
// internals (ScrollState.contentSize/viewportSize) that a copy outside
// :foundation cannot access. apidemo only scrollbars a LazyColumn.)
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. */
/** 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) }
@@ -148,7 +131,7 @@ private class LazyListScrollbarAdapter(private val state: LazyListState) : Scrol
// 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
// 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) {
@@ -180,21 +163,12 @@ fun VerticalScrollbar(
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. */
/** 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,
@@ -210,7 +184,7 @@ private fun Scrollbar(
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
// 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.
@@ -247,22 +221,22 @@ private fun Scrollbar(
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)
)
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
// 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
// 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
@@ -272,7 +246,7 @@ private fun Scrollbar(
val vDown = awaitFirstDown(requireUnconsumed = false)
vDown.consume()
// Live geometry — stable for the drag's duration (content /
// 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
@@ -283,12 +257,12 @@ private fun Scrollbar(
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
val vTravelPx = vTrackPx - vLen
val vRaw = if (vTravelPx <= 0) 0
else (vTravelPx * (adapter.scrollOffset / vMaxScrollPx)).roundToInt()
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
// 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).
@@ -334,9 +308,9 @@ private fun Scrollbar(
}
}
/* 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). */
/** 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)
@@ -0,0 +1,99 @@
// COPY of the port's SplitPane (package com.compose.sdl.widgets) so the SAME
// implementation runs on the jvm parity target - the port modules are
// native-only, so apidemo's commonMain cannot reference them directly.
// Keep in sync with the original when it changes.
package apidemo.compat
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
// ==================
// MARK: SplitPane
// ==================
/** Two-pane resizable split. `initialFirstSize` seeds the first pane's width
* (horizontal split) or height (vertical split); after first layout it tracks
* the user's drag. `minFirstSize` / `minSecondSize` clamp so neither pane can
* shrink past its minimum.
*
* Internal sizing is in **pixels** to match this port's pixel-space layout
* tree (Option-B density flow). `Modifier.width((px / density).dp)` converts
* back at the modifier boundary. */
@Composable
fun HorizontalSplitPane(
modifier: Modifier = Modifier,
initialFirstSize: Dp = 200.dp,
minFirstSize: Dp = 50.dp,
minSecondSize: Dp = 50.dp,
dividerColor: Color = Color(0x33FFFFFFL),
dividerHoverColor: Color = Color(0x66FFFFFFL),
first: @Composable () -> Unit,
second: @Composable () -> Unit,
) {
val vDensity = LocalDensity.current
val vDividerPx = with(vDensity) { SplitPaneDefaults.DividerThickness.toPx().toInt() }
val vMinFirstPx = with(vDensity) { minFirstSize.toPx().toInt() }
val vMinSecondPx = with(vDensity) { minSecondSize.toPx().toInt() }
val vInitialFirstPx = with(vDensity) { initialFirstSize.toPx().toInt() }
var vTotalWidthPx by remember { mutableStateOf(0) }
var vFirstWidthPx by remember { mutableStateOf(vInitialFirstPx) }
val vDividerSource = remember { MutableInteractionSource() }
val vDividerHover by vDividerSource.collectIsHoveredAsState()
var vDragging by remember { mutableStateOf(false) }
Row(
modifier = modifier
.fillMaxSize()
.onSizeChanged { vTotalWidthPx = it.width },
) {
val vClampedFirstPx = if (vTotalWidthPx > 0)
vFirstWidthPx.coerceIn(vMinFirstPx, (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx))
else vFirstWidthPx
val vSecondWidthPx = (vTotalWidthPx - vClampedFirstPx - vDividerPx).coerceAtLeast(0)
Box(modifier = Modifier.width(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxHeight()) { first() }
// Solid divider that fills its own slot - what you see is exactly what
// you can grab. Hover or an in-progress drag just changes the colour (no
// size change → no layout shift, and no flicker when the pointer briefly
// leaves the slot mid-drag).
val vActive = vDividerHover || vDragging
Box(
modifier = Modifier
.width(SplitPaneDefaults.DividerThickness)
.fillMaxHeight()
.background(if (vActive) dividerHoverColor else dividerColor)
.hoverable(vDividerSource)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { vDragging = true },
onDragEnd = { vDragging = false },
onDragCancel = { vDragging = false },
onDrag = { _, delta ->
val vMax = (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx)
vFirstWidthPx = (vFirstWidthPx + delta.x.toInt()).coerceIn(vMinFirstPx, vMax)
},
)
},
)
Box(modifier = Modifier.width(with(vDensity) { vSecondWidthPx.toDp() }).fillMaxHeight()) { second() }
}
}
object SplitPaneDefaults {
val DividerThickness: Dp = 4.dp // divider width = hit/drag area (solid; what you see is what you grab)
}
+35
View File
@@ -0,0 +1,35 @@
package apidemo
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.platform.Font
// ==================
// MARK: Monospace body font - JVM actuals (classpath font/ staged by
// jvmProcessResources)
// ==================
actual val monoFontFamily: FontFamily? by lazy {
val vBytes = object {}.javaClass.getResourceAsStream("/font/NotoSansMono.ttf")?.use { it.readBytes() }
if (vBytes == null) {
println("apidemo: NotoSansMono.ttf not on the classpath - body uses the default font")
null
} else {
FontFamily(Font(identity = kMonoFamily, data = vBytes))
}
}
actual val monoFontFamilyName: String? by lazy {
if (monoFontFamily != null) kMonoFamily else null
}
/** The JVM leg would otherwise inherit a SYSTEM font here; load the same bundled
NotoSans the native build uses so the two stacks are comparable. */
actual val defaultFontFamily: FontFamily? by lazy {
val vBytes = object {}.javaClass.getResourceAsStream("/font/NotoSans.ttf")?.use { it.readBytes() }
if (vBytes == null) {
println("apidemo: NotoSans.ttf not on the classpath - using the platform default font")
null
} else {
FontFamily(Font(identity = kDefaultFamily, data = vBytes))
}
}
+107
View File
@@ -0,0 +1,107 @@
package apidemo
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.InfiniteAnimationPolicy
import androidx.compose.ui.res.useResource
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import apidemo.compat.jvmOnCloseRequest
import apidemo.compat.jvmOnKeyShortcut
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import org.jetbrains.compose.resources.decodeToImageBitmap
import org.jetbrains.skia.EncodedImageFormat
import java.io.File
// The JVM comparison app's entry point. Runs the SAME shared App() as the
// native :apidemo, but on Compose Desktop (JVM) against upstream
// org.jetbrains.compose. Client-certificate (mTLS) features are native-only
// (they drive the bundled libcurl); the jvm actuals report that instead.
//
// --screenshot=<path.png> render headlessly to quiescence, write, exit
//
// The screenshot mode is the JVM half of the native-vs-JVM drift check; the
// native half is `apidemo.exe --screenshot=<path.bmp>`. Both render offscreen
// (ImageComposeScene here, a GPU readback there), so neither needs a visible
// window and the comparison is unaffected by whatever is on screen.
fun main(args: Array<String>) {
val screenshot = args.firstOrNull { it.startsWith("--screenshot=") }?.substringAfter('=')
if (screenshot != null) {
screenshotApp(File(screenshot), kWidth, kHeight)
return
}
runInteractive()
}
private const val kWidth = 1240
private const val kHeight = 820
private fun runInteractive() = application {
// Voltic window icon (mirrors the native window icon). Staged onto the
// classpath at icon/ by jvmProcessResources.
val vIcon = remember {
BitmapPainter(useResource("icon/voltic-icon-256.png") { it.readAllBytes().decodeToImageBitmap() })
}
Window(
// The shared App installs a persist-then-close hook (InstallWindowHooks).
onCloseRequest = { if (jvmOnCloseRequest?.invoke() != false) exitApplication() },
onPreviewKeyEvent = { jvmOnKeyShortcut?.invoke(it) ?: false },
title = "API Manager - JVM (upstream Compose)",
icon = vIcon,
state = rememberWindowState(width = kWidth.dp, height = kHeight.dp),
) {
App()
}
}
// ==================
// MARK: Headless screenshot (drift check vs the native leg)
// ==================
private const val kFrameNanos = 16_666_667L
private const val kMaxFrames = 300
/** Infinite animations would never let the scene go quiet; upstream's test policy
freezes them at their initial value (same trick :demo's parity leg uses). */
private object CancelInfiniteAnimations : InfiniteAnimationPolicy {
override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R =
throw CancellationException("infinite animations disabled for the drift screenshot")
}
/** Render [App] offscreen at density 1 - matching the native leg, which lays out in
physical pixels - stepping a virtual 60fps clock until the scene stops invalidating. */
@OptIn(ExperimentalComposeUiApi::class)
private fun screenshotApp(outFile: File, width: Int, height: Int) {
outFile.parentFile?.mkdirs()
val scene = ImageComposeScene(
width, height, density = Density(1f),
coroutineContext = Dispatchers.Unconfined + CancelInfiniteAnimations,
) {
App()
}
try {
var nanos = 0L
var frames = 0
var image = scene.render(nanos)
while (scene.hasInvalidations() && frames < kMaxFrames) {
nanos += kFrameNanos
image = scene.render(nanos)
frames++
}
val png = image.encodeToData(EncodedImageFormat.PNG)
if (png == null) {
println("jvm screenshot: PNG encode failed")
return
}
outFile.writeBytes(png.bytes)
println("jvm screenshot: ${outFile.path} (settled after $frames frame(s))")
} finally {
scene.close()
}
}
+25
View File
@@ -0,0 +1,25 @@
package apidemo
// ==================
// MARK: TLS chain / client-certificate - JVM actuals (native-only feature)
// ==================
// The native builds drive the bundled libcurl (CURLOPT_SSLCERT / CERTINFO);
// there is no libcurl on the jvm parity stack, so both entry points report
// the limitation instead of half-implementing it over JSSE.
private const val kNativeOnly =
"Client-certificate (mTLS) features need the native build - they drive the bundled libcurl directly."
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain = TlsChain(emptyList(), kNativeOnly)
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse = ApiResponse(
status = 0,
statusText = "",
timeMs = 0,
sizeBytes = 0,
headers = emptyList(),
body = "",
error = kNativeOnly,
)
actual fun sweepTempClientCerts() {}
@@ -0,0 +1,165 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package apidemo.compat
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.createFontFamilyResolver
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import apidemo.monoFontFamily
import org.jetbrains.compose.resources.decodeToSvgPainter
import org.jetbrains.skia.Image
import java.awt.Desktop
import java.awt.EventQueue
import java.awt.FileDialog
import java.awt.Frame
import java.awt.datatransfer.StringSelection
import java.io.File
// ==================
// MARK: JVM actuals - AWT + upstream Compose Desktop
// ==================
private val kIsWindows = System.getProperty("os.name").startsWith("Windows")
private val kIsMac = System.getProperty("os.name").startsWith("Mac")
internal actual val systemFileSystem: okio.FileSystem = okio.FileSystem.SYSTEM
actual fun createApiHttpClient(): io.ktor.client.HttpClient =
io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO)
// ============
// Window hooks - the shared App installs them; MainJvm wires them into the
// upstream Window() (onCloseRequest / onPreviewKeyEvent).
internal var jvmOnCloseRequest: (() -> Boolean)? = null
internal var jvmOnKeyShortcut: ((KeyEvent) -> Boolean)? = null
@Composable
actual fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean) {
androidx.compose.runtime.DisposableEffect(inOnCloseRequest, inOnKeyShortcut) {
jvmOnCloseRequest = inOnCloseRequest
jvmOnKeyShortcut = inOnKeyShortcut
onDispose { jvmOnCloseRequest = null; jvmOnKeyShortcut = null }
}
}
/** Mirrors SDL_GetPrefPath's layout: <os user-data dir>/<org>/<app>/ with a
trailing separator, created on demand. */
actual fun appDataDir(inOrg: String, inApp: String): String? {
val vHome = System.getProperty("user.home") ?: return null
val vBase = when {
kIsWindows -> System.getenv("APPDATA") ?: "$vHome/AppData/Roaming"
kIsMac -> "$vHome/Library/Application Support"
else -> System.getenv("XDG_DATA_HOME") ?: "$vHome/.local/share"
}
val vDir = File(File(vBase, inOrg), inApp)
if (!vDir.exists() && !vDir.mkdirs()) return null
return vDir.absolutePath + File.separator
}
/** Opens the path's containing folder (AWT has no cross-platform "select in
file manager"); fire-and-forget off the EDT. */
actual fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)?) {
Thread {
val vOk = runCatching {
val vFile = File(inPath)
Desktop.getDesktop().open(if (vFile.isDirectory) vFile else vFile.parentFile ?: vFile)
}.isSuccess
inOnResult?.invoke(vOk)
}.apply { isDaemon = true }.start()
}
actual fun fileManagerName(): String = when {
kIsWindows -> "Explorer"
kIsMac -> "Finder"
else -> "Files"
}
/** AWT FileDialog runs its own nested event loop, so invoking from the EDT
(where Compose Desktop also runs) keeps the UI serviced while modal. */
actual fun showSaveFileDialog(inDefaultName: String?, inOnResult: (String?) -> Unit) {
EventQueue.invokeLater {
val vDlg = FileDialog(null as Frame?, "Save", FileDialog.SAVE)
inDefaultName?.let { vDlg.file = it }
vDlg.isVisible = true
inOnResult(vDlg.file?.let { vDlg.directory + it })
}
}
actual fun showOpenFileDialog(inOnResult: (String?) -> Unit) {
EventQueue.invokeLater {
val vDlg = FileDialog(null as Frame?, "Open", FileDialog.LOAD)
vDlg.isVisible = true
inOnResult(vDlg.file?.let { vDlg.directory + it })
}
}
/** Upstream desktop's ClipEntry wraps an AWT Transferable. */
actual fun clipEntryOfText(inText: String): ClipEntry = ClipEntry(StringSelection(inText))
// ============
// In-memory response images
private val kMemoryImages = HashMap<String, ByteArray>()
actual fun registerMemoryImage(inKey: String, inBytes: ByteArray) {
kMemoryImages[inKey] = inBytes
}
actual fun removeMemoryImage(inKey: String) {
kMemoryImages.remove(inKey)
}
@Composable
actual fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter {
val vDensity = LocalDensity.current
return remember(inKey, inSvg) {
val vBytes = kMemoryImages[inKey]
runCatching {
if (vBytes == null) error("no bytes for $inKey")
if (inSvg) vBytes.inputStream().readAllBytes().decodeToSvgPainter(vDensity)
else BitmapPainter(Image.makeFromEncoded(vBytes).toComposeImageBitmap())
}.getOrElse { BitmapPainter(ImageBitmap(1, 1)) }
}
}
// ============
// Text layout
/** Upstream TextMeasurer at density 1 so the px-valued font size maps 1:1
(the caller passes an already density-scaled size). */
private val kRowMeasurer: TextMeasurer by lazy {
TextMeasurer(createFontFamilyResolver(), Density(1f), LayoutDirection.Ltr)
}
actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int =
runCatching {
kRowMeasurer.measure(
AnnotatedString(inText),
TextStyle(fontSize = inFontPx.sp, fontFamily = monoFontFamily ?: FontFamily.Monospace),
constraints = Constraints(maxWidth = inMaxWidthPx),
).lineCount
}.getOrDefault(1)
/** The jvm parity app only stores the preference - upstream's text pipeline has
no global tab-width knob. Backed by state so menu checkmarks recompose. */
private var mTabWidth by mutableStateOf(4)
actual var editorTabWidth: Int
get() = mTabWidth
set(value) {
mTabWidth = value
}
+13 -13
View File
@@ -1,22 +1,22 @@
package apidemo
// Linux: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
// certificate and key files directly — no certificate-store dance needed.
// certificate and key files directly - no certificate-store dance needed.
/* Point libcurl straight at the certificate / key files. */
/** Point libcurl straight at the certificate / key files. */
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
PreparedCert(
sslCert = inReq.certPath,
sslCertType = inReq.certFormat.curlName,
sslKey = inReq.keyPath.ifBlank { null },
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
keyPassword = inReq.certPassword.ifEmpty { null },
cleanup = {},
)
PreparedCert(
sslCert = inReq.certPath,
sslCertType = inReq.certFormat.curlName,
sslKey = inReq.keyPath.ifBlank { null },
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
keyPassword = inReq.certPassword.ifEmpty { null },
cleanup = {},
)
/* No temporary certificate store off Windows. */
/** No temporary certificate store off Windows. */
actual fun sweepTempClientCerts() {}
/* No OS-store issuer resolution here — just continue with the name-only issuer. */
/** No OS-store issuer resolution here - just continue with the name-only issuer. */
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> =
serverChainWithIssuerName(inServerCerts)
serverChainWithIssuerName(inServerCerts)
@@ -3,7 +3,6 @@ package apidemo
import kotlinx.cinterop.*
import platform.windows.*
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
// ==================
// MARK: Windows client-certificate store import (Schannel mTLS)
@@ -12,14 +11,14 @@ import kotlin.io.encoding.ExperimentalEncodingApi
// Windows certificate store, referenced by SHA-1 thumbprint. So for a request
// that carries a certificate we import the cert + private key into the standard
// CurrentUser\MY ("Personal") store, hand curl "CurrentUser\MY\<thumbprint>",
// and delete it again the instant the request finishes — nothing accumulates.
// and delete it again the instant the request finishes - nothing accumulates.
//
// Our entries are tagged (friendly name + key-container name) with a unique
// prefix so the startup sweep can clear crash leftovers without ever touching
// the user's own certificates.
//
// Kotlin/Native maps several CryptoAPI LPCSTR params to String?, which makes the
// CryptDecodeObjectEx route (numeric struct-type sentinels) unusable — so the
// CryptDecodeObjectEx route (numeric struct-type sentinels) unusable - so the
// RSA private key is parsed here (ASN.1) and laid out as a CryptoAPI
// PRIVATEKEYBLOB directly, then handed to CryptImportKey. RSA (PKCS#1 or PKCS#8)
// and PKCS#12 are handled; EC PEM/DER is reported as unsupported with a hint.
@@ -32,464 +31,529 @@ private const val kCryptUserKeyset = 0x00001000u // CRYPT_USER_KEYSET (no
private val kEncoding: UInt = X509_ASN_ENCODING.toUInt() or PKCS_7_ASN_ENCODING.toUInt()
/* Import the request's certificate into CurrentUser\MY and return a libcurl
store reference plus a cleanup that removes it again. */
/** Import the request's certificate into CurrentUser\MY and return a libcurl
store reference plus a cleanup that removes it again. */
@OptIn(ExperimentalForeignApi::class)
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert
{
val vCertBytes = readFileBytes(inReq.certPath)
val (vThumb, vContainer) = when (inReq.certFormat)
{
CertFormat.PKCS12 -> importPkcs12(vCertBytes, inReq.certPassword)
else -> importPemDerRsa(vCertBytes, inReq)
}
return PreparedCert(
sslCert = "$kStorePath\\$vThumb",
sslCertType = null, // store reference — not a file type
sslKey = null,
sslKeyType = null,
keyPassword = null,
cleanup = { deleteTempCert(vThumb, vContainer) },
)
}
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert {
val vCertBytes = readFileBytes(inReq.certPath)
val (vThumb, vContainer) = when (inReq.certFormat) {
CertFormat.PKCS12 -> importPkcs12(vCertBytes, inReq.certPassword)
else -> importPemDerRsa(vCertBytes, inReq)
}
return PreparedCert(
sslCert = "$kStorePath\\$vThumb",
sslCertType = null, // store reference - not a file type
sslKey = null,
sslKeyType = null,
keyPassword = null,
cleanup = { deleteTempCert(vThumb, vContainer) },
)
}
// ============
// PEM / DER (RSA) → store
// ============
/* Build a cert context from PEM/DER, import its RSA private key into a named
container, link the two, tag and add to CurrentUser\MY. Returns
(thumbprintHex, containerName). */
/** Build a cert context from PEM/DER, import its RSA private key into a named
container, link the two, tag and add to CurrentUser\MY. Returns
(thumbprintHex, containerName). */
@OptIn(ExperimentalForeignApi::class)
private fun importPemDerRsa(inCertBytes: ByteArray, inReq: ApiRequest): Pair<String, String?>
{
val vCertDer = derFromMaybePem(inCertBytes, "CERTIFICATE")
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
val vKeyDer = privateKeyDer(vKeyBytes)
?: throw RuntimeException("No private key found — PEM/DER on Windows needs the RSA key (a key file, or a key block in the certificate file).")
val vBlob = try { rsaKeyDerToBlob(vKeyDer) }
catch (e: Throwable) { throw RuntimeException("Unsupported private key (RSA PKCS#1/PKCS#8 is supported; EC isn't yet). Convert to PKCS#12: openssl pkcs12 -export -inkey key.pem -in cert.pem -out client.p12") }
private fun importPemDerRsa(inCertBytes: ByteArray, inReq: ApiRequest): Pair<String, String?> {
val vCertDer = derFromMaybePem(inCertBytes, "CERTIFICATE")
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
val vKeyDer = privateKeyDer(vKeyBytes)
?: throw RuntimeException("No private key found - PEM/DER on Windows needs the RSA key (a key file, or a key block in the certificate file).")
val vBlob = try {
rsaKeyDerToBlob(vKeyDer)
} catch (e: Throwable) {
throw RuntimeException("Unsupported private key (RSA PKCS#1/PKCS#8 is supported; EC isn't yet). Convert to PKCS#12: openssl pkcs12 -export -inkey key.pem -in cert.pem -out client.p12")
}
val vCert = vCertDer.usePinned { vPin ->
CertCreateCertificateContext(kEncoding, vPin.addressOf(0).reinterpret(), vCertDer.size.convert())
} ?: winError("CertCreateCertificateContext")
val vCert = vCertDer.usePinned { vPin ->
CertCreateCertificateContext(kEncoding, vPin.addressOf(0).reinterpret(), vCertDer.size.convert())
} ?: winError("CertCreateCertificateContext")
try
{
val vThumb = thumbprintHex(vCert)
val vContainer = kTagPrefix + vThumb
importRsaIntoContainer(vContainer, vBlob)
try
{
linkKeyToCert(vCert, vContainer)
tagFriendlyName(vCert, kTagPrefix + vThumb)
addToStore(vCert)
return vThumb to vContainer
}
catch (e: Throwable) { deleteContainer(vContainer); throw e }
}
finally { CertFreeCertificateContext(vCert) }
}
/* Create a fresh named key container and import the RSA blob into it. */
@OptIn(ExperimentalForeignApi::class)
private fun importRsaIntoContainer(inContainer: String, inBlob: ByteArray) = memScoped {
deleteContainer(inContainer) // drop a stale one if it somehow exists
val vProv = alloc<ULongVar>()
if (CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_NEWKEYSET.toUInt()) == 0)
winError("CryptAcquireContext(NEWKEYSET)")
try
{
val vKey = alloc<ULongVar>()
val vOk = inBlob.usePinned { vPin ->
CryptImportKey(vProv.value, vPin.addressOf(0).reinterpret(), inBlob.size.convert(), 0uL, CRYPT_EXPORTABLE.toUInt(), vKey.ptr)
}
if (vOk == 0) winError("CryptImportKey")
CryptDestroyKey(vKey.value)
}
finally { CryptReleaseContext(vProv.value, 0u) }
try {
val vThumb = thumbprintHex(vCert)
val vContainer = kTagPrefix + vThumb
importRsaIntoContainer(vContainer, vBlob)
try {
linkKeyToCert(vCert, vContainer)
tagFriendlyName(vCert, kTagPrefix + vThumb)
addToStore(vCert)
return vThumb to vContainer
} catch (e: Throwable) {
deleteContainer(vContainer); throw e
}
} finally {
CertFreeCertificateContext(vCert)
}
}
/* Attach the named container to the cert so Schannel can find its key. */
/** Create a fresh named key container and import the RSA blob into it. */
@OptIn(ExperimentalForeignApi::class)
private fun importRsaIntoContainer(inContainer: String, inBlob: ByteArray) = memScoped {
deleteContainer(inContainer) // drop a stale one if it somehow exists
val vProv = alloc<ULongVar>()
if (CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_NEWKEYSET.toUInt()) == 0)
winError("CryptAcquireContext(NEWKEYSET)")
try {
val vKey = alloc<ULongVar>()
val vOk = inBlob.usePinned { vPin ->
CryptImportKey(
vProv.value,
vPin.addressOf(0).reinterpret(),
inBlob.size.convert(),
0uL,
CRYPT_EXPORTABLE.toUInt(),
vKey.ptr
)
}
if (vOk == 0) winError("CryptImportKey")
CryptDestroyKey(vKey.value)
} finally {
CryptReleaseContext(vProv.value, 0u)
}
}
/** Attach the named container to the cert so Schannel can find its key. */
@OptIn(ExperimentalForeignApi::class)
private fun linkKeyToCert(inCert: CPointer<CERT_CONTEXT>, inContainer: String) = memScoped {
val vInfo = alloc<CRYPT_KEY_PROV_INFO>()
vInfo.pwszContainerName = inContainer.wcstr.ptr
vInfo.pwszProvName = kProvName.wcstr.ptr
vInfo.dwProvType = PROV_RSA_FULL.toUInt()
vInfo.dwFlags = 0u
vInfo.cProvParam = 0u
vInfo.rgProvParam = null
vInfo.dwKeySpec = AT_KEYEXCHANGE.toUInt()
if (CertSetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), 0u, vInfo.ptr) == 0)
winError("CertSetCertificateContextProperty(KEY_PROV_INFO)")
val vInfo = alloc<CRYPT_KEY_PROV_INFO>()
vInfo.pwszContainerName = inContainer.wcstr.ptr
vInfo.pwszProvName = kProvName.wcstr.ptr
vInfo.dwProvType = PROV_RSA_FULL.toUInt()
vInfo.dwFlags = 0u
vInfo.cProvParam = 0u
vInfo.rgProvParam = null
vInfo.dwKeySpec = AT_KEYEXCHANGE.toUInt()
if (CertSetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), 0u, vInfo.ptr) == 0)
winError("CertSetCertificateContextProperty(KEY_PROV_INFO)")
}
// ============
// RSA key ASN.1 → CryptoAPI PRIVATEKEYBLOB
// ============
/* Minimal DER reader for the RSA key structures. */
private class Der(val b: ByteArray)
{
var p = 0
fun tag(): Int = b[p++].toInt() and 0xFF
fun len(): Int
{
val vFirst = b[p++].toInt() and 0xFF
if (vFirst and 0x80 == 0) return vFirst
var vLen = 0
repeat(vFirst and 0x7F) { vLen = (vLen shl 8) or (b[p++].toInt() and 0xFF) }
return vLen
}
fun sequence() { require(tag() == 0x30) { "SEQUENCE expected" }; len() }
fun peek(): Int = b[p].toInt() and 0xFF
fun skip() { tag(); val vL = len(); p += vL }
fun octetString(): ByteArray { require(tag() == 0x04) { "OCTET STRING expected" }; val vL = len(); return b.copyOfRange(p, p + vL).also { p += vL } }
// An INTEGER's magnitude (big-endian, leading zero stripped).
fun integer(): ByteArray
{
require(tag() == 0x02) { "INTEGER expected" }
val vL = len(); val vV = b.copyOfRange(p, p + vL); p += vL
var vS = 0; while (vS < vV.size - 1 && vV[vS].toInt() == 0) vS++
return vV.copyOfRange(vS, vV.size)
}
}
/** Minimal DER reader for the RSA key structures. */
private class Der(val b: ByteArray) {
var p = 0
fun tag(): Int = b[p++].toInt() and 0xFF
fun len(): Int {
val vFirst = b[p++].toInt() and 0xFF
if (vFirst and 0x80 == 0) return vFirst
var vLen = 0
repeat(vFirst and 0x7F) { vLen = (vLen shl 8) or (b[p++].toInt() and 0xFF) }
return vLen
}
/* Turn a PKCS#1 or PKCS#8 RSA private-key DER into a CryptoAPI PRIVATEKEYBLOB.
Throws if it isn't an RSA key we can lay out. */
private fun rsaKeyDerToBlob(inDer: ByteArray): ByteArray
{
val vR = Der(inDer)
vR.sequence(); vR.integer() // outer version
return if (vR.peek() == 0x30)
{
// PKCS#8 PrivateKeyInfo: skip AlgorithmIdentifier, parse the inner key.
vR.skip()
parsePkcs1(vR.octetString())
}
else
// PKCS#1: n, e, d, p, q, dp, dq, iqmp follow the version we just read.
buildBlob(vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer())
}
fun sequence() {
require(tag() == 0x30) { "SEQUENCE expected" }; len()
}
private fun parsePkcs1(inDer: ByteArray): ByteArray
{
val vR = Der(inDer)
vR.sequence(); vR.integer() // version
return buildBlob(vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer())
}
fun peek(): Int = b[p].toInt() and 0xFF
fun skip() {
tag()
val vL = len(); p += vL
}
/* Lay out the MS PRIVATEKEYBLOB (all little-endian) from the RSA components. */
fun octetString(): ByteArray {
require(tag() == 0x04) { "OCTET STRING expected" }
val vL = len(); return b.copyOfRange(p, p + vL).also { p += vL }
}
// An INTEGER's magnitude (big-endian, leading zero stripped).
fun integer(): ByteArray {
require(tag() == 0x02) { "INTEGER expected" }
val vL = len()
val vV = b.copyOfRange(p, p + vL); p += vL
var vS = 0; while (vS < vV.size - 1 && vV[vS].toInt() == 0) vS++
return vV.copyOfRange(vS, vV.size)
}
}
/** Turn a PKCS#1 or PKCS#8 RSA private-key DER into a CryptoAPI PRIVATEKEYBLOB.
Throws if it isn't an RSA key we can lay out. */
private fun rsaKeyDerToBlob(inDer: ByteArray): ByteArray {
val vR = Der(inDer)
vR.sequence(); vR.integer() // outer version
return if (vR.peek() == 0x30) {
// PKCS#8 PrivateKeyInfo: skip AlgorithmIdentifier, parse the inner key.
vR.skip()
parsePkcs1(vR.octetString())
} else
// PKCS#1: n, e, d, p, q, dp, dq, iqmp follow the version we just read.
buildBlob(
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer()
)
}
private fun parsePkcs1(inDer: ByteArray): ByteArray {
val vR = Der(inDer)
vR.sequence(); vR.integer() // version
return buildBlob(
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer()
)
}
/** Lay out the MS PRIVATEKEYBLOB (all little-endian) from the RSA components. */
private fun buildBlob(
inN: ByteArray, inE: ByteArray, inD: ByteArray,
inP: ByteArray, inQ: ByteArray, inDp: ByteArray, inDq: ByteArray, inIq: ByteArray,
): ByteArray
{
val vMod = inN.size // modulus length in bytes
val vHalf = vMod / 2
val vOut = ArrayList<Byte>(20 + vMod * 2 + vHalf * 5)
vOut.add(PRIVATEKEYBLOB.toByte()); vOut.add(CUR_BLOB_VERSION.toByte()); vOut.add(0); vOut.add(0) // BLOBHEADER
vOut.addAll(le32(CALG_RSA_KEYX.toUInt()).toList())
vOut.addAll(le32(0x32415352u).toList()) // "RSA2"
vOut.addAll(le32((vMod * 8).toUInt()).toList()) // bitlen
vOut.addAll(le32(beToUInt(inE)).toList()) // pubexp
vOut.addAll(leField(inN, vMod).toList())
vOut.addAll(leField(inP, vHalf).toList())
vOut.addAll(leField(inQ, vHalf).toList())
vOut.addAll(leField(inDp, vHalf).toList())
vOut.addAll(leField(inDq, vHalf).toList())
vOut.addAll(leField(inIq, vHalf).toList())
vOut.addAll(leField(inD, vMod).toList())
return vOut.toByteArray()
}
inN: ByteArray, inE: ByteArray, inD: ByteArray,
inP: ByteArray, inQ: ByteArray, inDp: ByteArray, inDq: ByteArray, inIq: ByteArray,
): ByteArray {
val vMod = inN.size // modulus length in bytes
val vHalf = vMod / 2
val vOut = ArrayList<Byte>(20 + vMod * 2 + vHalf * 5)
vOut.add(PRIVATEKEYBLOB.toByte()); vOut.add(CUR_BLOB_VERSION.toByte()); vOut.add(0); vOut.add(0) // BLOBHEADER
vOut.addAll(le32(CALG_RSA_KEYX.toUInt()).toList())
vOut.addAll(le32(0x32415352u).toList()) // "RSA2"
vOut.addAll(le32((vMod * 8).toUInt()).toList()) // bitlen
vOut.addAll(le32(beToUInt(inE)).toList()) // pubexp
vOut.addAll(leField(inN, vMod).toList())
vOut.addAll(leField(inP, vHalf).toList())
vOut.addAll(leField(inQ, vHalf).toList())
vOut.addAll(leField(inDp, vHalf).toList())
vOut.addAll(leField(inDq, vHalf).toList())
vOut.addAll(leField(inIq, vHalf).toList())
vOut.addAll(leField(inD, vMod).toList())
return vOut.toByteArray()
}
/* A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
private fun leField(inBe: ByteArray, inSize: Int): ByteArray
{
val vOut = ByteArray(inSize)
for (vI in inBe.indices) if (vI < inSize) vOut[vI] = inBe[inBe.size - 1 - vI]
return vOut
}
/** A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
private fun leField(inBe: ByteArray, inSize: Int): ByteArray {
val vOut = ByteArray(inSize)
for (vI in inBe.indices) if (vI < inSize) vOut[vI] = inBe[inBe.size - 1 - vI]
return vOut
}
private fun le32(inV: UInt): ByteArray =
byteArrayOf((inV and 0xFFu).toByte(), ((inV shr 8) and 0xFFu).toByte(), ((inV shr 16) and 0xFFu).toByte(), ((inV shr 24) and 0xFFu).toByte())
byteArrayOf(
(inV and 0xFFu).toByte(),
((inV shr 8) and 0xFFu).toByte(),
((inV shr 16) and 0xFFu).toByte(),
((inV shr 24) and 0xFFu).toByte()
)
private fun beToUInt(inBe: ByteArray): UInt
{
var vV = 0u
for (vB in inBe) vV = (vV shl 8) or (vB.toInt() and 0xFF).toUInt()
return vV
}
private fun beToUInt(inBe: ByteArray): UInt {
var vV = 0u
for (vB in inBe) vV = (vV shl 8) or (vB.toInt() and 0xFF).toUInt()
return vV
}
// ============
// PKCS#12 → store
// ============
/* Import a .p12/.pfx into a memory store, then copy its cert (with the key
container PFXImportCertStore created) into CurrentUser\MY. */
/** Import a .p12/.pfx into a memory store, then copy its cert (with the key
container PFXImportCertStore created) into CurrentUser\MY. */
@OptIn(ExperimentalForeignApi::class)
private fun importPkcs12(inBytes: ByteArray, inPassword: String): Pair<String, String?> = memScoped {
inBytes.usePinned { vPin ->
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = inBytes.size.convert()
vBlob.pbData = vPin.addressOf(0).reinterpret()
val vMem = PFXImportCertStore(vBlob.ptr, inPassword, kCryptUserKeyset or CRYPT_EXPORTABLE.toUInt())
?: winError("PFXImportCertStore (wrong password, or not a PKCS#12 file?)")
try
{
val vSrc = CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
val vThumb = thumbprintHex(vSrc)
val vContainer = containerNameOf(vSrc)
tagFriendlyName(vSrc, kTagPrefix + vThumb)
addToStore(vSrc)
vThumb to vContainer
}
finally { CertCloseStore(vMem, 0u) }
}
inBytes.usePinned { vPin ->
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = inBytes.size.convert()
vBlob.pbData = vPin.addressOf(0).reinterpret()
val vMem = PFXImportCertStore(vBlob.ptr, inPassword, kCryptUserKeyset or CRYPT_EXPORTABLE.toUInt())
?: winError("PFXImportCertStore (wrong password, or not a PKCS#12 file?)")
try {
val vSrc =
CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
val vThumb = thumbprintHex(vSrc)
val vContainer = containerNameOf(vSrc)
tagFriendlyName(vSrc, kTagPrefix + vThumb)
addToStore(vSrc)
vThumb to vContainer
} finally {
CertCloseStore(vMem, 0u)
}
}
}
/* The key-container name PFXImportCertStore assigned to a cert (from its
CERT_KEY_PROV_INFO), so cleanup can delete it. */
/** The key-container name PFXImportCertStore assigned to a cert (from its
CERT_KEY_PROV_INFO), so cleanup can delete it. */
@OptIn(ExperimentalForeignApi::class)
private fun containerNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
val vLen = alloc<UIntVar>()
if (CertGetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), null, vLen.ptr) == 0) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CertGetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
vBuf.reinterpret<CRYPT_KEY_PROV_INFO>().pointed.pwszContainerName?.toKStringFromUtf16()
val vLen = alloc<UIntVar>()
if (CertGetCertificateContextProperty(
inCert,
CERT_KEY_PROV_INFO_PROP_ID.toUInt(),
null,
vLen.ptr
) == 0
) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CertGetCertificateContextProperty(
inCert,
CERT_KEY_PROV_INFO_PROP_ID.toUInt(),
vBuf,
vLen.ptr
) == 0
) return@memScoped null
vBuf.reinterpret<CRYPT_KEY_PROV_INFO>().pointed.pwszContainerName?.toKStringFromUtf16()
}
// ============
// Shared store helpers
// ============
/* Tag a cert with our friendly-name prefix so the startup sweep recognises it. */
/** Tag a cert with our friendly-name prefix so the startup sweep recognises it. */
@OptIn(ExperimentalForeignApi::class)
private fun tagFriendlyName(inCert: CPointer<CERT_CONTEXT>, inName: String) = memScoped {
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = ((inName.length + 1) * 2).convert()
vBlob.pbData = inName.wcstr.ptr.reinterpret()
CertSetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), 0u, vBlob.ptr)
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = ((inName.length + 1) * 2).convert()
vBlob.pbData = inName.wcstr.ptr.reinterpret()
CertSetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), 0u, vBlob.ptr)
}
/* Add a cert context to CurrentUser\MY (replacing any same-thumbprint entry). */
/** Add a cert context to CurrentUser\MY (replacing any same-thumbprint entry). */
@OptIn(ExperimentalForeignApi::class)
private fun addToStore(inCert: CPointer<CERT_CONTEXT>)
{
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
try
{
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
winError("CertAddCertificateContextToStore")
}
finally { CertCloseStore(vStore, 0u) }
}
private fun addToStore(inCert: CPointer<CERT_CONTEXT>) {
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
try {
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
winError("CertAddCertificateContextToStore")
} finally {
CertCloseStore(vStore, 0u)
}
}
/* Open CurrentUser\MY (the current user's Personal store). */
/** Open CurrentUser\MY (the current user's Personal store). */
@OptIn(ExperimentalForeignApi::class)
private fun openMyStore() = CertOpenSystemStoreW(0.convert(), kStoreName)
/* The SHA-1 thumbprint of a cert as uppercase hex. */
/** The SHA-1 thumbprint of a cert as uppercase hex. */
@OptIn(ExperimentalForeignApi::class)
private fun thumbprintHex(inCert: CPointer<CERT_CONTEXT>): String = memScoped {
val vLen = alloc<UIntVar>().apply { value = 20u }
val vBuf = allocArray<UByteVar>(20)
if (CertGetCertificateContextProperty(inCert, CERT_SHA1_HASH_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0)
winError("CertGetCertificateContextProperty(SHA1)")
buildString { for (vI in 0 until vLen.value.toInt()) append(vBuf[vI].toInt().toString(16).uppercase().padStart(2, '0')) }
val vLen = alloc<UIntVar>().apply { value = 20u }
val vBuf = allocArray<UByteVar>(20)
if (CertGetCertificateContextProperty(inCert, CERT_SHA1_HASH_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0)
winError("CertGetCertificateContextProperty(SHA1)")
buildString {
for (vI in 0 until vLen.value.toInt()) append(
vBuf[vI].toInt().toString(16).uppercase().padStart(2, '0')
)
}
}
/* Remove a temporary cert (by thumbprint) from CurrentUser\MY and delete its
key container. Called per-request in finally; ignores what's already gone. */
/** Remove a temporary cert (by thumbprint) from CurrentUser\MY and delete its
key container. Called per-request in finally; ignores what's already gone. */
@OptIn(ExperimentalForeignApi::class)
private fun deleteTempCert(inThumbHex: String, inContainer: String?)
{
val vStore = openMyStore()
if (vStore != null) memScoped {
val vHash = hexToBytes(inThumbHex)
val vBuf = allocArray<UByteVar>(vHash.size)
for (vI in vHash.indices) vBuf[vI] = vHash[vI].toUByte()
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = vHash.size.convert()
vBlob.pbData = vBuf
val vFound = CertFindCertificateInStore(vStore, kEncoding, 0u, CERT_FIND_SHA1_HASH.toUInt(), vBlob.ptr, null)
if (vFound != null) CertDeleteCertificateFromStore(vFound) // also frees the context
CertCloseStore(vStore, 0u)
}
if (inContainer != null) deleteContainer(inContainer)
}
private fun deleteTempCert(inThumbHex: String, inContainer: String?) {
val vStore = openMyStore()
if (vStore != null) memScoped {
val vHash = hexToBytes(inThumbHex)
val vBuf = allocArray<UByteVar>(vHash.size)
for (vI in vHash.indices) vBuf[vI] = vHash[vI].toUByte()
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = vHash.size.convert()
vBlob.pbData = vBuf
val vFound = CertFindCertificateInStore(vStore, kEncoding, 0u, CERT_FIND_SHA1_HASH.toUInt(), vBlob.ptr, null)
if (vFound != null) CertDeleteCertificateFromStore(vFound) // also frees the context
CertCloseStore(vStore, 0u)
}
if (inContainer != null) deleteContainer(inContainer)
}
/* Delete a named key container (best-effort). */
/** Delete a named key container (best-effort). */
@OptIn(ExperimentalForeignApi::class)
private fun deleteContainer(inContainer: String) = memScoped {
val vProv = alloc<ULongVar>()
CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_DELETEKEYSET.toUInt())
val vProv = alloc<ULongVar>()
CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_DELETEKEYSET.toUInt())
}
/* Remove every temp cert we ever added (prefix-tagged) plus its container — for
clearing crash leftovers at startup. The user's own certs are never matched. */
/** Remove every temp cert we ever added (prefix-tagged) plus its container - for
clearing crash leftovers at startup. The user's own certs are never matched. */
@OptIn(ExperimentalForeignApi::class)
actual fun sweepTempClientCerts()
{
val vStore = openMyStore() ?: return
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
var vCert = CertEnumCertificatesInStore(vStore, null)
while (vCert != null)
{
val vFriendly = friendlyNameOf(vCert)
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
vCert = CertEnumCertificatesInStore(vStore, vCert)
}
CertCloseStore(vStore, 0u)
vDoomed.forEach { deleteTempCert(it.first, it.second) }
}
actual fun sweepTempClientCerts() {
val vStore = openMyStore() ?: return
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
var vCert = CertEnumCertificatesInStore(vStore, null)
while (vCert != null) {
val vFriendly = friendlyNameOf(vCert)
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
vCert = CertEnumCertificatesInStore(vStore, vCert)
}
CertCloseStore(vStore, 0u)
vDoomed.forEach { deleteTempCert(it.first, it.second) }
}
/* A cert's friendly name, or null. */
/** A cert's friendly name, or null. */
@OptIn(ExperimentalForeignApi::class)
private fun friendlyNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
val vLen = alloc<UIntVar>()
if (CertGetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), null, vLen.ptr) == 0) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CertGetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
vBuf.reinterpret<UShortVar>().toKStringFromUtf16()
val vLen = alloc<UIntVar>()
if (CertGetCertificateContextProperty(
inCert,
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
null,
vLen.ptr
) == 0
) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CertGetCertificateContextProperty(
inCert,
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
vBuf,
vLen.ptr
) == 0
) return@memScoped null
vBuf.reinterpret<UShortVar>().toKStringFromUtf16()
}
// ============
// Chain extension via the OS certificate store
// ============
/* Continue the server's chain by asking the OS to build it from the leaf — this
pulls intermediates/roots from the Windows store with full info. Falls back to
a name-only issuer if the OS can't (or the leaf can't be parsed). */
/** Continue the server's chain by asking the OS to build it from the leaf - this
pulls intermediates/roots from the Windows store with full info. Falls back to
a name-only issuer if the OS can't (or the leaf can't be parsed). */
@OptIn(ExperimentalForeignApi::class)
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert>
{
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
?: return serverChainWithIssuerName(inServerCerts)
val vOs = runCatching { osChain(vLeafPem, inServerCerts.size) }.getOrNull()
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
}
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> {
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
?: return serverChainWithIssuerName(inServerCerts)
val vOs = runCatching { osChain(vLeafPem, inServerCerts.size) }.getOrNull()
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
}
/* Build the full chain from the leaf via CertGetCertificateChain (OS store). */
/** Build the full chain from the leaf via CertGetCertificateChain (OS store). */
@OptIn(ExperimentalForeignApi::class)
private fun osChain(inLeafPem: String, inServerCount: Int): List<ChainCert> = memScoped {
val vDer = derFromMaybePem(inLeafPem.encodeToByteArray(), "CERTIFICATE")
val vLeaf = vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
?: return@memScoped emptyList()
try
{
val vPara = alloc<CERT_CHAIN_PARA>()
vPara.cbSize = sizeOf<CERT_CHAIN_PARA>().convert()
val vChainPtr = alloc<CPointerVar<CERT_CHAIN_CONTEXT>>()
if (CertGetCertificateChain(null, vLeaf, null, null, vPara.ptr, 0u, null, vChainPtr.ptr) == 0)
return@memScoped emptyList()
val vChain = vChainPtr.value ?: return@memScoped emptyList()
try
{
val vSimple = vChain.pointed.rgpChain?.get(0)?.pointed ?: return@memScoped emptyList()
val vElems = vSimple.rgpElement ?: return@memScoped emptyList()
val vResult = ArrayList<ChainCert>()
for (vI in 0 until vSimple.cElement.toInt())
{
val vCtx = vElems[vI]?.pointed?.pCertContext ?: continue
vResult.add(ChainCert(certFieldsFromContext(vCtx), vI < inServerCount))
}
// If the chain didn't reach a self-signed root, end with the name only.
val vLast = vResult.lastOrNull()
if (vLast != null)
{
val vSub = certFieldOf(vLast.fields, "Subject")
val vIss = certFieldOf(vLast.fields, "Issuer")
if (!vIss.isNullOrBlank() && vIss != vSub) vResult.add(ChainCert(listOf("Subject" to vIss), false))
}
vResult
}
finally { CertFreeCertificateChain(vChain) }
}
finally { CertFreeCertificateContext(vLeaf) }
val vDer = derFromMaybePem(inLeafPem.encodeToByteArray(), "CERTIFICATE")
val vLeaf =
vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
?: return@memScoped emptyList()
try {
val vPara = alloc<CERT_CHAIN_PARA>()
vPara.cbSize = sizeOf<CERT_CHAIN_PARA>().convert()
val vChainPtr = alloc<CPointerVar<CERT_CHAIN_CONTEXT>>()
if (CertGetCertificateChain(null, vLeaf, null, null, vPara.ptr, 0u, null, vChainPtr.ptr) == 0)
return@memScoped emptyList()
val vChain = vChainPtr.value ?: return@memScoped emptyList()
try {
val vSimple = vChain.pointed.rgpChain?.get(0)?.pointed ?: return@memScoped emptyList()
val vElems = vSimple.rgpElement ?: return@memScoped emptyList()
val vResult = ArrayList<ChainCert>()
for (vI in 0 until vSimple.cElement.toInt()) {
val vCtx = vElems[vI]?.pointed?.pCertContext ?: continue
vResult.add(ChainCert(certFieldsFromContext(vCtx), vI < inServerCount))
}
// If the chain didn't reach a self-signed root, end with the name only.
val vLast = vResult.lastOrNull()
if (vLast != null) {
val vSub = certFieldOf(vLast.fields, "Subject")
val vIss = certFieldOf(vLast.fields, "Issuer")
if (!vIss.isNullOrBlank() && vIss != vSub) vResult.add(ChainCert(listOf("Subject" to vIss), false))
}
vResult
} finally {
CertFreeCertificateChain(vChain)
}
} finally {
CertFreeCertificateContext(vLeaf)
}
}
/* Subject / Issuer (RDN strings) + the PEM, read from a cert context. */
/** Subject / Issuer (RDN strings) + the PEM, read from a cert context. */
@OptIn(ExperimentalForeignApi::class)
private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<String, String>>
{
val vFields = ArrayList<Pair<String, String>>()
certNameString(inCtx, 0u)?.let { vFields.add("Subject" to it) }
certNameString(inCtx, CERT_NAME_ISSUER_FLAG.toUInt())?.let { vFields.add("Issuer" to it) }
certPem(inCtx)?.let { vFields.add("Cert" to it) }
return vFields
}
private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<String, String>> {
val vFields = ArrayList<Pair<String, String>>()
certNameString(inCtx, 0u)?.let { vFields.add("Subject" to it) }
certNameString(inCtx, CERT_NAME_ISSUER_FLAG.toUInt())?.let { vFields.add("Issuer" to it) }
certPem(inCtx)?.let { vFields.add("Cert" to it) }
return vFields
}
/* A cert's Subject (inFlags=0) or Issuer (CERT_NAME_ISSUER_FLAG) as an X.500 string. */
/** A cert's Subject (inFlags=0) or Issuer (CERT_NAME_ISSUER_FLAG) as an X.500 string. */
@OptIn(ExperimentalForeignApi::class)
private fun certNameString(inCtx: CPointer<CERT_CONTEXT>, inFlags: UInt): String? = memScoped {
val vType = alloc<UIntVar>().apply { value = CERT_X500_NAME_STR.toUInt() }
val vLen = CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, null, 0u)
if (vLen <= 1u) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.toInt())
CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, vBuf, vLen)
vBuf.toKString()
val vType = alloc<UIntVar>().apply { value = CERT_X500_NAME_STR.toUInt() }
val vLen = CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, null, 0u)
if (vLen <= 1u) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.toInt())
CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, vBuf, vLen)
vBuf.toKString()
}
/* The PEM (base64-with-header) of a cert context. */
/** The PEM (base64-with-header) of a cert context. */
@OptIn(ExperimentalForeignApi::class)
private fun certPem(inCtx: CPointer<CERT_CONTEXT>): String? = memScoped {
val vData = inCtx.pointed.pbCertEncoded ?: return@memScoped null
val vSize = inCtx.pointed.cbCertEncoded
val vLen = alloc<UIntVar>()
if (CryptBinaryToStringA(vData, vSize, CRYPT_STRING_BASE64HEADER.toUInt(), null, vLen.ptr) == 0) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CryptBinaryToStringA(vData, vSize, CRYPT_STRING_BASE64HEADER.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
vBuf.toKString()
val vData = inCtx.pointed.pbCertEncoded ?: return@memScoped null
val vSize = inCtx.pointed.cbCertEncoded
val vLen = alloc<UIntVar>()
if (CryptBinaryToStringA(
vData,
vSize,
CRYPT_STRING_BASE64HEADER.toUInt(),
null,
vLen.ptr
) == 0
) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CryptBinaryToStringA(
vData,
vSize,
CRYPT_STRING_BASE64HEADER.toUInt(),
vBuf,
vLen.ptr
) == 0
) return@memScoped null
vBuf.toKString()
}
// ============
// Small helpers
// ============
/* Decode a PEM block (by label) to DER, or pass bytes through if already DER. */
@OptIn(ExperimentalEncodingApi::class)
private fun derFromMaybePem(inBytes: ByteArray, inLabel: String): ByteArray
{
val vText = inBytes.decodeToString()
if (!vText.contains("-----BEGIN")) return inBytes
val vBegin = vText.indexOf("-----BEGIN $inLabel-----")
val vEnd = vText.indexOf("-----END $inLabel-----")
val vBlock = if (vBegin >= 0 && vEnd >= 0) vText.substring(vBegin, vEnd) else vText
return Base64.Default.decode(pemBody(vBlock))
}
/** Decode a PEM block (by label) to DER, or pass bytes through if already DER. */
private fun derFromMaybePem(inBytes: ByteArray, inLabel: String): ByteArray {
val vText = inBytes.decodeToString()
if (!vText.contains("-----BEGIN")) return inBytes
val vBegin = vText.indexOf("-----BEGIN $inLabel-----")
val vEnd = vText.indexOf("-----END $inLabel-----")
val vBlock = if (vBegin >= 0 && vEnd >= 0) vText.substring(vBegin, vEnd) else vText
return Base64.decode(pemBody(vBlock))
}
/* Extract + DER-decode the first PRIVATE KEY block from PEM text, or pass DER
bytes through. Null if no key. */
@OptIn(ExperimentalEncodingApi::class)
private fun privateKeyDer(inBytes: ByteArray): ByteArray?
{
val vText = inBytes.decodeToString()
if (!vText.contains("-----BEGIN")) return inBytes // already DER
val vMatch = Regex("-----BEGIN [A-Z 0-9]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z 0-9]*PRIVATE KEY-----").find(vText)
?: return null
return Base64.Default.decode(pemBody(vMatch.value))
}
/** Extract + DER-decode the first PRIVATE KEY block from PEM text, or pass DER
bytes through. Null if no key. */
private fun privateKeyDer(inBytes: ByteArray): ByteArray? {
val vText = inBytes.decodeToString()
if (!vText.contains("-----BEGIN")) return inBytes // already DER
val vMatch = Regex("-----BEGIN [A-Z 0-9]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z 0-9]*PRIVATE KEY-----").find(vText)
?: return null
return Base64.decode(pemBody(vMatch.value))
}
/* The base64 body of a PEM block (drop the BEGIN/END lines and whitespace). */
/** The base64 body of a PEM block (drop the BEGIN/END lines and whitespace). */
private fun pemBody(inPem: String): String =
inPem.lineSequence().filterNot { it.startsWith("-----") }.joinToString("") { it.trim() }
inPem.lineSequence().filterNot { it.startsWith("-----") }.joinToString("") { it.trim() }
private fun hexToBytes(inHex: String): ByteArray =
ByteArray(inHex.length / 2) { inHex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
ByteArray(inHex.length / 2) { inHex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
/* Read a null-terminated UTF-16 (wide) string. */
/** Read a null-terminated UTF-16 (wide) string. */
@OptIn(ExperimentalForeignApi::class)
private fun CPointer<UShortVar>.toKStringFromUtf16(): String
{
val vSb = StringBuilder()
var vI = 0
while (true) { val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++ }
return vSb.toString()
}
private fun CPointer<UShortVar>.toKStringFromUtf16(): String {
val vSb = StringBuilder()
var vI = 0
while (true) {
val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++
}
return vSb.toString()
}
@OptIn(ExperimentalForeignApi::class)
private fun winError(inWhat: String): Nothing =
throw RuntimeException("$inWhat failed (Windows error 0x${GetLastError().toString(16)})")
throw RuntimeException("$inWhat failed (Windows error 0x${GetLastError().toString(16)})")
-202
View File
@@ -1,202 +0,0 @@
package apidemo
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
// ==================
// MARK: Minimal X.509 / DER parser
// ==================
// Some TLS backends (notably Schannel on Windows) expose only Subject / Issuer /
// the PEM through CURLINFO_CERTINFO — not the parsed fields (validity, serial,
// SAN, algorithms). Since every backend gives us the PEM, we parse it ourselves
// to fill in the detail. Just enough ASN.1 to walk a Certificate; anything odd
// is swallowed and the field is simply omitted.
/* Parsed certificate detail, all nullable/empty when not found or unparseable. */
class CertDetails(
val version: Int?, // X.509 version (1/2/3)
val serial: String?, // colon-separated hex
val sigAlg: String?, // signature algorithm (friendly name or OID)
val keyAlg: String?, // public-key algorithm (friendly name or OID)
val notBefore: String?, // "YYYY-MM-DD HH:MM:SS UTC"
val notAfter: String?,
val sans: List<String>, // dNSName SANs
)
// ============
// DER reader: each next() returns [tag, contentStart, contentEnd] and advances.
private class DerReader(val b: ByteArray, var pos: Int, val end: Int)
{
fun hasMore() = pos < end
fun next(): IntArray
{
val vTag = b[pos++].toInt() and 0xFF
var vLen = b[pos++].toInt() and 0xFF
if (vLen and 0x80 != 0)
{
val vN = vLen and 0x7F
vLen = 0
repeat(vN) { vLen = (vLen shl 8) or (b[pos++].toInt() and 0xFF) }
}
val vStart = pos
pos += vLen
return intArrayOf(vTag, vStart, vStart + vLen)
}
}
/* Parse the PEM into the fields the chain dialog shows. Returns null on any
structural surprise (the UI just falls back to whatever the backend gave). */
fun parseCertDetails(inPem: String): CertDetails?
{
val vDer = pemToDer(inPem) ?: return null
return try
{
val vCert = DerReader(vDer, 0, vDer.size).next() // Certificate SEQUENCE
val vCertInner = DerReader(vDer, vCert[1], vCert[2])
val vTbs = vCertInner.next() // tbsCertificate
val vSigAlg = firstOidName(vDer, vCertInner.next()) // signatureAlgorithm
val vT = DerReader(vDer, vTbs[1], vTbs[2])
var vE = vT.next()
var vVersion: Int? = null
if (vE[0] == 0xA0) // [0] EXPLICIT version
{
val vVi = DerReader(vDer, vE[1], vE[2]).next()
if (vVi[0] == 0x02 && vVi[2] > vVi[1]) vVersion = (vDer[vVi[1]].toInt() and 0xFF) + 1
vE = vT.next()
}
val vSerial = if (vE[0] == 0x02) bytesToHex(vDer, vE[1], vE[2]) else null
vT.next() // signature
vT.next() // issuer
val vValidity = vT.next() // validity SEQUENCE
val vVr = DerReader(vDer, vValidity[1], vValidity[2])
val vNotBefore = parseAsn1Time(vDer, vVr.next())
val vNotAfter = parseAsn1Time(vDer, vVr.next())
vT.next() // subject
val vSpki = vT.next() // subjectPublicKeyInfo SEQUENCE
// SPKI = SEQUENCE { algorithm AlgorithmIdentifier SEQUENCE { OID … }, key BIT STRING }
val vKeyAlg = firstOidName(vDer, DerReader(vDer, vSpki[1], vSpki[2]).next())
val vSans = ArrayList<String>()
while (vT.hasMore())
{
val vX = vT.next()
if (vX[0] == 0xA3) parseSans(vDer, vX, vSans) // extensions [3]
}
CertDetails(vVersion, vSerial, vSigAlg, vKeyAlg, vNotBefore, vNotAfter, vSans)
}
catch (e: Throwable) { null }
}
// ============
// Helpers
@OptIn(ExperimentalEncodingApi::class)
private fun pemToDer(inPem: String): ByteArray?
{
val vBody = inPem.lineSequence().filterNot { it.contains("-----") }.joinToString("") { it.trim() }
if (vBody.isBlank()) return null
return try { Base64.decode(vBody) } catch (e: Throwable) { null }
}
/* The friendly name (or dotted OID) of the first child OID of a SEQUENCE — used
for signatureAlgorithm and subjectPublicKeyInfo's AlgorithmIdentifier. */
private fun firstOidName(inB: ByteArray, inSeq: IntArray): String?
{
val vR = DerReader(inB, inSeq[1], inSeq[2])
val vOid = vR.next()
if (vOid[0] != 0x06) return null
return oidName(oidToString(inB, vOid[1], vOid[2]))
}
/* Pull dNSName entries out of the SubjectAltName extension under [3]. */
private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<String>)
{
val vSeq = DerReader(inB, inExt3[1], inExt3[2]).next() // SEQUENCE OF Extension
val vEs = DerReader(inB, vSeq[1], vSeq[2])
while (vEs.hasMore())
{
val vExtn = vEs.next()
val vEr = DerReader(inB, vExtn[1], vExtn[2])
val vOid = vEr.next() // extnID
// SAN OID 2.5.29.17 == bytes 55 1D 11
val vIsSan = vOid[2] - vOid[1] == 3 &&
(inB[vOid[1]].toInt() and 0xFF) == 0x55 &&
(inB[vOid[1] + 1].toInt() and 0xFF) == 0x1D &&
(inB[vOid[1] + 2].toInt() and 0xFF) == 0x11
var vVal = vEr.next()
if (vVal[0] == 0x01) vVal = vEr.next() // skip critical BOOLEAN
if (vIsSan && vVal[0] == 0x04) // OCTET STRING wrapping GeneralNames
{
val vGnSeq = DerReader(inB, vVal[1], vVal[2]).next()
val vGn = DerReader(inB, vGnSeq[1], vGnSeq[2])
while (vGn.hasMore())
{
val vName = vGn.next()
if (vName[0] == 0x82) inOut.add(inB.copyOfRange(vName[1], vName[2]).decodeToString()) // [2] dNSName
}
}
}
}
/* ASN.1 UTCTime (YYMMDD…) / GeneralizedTime (YYYYMMDD…) → readable UTC string. */
private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String?
{
val vS = inB.copyOfRange(inTlv[1], inTlv[2]).decodeToString()
return try
{
val vYear: String
val vRest: String
if (inTlv[0] == 0x18) // GeneralizedTime
{ vYear = vS.substring(0, 4); vRest = vS.substring(4) }
else // UTCTime — RFC 5280 pivot at 50
{
val vYy = vS.substring(0, 2).toInt()
vYear = (if (vYy >= 50) "19" else "20") + vS.substring(0, 2)
vRest = vS.substring(2)
}
val vMo = vRest.substring(0, 2); val vDa = vRest.substring(2, 4)
val vHh = vRest.substring(4, 6); val vMi = vRest.substring(6, 8)
val vSs = if (vRest.length >= 10 && vRest[8].isDigit()) vRest.substring(8, 10) else "00"
"$vYear-$vMo-$vDa $vHh:$vMi:$vSs UTC"
}
catch (e: Throwable) { vS }
}
/* Colon-separated hex of a byte range (serial number rendering). */
private fun bytesToHex(inB: ByteArray, inFrom: Int, inTo: Int): String =
(inFrom until inTo).joinToString(":") { (inB[it].toInt() and 0xFF).toString(16).padStart(2, '0') }
/* Decode an OID's content bytes to dotted-decimal. */
private fun oidToString(inB: ByteArray, inFrom: Int, inTo: Int): String
{
if (inTo <= inFrom) return ""
val vSb = StringBuilder()
val vFirst = inB[inFrom].toInt() and 0xFF
vSb.append(vFirst / 40).append('.').append(vFirst % 40)
var vAcc = 0L
for (vI in (inFrom + 1) until inTo)
{
val vByte = inB[vI].toInt() and 0xFF
vAcc = (vAcc shl 7) or (vByte and 0x7F).toLong()
if (vByte and 0x80 == 0) { vSb.append('.').append(vAcc); vAcc = 0 }
}
return vSb.toString()
}
/* Friendly name for the common signature / public-key OIDs, else the OID. */
private fun oidName(inOid: String): String = when (inOid)
{
"1.2.840.113549.1.1.1" -> "RSA"
"1.2.840.113549.1.1.5" -> "SHA1withRSA"
"1.2.840.113549.1.1.11" -> "SHA256withRSA"
"1.2.840.113549.1.1.12" -> "SHA384withRSA"
"1.2.840.113549.1.1.13" -> "SHA512withRSA"
"1.2.840.113549.1.1.10" -> "RSASSA-PSS"
"1.2.840.10045.2.1" -> "EC"
"1.2.840.10045.4.3.2" -> "ECDSAwithSHA256"
"1.2.840.10045.4.3.3" -> "ECDSAwithSHA384"
"1.2.840.10045.4.3.4" -> "ECDSAwithSHA512"
"1.3.101.112" -> "Ed25519"
"1.3.101.113" -> "Ed448"
else -> inOid
}
+318 -290
View File
@@ -1,6 +1,6 @@
package apidemo
import io.ktor.http.HttpStatusCode
import io.ktor.http.*
import kotlinx.cinterop.*
import libcurl.*
import okio.Buffer
@@ -11,7 +11,7 @@ import kotlin.time.TimeSource
// MARK: Client-certificate (mTLS) sending via libcurl
// ==================
// Ktor's native engines expose no client-certificate API, so a request that
// carries a client certificate is sent here instead — straight through the
// carries a client certificate is sent here instead - straight through the
// libcurl that ktor-client-curl already bundles (package `libcurl`, embedded
// static archive; Schannel on Windows, OpenSSL on macOS/Linux). This is the
// same TLS stack the default engine uses, so behaviour matches; we just get to
@@ -19,323 +19,351 @@ import kotlin.time.TimeSource
//
// The transfer is synchronous (curl_easy_perform) and runs on Dispatchers.Default
// from HttpRunner.run(); cancellation is best-effort (an in-flight perform can't
// be interrupted — the result is simply discarded if the caller cancelled).
// be interrupted - the result is simply discarded if the caller cancelled).
/* A client certificate readied for libcurl: the CURLOPT_SSLCERT value plus the
other SSL options to set, and a cleanup to run once the request is done.
macOS/Linux (OpenSSL) just point curl at the files. Windows (Schannel) imports
the cert + key into CurrentUser\MY, references it by SHA-1 thumbprint, and the
cleanup removes it again so the store never accumulates entries. */
/** A client certificate readied for libcurl: the CURLOPT_SSLCERT value plus the
other SSL options to set, and a cleanup to run once the request is done.
macOS/Linux (OpenSSL) just point curl at the files. Windows (Schannel) imports
the cert + key into CurrentUser\MY, references it by SHA-1 thumbprint, and the
cleanup removes it again so the store never accumulates entries. */
class PreparedCert(
val sslCert: String, // CURLOPT_SSLCERT (file path, or "CurrentUser\\MY\\<thumbprint>")
val sslCertType: String?, // CURLOPT_SSLCERTTYPE (null for a store reference)
val sslKey: String?, // CURLOPT_SSLKEY (null when the cert carries its key)
val sslKeyType: String?, // CURLOPT_SSLKEYTYPE
val keyPassword: String?, // CURLOPT_KEYPASSWD
val cleanup: () -> Unit,
val sslCert: String, // CURLOPT_SSLCERT (file path, or "CurrentUser\\MY\\<thumbprint>")
val sslCertType: String?, // CURLOPT_SSLCERTTYPE (null for a store reference)
val sslKey: String?, // CURLOPT_SSLKEY (null when the cert carries its key)
val sslKeyType: String?, // CURLOPT_SSLKEYTYPE
val keyPassword: String?, // CURLOPT_KEYPASSWD
val cleanup: () -> Unit,
)
/* Ready the request's client certificate for libcurl (platform-specific).
Throws with a user-facing message if the cert/key can't be loaded. */
/** Ready the request's client certificate for libcurl (platform-specific).
Throws with a user-facing message if the cert/key can't be loaded. */
expect fun prepareClientCert(inReq: ApiRequest): PreparedCert
/* Remove any temporary client certs (and their key containers) a previous run
left behind in the Windows store after a crash — identified by our prefix, so
the user's own certificates are never touched. No-op off Windows. Call once
at startup. */
expect fun sweepTempClientCerts()
// ChainCert / TlsChain and the sweepTempClientCerts / inspectTlsChain /
// curlSendWithClientCert expects live in commonMain (TlsCommon.kt) - the UI
// consumes them from shared code; the per-OS ClientCert*.kt actuals below the
// native tree actualize sweepTempClientCerts directly.
/* One certificate in the chain: its CURLINFO_CERTINFO-style fields (Subject /
Issuer / the PEM under "Cert" / dates …) and whether the server actually
presented it. Derived certs — an issuer resolved from the OS store, or a
name-only placeholder — have fromServer = false and are drawn dotted. */
class ChainCert(val fields: List<Pair<String, String>>, val fromServer: Boolean)
/* The server's TLS certificate chain. error is set instead when it couldn't be
fetched; fields vary by TLS backend. */
class TlsChain(val certs: List<ChainCert>, val error: String?)
/* Continue a server-presented chain with its issuer(s). Where possible the
issuer is resolved from the OS certificate store with full info; otherwise the
chain ends with a name-only placeholder. Every derived (non-server) cert is
flagged fromServer = false. Platform-specific (OS store on Windows). */
/** Continue a server-presented chain with its issuer(s). Where possible the
issuer is resolved from the OS certificate store with full info; otherwise the
chain ends with a name-only placeholder. Every derived (non-server) cert is
flagged fromServer = false. Platform-specific (OS store on Windows). */
expect fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert>
/* A CURLINFO_CERTINFO field by name (case-insensitive). */
/** A CURLINFO_CERTINFO field by name (case-insensitive). */
internal fun certFieldOf(inFields: List<Pair<String, String>>, inName: String): String? =
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
/* Server certs as ChainCerts; if the last one isn't self-signed, append a
dotted, name-only issuer so the chain visibly continues. The cross-platform
fallback when the OS store can't (or isn't able to) resolve the real issuer. */
internal fun serverChainWithIssuerName(inServer: List<List<Pair<String, String>>>): List<ChainCert>
{
val vOut = inServer.map { ChainCert(it, true) }.toMutableList()
val vLast = inServer.lastOrNull()
if (vLast != null)
{
val vSubject = certFieldOf(vLast, "Subject")
val vIssuer = certFieldOf(vLast, "Issuer")
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
vOut.add(ChainCert(listOf("Subject" to vIssuer), false))
}
return vOut
}
/* Per-transfer accumulators handed to the C callbacks through a StableRef. */
private class CurlSink
{
val body = Buffer() // response body bytes (already decompressed by curl)
val headerRaw = StringBuilder() // raw response header lines, all responses incl. redirects
}
/* Body write callback — appends each chunk to the sink's buffer. */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlBody(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t
{
val vSink = inUserdata.asStableRef<CurlSink>().get()
val vLen = (inSize * inCount).toLong()
if (vLen > 0) vSink.body.write(inBuffer.readBytes(vLen.toInt()))
return vLen.convert()
}
/* Header callback — one parsed header line per call; accumulated for parsing. */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlHeader(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t
{
val vSink = inUserdata.asStableRef<CurlSink>().get()
val vLen = (inSize * inCount).toLong()
if (vLen > 0) vSink.headerRaw.append(inBuffer.readBytes(vLen.toInt()).decodeToString())
return vLen.convert()
}
/* Sink callback that discards everything (used by the TLS-chain probe so the
response body/headers don't spill to stdout). */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlDiscard(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t =
(inSize * inCount)
/* Open a TLS connection to the request's URL (handshake only — no body) and
return the server's certificate chain via CURLINFO_CERTINFO. Reuses the
request's client certificate if it has one (so mTLS endpoints work too).
inReq is already var-resolved. */
@OptIn(ExperimentalForeignApi::class)
fun inspectTlsChain(inReq: ApiRequest): TlsChain
{
val vCert = try { if (inReq.hasClientCert) prepareClientCert(inReq) else null }
catch (e: Throwable) { return TlsChain(emptyList(), e.message ?: "Client certificate error") }
val vCurl = curl_easy_init() ?: run { vCert?.cleanup(); return TlsChain(emptyList(), "Could not initialize libcurl") }
try
{
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L) // just the handshake + headers
curl_easy_setopt(vCurl, CURLOPT_CERTINFO, 1L)
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 0L) // inspect the host as typed
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlDiscard))
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlDiscard))
vCert?.let { vC ->
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vC.sslCert)
vC.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
vC.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
vC.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
vC.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
}
val vCode = curl_easy_perform(vCurl)
if (vCode != CURLE_OK)
return TlsChain(emptyList(), curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode")
val vRaw = readCertInfo(vCurl)
if (vRaw.isEmpty()) return TlsChain(emptyList(), "No certificate chain reported (not an HTTPS URL, or the TLS backend didn't expose it).")
return TlsChain(extendChain(vRaw), null)
}
finally
{
curl_easy_cleanup(vCurl)
vCert?.cleanup()
}
}
/* Read CURLINFO_CERTINFO off a performed handle into per-cert field lists. */
@OptIn(ExperimentalForeignApi::class)
private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>>> = memScoped {
val vPtr = alloc<COpaquePointerVar>()
if (curl_easy_getinfo(inCurl, CURLINFO_CERTINFO, vPtr.ptr) != CURLE_OK) return@memScoped emptyList()
val vInfo = vPtr.value?.reinterpret<curl_certinfo>()?.pointed ?: return@memScoped emptyList()
val vArr = vInfo.certinfo ?: return@memScoped emptyList()
val vResult = ArrayList<List<Pair<String, String>>>()
for (vI in 0 until vInfo.num_of_certs)
{
val vFields = ArrayList<Pair<String, String>>()
var vNode = vArr[vI]
while (vNode != null)
{
val vData = vNode.pointed.data?.toKString()
if (vData != null)
{
val vColon = vData.indexOf(':')
if (vColon > 0) vFields.add(vData.substring(0, vColon) to vData.substring(vColon + 1))
else vFields.add("" to vData)
}
vNode = vNode.pointed.next
}
vResult.add(vFields)
}
vResult
/** Server certs as ChainCerts; if the last one isn't self-signed, append a
dotted, name-only issuer so the chain visibly continues. The cross-platform
fallback when the OS store can't (or isn't able to) resolve the real issuer. */
internal fun serverChainWithIssuerName(inServer: List<List<Pair<String, String>>>): List<ChainCert> {
val vOut = inServer.map { ChainCert(it, true) }.toMutableList()
val vLast = inServer.lastOrNull()
if (vLast != null) {
val vSubject = certFieldOf(vLast, "Subject")
val vIssuer = certFieldOf(vLast, "Issuer")
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
vOut.add(ChainCert(listOf("Subject" to vIssuer), false))
}
return vOut
}
/* Send a client-certificate request through libcurl and adapt the result into
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
/** Per-transfer accumulators handed to the C callbacks through a StableRef. */
private class CurlSink {
val body = Buffer() // response body bytes (already decompressed by curl)
val headerRaw = StringBuilder() // raw response header lines, all responses incl. redirects
}
/** Body write callback - appends each chunk to the sink's buffer. */
@OptIn(ExperimentalForeignApi::class)
fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
{
val vMark = TimeSource.Monotonic.markNow()
val vCert = try { prepareClientCert(inReq) }
catch (e: Throwable) { return errorResponse(e.message ?: "Client certificate error", vMark.elapsedNow().inWholeMilliseconds) }
val vCurl = curl_easy_init()
?: run { vCert.cleanup(); return errorResponse("Could not initialize libcurl", vMark.elapsedNow().inWholeMilliseconds) }
private fun onCurlBody(
inBuffer: CPointer<ByteVar>,
inSize: size_t,
inCount: size_t,
inUserdata: COpaquePointer
): size_t {
val vSink = inUserdata.asStableRef<CurlSink>().get()
val vLen = (inSize * inCount).toLong()
if (vLen > 0) vSink.body.write(inBuffer.readBytes(vLen.toInt()))
return vLen.convert()
}
val vSink = CurlSink()
val vSinkRef = StableRef.create(vSink)
var vHeaders: CPointer<curl_slist>? = null
/** Header callback - one parsed header line per call; accumulated for parsing. */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlHeader(
inBuffer: CPointer<ByteVar>,
inSize: size_t,
inCount: size_t,
inUserdata: COpaquePointer
): size_t {
val vSink = inUserdata.asStableRef<CurlSink>().get()
val vLen = (inSize * inCount).toLong()
if (vLen > 0) vSink.headerRaw.append(inBuffer.readBytes(vLen.toInt()).decodeToString())
return vLen.convert()
}
try
{
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 1L)
curl_easy_setopt(vCurl, CURLOPT_ACCEPT_ENCODING, "") // advertise + auto-decompress
/** Sink callback that discards everything (used by the TLS-chain probe so the
response body/headers don't spill to stdout). */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlDiscard(
inBuffer: CPointer<ByteVar>,
inSize: size_t,
inCount: size_t,
inUserdata: COpaquePointer
): size_t =
(inSize * inCount)
// ============
// Method + body
val vBody = if (inReq.method == ReqMethod.HEAD) null else requestBodyBytes(inReq)
if (inReq.method == ReqMethod.HEAD)
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
else
curl_easy_setopt(vCurl, CURLOPT_CUSTOMREQUEST, inReq.method.name)
if (vBody != null && vBody.bytes.isNotEmpty())
{
curl_easy_setopt(vCurl, CURLOPT_POSTFIELDSIZE_LARGE, vBody.bytes.size.toLong())
vBody.bytes.usePinned { curl_easy_setopt(vCurl, CURLOPT_COPYPOSTFIELDS, it.addressOf(0)) }
}
/** Open a TLS connection to the request's URL (handshake only - no body) and
return the server's certificate chain via CURLINFO_CERTINFO. Reuses the
request's client certificate if it has one (so mTLS endpoints work too).
inReq is already var-resolved. */
@OptIn(ExperimentalForeignApi::class)
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain {
val vCert = try {
if (inReq.hasClientCert) prepareClientCert(inReq) else null
} catch (e: Throwable) {
return TlsChain(emptyList(), e.message ?: "Client certificate error")
}
val vCurl =
curl_easy_init() ?: run { vCert?.cleanup(); return TlsChain(emptyList(), "Could not initialize libcurl") }
try {
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L) // just the handshake + headers
curl_easy_setopt(vCurl, CURLOPT_CERTINFO, 1L)
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 0L) // inspect the host as typed
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlDiscard))
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlDiscard))
vCert?.let { vC ->
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vC.sslCert)
vC.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
vC.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
vC.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
vC.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
}
val vCode = curl_easy_perform(vCurl)
if (vCode != CURLE_OK)
return TlsChain(emptyList(), curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode")
val vRaw = readCertInfo(vCurl)
if (vRaw.isEmpty()) return TlsChain(
emptyList(),
"No certificate chain reported (not an HTTPS URL, or the TLS backend didn't expose it)."
)
return TlsChain(extendChain(vRaw), null)
} finally {
curl_easy_cleanup(vCurl)
vCert?.cleanup()
}
}
// ============
// Headers (+ Content-Type for the body type if the user didn't set one)
inReq.headers
.filter { it.enabled && it.key.isNotBlank() }
.forEach { vHeaders = curl_slist_append(vHeaders, "${it.key}: ${it.value}") }
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
if (vBody?.contentType != null && !vHasCt)
vHeaders = curl_slist_append(vHeaders, "Content-Type: ${vBody.contentType}")
vHeaders = curl_slist_append(vHeaders, "Expect:") // disable 100-continue, as Ktor's engine does
vHeaders?.let { curl_easy_setopt(vCurl, CURLOPT_HTTPHEADER, it) }
/** Read CURLINFO_CERTINFO off a performed handle into per-cert field lists. */
@OptIn(ExperimentalForeignApi::class)
private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>>> = memScoped {
val vPtr = alloc<COpaquePointerVar>()
if (curl_easy_getinfo(inCurl, CURLINFO_CERTINFO, vPtr.ptr) != CURLE_OK) return@memScoped emptyList()
val vInfo = vPtr.value?.reinterpret<curl_certinfo>()?.pointed ?: return@memScoped emptyList()
val vArr = vInfo.certinfo ?: return@memScoped emptyList()
val vResult = ArrayList<List<Pair<String, String>>>()
for (vI in 0 until vInfo.num_of_certs) {
val vFields = ArrayList<Pair<String, String>>()
var vNode = vArr[vI]
while (vNode != null) {
val vData = vNode.pointed.data?.toKString()
if (vData != null) {
val vColon = vData.indexOf(':')
if (vColon > 0) vFields.add(vData.substring(0, vColon) to vData.substring(vColon + 1))
else vFields.add("" to vData)
}
vNode = vNode.pointed.next
}
vResult.add(vFields)
}
vResult
}
// ============
// Response capture callbacks
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlBody))
curl_easy_setopt(vCurl, CURLOPT_WRITEDATA, vSinkRef.asCPointer())
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlHeader))
curl_easy_setopt(vCurl, CURLOPT_HEADERDATA, vSinkRef.asCPointer())
/** Send a client-certificate request through libcurl and adapt the result into
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
@OptIn(ExperimentalForeignApi::class)
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse {
val vMark = TimeSource.Monotonic.markNow()
val vCert = try {
prepareClientCert(inReq)
} catch (e: Throwable) {
return errorResponse(e.message ?: "Client certificate error", vMark.elapsedNow().inWholeMilliseconds)
}
val vCurl = curl_easy_init()
?: run {
vCert.cleanup(); return errorResponse(
"Could not initialize libcurl",
vMark.elapsedNow().inWholeMilliseconds
)
}
// ============
// Client certificate (prepared per-platform: cert/key files on the
// OpenSSL backends, a CurrentUser\MY thumbprint on Windows/Schannel).
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vCert.sslCert)
vCert.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
vCert.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
vCert.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
vCert.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
val vSink = CurlSink()
val vSinkRef = StableRef.create(vSink)
var vHeaders: CPointer<curl_slist>? = null
// ============
// Perform
val vCode = curl_easy_perform(vCurl)
val vMs = vMark.elapsedNow().inWholeMilliseconds
if (vCode != CURLE_OK)
return errorResponse(curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode", vMs)
try {
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 1L)
curl_easy_setopt(vCurl, CURLOPT_ACCEPT_ENCODING, "") // advertise + auto-decompress
val vBytes = vSink.body.readByteArray()
val vStatus = memScoped { val p = alloc<LongVar>(); curl_easy_getinfo(vCurl, CURLINFO_RESPONSE_CODE, p.ptr); p.value.toInt() }
val vCt = memScoped { val p = alloc<CPointerVar<ByteVar>>(); curl_easy_getinfo(vCurl, CURLINFO_CONTENT_TYPE, p.ptr); p.value?.toKString() }
val vVer = memScoped { val p = alloc<LongVar>(); curl_easy_getinfo(vCurl, CURLINFO_HTTP_VERSION, p.ptr); httpVersionName(p.value) }
val vIsImage = vCt?.startsWith("image/", ignoreCase = true) == true
val vBinary = !vIsImage && isBinaryBody(vCt, vBytes)
// ============
// Method + body
val vBody = if (inReq.method == ReqMethod.HEAD) null else requestBodyBytes(inReq)
if (inReq.method == ReqMethod.HEAD)
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
else
curl_easy_setopt(vCurl, CURLOPT_CUSTOMREQUEST, inReq.method.name)
if (vBody != null && vBody.bytes.isNotEmpty()) {
curl_easy_setopt(vCurl, CURLOPT_POSTFIELDSIZE_LARGE, vBody.bytes.size.toLong())
vBody.bytes.usePinned { curl_easy_setopt(vCurl, CURLOPT_COPYPOSTFIELDS, it.addressOf(0)) }
}
// Headers we explicitly put on the wire (curl also adds Host / Accept /
// User-Agent / Content-Length, which it doesn't expose without a debug hook).
val vSent = buildList {
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { add(it.key to it.value) }
if (vBody?.contentType != null && !vHasCt) add("Content-Type" to vBody.contentType)
}.sortedBy { it.first.lowercase() }
// ============
// Headers (+ Content-Type for the body type if the user didn't set one)
inReq.headers
.filter { it.enabled && it.key.isNotBlank() }
.forEach { vHeaders = curl_slist_append(vHeaders, "${it.key}: ${it.value}") }
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
if (vBody?.contentType != null && !vHasCt)
vHeaders = curl_slist_append(vHeaders, "Content-Type: ${vBody.contentType}")
vHeaders = curl_slist_append(vHeaders, "Expect:") // disable 100-continue, as Ktor's engine does
vHeaders?.let { curl_easy_setopt(vCurl, CURLOPT_HTTPHEADER, it) }
return ApiResponse(
ok = true,
status = vStatus,
statusText = runCatching { HttpStatusCode.fromValue(vStatus).description }.getOrDefault(""),
timeMs = vMs,
sizeBytes = vBytes.size.toLong(),
headers = parseRawHeaders(vSink.headerRaw.toString()),
body = when {
vIsImage -> ""
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes — not shown; use Save as…)"
else -> vBytes.decodeToString()
},
bytes = vBytes,
contentType = vCt,
requestHeaders = vSent,
httpVersion = vVer,
)
}
finally
{
vHeaders?.let { curl_slist_free_all(it) }
curl_easy_cleanup(vCurl)
vSinkRef.dispose()
vCert.cleanup()
}
}
// ============
// Response capture callbacks
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlBody))
curl_easy_setopt(vCurl, CURLOPT_WRITEDATA, vSinkRef.asCPointer())
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlHeader))
curl_easy_setopt(vCurl, CURLOPT_HEADERDATA, vSinkRef.asCPointer())
/* A failed-request ApiResponse with a message (mirrors HttpRunner's catch). */
// ============
// Client certificate (prepared per-platform: cert/key files on the
// OpenSSL backends, a CurrentUser\MY thumbprint on Windows/Schannel).
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vCert.sslCert)
vCert.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
vCert.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
vCert.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
vCert.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
// ============
// Perform
val vCode = curl_easy_perform(vCurl)
val vMs = vMark.elapsedNow().inWholeMilliseconds
if (vCode != CURLE_OK)
return errorResponse(curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode", vMs)
val vBytes = vSink.body.readByteArray()
val vStatus = memScoped {
val p = alloc<LongVar>(); curl_easy_getinfo(
vCurl,
CURLINFO_RESPONSE_CODE,
p.ptr
); p.value.toInt()
}
val vCt = memScoped {
val p = alloc<CPointerVar<ByteVar>>(); curl_easy_getinfo(
vCurl,
CURLINFO_CONTENT_TYPE,
p.ptr
); p.value?.toKString()
}
val vVer = memScoped {
val p = alloc<LongVar>(); curl_easy_getinfo(
vCurl,
CURLINFO_HTTP_VERSION,
p.ptr
); httpVersionName(p.value)
}
val vIsImage = vCt?.startsWith("image/", ignoreCase = true) == true
val vBinary = !vIsImage && isBinaryBody(vCt, vBytes)
// Headers we explicitly put on the wire (curl also adds Host / Accept /
// User-Agent / Content-Length, which it doesn't expose without a debug hook).
val vSent = buildList {
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { add(it.key to it.value) }
if (vBody?.contentType != null && !vHasCt) add("Content-Type" to vBody.contentType)
}.sortedBy { it.first.lowercase() }
return ApiResponse(
status = vStatus,
statusText = runCatching { HttpStatusCode.fromValue(vStatus).description }.getOrDefault(""),
timeMs = vMs,
sizeBytes = vBytes.size.toLong(),
headers = parseRawHeaders(vSink.headerRaw.toString()),
body = when {
vIsImage -> ""
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes - not shown; use Save as…)"
else -> vBytes.decodeToString()
},
bytes = vBytes,
contentType = vCt,
requestHeaders = vSent,
httpVersion = vVer,
)
} finally {
vHeaders?.let { curl_slist_free_all(it) }
curl_easy_cleanup(vCurl)
vSinkRef.dispose()
vCert.cleanup()
}
}
/** A failed-request ApiResponse with a message (mirrors HttpRunner's catch). */
private fun errorResponse(inMessage: String, inMs: Long): ApiResponse =
ApiResponse(ok = false, status = 0, statusText = "—", timeMs = inMs, sizeBytes = 0, headers = emptyList(), body = "", error = inMessage)
ApiResponse(
status = 0,
statusText = "-",
timeMs = inMs,
sizeBytes = 0,
headers = emptyList(),
body = "",
error = inMessage
)
/* The encoded body for the request's body type, with the Content-Type curl
should send (null when there's no body). Mirrors HttpRunner.run(). */
/** The encoded body for the request's body type, with the Content-Type curl
should send (null when there's no body). Mirrors HttpRunner.run(). */
private class CurlBody(val contentType: String?, val bytes: ByteArray)
private fun requestBodyBytes(inReq: ApiRequest): CurlBody?
{
if (inReq.bodyType == BodyType.NONE) return null
return when (inReq.bodyType)
{
BodyType.TEXT -> CurlBody(inReq.bodyContentType(), inReq.body.encodeToByteArray())
BodyType.FORM -> CurlBody(inReq.bodyContentType(), formEncode(inReq.form).encodeToByteArray())
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(inReq.bodyContentType(), readFileBytes(inReq.body))
BodyType.NONE -> null
}
}
private fun requestBodyBytes(inReq: ApiRequest): CurlBody? {
if (inReq.bodyType == BodyType.NONE) return null
return when (inReq.bodyType) {
BodyType.TEXT -> CurlBody(inReq.bodyContentType(), inReq.body.encodeToByteArray())
BodyType.FORM -> CurlBody(inReq.bodyContentType(), formEncode(inReq.form).encodeToByteArray())
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(
inReq.bodyContentType(),
readFileBytes(inReq.body)
)
/* Parse libcurl's accumulated raw header text into ordered key/value pairs,
keeping only the final response's block (a new "HTTP/" status line — emitted
per redirect hop — resets what we've gathered). */
private fun parseRawHeaders(inRaw: String): List<Pair<String, String>>
{
var vCurrent = mutableListOf<Pair<String, String>>()
for (vRawLine in inRaw.split("\r\n", "\n"))
{
val vLine = vRawLine.trimEnd()
if (vLine.isEmpty()) continue
if (vLine.startsWith("HTTP/", ignoreCase = true)) { vCurrent = mutableListOf(); continue }
val vColon = vLine.indexOf(':')
if (vColon <= 0) continue
vCurrent.add(vLine.substring(0, vColon).trim() to vLine.substring(vColon + 1).trim())
}
return vCurrent.sortedBy { it.first.lowercase() }
}
BodyType.NONE -> null
}
}
/* libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
/** Parse libcurl's accumulated raw header text into ordered key/value pairs,
keeping only the final response's block (a new "HTTP/" status line - emitted
per redirect hop - resets what we've gathered). */
private fun parseRawHeaders(inRaw: String): List<Pair<String, String>> {
var vCurrent = mutableListOf<Pair<String, String>>()
for (vRawLine in inRaw.split("\r\n", "\n")) {
val vLine = vRawLine.trimEnd()
if (vLine.isEmpty()) continue
if (vLine.startsWith("HTTP/", ignoreCase = true)) {
vCurrent = mutableListOf(); continue
}
val vColon = vLine.indexOf(':')
if (vColon <= 0) continue
vCurrent.add(vLine.substring(0, vColon).trim() to vLine.substring(vColon + 1).trim())
}
return vCurrent.sortedBy { it.first.lowercase() }
}
/** libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
@OptIn(ExperimentalForeignApi::class)
private fun httpVersionName(inCode: Long): String = when (inCode)
{
CURL_HTTP_VERSION_1_0.toLong() -> "HTTP/1.0"
CURL_HTTP_VERSION_1_1.toLong() -> "HTTP/1.1"
CURL_HTTP_VERSION_2_0.toLong() -> "HTTP/2"
CURL_HTTP_VERSION_3.toLong() -> "HTTP/3"
else -> "HTTP/1.1"
}
private fun httpVersionName(inCode: Long): String = when (inCode) {
CURL_HTTP_VERSION_1_0.toLong() -> "HTTP/1.0"
CURL_HTTP_VERSION_1_1.toLong() -> "HTTP/1.1"
CURL_HTTP_VERSION_2_0.toLong() -> "HTTP/2"
CURL_HTTP_VERSION_3.toLong() -> "HTTP/3"
else -> "HTTP/1.1"
}
+17 -14
View File
@@ -6,20 +6,13 @@ import com.compose.sdl.loadComposeResourceBytes
import com.compose.sdl.text.namedFontFamily
// ==================
// MARK: Monospace body font
// MARK: Monospace body font - native actuals (data.kres + IconFont)
// ==================
// Family name the body editor passes for monospace text. Resolves to the bundled
// Noto Sans Mono once registered; null means the font isn't in data.kres, in
// which case the body falls back to the default (proportional Noto Sans).
const val kMonoFamily = "noto-mono"
// The raw project family name string — used by the SyntaxHighlight tokeniser's
// wrap() call which measures via NativeTextMeasurer (accepts a name string).
val monoFontFamilyName: String? by lazy {
actual val monoFontFamilyName: String? by lazy {
val vBytes = loadComposeResourceBytes("font/NotoSansMono.ttf")
if (vBytes == null) {
println("apidemo: NotoSansMono.ttf not bundled — body uses the default font")
println("apidemo: NotoSansMono.ttf not bundled - body uses the default font")
null
} else {
IconFont.register(kMonoFamily, vBytes)
@@ -27,9 +20,19 @@ val monoFontFamilyName: String? by lazy {
}
}
// The material3 Text / BasicTextField-shaped FontFamily wrapper. Wraps the
// registered project family in a `NamedFont`-backed FontFamily so the m3 Text
// signature (which takes `FontFamily?` proper) can be given the mono family.
val monoFontFamily: FontFamily? by lazy {
actual val monoFontFamily: FontFamily? by lazy {
monoFontFamilyName?.let { namedFontFamily(it) }
}
/** Native already resolves FontFamily.Default to the bundled NotoSans, but the
family is registered by name so both stacks go through the same lookup. */
actual val defaultFontFamily: FontFamily? by lazy {
val vBytes = loadComposeResourceBytes("font/NotoSans.ttf")
if (vBytes == null) {
println("apidemo: NotoSans.ttf not bundled - using the platform default font")
null
} else {
IconFont.register(kDefaultFamily, vBytes)
namedFontFamily(kDefaultFamily)
}
}
File diff suppressed because it is too large Load Diff
-247
View File
@@ -1,247 +0,0 @@
package apidemo
import kotlinx.serialization.Serializable
// ==================
// MARK: Pack model (serialized to / from .json)
// ==================
/* A "pack" is a savable collection of requests plus its environment variables
— the export/import unit. Variables are referenced as {{name}} in any URL,
query value, header or body and substituted just before the request is sent
(see resolveVars in Tools.kt). Packs may be empty; the rich starter set lives
in defaultSession(). */
@Serializable
data class Pack(
val name: String = "My Pack",
val requests: List<ApiRequest> = emptyList(),
val variables: List<KeyVal> = emptyList(),
val color: Int = 0, // 1-based index into the pack-colour palette; 0 = none
val headers: List<KeyVal> = emptyList(), // pack-level headers, inherited by requests
val params: List<KeyVal> = emptyList(), // pack-level query params, inherited by requests
val cert: CertConfig? = null, // pack-level client cert, inherited by requests
val id: String = "", // stable id (used by linked-copy packs)
val linkedTo: String? = null, // id of the source pack when this is a linked copy
val isRoot: Boolean = false, // the hidden session-root "pack" holding loose requests
val subPacks: List<Pack> = emptyList(), // nested packs (a pack tree)
)
/* A client-certificate (mutual TLS) configuration. Lives on a request today and
— with the pack tree — on the session / packs too (inherited downward). Maps
to libcurl's CURLOPT_SSLCERT / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. */
@Serializable
data class CertConfig(
val certPath: String = "",
val certFormat: CertFormat = CertFormat.PEM,
val keyPath: String = "",
val keyFormat: CertFormat = CertFormat.PEM,
val certPassword: String = "",
)
/* The starter SESSION loaded on first launch (and on demand from the session
menu) — a guided tour of every feature against httpbin.org: the inheritance
ladder (session → pack → sub-pack → request, for variables / query params /
headers / client cert, innermost wins), loose root requests, a nested sub-pack,
per-request overrides, a client-cert pack, and a linked-copy pack. Each /get or
/anything endpoint echoes the args + headers it received, so the merged,
inherited result is visible in the response. Loaded as an unsaved session. */
fun defaultSession(): Session = Session(
activePack = 0,
// ── Session level — the base of every inheritance ladder ──
globalEnv = listOf(
KeyVal("baseUrl", "https://httpbin.org"),
KeyVal("token", "session-token-abc"),
KeyVal("user", "session-user"),
KeyVal("password", "passwd"),
KeyVal("apiVer", "v1"), // overridden by Methods (v2) then Nested (v3)
),
globalHeaders = listOf(KeyVal("X-Client", "compose-apidemo")), // every request sends it (echoed by /headers)
globalParams = listOf(KeyVal("trace", "session")), // every request gets ?trace=session (echoed in /get args)
// No session-level cert: it would route every request through mTLS and fail
// without a real cert file. Client certs are shown on the "Secure (mTLS)" pack.
// ── Loose requests at the session root (in no pack — inherit session only) ──
root = Pack(isRoot = true, name = "", requests = listOf(
ApiRequest(name = "Ping (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
ApiRequest(name = "Headers echo (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/headers"),
)),
packs = listOf(
// ── Methods — pack header/param/var inherited by its requests, plus a
// nested sub-pack and per-request overrides of each kind. ──
SavedPack(pack = Pack(name = "Methods", id = "pk-methods", color = 1,
headers = listOf(KeyVal("Accept", "application/json")), // pack header, inherited by its requests
params = listOf(KeyVal("source", "methods")), // pack query param, inherited (echoed in /get args)
variables = listOf(KeyVal("apiVer", "v2")), // overrides the session's apiVer for this pack
requests = listOf(
ApiRequest(name = "GET — echoes inherited", method = ReqMethod.GET, url = "{{baseUrl}}/get",
params = listOf(KeyVal("q", "compose"))), // own q + inherited trace=session + source=methods
ApiRequest(name = "GET — overrides query param", method = ReqMethod.GET, url = "{{baseUrl}}/get",
params = listOf(KeyVal("trace", "request"))), // request's trace wins over the session's
ApiRequest(name = "POST — overrides header", method = ReqMethod.POST, url = "{{baseUrl}}/post",
headers = listOf(KeyVal("Accept", "text/plain")), // request's Accept wins over the pack's
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{\n \"user\": \"{{user}}\",\n \"api\": \"{{apiVer}}\"\n}"),
ApiRequest(name = "POST — overrides {{user}} var", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
variables = listOf(KeyVal("user", "request-user")), // request var beats session/pack — see body echo
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"who\": \"{{user}}\" }"),
ApiRequest(name = "PUT — replace", method = ReqMethod.PUT, url = "{{baseUrl}}/put",
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"id\": 1, \"name\": \"updated\" }"),
ApiRequest(name = "DELETE", method = ReqMethod.DELETE, url = "{{baseUrl}}/delete"),
ApiRequest(name = "Anything echo", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"hello\": \"world\" }"),
),
// Nested sub-pack — inherits Session → Methods → here (apiVer becomes v3,
// plus its own X-Nested header; trace / source / Accept still flow down).
subPacks = listOf(
Pack(name = "Nested", color = 4,
variables = listOf(KeyVal("apiVer", "v3")),
headers = listOf(KeyVal("X-Nested", "true")),
requests = listOf(
ApiRequest(name = "GET — deep inheritance", method = ReqMethod.GET, url = "{{baseUrl}}/anything/{{apiVer}}"),
)),
))),
// ── Auth & status — uses the inherited {{token}} / {{user}} / {{password}} ──
SavedPack(pack = Pack(name = "Auth & status", color = 2, requests = listOf(
ApiRequest(name = "Bearer auth", method = ReqMethod.GET, url = "{{baseUrl}}/bearer",
headers = listOf(KeyVal("Authorization", "Bearer {{token}}"))),
ApiRequest(name = "Basic auth", method = ReqMethod.GET, url = "{{baseUrl}}/basic-auth/{{user}}/{{password}}"),
ApiRequest(name = "Status 200", method = ReqMethod.GET, url = "{{baseUrl}}/status/200"),
ApiRequest(name = "Status 500", method = ReqMethod.GET, url = "{{baseUrl}}/status/500"),
ApiRequest(name = "Redirect x3", method = ReqMethod.GET, url = "{{baseUrl}}/redirect/3"),
ApiRequest(name = "Delay 3s (try Cancel)", method = ReqMethod.GET, url = "{{baseUrl}}/delay/3"),
))),
// ── Formats — response viewer (JSON / XML / HTML / image) ──
SavedPack(pack = Pack(name = "Formats", color = 3, requests = listOf(
ApiRequest(name = "JSON", method = ReqMethod.GET, url = "{{baseUrl}}/json"),
ApiRequest(name = "XML", method = ReqMethod.GET, url = "{{baseUrl}}/xml"),
ApiRequest(name = "HTML", method = ReqMethod.GET, url = "{{baseUrl}}/html"),
ApiRequest(name = "UTF-8 text", method = ReqMethod.GET, url = "{{baseUrl}}/encoding/utf8"),
ApiRequest(name = "PNG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/png"),
ApiRequest(name = "SVG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/svg"),
))),
// ── Secure (mTLS) — a PACK-LEVEL client cert, inherited by its requests.
// Open a request's Cert tab to see the inherited cert (source pill +
// Override). Set real cert paths to actually send; the example paths
// won't load. The second request overrides with its own cert. ──
SavedPack(pack = Pack(name = "Secure (mTLS)", color = 5,
cert = CertConfig(certPath = "/path/to/client.p12", certFormat = CertFormat.PKCS12, certPassword = "changeit"),
requests = listOf(
ApiRequest(name = "Inherits the pack cert", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
ApiRequest(name = "Overrides with own cert", method = ReqMethod.GET, url = "{{baseUrl}}/get",
certPath = "/path/to/other-client.pem", certFormat = CertFormat.PEM, keyPath = "/path/to/other-key.pem"),
))),
// ── Linked copy of "Methods": a read-only mirror of its requests, but with
// its own baseUrl var → the same calls run against the httpbingo sibling.
// Showcases linked packs + per-pack variable override. ──
SavedPack(pack = Pack(name = "Methods · httpbingo", color = 6, requests = emptyList(),
variables = listOf(KeyVal("baseUrl", "https://httpbingo.org")), linkedTo = "pk-methods")),
),
)
@Serializable
data class ApiRequest(
val name: String = "New request",
val method: ReqMethod = ReqMethod.GET,
val url: String = "https://",
val params: List<KeyVal> = emptyList(),
val variables: List<KeyVal> = emptyList(), // request-level vars ({{name}}); override inherited ones
val headers: List<KeyVal> = emptyList(),
val bodyType: BodyType = BodyType.NONE,
val body: String = "", // TEXT content, or the file path when bodyType == FILE
val bodyFormat: BodyFormat = BodyFormat.RAW, // for a TEXT body: syntax-highlight type + sent Content-Type
val form: List<KeyVal> = emptyList(), // key/value fields when bodyType == FORM
// Client certificate (mutual TLS). When certPath is set, the request is sent
// through the libcurl path (CurlMtls.kt) with these mapped to CURLOPT_SSLCERT
// / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. A PKCS#12 file bundles its
// own key, so keyPath is left blank for it.
val certPath: String = "",
val certFormat: CertFormat = CertFormat.PEM,
val keyPath: String = "",
val keyFormat: CertFormat = CertFormat.PEM,
val certPassword: String = "",
)
/* Whether this request carries a client certificate (and so is sent via the
libcurl mTLS path rather than the default engine). */
val ApiRequest.hasClientCert: Boolean
get() = certPath.isNotBlank()
/* This request's cert fields as a CertConfig (certPath may be blank = none). */
fun ApiRequest.certConfig(): CertConfig = CertConfig(certPath, certFormat, keyPath, keyFormat, certPassword)
/* This request with its cert fields replaced by inCert's. */
fun ApiRequest.withCert(inCert: CertConfig): ApiRequest =
copy(certPath = inCert.certPath, certFormat = inCert.certFormat,
keyPath = inCert.keyPath, keyFormat = inCert.keyFormat, certPassword = inCert.certPassword)
/* True when this config actually selects a certificate. */
val CertConfig.isSet: Boolean
get() = certPath.isNotBlank()
/* Certificate / private-key encodings, mapped to libcurl's CURLOPT_SSLCERTTYPE
/ CURLOPT_SSLKEYTYPE string values and to `curl --cert-type` / `--key-type`.
Runtime support depends on the TLS backend: OpenSSL (macOS/Linux) handles all
three; Schannel (Windows) effectively supports P12. */
@Serializable
enum class CertFormat(val curlName: String, val label: String) {
PEM("PEM", "PEM"),
DER("DER", "DER"),
PKCS12("P12", "PKCS#12"),
}
/* A toggleable key/value row, used for both query params and headers. */
@Serializable
data class KeyVal(val key: String = "", val value: String = "", val enabled: Boolean = true)
@Serializable
enum class ReqMethod { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS }
/* HTTP doesn't forbid a body on GET / HEAD / OPTIONS — RFC 7230 §3.3
leaves it as "SHOULD NOT" / server discretion. Many APIs use a body
on GET (Elasticsearch search, GraphQL queries) so we don't gate it
here; the UI just sets bodyType=NONE by default for those methods. */
val ReqMethod.allowsBody: Boolean
get() = true
@Serializable
enum class BodyType { NONE, TEXT, FORM, FILE }
/* Title-case label for the body-type picker. */
val BodyType.label: String
get() = when (this) {
BodyType.NONE -> "None"
BodyType.TEXT -> "Text"
BodyType.FORM -> "Form"
BodyType.FILE -> "File"
}
/* The Content-Type the request body implies (null when there's no body). For a
TEXT body it comes from the chosen format (Text→text/plain, JSON→application/
json, …); FORM / FILE are fixed. Sent unless the request already sets one. */
fun ApiRequest.bodyContentType(): String? = when (bodyType) {
BodyType.NONE -> null
BodyType.TEXT -> bodyFormat.contentType
BodyType.FORM -> "application/x-www-form-urlencoded"
BodyType.FILE -> "application/octet-stream"
}
// ==================
// MARK: Response (runtime only — not part of a pack)
// ==================
class ApiResponse(
val ok: Boolean,
val status: Int,
val statusText: String,
val timeMs: Long,
val sizeBytes: Long,
val headers: List<Pair<String, String>>,
val body: String,
val bytes: ByteArray = ByteArray(0),
val contentType: String? = null,
val requestHeaders: List<Pair<String, String>> = emptyList(), // headers actually sent
val httpVersion: String = "HTTP/1.1", // from Ktor's response.version, e.g. "HTTP/1.1" or "HTTP/2"
val error: String? = null,
)
/* True when the response carries an image payload (rendered, not shown as text). */
val ApiResponse.isImage: Boolean
get() = contentType?.startsWith("image/", ignoreCase = true) == true
@@ -1,241 +0,0 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package apidemo
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
import com.compose.sdl.layout.x
// ==================
// MARK: Request tab strip (open requests; drag a tab to reorder)
// ==================
/* Strip of open requests above the editor. Each tab is clickable to select
and drag-reorderable. While dragging, the grabbed tab lifts (zIndex),
fades, and follows the cursor via a graphicsLayer translation — its
neighbours don't shuffle; the reorder commits on drop, to the slot whose
neighbours' centres the cursor passed. key(tab) keeps each tab's
LayoutNode stable so the drag session survives the reorder. */
@Composable
internal fun RequestTabStrip(
inTabs: List<StripTab>,
inActiveKey: Any?,
inOnSelect: (StripTab) -> Unit,
inOnClose: (StripTab) -> Unit,
inOnCloseOthers: (StripTab) -> Unit,
inOnCloseAll: () -> Unit,
inOnReorder: (Int, Int) -> Unit,
) {
val c = LocalAppColors.current
val vScroll = rememberScrollState() // strip's horizontal scroll
val vLeft = remember { mutableStateMapOf<Any, Int>() } // window-x of each tab's left edge, by tab key
val vWidth = remember { mutableStateMapOf<Any, Int>() } // measured tab width, by tab key
var vDragKey by remember { mutableStateOf<Any?>(null) }
var vPressRelX by remember { mutableStateOf(0) }
var vDragDx by remember { mutableStateOf(0f) }
var vDragTarget by remember { mutableStateOf(-1) }
// One shared context menu, anchored to the tab that opened it (m3 DropdownMenu
// is parent-anchored, not cursor-anchored — the old x/y capture is gone).
var vMenu by remember { mutableStateOf(false) }
var vMenuTab by remember { mutableStateOf<StripTab?>(null) }
var vListOpen by remember { mutableStateOf(false) } // overflow list
val vShowBar = vDragKey != null && vDragDx != 0f
val vOthers = if (vShowBar) inTabs.filter { it.tabKey != vDragKey } else emptyList()
val vBarBeforeKey = if (vShowBar) vOthers.getOrNull(vDragTarget)?.tabKey else null
val vBarAtEnd = vShowBar && vDragTarget >= vOthers.size
Row(
modifier = Modifier.fillMaxWidth().background(c.panel),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f)
.horizontalScroll(vScroll).padding(horizontal = 6.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
inTabs.forEach { vTab ->
val vKey = vTab.tabKey
if (vKey == vBarBeforeKey) DropBar()
key(vKey) {
val vSel = vKey == inActiveKey
val vDragged = vKey == vDragKey
var vMod = Modifier
.onGloballyPositioned { vLeft[vKey] = it.x }
.onSizeChanged { vWidth[vKey] = it.width }
// NOTE: `alpha` and `translationX` MUST be on the same graphicsLayer.
// `Modifier.alpha` expands to `graphicsLayer(alpha, clip = true)` — clip is
// applied in the ALPHA layer's local coord space (before any child layer's
// translation), so `.alpha().graphicsLayer(translationX = X)` clips the drag
// ghost inside its original bounds and the ghost disappears as it slides. One
// layer with both properties = clip stays false, alpha modulates via layerPaint.
if (vDragged) vMod = vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = vDragDx, translationY = 0f)
vMod = vMod
.clip(RoundedCornerShape(7.dp))
.background(if (vSel) c.accent.copy(alpha = 0.20f) else c.field, RoundedCornerShape(7.dp))
.border(1.dp, if (vSel || vDragged) c.accent else c.border, RoundedCornerShape(7.dp))
.pointerInput(vKey) {
// Accumulate the per-frame delta rather than reading change.position.
// The tab wears a graphicsLayer(translationX = vDragDx) while dragging,
// which transforms the modifier's local pointer frame — so position.x
// would compensate for the translation each frame and the tab would
// trail the mouse by roughly half its offset. dragAmount is the
// root-frame delta and doesn't feed back through the layer transform.
detectDragGestures(
onDragStart = { offset ->
vDragKey = vKey
vPressRelX = offset.x.toInt()
vDragDx = 0f
vDragTarget = inTabs.indexOfFirst { it.tabKey == vKey }
},
onDrag = { _, dragAmount ->
val vd = vDragKey ?: return@detectDragGestures
vDragDx += dragAmount.x
val vRelX = vPressRelX + vDragDx.toInt()
val vCursorX = (vLeft[vd] ?: 0) + vRelX
var vCount = 0
inTabs.forEach { vT ->
if (vT.tabKey != vd) {
val vCenter = (vLeft[vT.tabKey] ?: 0) + (vWidth[vT.tabKey] ?: 0) / 2
if (vCursorX > vCenter) vCount++
}
}
vDragTarget = vCount
},
onDragEnd = {
val vd = vDragKey
if (vd != null) {
val vFrom = inTabs.indexOfFirst { it.tabKey == vd }
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(vFrom, vDragTarget)
}
vDragKey = null; vDragDx = 0f; vDragTarget = -1
},
onDragCancel = { vDragKey = null; vDragDx = 0f; vDragTarget = -1 },
)
}
// Middle-click closes the tab; right-click opens the tab menu. Compose
// has no first-class match for either — inline pointerInput checks the
// pressed PointerButton at each Press.
.pointerInput(vTab) {
awaitPointerEventScope {
while (true) {
val vEv = awaitPointerEvent()
if (vEv.type != PointerEventType.Press) continue
when (vEv.button) {
PointerButton.Tertiary -> {
vEv.changes.firstOrNull()?.consume()
inOnClose(vTab)
}
PointerButton.Secondary -> {
vEv.changes.firstOrNull()?.consume()
vMenuTab = vTab; vMenu = true
}
}
}
}
}
.clickable { inOnSelect(vTab) }
.padding(start = 9.dp, top = 6.dp, bottom = 6.dp, end = 3.dp)
Row(
modifier = vMod,
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val vRs = vTab.req
if (vRs == null) {
// Session / pack settings tab.
MaterialSymbolsOutlined(MaterialSymbols.Tune, tint = if (vSel) c.accent else c.dim, size = 13.dp)
Text(if (vTab.isSession) "Session" else (vTab.pack?.name ?: ""), color = if (vSel) c.text else c.dim, fontSize = 13.sp)
} else {
Box(modifier = Modifier.size(7.dp).background(methodColor(vRs.req.method), RoundedCornerShape(4.dp)))
Text(vRs.req.name, color = if (vSel) c.text else c.dim, fontSize = 13.sp)
if (vRs.loading) CircularProgressIndicator(modifier = Modifier.size(12.dp), color = c.accent, strokeWidth = 1.5.dp)
}
IconBtn(MaterialSymbols.Close, "Close", inSize = 13.dp) { inOnClose(vTab) }
}
}
}
if (vBarAtEnd) DropBar()
// Shared right-click menu, opened at the cursor over the clicked tab.
val vMTab = vMenuTab
if (vMenu && vMTab != null) {
DropdownMenu(
expanded = true,
onDismissRequest = { vMenu = false },
modifier = Modifier.widthIn(min = 188.dp),
) {
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Close, "Close tab") }, onClick = { vMenu = false; inOnClose(vMTab) })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Clear, "Close other tabs") }, onClick = { vMenu = false; inOnCloseOthers(vMTab) })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Clear, "Close all tabs") }, onClick = { vMenu = false; inOnCloseAll() })
}
}
}
// Overflow → chevron opens a scrollable list of every open tab.
if (vScroll.maxValue > 0) {
Box(modifier = Modifier.padding(end = 4.dp)) {
IconBtn(MaterialSymbols.ExpandMore, "All tabs") { vListOpen = true }
DropdownMenu(expanded = vListOpen, onDismissRequest = { vListOpen = false }, modifier = Modifier.widthIn(min = 240.dp)) {
Column(modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState())) {
inTabs.forEach { vTab ->
DropdownMenuItem(text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val vSel = vTab.tabKey == inActiveKey
val vRs = vTab.req
if (vRs == null) {
MaterialSymbolsOutlined(
MaterialSymbols.Tune,
tint = if (vSel) c.accent else c.dim,
size = 13.dp
)
Text(if (vTab.isSession) "Session" else "${vTab.pack?.name} · var", color = if (vSel) c.accent else c.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
} else {
Box(modifier = Modifier.size(7.dp).background(methodColor(vRs.req.method), RoundedCornerShape(4.dp)))
Text(vRs.req.name, color = if (vSel) c.accent else c.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
if (vRs.loading) CircularProgressIndicator(modifier = Modifier.size(12.dp), color = c.accent, strokeWidth = 1.5.dp)
}
}
}, onClick = { vListOpen = false; inOnSelect(vTab) })
}
}
}
}
}
}
}
/* Thin accent bar marking where a dragged tab will be inserted. Rendered in-flow
between tabs so the Row centres it vertically and reserves a little space. */
@Composable
internal fun DropBar() {
val c = LocalAppColors.current
Box(modifier = Modifier.width(3.dp).height(24.dp).clip(RoundedCornerShape(2.dp)).background(c.accent, RoundedCornerShape(2.dp)))
}
@@ -1,366 +0,0 @@
package apidemo
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import kotlinx.serialization.Serializable
// ==================
// MARK: BodyFormat — viewer/highlighter selection
// ==================
/* Format of a body payload as shown in the viewer, and (for a TEXT request body)
the type the user picks on the right of the Body tab — it drives both syntax
highlighting and the sent Content-Type. RAW disables highlighting and falls back
to the selectable BasicTextField; other values pick a tokeniser. */
@Serializable
enum class BodyFormat { RAW, JSON, XML, YAML, HTML }
/* Short label for the format / "type" picker (RAW = plain text, no highlighting). */
val BodyFormat.label: String
get() = when (this) {
BodyFormat.RAW -> "RAW"
BodyFormat.JSON -> "JSON"
BodyFormat.XML -> "XML"
BodyFormat.YAML -> "YAML"
BodyFormat.HTML -> "HTML"
}
/* The Content-Type a TEXT body of this format is sent with. */
val BodyFormat.contentType: String
get() = when (this) {
BodyFormat.RAW -> "text/plain"
BodyFormat.JSON -> "application/json"
BodyFormat.XML -> "application/xml"
BodyFormat.YAML -> "application/yaml"
BodyFormat.HTML -> "text/html"
}
/* Pick a format from a Content-Type header. JSON / XML / YAML / HTML
match their canonical types and common variants; anything else
(image types, text/plain, …) falls back to RAW. */
fun autoFormatFor(inContentType: String?): BodyFormat {
val vCt = inContentType?.substringBefore(';')?.trim()?.lowercase() ?: return BodyFormat.RAW
return when {
vCt.endsWith("+json") || vCt == "application/json" -> BodyFormat.JSON
vCt.endsWith("+xml") || vCt == "application/xml" || vCt == "text/xml" -> BodyFormat.XML
vCt == "application/yaml" || vCt == "text/yaml" || vCt == "application/x-yaml" -> BodyFormat.YAML
vCt == "text/html" -> BodyFormat.HTML
else -> BodyFormat.RAW
}
}
// ==================
// MARK: Highlight palette
// ==================
/* Per-token colours. Two preset themes: a dark-on-dark and a dark-on-
light variant matching VS Code's defaults. Caller picks based on
surrounding theme; auto-pick available via SyntaxPalette.forDark(). */
data class SyntaxPalette(
val key: Color,
val string: Color,
val number: Color,
val keyword: Color,
val tag: Color,
val comment: Color,
val punct: Color,
) {
companion object {
/* Light-on-dark — VS Code Dark+. */
val Dark = SyntaxPalette(
key = Color(0xFF9CDCFE),
string = Color(0xFFCE9178),
number = Color(0xFFB5CEA8),
keyword = Color(0xFFC586C0),
tag = Color(0xFF569CD6),
comment = Color(0xFF6A9955),
punct = Color(0xFFCCCCCC),
)
/* Dark-on-light — VS Code Light+. Higher contrast on white. */
val Light = SyntaxPalette(
key = Color(0xFF0451A5),
string = Color(0xFFA31515),
number = Color(0xFF098658),
keyword = Color(0xFF0000FF),
tag = Color(0xFF800000),
comment = Color(0xFF008000),
punct = Color(0xFF333333),
)
/* Pick a palette by the host theme's background brightness. */
fun forDark(inIsDark: Boolean): SyntaxPalette = if (inIsDark) Dark else Light
}
}
// ==================
// MARK: Entry point
// ==================
/* Render the given text with per-token colour spans for the requested
format. RAW returns an unstyled AnnotatedString. */
fun highlight(
inText: String,
inFormat: BodyFormat,
inPalette: SyntaxPalette = SyntaxPalette.Dark,
): AnnotatedString = when (inFormat) {
BodyFormat.RAW -> AnnotatedString(inText)
BodyFormat.JSON -> highlightJson(inText, inPalette)
BodyFormat.XML, BodyFormat.HTML -> highlightXml(inText, inPalette)
BodyFormat.YAML -> highlightYaml(inText, inPalette)
}
// ==================
// MARK: JSON tokeniser — single-pass, error-tolerant
// ==================
/* Tokeniser for JSON. Walks the string once and emits style spans for
strings, numbers, booleans, null, punctuation. Distinguishes "keys"
(strings followed by ':') from string values by peeking after each
string. Error-tolerant — malformed JSON still highlights as best
effort rather than throwing. */
private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
val vN = inText.length
var vI = 0
while (vI < vN) {
val vC = inText[vI]
when {
vC == '"' -> {
// Find closing quote, honouring backslash-escaped chars so an
// escaped \" doesn't terminate the string early.
val vStart = vI
var vJ = vI + 1
while (vJ < vN) {
if (inText[vJ] == '\\' && vJ + 1 < vN) { vJ += 2; continue }
if (inText[vJ] == '"') { vJ++; break }
vJ++
}
// Peek for ':' (skipping whitespace) → it's a key, not a value.
var vK = vJ
while (vK < vN && inText[vK].isWhitespace()) vK++
val vIsKey = vK < vN && inText[vK] == ':'
val vColor = if (vIsKey) inP.key else inP.string
pushStyle(SpanStyle(color = vColor))
append(inText.substring(vStart, vJ))
pop()
vI = vJ
}
vC.isDigit() || (vC == '-' && vI + 1 < vN && inText[vI + 1].isDigit()) -> {
val vStart = vI
if (vC == '-') vI++
while (vI < vN && (inText[vI].isDigit() || inText[vI] in ".eE+-")) vI++
pushStyle(SpanStyle(color = inP.number))
append(inText.substring(vStart, vI))
pop()
}
vC.isLetter() -> {
val vStart = vI
while (vI < vN && inText[vI].isLetter()) vI++
val vWord = inText.substring(vStart, vI)
if (vWord == "true" || vWord == "false" || vWord == "null") {
pushStyle(SpanStyle(color = inP.keyword))
append(vWord)
pop()
} else {
append(vWord)
}
}
vC in "{}[],:" -> {
pushStyle(SpanStyle(color = inP.punct))
append(vC)
pop()
vI++
}
else -> { append(vC); vI++ }
}
}
}
// ==================
// MARK: XML / HTML tokeniser
// ==================
/* Tokeniser for XML / HTML. Distinguishes tag names, attribute names,
attribute values (string), and comments. Inside tags, attr=value pairs
are recognised; outside tags text is left in the default colour. */
private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
val vN = inText.length
var vI = 0
while (vI < vN) {
val vC = inText[vI]
when {
// Comment block: <!-- ... -->
vC == '<' && inText.startsWith("<!--", vI) -> {
val vEnd = inText.indexOf("-->", vI + 4).let { if (it < 0) vN else it + 3 }
pushStyle(SpanStyle(color = inP.comment))
append(inText.substring(vI, vEnd))
pop()
vI = vEnd
}
vC == '<' -> {
// Tag open: <[/]name attr=val attr2="val2">
val vGt = inText.indexOf('>', vI).let { if (it < 0) vN else it + 1 }
pushStyle(SpanStyle(color = inP.punct)); append('<'); pop()
var vJ = vI + 1
if (vJ < vN && inText[vJ] == '/') { pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++ }
// Tag name.
val vTagStart = vJ
while (vJ < vN && !inText[vJ].isWhitespace() && inText[vJ] != '>' && inText[vJ] != '/') vJ++
if (vJ > vTagStart) {
pushStyle(SpanStyle(color = inP.tag))
append(inText.substring(vTagStart, vJ))
pop()
}
// Attributes — name="value" pairs.
while (vJ < vGt - 1) {
val vC2 = inText[vJ]
when {
vC2.isWhitespace() -> { append(vC2); vJ++ }
vC2 == '/' -> { pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++ }
vC2 == '=' -> { pushStyle(SpanStyle(color = inP.punct)); append('='); pop(); vJ++ }
vC2 == '"' || vC2 == '\'' -> {
val vEnd = inText.indexOf(vC2, vJ + 1).let { if (it < 0) vGt - 1 else it + 1 }
pushStyle(SpanStyle(color = inP.string))
append(inText.substring(vJ, vEnd))
pop()
vJ = vEnd
}
else -> {
val vAttrStart = vJ
while (vJ < vGt - 1 && !inText[vJ].isWhitespace() && inText[vJ] != '=' && inText[vJ] != '>' && inText[vJ] != '/') vJ++
pushStyle(SpanStyle(color = inP.key))
append(inText.substring(vAttrStart, vJ))
pop()
}
}
}
if (vGt > vI && inText.getOrNull(vGt - 1) == '>') {
pushStyle(SpanStyle(color = inP.punct)); append('>'); pop()
}
vI = vGt
}
else -> { append(vC); vI++ }
}
}
}
// ==================
// MARK: YAML tokeniser — line-based
// ==================
/* Tokeniser for YAML. Walks line by line because YAML's grammar is
indent-sensitive and most of the interesting colour decisions
(key vs value, list item vs scalar) depend on position within a
line. Comments (#), keys (text before ':'), quoted strings, numbers
and booleans are coloured; everything else stays default. */
private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
val vLines = inText.split('\n')
for ((vIdx, vLineRaw) in vLines.withIndex()) {
val vLine = vLineRaw
// Comment: rest of the line after a # (not inside a quoted string).
val vCommentAt = findUnquotedHash(vLine)
val vCodePart = if (vCommentAt >= 0) vLine.substring(0, vCommentAt) else vLine
val vCommentPart = if (vCommentAt >= 0) vLine.substring(vCommentAt) else ""
// Find a key: indent + (optional "- ") + key + ':' + (space|eol).
val vColon = indexOfKeyColon(vCodePart)
if (vColon > 0) {
val vPrefix = vCodePart.substring(0, vColon)
val vRest = vCodePart.substring(vColon + 1)
// Lead whitespace + optional list marker, then key.
var vK = 0
while (vK < vPrefix.length && vPrefix[vK].isWhitespace()) { append(vPrefix[vK]); vK++ }
if (vK + 1 < vPrefix.length && vPrefix[vK] == '-' && vPrefix[vK + 1].isWhitespace()) {
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
append(vPrefix[vK + 1])
vK += 2
}
pushStyle(SpanStyle(color = inP.key))
append(vPrefix.substring(vK))
pop()
pushStyle(SpanStyle(color = inP.punct)); append(':'); pop()
appendYamlValue(vRest, inP)
} else {
appendYamlValue(vCodePart, inP)
}
if (vCommentPart.isNotEmpty()) {
pushStyle(SpanStyle(color = inP.comment))
append(vCommentPart)
pop()
}
if (vIdx < vLines.size - 1) append('\n')
}
}
/* Render the value portion of a YAML line — applies string / number /
keyword / list-marker colours where applicable. */
private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPalette) {
val vTrimmed = inSeg.trimStart()
val vPad = inSeg.substring(0, inSeg.length - vTrimmed.length)
append(vPad)
when {
vTrimmed.isEmpty() -> { /* nothing */ }
vTrimmed.startsWith('-') && (vTrimmed.length == 1 || vTrimmed[1].isWhitespace()) -> {
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
appendYamlValue(vTrimmed.substring(1), inP)
}
vTrimmed.startsWith('"') || vTrimmed.startsWith('\'') -> {
val vQ = vTrimmed[0]
val vEnd = vTrimmed.indexOf(vQ, 1).let { if (it < 0) vTrimmed.length else it + 1 }
pushStyle(SpanStyle(color = inP.string))
append(vTrimmed.substring(0, vEnd))
pop()
append(vTrimmed.substring(vEnd))
}
vTrimmed == "true" || vTrimmed == "false" || vTrimmed == "null" || vTrimmed == "~" -> {
pushStyle(SpanStyle(color = inP.keyword)); append(vTrimmed); pop()
}
vTrimmed.first().let { it.isDigit() || it == '-' && vTrimmed.length > 1 && vTrimmed[1].isDigit() } -> {
var vJ = 0
if (vTrimmed[0] == '-') vJ++
while (vJ < vTrimmed.length && (vTrimmed[vJ].isDigit() || vTrimmed[vJ] in ".eE+-")) vJ++
pushStyle(SpanStyle(color = inP.number))
append(vTrimmed.substring(0, vJ))
pop()
append(vTrimmed.substring(vJ))
}
else -> append(vTrimmed)
}
}
/* Find the index of the first '#' that isn't inside a quoted string,
or -1 if there is none. Used to split a YAML line into code + comment. */
private fun findUnquotedHash(inLine: String): Int {
var vInSingle = false
var vInDouble = false
for (vI in inLine.indices) {
val vC = inLine[vI]
when {
vC == '"' && !vInSingle -> vInDouble = !vInDouble
vC == '\'' && !vInDouble -> vInSingle = !vInSingle
vC == '#' && !vInSingle && !vInDouble -> return vI
}
}
return -1
}
/* Locate the colon that separates a YAML key from its value at the
current indent level — the FIRST unquoted ':' that's followed by
either whitespace or end-of-line. Returns -1 if the line has no key. */
private fun indexOfKeyColon(inLine: String): Int {
var vInSingle = false
var vInDouble = false
for (vI in inLine.indices) {
val vC = inLine[vI]
when {
vC == '"' && !vInSingle -> vInDouble = !vInDouble
vC == '\'' && !vInDouble -> vInSingle = !vInSingle
vC == ':' && !vInSingle && !vInDouble -> {
val vNext = if (vI + 1 < inLine.length) inLine[vI + 1] else ' '
if (vNext.isWhitespace() || vI + 1 == inLine.length) return vI
}
}
}
return -1
}
-25
View File
@@ -1,25 +0,0 @@
package apidemo
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// ==================
// MARK: Theme — switchable dark / light palette
// ==================
class AppColors(
val bg: Color, val panel: Color, val field: Color, val border: Color,
val accent: Color, val text: Color, val dim: Color, val onAccent: Color,
)
internal val DarkColors = AppColors(
bg = Color(0xFF15161A), panel = Color(0xFF23252C), field = Color(0xFF2D2F37),
border = Color(0xFF474C57), accent = Color(0xFF9F88FF), text = Color(0xFFECEEF2),
dim = Color(0xFFAEB4BD), onAccent = Color(0xFFFFFFFF),
)
internal val LightColors = AppColors(
bg = Color(0xFFF3F4F7), panel = Color(0xFFFFFFFF), field = Color(0xFFFFFFFF),
border = Color(0xFFCED3DB), accent = Color(0xFF6B4BE6), text = Color(0xFF1B1D22),
dim = Color(0xFF5C636E), onAccent = Color(0xFFFFFFFF),
)
internal val LocalAppColors = staticCompositionLocalOf { DarkColors }
@@ -0,0 +1,103 @@
package apidemo.compat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.platform.ClipEntry
import com.compose.sdl.LocalComposeNativeWindow
import com.compose.sdl.TextLayoutConfig
import com.compose.sdl.appDataDir
import com.compose.sdl.fileManagerName
import com.compose.sdl.registerMemoryResource
import com.compose.sdl.removeMemoryResource
import com.compose.sdl.res.ResourceKind
import com.compose.sdl.res.painterResource
import com.compose.sdl.revealInFileManager
import com.compose.sdl.showOpenFileDialog
import com.compose.sdl.showSaveFileDialog
import com.compose.sdl.text.namedFontFamily
import androidx.compose.ui.text.Paragraph
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.createFontFamilyResolver
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import io.ktor.client.*
import io.ktor.client.engine.curl.*
import okio.FileSystem
// ==================
// MARK: Native actuals - pure delegation to the port's SDL-backed APIs
// ==================
actual fun appDataDir(inOrg: String, inApp: String): String? =
appDataDir(inOrg, inApp)
actual fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)?) =
revealInFileManager(inPath, inOnResult)
actual fun fileManagerName(): String = fileManagerName()
actual fun showSaveFileDialog(inDefaultName: String?, inOnResult: (String?) -> Unit) =
showSaveFileDialog(inDefaultName, inOnResult)
actual fun showOpenFileDialog(inOnResult: (String?) -> Unit) =
showOpenFileDialog(inOnResult)
actual fun clipEntryOfText(inText: String): ClipEntry = ClipEntry.withPlainText(inText)
actual fun registerMemoryImage(inKey: String, inBytes: ByteArray) = registerMemoryResource(inKey, inBytes)
actual fun removeMemoryImage(inKey: String) = removeMemoryResource(inKey)
@Composable
actual fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter =
painterResource(inKey, if (inSvg) ResourceKind.Svg else ResourceKind.Raster)
// Cached resolver for wrappedRowCount - the skiko engine resolves fonts by name
// (projectFontName), so this is just the required non-null Paragraph argument.
private val mRowCountFontResolver = createFontFamilyResolver()
actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int {
if (inText.isEmpty()) return 1
// inFontPx is already physical pixels, so measure at density 1 (fontSize.value
// * density = inFontPx) and wrap at inMaxWidthPx px by building a Paragraph.
// lineCount comes from skiko's paragraph layout.
val vStyle = TextStyle(
fontSize = inFontPx.sp,
fontFamily = inFamilyName?.let { namedFontFamily(it) },
)
return Paragraph(
text = inText,
style = vStyle,
constraints = Constraints(maxWidth = inMaxWidthPx.coerceAtLeast(0)),
density = Density(1f),
fontFamilyResolver = mRowCountFontResolver,
spanStyles = emptyList(),
placeholders = emptyList(),
maxLines = Int.MAX_VALUE,
ellipsis = false,
).lineCount
}
actual var editorTabWidth: Int
get() = TextLayoutConfig.tabWidth
set(value) {
TextLayoutConfig.tabWidth = value
}
internal actual val systemFileSystem: FileSystem = FileSystem.SYSTEM
actual fun createApiHttpClient(): HttpClient =
HttpClient(Curl)
@Composable
actual fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean) {
val vWindow = LocalComposeNativeWindow.current
DisposableEffect(inOnCloseRequest, inOnKeyShortcut) {
vWindow.setOnCloseRequest(inOnCloseRequest)
vWindow.setOnKeyShortcut(inOnKeyShortcut)
onDispose { vWindow.setOnCloseRequest(null); vWindow.setOnKeyShortcut(null) }
}
}
@@ -0,0 +1,83 @@
package utils
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.refTo
import platform.posix.fclose
import platform.posix.fopen
import platform.posix.fwrite
// ==================
// MARK: BMP save
// ==================
// Copy of demo/src/nativeMain/kotlin/utils/Bmp.kt. Kept duplicated rather than
// promoted into a library module: it is app-level tooling for the drift-check
// screenshots, and :ui-graphics deliberately does not re-export skiko to apps,
// so there is no natural shared home that would not widen the published API.
// ==================
@OptIn(ExperimentalForeignApi::class)
internal fun writeFile(inPath: String, inBytes: ByteArray) {
val vFile = fopen(inPath, "wb") ?: run {
println("fopen failed for $inPath")
return
}
fwrite(inBytes.refTo(0), 1u, inBytes.size.toULong(), vFile)
fclose(vFile)
}
/** Minimal BMP writer: 24-bit BGR (drop alpha) with the classic 40-byte
BITMAPINFOHEADER. Universally readable, including `sips`. Rows are
bottom-up (positive height) and padded to 4 bytes. */
internal fun encodeBmpBgra32(inWidth: Int, inHeight: Int, inBgra: ByteArray): ByteArray {
val kFileHeader = 14
val kInfoHeader = 40
val vRowBytes = inWidth * 3
val vRowPad = (4 - vRowBytes % 4) % 4
val vStride = vRowBytes + vRowPad
val vPixelBytes = vStride * inHeight
val vTotal = kFileHeader + kInfoHeader + vPixelBytes
val vOut = ByteArray(vTotal)
fun putU16LE(off: Int, v: Int) {
vOut[off] = (v and 0xFF).toByte()
vOut[off + 1] = ((v ushr 8) and 0xFF).toByte()
}
fun putU32LE(off: Int, v: Int) {
vOut[off] = (v and 0xFF).toByte()
vOut[off + 1] = ((v ushr 8) and 0xFF).toByte()
vOut[off + 2] = ((v ushr 16) and 0xFF).toByte()
vOut[off + 3] = ((v ushr 24) and 0xFF).toByte()
}
// BITMAPFILEHEADER
vOut[0] = 'B'.code.toByte(); vOut[1] = 'M'.code.toByte()
putU32LE(2, vTotal)
putU32LE(10, kFileHeader + kInfoHeader)
// BITMAPINFOHEADER
val info = kFileHeader
putU32LE(info + 0, kInfoHeader)
putU32LE(info + 4, inWidth)
putU32LE(info + 8, inHeight) // positive → bottom-up
putU16LE(info + 12, 1)
putU16LE(info + 14, 24)
putU32LE(info + 16, 0) // BI_RGB
putU32LE(info + 20, vPixelBytes)
putU32LE(info + 24, 2835)
putU32LE(info + 28, 2835)
putU32LE(info + 32, 0)
putU32LE(info + 36, 0)
// Source is top-down BGRA; convert to bottom-up 24-bit BGR.
val vPixelOffset = kFileHeader + kInfoHeader
for (y in 0 until inHeight) {
val vSrcRow = (inHeight - 1 - y) * inWidth * 4
val vDstRow = vPixelOffset + y * vStride
for (x in 0 until inWidth) {
vOut[vDstRow + x * 3 + 0] = inBgra[vSrcRow + x * 4 + 0] // B
vOut[vDstRow + x * 3 + 1] = inBgra[vSrcRow + x * 4 + 1] // G
vOut[vDstRow + x * 3 + 2] = inBgra[vSrcRow + x * 4 + 2] // R
}
}
return vOut
}
+178 -38
View File
@@ -1,43 +1,92 @@
// Root build — declare the Kotlin/Compose plugins once (apply false) so every
// subproject shares a single plugin classloader. Applying them independently
// per-subproject can load Kotlin Gradle plugin build services under isolated
// classloaders, which fails with "X cannot be cast to X" once several modules
// apply the multiplatform plugin. Each subproject still applies the ones it needs.
import kotlinx.validation.ExperimentalBCVApi
plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.plugin.compose) apply false
alias(libs.plugins.kotlin.plugin.serialization) apply false
alias(libs.plugins.compose.multiplatform) apply false
// Public-ABI dumps (api/<module>.klib.api). Used by scripts/compose-coverage.py
// to diff our mirrored API surface against the official upstream klib ABI dumps.
alias(libs.plugins.binary.compatibility.validator)
}
// Generate klib ABI dumps; the apps and icon-font modules carry no public API
// surface worth tracking, so skip them.
apiValidation {
@OptIn(ExperimentalBCVApi::class)
klib { enabled = true }
ignoredProjects.addAll(listOf("demo", "apidemo"))
// demo/apidemo: apps, no published API. sdl-core: naked SDL cinterop - its
// "API" is the generated sdl3.* bindings (per-OS, can't infer macos), not our
// surface. material-symbols: auto-generated icon codepoint maps + its jvm
// dump trips BCV's ASM on newer JDKs (class file major 69). Track the
// hand-authored Compose surface, not generated/binding code.
ignoredProjects.addAll(listOf("demo", "apidemo", "sdl-core", "material-symbols"))
}
// Project coordinates. `group` is baked into every module's Kotlin/Native klib
// metadata as its module ID (e.g. `com.bitsycore.compose.sdl:ui`) — keep it
// stable, or a stale IC cache surfaces as "Unknown dependent library …" (see
// the pitfall note in CLAUDE.md).
//
// Version is driven by PUBLISH_VERSION (set from the git tag in the publish
// workflow, `v1.2.3` → `1.2.3`). Local dev / demo runs default to SNAPSHOT.
val vPublishVersion = (System.getenv("PUBLISH_VERSION") ?: "0.0.0-SNAPSHOT").removePrefix("v")
// Published groups mirror the org.jetbrains.compose.<area> originals under the
// com.bitsycore fork name, so each module is the obvious 1:1 fork of its upstream
// coord (e.g. org.jetbrains.compose.ui:ui → com.bitsycore.compose.ui:ui). Modules
// with no upstream area (project-only: :sdl-core, :material-symbols) fall through
// to the default com.bitsycore.compose.sdl group; :desktop-native-window is the
// headline app-shell artifact, published under com.bitsycore.compose.
val kDefaultGroup = "com.bitsycore.compose.sdl"
val kAreaGroups = mapOf(
":compose:ui:ui" to "com.bitsycore.compose.ui",
":compose:ui:ui-graphics" to "com.bitsycore.compose.ui",
":compose:ui:ui-text" to "com.bitsycore.compose.ui",
":compose:ui:ui-unit" to "com.bitsycore.compose.ui",
":compose:ui:ui-geometry" to "com.bitsycore.compose.ui",
":compose:ui:ui-util" to "com.bitsycore.compose.ui",
":compose:ui:ui-backhandler" to "com.bitsycore.compose.ui",
":compose:ui:ui-tooling-preview" to "com.bitsycore.compose.ui",
":compose:foundation:foundation" to "com.bitsycore.compose.foundation",
":compose:foundation:foundation-layout" to "com.bitsycore.compose.foundation",
":compose:animation:animation" to "com.bitsycore.compose.animation",
":compose:animation:animation-core" to "com.bitsycore.compose.animation",
":compose:animation:animation-graphics" to "com.bitsycore.compose.animation",
":compose:material3:material3" to "com.bitsycore.compose.material3",
":compose:material3:adaptive:adaptive" to "com.bitsycore.compose.material3.adaptive",
":compose:material3:adaptive:adaptive-layout" to "com.bitsycore.compose.material3.adaptive",
":compose:material3:adaptive:adaptive-navigation" to "com.bitsycore.compose.material3.adaptive",
":compose:material3:adaptive:adaptive-navigation3" to "com.bitsycore.compose.material3.adaptive",
":compose:material:material-ripple" to "com.bitsycore.compose.material",
":components:resources:components-resources" to "com.bitsycore.compose.components",
":navigation3:navigation3-ui" to "com.bitsycore.navigation3",
":koin:koin-core-viewmodel" to "com.bitsycore.koin",
":koin:koin-compose" to "com.bitsycore.koin",
":koin:koin-compose-viewmodel" to "com.bitsycore.koin",
":koin:koin-compose-navigation3" to "com.bitsycore.koin",
":coil:coil-core" to "com.bitsycore.coil3",
":coil:coil" to "com.bitsycore.coil3",
":coil:coil-compose-core" to "com.bitsycore.coil3",
":coil:coil-compose" to "com.bitsycore.coil3",
":coil:coil-svg" to "com.bitsycore.coil3",
":coil:coil-network-core" to "com.bitsycore.coil3",
":coil:coil-network-ktor3" to "com.bitsycore.coil3",
":pulse:pulse" to "com.bitsycore.compose.desktop.native.pulse",
":pulse:pulse-viewmodel" to "com.bitsycore.compose.desktop.native.pulse",
":pulse:pulse-savedstate" to "com.bitsycore.compose.desktop.native.pulse",
":pulse:pulse-compose" to "com.bitsycore.compose.desktop.native.pulse",
":compose:desktop:native:desktop-native-window" to "com.bitsycore.compose",
)
fun groupFor(path: String): String = kAreaGroups[path] ?: kDefaultGroup
// The published artifactId is always the leaf project name - settings.gradle.kts
// keeps the leaf equal to the artifactId even where the directory differs.
fun artifactIdFor(path: String): String = path.substringAfterLast(':')
allprojects {
group = "com.bitsycore.compose.sdl"
group = groupFor(path)
version = vPublishVersion
}
// ==================
// MARK: Publish to GitHub Packages
// ==================
// Every library module (everything except the two demo apps) auto-registers a
// MavenPublication via the kotlin-multiplatform plugin — one per target + one
// MavenPublication via the kotlin-multiplatform plugin - one per target + one
// for the shared kotlinMultiplatform metadata. The CI publish workflow runs on
// three hosts (macOS / Linux / Windows) and each invokes only the publication
// tasks Gradle actually generated for its own targets, so the group of hosts
@@ -46,37 +95,73 @@ allprojects {
val kAppModules = setOf(":demo", ":apidemo")
val kPublishedLibs = setOf(
":ui", ":ui-util", ":ui-geometry", ":ui-unit", ":ui-backhandler",
":animation-core", ":animation", ":animation-graphics",
":foundation", ":foundation-layout",
":material3", ":material-ripple",
":window", ":material-symbols",
":navigation3-ui", ":components-resources",
":sdl:sdl-core",
":compose:ui:ui", ":compose:ui:ui-util", ":compose:ui:ui-geometry",
":compose:ui:ui-graphics", ":compose:ui:ui-text",
":compose:ui:ui-unit", ":compose:ui:ui-backhandler", ":compose:ui:ui-tooling-preview",
":compose:animation:animation-core", ":compose:animation:animation",
":compose:animation:animation-graphics",
":compose:foundation:foundation", ":compose:foundation:foundation-layout",
":compose:material3:material3", ":compose:material:material-ripple",
":compose:material3:adaptive:adaptive", ":compose:material3:adaptive:adaptive-layout", ":compose:material3:adaptive:adaptive-navigation", ":compose:material3:adaptive:adaptive-navigation3",
":compose:desktop:native:desktop-native-window", ":utils:material-symbols",
":navigation3:navigation3-ui", ":components:resources:components-resources",
":koin:koin-core-viewmodel", ":koin:koin-compose",
":koin:koin-compose-viewmodel", ":koin:koin-compose-navigation3",
":coil:coil-core", ":coil:coil", ":coil:coil-compose-core", ":coil:coil-compose",
":coil:coil-svg", ":coil:coil-network-core", ":coil:coil-network-ktor3",
":pulse:pulse", ":pulse:pulse-viewmodel", ":pulse:pulse-savedstate", ":pulse:pulse-compose",
)
// -PuseGithubPackages=true swaps every `project(":<lib>")` reference the demo
// apps make for the published Maven coordinate. Library modules keep resolving
// each other as `project(...)` — the substitution only fires at the
// each other as `project(...)` - the substitution only fires at the
// app→library boundary, so the swap validates end-to-end consumption of the
// published klibs without touching the source of `implementation(project(...))`.
// Version defaults to 0.1.0 (matches the git tag) but can be pinned via -PconsumeVersion=….
val kUseGhPackages = (findProperty("useGithubPackages") as? String)?.toBoolean() == true
val kConsumeVersion = (findProperty("consumeVersion") as? String) ?: "0.1.0"
// JVM-PARITY VERSION PIN (app modules): force the org.jetbrains.compose groups
// on every jvm configuration to the catalog pin matching COMPOSE_CORE_REF.
// Gradle orders "+dev" BELOW the plain version, so whenever the pin is a dev
// build the umbrella plugin's published artifacts would win conflict resolution
// and silently break byte-exact parity with the vendored sources.
val kComposeJvmForced = mapOf(
"org.jetbrains.compose.runtime" to libs.versions.compose.get(),
"org.jetbrains.compose.ui" to libs.versions.compose.get(),
"org.jetbrains.compose.foundation" to libs.versions.compose.get(),
"org.jetbrains.compose.animation" to libs.versions.compose.get(),
"org.jetbrains.compose.material" to libs.versions.compose.get(),
"org.jetbrains.compose.material3" to libs.versions.composeMaterial3.get(),
)
subprojects {
if (kUseGhPackages && path in kAppModules) {
configurations.configureEach {
resolutionStrategy.dependencySubstitution {
kPublishedLibs.forEach { modulePath ->
val vArtifactId = modulePath.removePrefix(":")
val vArtifactId = artifactIdFor(modulePath)
substitute(project(modulePath))
.using(module("com.bitsycore.compose.sdl:$vArtifactId:$kConsumeVersion"))
.using(module("${groupFor(modulePath)}:$vArtifactId:$kConsumeVersion"))
.because("-PuseGithubPackages=true")
}
}
}
}
if (path in kAppModules) return@subprojects
if (path in kAppModules) {
configurations.configureEach {
if (name.startsWith("jvm")) {
resolutionStrategy.eachDependency {
kComposeJvmForced[requested.group]?.let { useVersion(it) }
}
}
}
return@subprojects
}
// Nesting the paths under compose/ / sdl/ / utils/ … materialises container
// projects (:compose, :compose:ui, …) that hold no code - skip them.
if (path !in kPublishedLibs) return@subprojects
plugins.apply("maven-publish")
afterEvaluate {
extensions.configure<PublishingExtension> {
@@ -90,11 +175,19 @@ 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 {
name.set("${rootProject.name} ${project.name}")
description.set("Compose Multiplatform on SDL3 (Kotlin/Native, no JVM) — ${project.name}")
description.set("Compose Multiplatform on SDL3 (Kotlin/Native, no JVM) - ${project.name}")
url.set("https://github.com/${System.getenv("GITHUB_REPOSITORY") ?: "bitsycore/ComposeDesktopNative"}")
licenses {
license {
@@ -111,29 +204,76 @@ subprojects {
// FULL-COMMONIZATION BRIDGE (repo-wide): a module may declare the OFFICIAL
// Maven Compose artifacts in its commonMain so metadata + jvm resolve them
// (e.g. :material-symbols' common API); every NATIVE target configuration
// swaps those modules for the port's project equivalents — the Maven
// swaps those modules for the port's project equivalents - the Maven
// artifacts ship no mingwX64/linux klibs. org.jetbrains.compose.runtime is
// deliberately NOT here: the port uses the official runtime klibs everywhere.
// (:demo carries the same pattern locally for components-resources.)
val vNativeTargetTokens = listOf("mingwX64", "linuxX64", "linuxArm64", "macosArm64")
allprojects {
configurations.configureEach {
if (vNativeTargetTokens.any { name.contains(it, ignoreCase = true) }) {
resolutionStrategy.dependencySubstitution {
substitute(module("org.jetbrains.compose.ui:ui")).using(project(":ui"))
substitute(module("org.jetbrains.compose.foundation:foundation")).using(project(":foundation"))
substitute(module("org.jetbrains.compose.material3:material3")).using(project(":material3"))
substitute(module("org.jetbrains.compose.ui:ui")).using(project(":compose:ui:ui"))
// ui-graphics / ui-text are their own modules (split out of :ui,
// upstream layout). Declare each DIRECTLY so the app commonMain sees
// them under the granular-metadata visibility rule.
substitute(module("org.jetbrains.compose.ui:ui-graphics")).using(project(":compose:ui:ui-graphics"))
substitute(module("org.jetbrains.compose.ui:ui-text")).using(project(":compose:ui:ui-text"))
substitute(module("org.jetbrains.compose.ui:ui-unit")).using(project(":compose:ui:ui-unit"))
substitute(module("org.jetbrains.compose.ui:ui-geometry")).using(project(":compose:ui:ui-geometry"))
substitute(module("org.jetbrains.compose.ui:ui-util")).using(project(":compose:ui:ui-util"))
substitute(module("org.jetbrains.compose.ui:ui-tooling-preview")).using(project(":compose:ui:ui-tooling-preview"))
substitute(module("org.jetbrains.compose.foundation:foundation")).using(project(":compose:foundation:foundation"))
substitute(module("org.jetbrains.compose.foundation:foundation-layout")).using(project(":compose:foundation:foundation-layout"))
substitute(module("org.jetbrains.compose.animation:animation")).using(project(":compose:animation:animation"))
substitute(module("org.jetbrains.compose.animation:animation-core")).using(project(":compose:animation:animation-core"))
substitute(module("org.jetbrains.compose.material3:material3")).using(project(":compose:material3:material3"))
// material3-adaptive: upstream publishes ios + macosArm64 only - no
// linux, no mingw (issues/4). Vendored as :compose:material3:adaptive:*.
substitute(module("org.jetbrains.compose.material3.adaptive:adaptive")).using(project(":compose:material3:adaptive:adaptive"))
substitute(module("org.jetbrains.compose.material3.adaptive:adaptive-layout")).using(project(":compose:material3:adaptive:adaptive-layout"))
substitute(module("org.jetbrains.compose.material3.adaptive:adaptive-navigation")).using(project(":compose:material3:adaptive:adaptive-navigation"))
substitute(module("org.jetbrains.compose.material3.adaptive:adaptive-navigation3")).using(project(":compose:material3:adaptive:adaptive-navigation3"))
// navigation3-ui: the JB Maven artifact has no K/N desktop
// klibs - the port vendors it as :navigation3-ui.
substitute(module("org.jetbrains.androidx.navigation3:navigation3-ui")).using(project(":navigation3:navigation3-ui"))
// components-resources: the official resources runtime ships no
// mingwX64/linux klibs - the port vendors it as :components-resources.
substitute(module("org.jetbrains.compose.components:components-resources")).using(project(":components:resources:components-resources"))
// Koin: koin-core itself publishes mingwX64 + linux and is used
// from Maven; these four stop at apple+android upstream.
substitute(module("io.insert-koin:koin-core-viewmodel")).using(project(":koin:koin-core-viewmodel"))
substitute(module("io.insert-koin:koin-compose")).using(project(":koin:koin-compose"))
substitute(module("io.insert-koin:koin-compose-viewmodel")).using(project(":koin:koin-compose-viewmodel"))
substitute(module("io.insert-koin:koin-compose-navigation3")).using(project(":koin:koin-compose-navigation3"))
// Coil 3: no mingwX64 upstream anywhere, and no linux for the
// compose layer either.
substitute(module("io.coil-kt.coil3:coil-core")).using(project(":coil:coil-core"))
substitute(module("io.coil-kt.coil3:coil")).using(project(":coil:coil"))
substitute(module("io.coil-kt.coil3:coil-compose-core")).using(project(":coil:coil-compose-core"))
substitute(module("io.coil-kt.coil3:coil-compose")).using(project(":coil:coil-compose"))
substitute(module("io.coil-kt.coil3:coil-svg")).using(project(":coil:coil-svg"))
substitute(module("io.coil-kt.coil3:coil-network-core")).using(project(":coil:coil-network-core"))
substitute(module("io.coil-kt.coil3:coil-network-ktor3")).using(project(":coil:coil-network-ktor3"))
// Pulse MVI: upstream publishes com.bitsycore.lib:* with no desktop
// native targets. Pulse is itself a com.bitsycore library, so the port's
// republish is namespaced under THIS project rather than a bare
// com.bitsycore.pulse, which would read like the upstream artifact. The
// two coordinates never collide, and substitutes the official one here.
substitute(module("com.bitsycore.lib:pulse")).using(project(":pulse:pulse"))
substitute(module("com.bitsycore.lib:pulse-viewmodel")).using(project(":pulse:pulse-viewmodel"))
substitute(module("com.bitsycore.lib:pulse-savedstate")).using(project(":pulse:pulse-savedstate"))
substitute(module("com.bitsycore.lib:pulse-compose")).using(project(":pulse:pulse-compose"))
}
}
}
}
// Whether the current host can build the mingwX64 target. Kotlin/Native can
// only cross-compile mingwX64 cinterops from a Windows host — the sdl3_ttf /
// sdl3_image cinterops need Windows SDL3 headers under libs/ (produced by
// scripts/build-sdl/build-all.py run on a Windows host). Declaring `mingwX64()` on
// a non-Windows host is safe for pure-Kotlin modules but blows up the moment
// a `depends = sdl3` cinterop tries to include SDL3_ttf/SDL_ttf.h.
// only cross-compile the mingwX64 sdl3 cinterop from a Windows host - it needs
// the Windows SDL3 headers under libs/ (produced by scripts/build-sdl/build-all.py
// run on a Windows host). Declaring `mingwX64()` on a non-Windows host is safe
// for pure-Kotlin modules but blows up the moment the sdl3 cinterop tries to
// include SDL3's headers.
// Override with `-PforceMingw=true` if you actually have the headers wired.
val vHostSupportsMingw = System.getProperty("os.name").startsWith("Windows") ||
(findProperty("forceMingw") as? String)?.toBoolean() == true
+7
View File
@@ -0,0 +1,7 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
@@ -0,0 +1,349 @@
package com.bitsycore.compose.sdl.build
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.file.Directory
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.AbstractCopyTask
import org.gradle.api.tasks.Exec
import org.gradle.api.tasks.TaskProvider
import org.gradle.api.tasks.bundling.Zip
import org.gradle.kotlin.dsl.extra
import org.gradle.kotlin.dsl.register
import org.gradle.kotlin.dsl.withType
import org.gradle.language.jvm.tasks.ProcessResources
import java.net.URI
// ==================
// MARK: Configuration
// ==================
/**
* Per-app knobs for [registerComposeFontBundling]. EVERYTHING is opt-in - with no
* flag set the call registers no tasks and stages nothing.
*/
class ComposeFontBundlingConfig {
/**
* Bundle font/NotoSans.ttf, the default UI font the text renderers load at startup.
* -PbundleDefaultFont=false still skips it (system-font fallback).
*/
var bundleNotoSans: Boolean = false
/**
* Always bundle font/NotoSansMono.ttf - for apps that load the mono font through
* their own seam (apidemo's body font, Fonts.kt), invisible to auto-detection.
*/
var bundleNotoSansMono: Boolean = false
/**
* Bundle font/NotoSansMono.ttf only when `FontFamily.Monospace` appears in the
* app's Kotlin sources (it backs the generic monospace family at runtime).
*/
var autoDetectNotoSansMono: Boolean = false
/**
* Bundle one Material Symbols font per style whose call sites appear in the app's
* Kotlin sources (MaterialSymbolsOutlined / Rounded / Sharp).
*/
var bundleMaterialSymbols: Boolean = false
/**
* 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
}
// A style's font is bundled only when its call sites appear in the sources.
private val kAllStyles = listOf(
"Outlined" to Regex("\\bMaterialSymbolsOutlined\\b"),
"Rounded" to Regex("\\bMaterialSymbolsRounded\\b"),
"Sharp" to Regex("\\bMaterialSymbolsSharp\\b"),
)
// FontFamily.Monospace call sites → NotoSansMono is worth bundling.
private val kMonospaceRegex = Regex("\\bFontFamily\\.Monospace\\b")
// ==================
// MARK: Entry point
// ==================
/**
* Wires the app font pipeline shared by :demo and :apidemo. Everything is OPT-IN via
* [ComposeFontBundlingConfig] - a bare call is a no-op.
*
* - Registers downloadNotoFonts (Noto Sans + Noto Sans Mono variable fonts →
* build/fonts/) when a Noto font is bundled.
* - Adds the opted-in font/ entries to every data.kres Zip task - the bridge plugin's
* package<Variant>ComposeResources<Target> tasks or an app's own copy* tasks
* (matching{} is lazy: the plugin's tasks appear in its afterEvaluate). Material
* Symbols fonts are hb-subset to the used glyphs when subsetting is enabled.
* - Stages the same fonts (always full, never subset) onto the JVM classpath via
* jvmProcessResources: the :material-symbols JVM actual loads font/<Style>.ttf, and
* the parity harness's JVM leg renders with the same NotoSans as the native leg
* (P0.3, RENDERER.md §8).
*/
fun Project.registerComposeFontBundling(configure: ComposeFontBundlingConfig.() -> Unit) {
val vConfig = ComposeFontBundlingConfig().apply(configure)
// ============
// What to bundle
// ============
val vBundleSans = vConfig.bundleNotoSans &&
providers.gradleProperty("bundleDefaultFont").map(String::toBoolean).getOrElse(true)
val vBundleMono = vConfig.bundleNotoSansMono ||
(vConfig.autoDetectNotoSansMono && sourcesMatch(kMonospaceRegex))
val vUsedStyles = if (vConfig.bundleMaterialSymbols) detectUsedStyles() else emptyList()
if (!vBundleSans && !vBundleMono && vUsedStyles.isEmpty()) return
val vSubsetIcons = vConfig.enableIconSubsetting && vUsedStyles.isNotEmpty() &&
providers.gradleProperty("subsetIcons").map(String::toBoolean).getOrElse(false)
// One subset task per used style, shared by all variant×target archives.
val vSubsetTasksByStyle = if (vSubsetIcons) registerIconSubsetPipeline(vUsedStyles) else emptyMap()
// ============
// Noto downloads
// ============
val vNotoSansFile = layout.buildDirectory.file("fonts/NotoSans.ttf")
val vNotoMonoFile = layout.buildDirectory.file("fonts/NotoSansMono.ttf")
val vNotoFiles = buildList {
if (vBundleSans) add(vNotoSansFile)
if (vBundleMono) add(vNotoMonoFile)
}
val vDownloadNotoFonts = if (vNotoFiles.isEmpty()) null else tasks.register("downloadNotoFonts") {
description = "Download the Google Noto variable fonts (Sans + SansMono) to build/fonts/."
val vDownloads = listOf(
"https://raw.githubusercontent.com/google/fonts/main/ofl/notosans/NotoSans%5Bwdth%2Cwght%5D.ttf"
to vNotoSansFile.get().asFile,
"https://raw.githubusercontent.com/google/fonts/main/ofl/notosansmono/NotoSansMono%5Bwdth%2Cwght%5D.ttf"
to vNotoMonoFile.get().asFile,
)
outputs.files(vDownloads.map { it.second })
doLast {
for ((vUrl, vOut) in vDownloads) {
if (vOut.exists() && vOut.length() > 0) continue
vOut.parentFile.mkdirs()
println("Downloading $vUrl")
URI(vUrl).toURL().openStream().use { vIn -> vOut.outputStream().use { vIn.copyTo(it) } }
}
}
}
// ============
// data.kres font/ entries (native) + JVM classpath analog
// ============
tasks.withType<Zip>().matching {
(it.name.startsWith("package") || it.name.startsWith("copy")) && it.name.contains("ComposeResources")
}.configureEach {
addFontEntries(this, vNotoFiles, vDownloadNotoFonts, vUsedStyles, vSubsetTasksByStyle)
}
tasks.withType<ProcessResources>().matching { it.name == "jvmProcessResources" }.configureEach {
// Full fonts on JVM - Skiko applies the variable axes itself (Typeface.makeClone).
addFontEntries(this, vNotoFiles, vDownloadNotoFonts, vUsedStyles, emptyMap())
}
}
// ==================
// MARK: Source scanning
// ==================
/**
* True when any Kotlin file under src/ matches inRegex.
*/
private fun Project.sourcesMatch(inRegex: Regex): Boolean {
val vSrcRoot = layout.projectDirectory.dir("src").asFile
return vSrcRoot.exists() && vSrcRoot.walk().any {
it.isFile && it.extension == "kt" && inRegex.containsMatchIn(it.readText())
}
}
/**
* The Material Symbols styles whose call sites appear in the app's Kotlin sources.
*/
private fun Project.detectUsedStyles(): List<String> {
val vSrcRoot = layout.projectDirectory.dir("src").asFile
if (!vSrcRoot.exists()) return emptyList()
val vUsed = mutableSetOf<String>()
vSrcRoot.walk().filter { it.isFile && it.extension == "kt" }.forEach { vFile ->
val vText = vFile.readText()
for ((vStyle, vRegex) in kAllStyles) {
if (vStyle !in vUsed && vRegex.containsMatchIn(vText)) vUsed.add(vStyle)
}
}
return vUsed.toList()
}
// ==================
// MARK: Font staging
// ==================
/**
* Adds the font/ entries (Notos + used Material Symbols styles) to inTask - a data.kres
* Zip or jvmProcessResources. inSubsetTasksByStyle maps styles to hb-subset tasks whose
* output replaces the full font; pass an empty map to stage the full fonts.
*/
private fun Project.addFontEntries(
inTask: AbstractCopyTask,
inNotoFiles: List<Provider<RegularFile>>,
inDownloadNotoFonts: TaskProvider<*>?,
inUsedStyles: List<String>,
inSubsetTasksByStyle: Map<String, TaskProvider<*>>,
) {
for (vNotoFile in inNotoFiles) {
inTask.from(vNotoFile) { into("font") }
inDownloadNotoFonts?.let { inTask.dependsOn(it) }
}
val vSymbolsProject = rootProject.project(":utils:material-symbols")
for (vStyle in inUsedStyles) {
@Suppress("UNCHECKED_CAST")
val vFontFile = vSymbolsProject.extra["iconFontFile$vStyle"] as Provider<RegularFile>
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$vStyle"] as TaskProvider<*>
val vSubsetTask = inSubsetTasksByStyle[vStyle]
if (vSubsetTask != null) {
// Subset output keeps the original filename : runtime registration unchanged.
val vOriginalName = vFontFile.get().asFile.name
inTask.from(vSubsetTask.get().outputs.files) {
into("font")
rename { vOriginalName }
}
inTask.dependsOn(vSubsetTask)
} else {
inTask.from(vFontFile) { into("font") }
}
inTask.dependsOn(vDownloadTask)
}
}
// ==================
// MARK: Icon subsetting
// ==================
/**
* Registers the icon-subset pipeline: findMaterialSymbolsUsage scans src/ for
* MaterialSymbols.<Name> usages → usage-codepoint.txt, then one subsetMaterialSymbols<Style>
* task per used style hb-subsets that style's font to the referenced glyphs.
*/
private fun Project.registerIconSubsetPipeline(inUsedStyles: List<String>): Map<String, TaskProvider<*>> {
val vIconsBuildDir = layout.buildDirectory.dir("icons")
val vFindUsage = tasks.register<Exec>("findMaterialSymbolsUsage") {
description = "Scan src/ for MaterialSymbols.<Name> usages → usage-codepoint.txt."
val vScript = rootProject.layout.projectDirectory.file("scripts/subset-material-symbols.py").asFile
val vConstants = rootProject.project(":utils:material-symbols").layout.projectDirectory
.file("src/commonMain/kotlin/com/compose/sdl/icons/MaterialSymbols.kt").asFile
val vUsageFile = vIconsBuildDir.get().file("usage-codepoint.txt").asFile
inputs.files(fileTree("src") { include("**/*.kt") })
inputs.file(vConstants)
outputs.file(vUsageFile)
commandLine(
"python3", vScript.absolutePath,
"--src", layout.projectDirectory.dir("src").asFile.absolutePath,
"--constants", vConstants.absolutePath,
"--out", vUsageFile.absolutePath,
)
}
return inUsedStyles.associateWith { vStyle -> registerSubsetTask(vStyle, vIconsBuildDir, vFindUsage) }
}
/**
* 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,
inIconsBuildDir: Provider<Directory>,
inFindUsage: TaskProvider<*>,
): TaskProvider<*> {
val vSymbolsProject = rootProject.project(":utils:material-symbols")
return tasks.register("subsetMaterialSymbols$inStyle") {
description = "hb-subset the $inStyle Material Symbols font to icons actually used."
@Suppress("UNCHECKED_CAST")
val vInputProvider = vSymbolsProject.extra["iconFontFile$inStyle"] as Provider<RegularFile>
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$inStyle"] as TaskProvider<*>
val vOut = inIconsBuildDir.get().file("MaterialSymbols$inStyle.subset.ttf").asFile
val vUsage = inIconsBuildDir.get().file("usage-codepoint.txt").asFile
inputs.file(vInputProvider)
inputs.file(vUsage)
outputs.file(vOut)
dependsOn(inFindUsage)
dependsOn(vDownloadTask)
doLast {
val vCodepoints = vUsage.readLines()
.filter { it.isNotBlank() && !it.startsWith("#") && it.contains("=") }
.map { it.substringAfter("=").trim().removePrefix("0x").removePrefix("0X") }
if (vCodepoints.isEmpty()) throw GradleException(
"usage-codepoint.txt has no entries : refusing to subset to an empty font."
)
val vUnicodes = vCodepoints.joinToString(",") { "U+$it" }
vOut.parentFile.mkdirs()
val vInputFile = vInputProvider.get().asFile
val vBefore = vInputFile.length()
// 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] 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."
)
}
}
}
@@ -0,0 +1,240 @@
// Klib ABI Dump
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
// Rendering settings:
// - Signature version: 2
// - Show manifest properties: true
// - Show declarations: true
// Library unique name: <com.bitsycore.coil3:coil-compose-core>
abstract fun interface coil3.compose/AsyncImagePreviewHandler { // coil3.compose/AsyncImagePreviewHandler|null[0]
abstract suspend fun handle(coil3/ImageLoader, coil3.request/ImageRequest): coil3.compose/AsyncImagePainter.State // coil3.compose/AsyncImagePreviewHandler.handle|handle(coil3.ImageLoader;coil3.request.ImageRequest){}[0]
final object Companion { // coil3.compose/AsyncImagePreviewHandler.Companion|null[0]
final val Default // coil3.compose/AsyncImagePreviewHandler.Companion.Default|{}Default[0]
final fun <get-Default>(): coil3.compose/AsyncImagePreviewHandler // coil3.compose/AsyncImagePreviewHandler.Companion.Default.<get-Default>|<get-Default>(){}[0]
}
}
abstract interface coil3.compose/AsyncImageModelEqualityDelegate { // coil3.compose/AsyncImageModelEqualityDelegate|null[0]
abstract fun equals(kotlin/Any?, kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImageModelEqualityDelegate.equals|equals(kotlin.Any?;kotlin.Any?){}[0]
abstract fun hashCode(kotlin/Any?): kotlin/Int // coil3.compose/AsyncImageModelEqualityDelegate.hashCode|hashCode(kotlin.Any?){}[0]
final object Companion { // coil3.compose/AsyncImageModelEqualityDelegate.Companion|null[0]
final val AllProperties // coil3.compose/AsyncImageModelEqualityDelegate.Companion.AllProperties|{}AllProperties[0]
final fun <get-AllProperties>(): coil3.compose/AsyncImageModelEqualityDelegate // coil3.compose/AsyncImageModelEqualityDelegate.Companion.AllProperties.<get-AllProperties>|<get-AllProperties>(){}[0]
final val Default // coil3.compose/AsyncImageModelEqualityDelegate.Companion.Default|{}Default[0]
final fun <get-Default>(): coil3.compose/AsyncImageModelEqualityDelegate // coil3.compose/AsyncImageModelEqualityDelegate.Companion.Default.<get-Default>|<get-Default>(){}[0]
}
}
abstract interface coil3.compose/DrawScopeSizeResolver : coil3.size/SizeResolver { // coil3.compose/DrawScopeSizeResolver|null[0]
abstract fun connect(kotlinx.coroutines.flow/Flow<androidx.compose.ui.geometry/Size>) // coil3.compose/DrawScopeSizeResolver.connect|connect(kotlinx.coroutines.flow.Flow<androidx.compose.ui.geometry.Size>){}[0]
}
abstract interface coil3.compose/SubcomposeAsyncImageScope : androidx.compose.foundation.layout/BoxScope { // coil3.compose/SubcomposeAsyncImageScope|null[0]
abstract val alignment // coil3.compose/SubcomposeAsyncImageScope.alignment|{}alignment[0]
abstract fun <get-alignment>(): androidx.compose.ui/Alignment // coil3.compose/SubcomposeAsyncImageScope.alignment.<get-alignment>|<get-alignment>(){}[0]
abstract val alpha // coil3.compose/SubcomposeAsyncImageScope.alpha|{}alpha[0]
abstract fun <get-alpha>(): kotlin/Float // coil3.compose/SubcomposeAsyncImageScope.alpha.<get-alpha>|<get-alpha>(){}[0]
abstract val clipToBounds // coil3.compose/SubcomposeAsyncImageScope.clipToBounds|{}clipToBounds[0]
abstract fun <get-clipToBounds>(): kotlin/Boolean // coil3.compose/SubcomposeAsyncImageScope.clipToBounds.<get-clipToBounds>|<get-clipToBounds>(){}[0]
abstract val colorFilter // coil3.compose/SubcomposeAsyncImageScope.colorFilter|{}colorFilter[0]
abstract fun <get-colorFilter>(): androidx.compose.ui.graphics/ColorFilter? // coil3.compose/SubcomposeAsyncImageScope.colorFilter.<get-colorFilter>|<get-colorFilter>(){}[0]
abstract val contentDescription // coil3.compose/SubcomposeAsyncImageScope.contentDescription|{}contentDescription[0]
abstract fun <get-contentDescription>(): kotlin/String? // coil3.compose/SubcomposeAsyncImageScope.contentDescription.<get-contentDescription>|<get-contentDescription>(){}[0]
abstract val contentScale // coil3.compose/SubcomposeAsyncImageScope.contentScale|{}contentScale[0]
abstract fun <get-contentScale>(): androidx.compose.ui.layout/ContentScale // coil3.compose/SubcomposeAsyncImageScope.contentScale.<get-contentScale>|<get-contentScale>(){}[0]
abstract val painter // coil3.compose/SubcomposeAsyncImageScope.painter|{}painter[0]
abstract fun <get-painter>(): coil3.compose/AsyncImagePainter // coil3.compose/SubcomposeAsyncImageScope.painter.<get-painter>|<get-painter>(){}[0]
}
final class coil3.compose/AsyncImagePainter : androidx.compose.runtime/RememberObserver, androidx.compose.ui.graphics.painter/Painter { // coil3.compose/AsyncImagePainter|null[0]
final val input // coil3.compose/AsyncImagePainter.input|{}input[0]
final fun <get-input>(): kotlinx.coroutines.flow/StateFlow<coil3.compose/AsyncImagePainter.Input> // coil3.compose/AsyncImagePainter.input.<get-input>|<get-input>(){}[0]
final val intrinsicSize // coil3.compose/AsyncImagePainter.intrinsicSize|{}intrinsicSize[0]
final fun <get-intrinsicSize>(): androidx.compose.ui.geometry/Size // coil3.compose/AsyncImagePainter.intrinsicSize.<get-intrinsicSize>|<get-intrinsicSize>(){}[0]
final val state // coil3.compose/AsyncImagePainter.state|{}state[0]
final fun <get-state>(): kotlinx.coroutines.flow/StateFlow<coil3.compose/AsyncImagePainter.State> // coil3.compose/AsyncImagePainter.state.<get-state>|<get-state>(){}[0]
final fun onAbandoned() // coil3.compose/AsyncImagePainter.onAbandoned|onAbandoned(){}[0]
final fun onForgotten() // coil3.compose/AsyncImagePainter.onForgotten|onForgotten(){}[0]
final fun onRemembered() // coil3.compose/AsyncImagePainter.onRemembered|onRemembered(){}[0]
final fun restart() // coil3.compose/AsyncImagePainter.restart|restart(){}[0]
sealed interface State { // coil3.compose/AsyncImagePainter.State|null[0]
abstract val painter // coil3.compose/AsyncImagePainter.State.painter|{}painter[0]
abstract fun <get-painter>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.painter.<get-painter>|<get-painter>(){}[0]
final class Error : coil3.compose/AsyncImagePainter.State { // coil3.compose/AsyncImagePainter.State.Error|null[0]
constructor <init>(androidx.compose.ui.graphics.painter/Painter?, coil3.request/ErrorResult) // coil3.compose/AsyncImagePainter.State.Error.<init>|<init>(androidx.compose.ui.graphics.painter.Painter?;coil3.request.ErrorResult){}[0]
final val painter // coil3.compose/AsyncImagePainter.State.Error.painter|{}painter[0]
final fun <get-painter>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.Error.painter.<get-painter>|<get-painter>(){}[0]
final val result // coil3.compose/AsyncImagePainter.State.Error.result|{}result[0]
final fun <get-result>(): coil3.request/ErrorResult // coil3.compose/AsyncImagePainter.State.Error.result.<get-result>|<get-result>(){}[0]
final fun component1(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.Error.component1|component1(){}[0]
final fun component2(): coil3.request/ErrorResult // coil3.compose/AsyncImagePainter.State.Error.component2|component2(){}[0]
final fun copy(androidx.compose.ui.graphics.painter/Painter? = ..., coil3.request/ErrorResult = ...): coil3.compose/AsyncImagePainter.State.Error // coil3.compose/AsyncImagePainter.State.Error.copy|copy(androidx.compose.ui.graphics.painter.Painter?;coil3.request.ErrorResult){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImagePainter.State.Error.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // coil3.compose/AsyncImagePainter.State.Error.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // coil3.compose/AsyncImagePainter.State.Error.toString|toString(){}[0]
}
final class Loading : coil3.compose/AsyncImagePainter.State { // coil3.compose/AsyncImagePainter.State.Loading|null[0]
constructor <init>(androidx.compose.ui.graphics.painter/Painter?) // coil3.compose/AsyncImagePainter.State.Loading.<init>|<init>(androidx.compose.ui.graphics.painter.Painter?){}[0]
final val painter // coil3.compose/AsyncImagePainter.State.Loading.painter|{}painter[0]
final fun <get-painter>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.Loading.painter.<get-painter>|<get-painter>(){}[0]
final fun component1(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.Loading.component1|component1(){}[0]
final fun copy(androidx.compose.ui.graphics.painter/Painter? = ...): coil3.compose/AsyncImagePainter.State.Loading // coil3.compose/AsyncImagePainter.State.Loading.copy|copy(androidx.compose.ui.graphics.painter.Painter?){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImagePainter.State.Loading.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // coil3.compose/AsyncImagePainter.State.Loading.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // coil3.compose/AsyncImagePainter.State.Loading.toString|toString(){}[0]
}
final class Success : coil3.compose/AsyncImagePainter.State { // coil3.compose/AsyncImagePainter.State.Success|null[0]
constructor <init>(androidx.compose.ui.graphics.painter/Painter, coil3.request/SuccessResult) // coil3.compose/AsyncImagePainter.State.Success.<init>|<init>(androidx.compose.ui.graphics.painter.Painter;coil3.request.SuccessResult){}[0]
final val painter // coil3.compose/AsyncImagePainter.State.Success.painter|{}painter[0]
final fun <get-painter>(): androidx.compose.ui.graphics.painter/Painter // coil3.compose/AsyncImagePainter.State.Success.painter.<get-painter>|<get-painter>(){}[0]
final val result // coil3.compose/AsyncImagePainter.State.Success.result|{}result[0]
final fun <get-result>(): coil3.request/SuccessResult // coil3.compose/AsyncImagePainter.State.Success.result.<get-result>|<get-result>(){}[0]
final fun component1(): androidx.compose.ui.graphics.painter/Painter // coil3.compose/AsyncImagePainter.State.Success.component1|component1(){}[0]
final fun component2(): coil3.request/SuccessResult // coil3.compose/AsyncImagePainter.State.Success.component2|component2(){}[0]
final fun copy(androidx.compose.ui.graphics.painter/Painter = ..., coil3.request/SuccessResult = ...): coil3.compose/AsyncImagePainter.State.Success // coil3.compose/AsyncImagePainter.State.Success.copy|copy(androidx.compose.ui.graphics.painter.Painter;coil3.request.SuccessResult){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImagePainter.State.Success.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // coil3.compose/AsyncImagePainter.State.Success.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // coil3.compose/AsyncImagePainter.State.Success.toString|toString(){}[0]
}
final object Empty : coil3.compose/AsyncImagePainter.State { // coil3.compose/AsyncImagePainter.State.Empty|null[0]
final val painter // coil3.compose/AsyncImagePainter.State.Empty.painter|{}painter[0]
final fun <get-painter>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/AsyncImagePainter.State.Empty.painter.<get-painter>|<get-painter>(){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImagePainter.State.Empty.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // coil3.compose/AsyncImagePainter.State.Empty.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // coil3.compose/AsyncImagePainter.State.Empty.toString|toString(){}[0]
}
}
final class Input { // coil3.compose/AsyncImagePainter.Input|null[0]
constructor <init>(coil3/ImageLoader, coil3.request/ImageRequest, coil3.compose/AsyncImageModelEqualityDelegate) // coil3.compose/AsyncImagePainter.Input.<init>|<init>(coil3.ImageLoader;coil3.request.ImageRequest;coil3.compose.AsyncImageModelEqualityDelegate){}[0]
final val imageLoader // coil3.compose/AsyncImagePainter.Input.imageLoader|{}imageLoader[0]
final fun <get-imageLoader>(): coil3/ImageLoader // coil3.compose/AsyncImagePainter.Input.imageLoader.<get-imageLoader>|<get-imageLoader>(){}[0]
final val modelEqualityDelegate // coil3.compose/AsyncImagePainter.Input.modelEqualityDelegate|{}modelEqualityDelegate[0]
final fun <get-modelEqualityDelegate>(): coil3.compose/AsyncImageModelEqualityDelegate // coil3.compose/AsyncImagePainter.Input.modelEqualityDelegate.<get-modelEqualityDelegate>|<get-modelEqualityDelegate>(){}[0]
final val request // coil3.compose/AsyncImagePainter.Input.request|{}request[0]
final fun <get-request>(): coil3.request/ImageRequest // coil3.compose/AsyncImagePainter.Input.request.<get-request>|<get-request>(){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.compose/AsyncImagePainter.Input.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // coil3.compose/AsyncImagePainter.Input.hashCode|hashCode(){}[0]
}
final object Companion { // coil3.compose/AsyncImagePainter.Companion|null[0]
final val DefaultTransform // coil3.compose/AsyncImagePainter.Companion.DefaultTransform|{}DefaultTransform[0]
final fun <get-DefaultTransform>(): kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State> // coil3.compose/AsyncImagePainter.Companion.DefaultTransform.<get-DefaultTransform>|<get-DefaultTransform>(){}[0]
}
}
final class coil3.compose/ConstraintsSizeResolver : androidx.compose.ui.layout/LayoutModifier, coil3.size/SizeResolver { // coil3.compose/ConstraintsSizeResolver|null[0]
constructor <init>() // coil3.compose/ConstraintsSizeResolver.<init>|<init>(){}[0]
final fun (androidx.compose.ui.layout/MeasureScope).measure(androidx.compose.ui.layout/Measurable, androidx.compose.ui.unit/Constraints): androidx.compose.ui.layout/MeasureResult // coil3.compose/ConstraintsSizeResolver.measure|measure@androidx.compose.ui.layout.MeasureScope(androidx.compose.ui.layout.Measurable;androidx.compose.ui.unit.Constraints){}[0]
final fun setConstraints(androidx.compose.ui.unit/Constraints) // coil3.compose/ConstraintsSizeResolver.setConstraints|setConstraints(androidx.compose.ui.unit.Constraints){}[0]
final suspend fun size(): coil3.size/Size // coil3.compose/ConstraintsSizeResolver.size|size(){}[0]
}
final class coil3.compose/CrossfadePainter : androidx.compose.ui.graphics.painter/Painter { // coil3.compose/CrossfadePainter|null[0]
constructor <init>(androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.layout/ContentScale = ..., kotlin.time/Duration = ..., kotlin.time/TimeSource = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.compose/CrossfadePainter.<init>|<init>(androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.layout.ContentScale;kotlin.time.Duration;kotlin.time.TimeSource;kotlin.Boolean;kotlin.Boolean){}[0]
constructor <init>(androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.layout/ContentScale = ..., kotlin.time/Duration = ..., kotlin.time/TimeSource = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.compose/CrossfadePainter.<init>|<init>(androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.layout.ContentScale;kotlin.time.Duration;kotlin.time.TimeSource;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0]
final val contentScale // coil3.compose/CrossfadePainter.contentScale|{}contentScale[0]
final fun <get-contentScale>(): androidx.compose.ui.layout/ContentScale // coil3.compose/CrossfadePainter.contentScale.<get-contentScale>|<get-contentScale>(){}[0]
final val duration // coil3.compose/CrossfadePainter.duration|{}duration[0]
final fun <get-duration>(): kotlin.time/Duration // coil3.compose/CrossfadePainter.duration.<get-duration>|<get-duration>(){}[0]
final val end // coil3.compose/CrossfadePainter.end|{}end[0]
final fun <get-end>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/CrossfadePainter.end.<get-end>|<get-end>(){}[0]
final val fadeStart // coil3.compose/CrossfadePainter.fadeStart|{}fadeStart[0]
final fun <get-fadeStart>(): kotlin/Boolean // coil3.compose/CrossfadePainter.fadeStart.<get-fadeStart>|<get-fadeStart>(){}[0]
final val intrinsicSize // coil3.compose/CrossfadePainter.intrinsicSize|{}intrinsicSize[0]
final fun <get-intrinsicSize>(): androidx.compose.ui.geometry/Size // coil3.compose/CrossfadePainter.intrinsicSize.<get-intrinsicSize>|<get-intrinsicSize>(){}[0]
final val preferEndFirstIntrinsicSize // coil3.compose/CrossfadePainter.preferEndFirstIntrinsicSize|{}preferEndFirstIntrinsicSize[0]
final fun <get-preferEndFirstIntrinsicSize>(): kotlin/Boolean // coil3.compose/CrossfadePainter.preferEndFirstIntrinsicSize.<get-preferEndFirstIntrinsicSize>|<get-preferEndFirstIntrinsicSize>(){}[0]
final val preferExactIntrinsicSize // coil3.compose/CrossfadePainter.preferExactIntrinsicSize|{}preferExactIntrinsicSize[0]
final fun <get-preferExactIntrinsicSize>(): kotlin/Boolean // coil3.compose/CrossfadePainter.preferExactIntrinsicSize.<get-preferExactIntrinsicSize>|<get-preferExactIntrinsicSize>(){}[0]
final val timeSource // coil3.compose/CrossfadePainter.timeSource|{}timeSource[0]
final fun <get-timeSource>(): kotlin.time/TimeSource // coil3.compose/CrossfadePainter.timeSource.<get-timeSource>|<get-timeSource>(){}[0]
final var start // coil3.compose/CrossfadePainter.start|{}start[0]
final fun <get-start>(): androidx.compose.ui.graphics.painter/Painter? // coil3.compose/CrossfadePainter.start.<get-start>|<get-start>(){}[0]
}
final class coil3.compose/ImagePainter : androidx.compose.ui.graphics.painter/Painter { // coil3.compose/ImagePainter|null[0]
constructor <init>(coil3/Image) // coil3.compose/ImagePainter.<init>|<init>(coil3.Image){}[0]
final val image // coil3.compose/ImagePainter.image|{}image[0]
final fun <get-image>(): coil3/Image // coil3.compose/ImagePainter.image.<get-image>|<get-image>(){}[0]
final val intrinsicSize // coil3.compose/ImagePainter.intrinsicSize|{}intrinsicSize[0]
final fun <get-intrinsicSize>(): androidx.compose.ui.geometry/Size // coil3.compose/ImagePainter.intrinsicSize.<get-intrinsicSize>|<get-intrinsicSize>(){}[0]
}
final val coil3.compose/LocalAsyncImageModelEqualityDelegate // coil3.compose/LocalAsyncImageModelEqualityDelegate|{}LocalAsyncImageModelEqualityDelegate[0]
final fun <get-LocalAsyncImageModelEqualityDelegate>(): androidx.compose.runtime/ProvidableCompositionLocal<coil3.compose/AsyncImageModelEqualityDelegate> // coil3.compose/LocalAsyncImageModelEqualityDelegate.<get-LocalAsyncImageModelEqualityDelegate>|<get-LocalAsyncImageModelEqualityDelegate>(){}[0]
final val coil3.compose/LocalAsyncImagePreviewHandler // coil3.compose/LocalAsyncImagePreviewHandler|{}LocalAsyncImagePreviewHandler[0]
final fun <get-LocalAsyncImagePreviewHandler>(): androidx.compose.runtime/ProvidableCompositionLocal<coil3.compose/AsyncImagePreviewHandler> // coil3.compose/LocalAsyncImagePreviewHandler.<get-LocalAsyncImagePreviewHandler>|<get-LocalAsyncImagePreviewHandler>(){}[0]
final val coil3.compose/LocalPlatformContext // coil3.compose/LocalPlatformContext|{}LocalPlatformContext[0]
final fun <get-LocalPlatformContext>(): androidx.compose.runtime/ProvidableCompositionLocal<coil3/PlatformContext> // coil3.compose/LocalPlatformContext.<get-LocalPlatformContext>|<get-LocalPlatformContext>(){}[0]
final val coil3.compose/coil3_compose_AsyncImagePainter$stableprop // coil3.compose/coil3_compose_AsyncImagePainter$stableprop|#static{}coil3_compose_AsyncImagePainter$stableprop[0]
final val coil3.compose/coil3_compose_AsyncImagePainter_Input$stableprop // coil3.compose/coil3_compose_AsyncImagePainter_Input$stableprop|#static{}coil3_compose_AsyncImagePainter_Input$stableprop[0]
final val coil3.compose/coil3_compose_AsyncImagePainter_State_Empty$stableprop // coil3.compose/coil3_compose_AsyncImagePainter_State_Empty$stableprop|#static{}coil3_compose_AsyncImagePainter_State_Empty$stableprop[0]
final val coil3.compose/coil3_compose_AsyncImagePainter_State_Error$stableprop // coil3.compose/coil3_compose_AsyncImagePainter_State_Error$stableprop|#static{}coil3_compose_AsyncImagePainter_State_Error$stableprop[0]
final val coil3.compose/coil3_compose_AsyncImagePainter_State_Loading$stableprop // coil3.compose/coil3_compose_AsyncImagePainter_State_Loading$stableprop|#static{}coil3_compose_AsyncImagePainter_State_Loading$stableprop[0]
final val coil3.compose/coil3_compose_AsyncImagePainter_State_Success$stableprop // coil3.compose/coil3_compose_AsyncImagePainter_State_Success$stableprop|#static{}coil3_compose_AsyncImagePainter_State_Success$stableprop[0]
final val coil3.compose/coil3_compose_ConstraintsSizeResolver$stableprop // coil3.compose/coil3_compose_ConstraintsSizeResolver$stableprop|#static{}coil3_compose_ConstraintsSizeResolver$stableprop[0]
final val coil3.compose/coil3_compose_CrossfadePainter$stableprop // coil3.compose/coil3_compose_CrossfadePainter$stableprop|#static{}coil3_compose_CrossfadePainter$stableprop[0]
final val coil3.compose/coil3_compose_ImagePainter$stableprop // coil3.compose/coil3_compose_ImagePainter$stableprop|#static{}coil3_compose_ImagePainter$stableprop[0]
final val coil3.compose/preferEndFirstIntrinsicSize // coil3.compose/preferEndFirstIntrinsicSize|@coil3.Extras.Key.Companion{}preferEndFirstIntrinsicSize[0]
final fun (coil3/Extras.Key.Companion).<get-preferEndFirstIntrinsicSize>(): coil3/Extras.Key<kotlin/Boolean> // coil3.compose/preferEndFirstIntrinsicSize.<get-preferEndFirstIntrinsicSize>|<get-preferEndFirstIntrinsicSize>@coil3.Extras.Key.Companion(){}[0]
final val coil3.compose/preferEndFirstIntrinsicSize // coil3.compose/preferEndFirstIntrinsicSize|@coil3.request.ImageRequest{}preferEndFirstIntrinsicSize[0]
final fun (coil3.request/ImageRequest).<get-preferEndFirstIntrinsicSize>(): kotlin/Boolean // coil3.compose/preferEndFirstIntrinsicSize.<get-preferEndFirstIntrinsicSize>|<get-preferEndFirstIntrinsicSize>@coil3.request.ImageRequest(){}[0]
final val coil3.compose/useExistingImageAsPlaceholder // coil3.compose/useExistingImageAsPlaceholder|@coil3.Extras.Key.Companion{}useExistingImageAsPlaceholder[0]
final fun (coil3/Extras.Key.Companion).<get-useExistingImageAsPlaceholder>(): coil3/Extras.Key<kotlin/Boolean> // coil3.compose/useExistingImageAsPlaceholder.<get-useExistingImageAsPlaceholder>|<get-useExistingImageAsPlaceholder>@coil3.Extras.Key.Companion(){}[0]
final val coil3.compose/useExistingImageAsPlaceholder // coil3.compose/useExistingImageAsPlaceholder|@coil3.request.ImageRequest{}useExistingImageAsPlaceholder[0]
final fun (coil3.request/ImageRequest).<get-useExistingImageAsPlaceholder>(): kotlin/Boolean // coil3.compose/useExistingImageAsPlaceholder.<get-useExistingImageAsPlaceholder>|<get-useExistingImageAsPlaceholder>@coil3.request.ImageRequest(){}[0]
final fun (coil3.compose/SubcomposeAsyncImageScope).coil3.compose/SubcomposeAsyncImageContent(androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, kotlin/String?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImageContent|SubcomposeAsyncImageContent@coil3.compose.SubcomposeAsyncImageScope(androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;kotlin.String?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun (coil3.request/ImageRequest.Builder).coil3.compose/preferEndFirstIntrinsicSize(kotlin/Boolean): coil3.request/ImageRequest.Builder // coil3.compose/preferEndFirstIntrinsicSize|preferEndFirstIntrinsicSize@coil3.request.ImageRequest.Builder(kotlin.Boolean){}[0]
final fun (coil3.request/ImageRequest.Builder).coil3.compose/useExistingImageAsPlaceholder(kotlin/Boolean): coil3.request/ImageRequest.Builder // coil3.compose/useExistingImageAsPlaceholder|useExistingImageAsPlaceholder@coil3.request.ImageRequest.Builder(kotlin.Boolean){}[0]
final fun (coil3/Image).coil3.compose/asPainter(coil3/PlatformContext, androidx.compose.ui.graphics/FilterQuality = ...): androidx.compose.ui.graphics.painter/Painter // coil3.compose/asPainter|asPainter@coil3.Image(coil3.PlatformContext;androidx.compose.ui.graphics.FilterQuality){}[0]
final fun (coil3/ImageLoader.Builder).coil3.compose/preferEndFirstIntrinsicSize(kotlin/Boolean): coil3/ImageLoader.Builder // coil3.compose/preferEndFirstIntrinsicSize|preferEndFirstIntrinsicSize@coil3.ImageLoader.Builder(kotlin.Boolean){}[0]
final fun (coil3/ImageLoader.Builder).coil3.compose/useExistingImageAsPlaceholder(kotlin/Boolean): coil3/ImageLoader.Builder // coil3.compose/useExistingImageAsPlaceholder|useExistingImageAsPlaceholder@coil3.ImageLoader.Builder(kotlin.Boolean){}[0]
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/DrawScopeSizeResolver(): coil3.compose/DrawScopeSizeResolver // coil3.compose/DrawScopeSizeResolver|DrawScopeSizeResolver(){}[0]
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, kotlin/Function3<coil3.compose/SubcomposeAsyncImageScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;kotlin.Function3<coil3.compose.SubcomposeAsyncImageScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, kotlin/Function3<coil3.compose/SubcomposeAsyncImageScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;kotlin.Function3<coil3.compose.SubcomposeAsyncImageScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Loading, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Success, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Error, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Loading,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Success,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Error,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Loading, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Success, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Error, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Loading,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Success,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Error,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/coil3_compose_AsyncImagePainter$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter$stableprop_getter|coil3_compose_AsyncImagePainter$stableprop_getter(){}[0]
final fun coil3.compose/coil3_compose_AsyncImagePainter_Input$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter_Input$stableprop_getter|coil3_compose_AsyncImagePainter_Input$stableprop_getter(){}[0]
final fun coil3.compose/coil3_compose_AsyncImagePainter_State_Empty$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter_State_Empty$stableprop_getter|coil3_compose_AsyncImagePainter_State_Empty$stableprop_getter(){}[0]
final fun coil3.compose/coil3_compose_AsyncImagePainter_State_Error$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter_State_Error$stableprop_getter|coil3_compose_AsyncImagePainter_State_Error$stableprop_getter(){}[0]
final fun coil3.compose/coil3_compose_AsyncImagePainter_State_Loading$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter_State_Loading$stableprop_getter|coil3_compose_AsyncImagePainter_State_Loading$stableprop_getter(){}[0]
final fun coil3.compose/coil3_compose_AsyncImagePainter_State_Success$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_AsyncImagePainter_State_Success$stableprop_getter|coil3_compose_AsyncImagePainter_State_Success$stableprop_getter(){}[0]
final fun coil3.compose/coil3_compose_ConstraintsSizeResolver$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_ConstraintsSizeResolver$stableprop_getter|coil3_compose_ConstraintsSizeResolver$stableprop_getter(){}[0]
final fun coil3.compose/coil3_compose_CrossfadePainter$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_CrossfadePainter$stableprop_getter|coil3_compose_CrossfadePainter$stableprop_getter(){}[0]
final fun coil3.compose/coil3_compose_ImagePainter$stableprop_getter(): kotlin/Int // coil3.compose/coil3_compose_ImagePainter$stableprop_getter|coil3_compose_ImagePainter$stableprop_getter(){}[0]
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, coil3/ImageLoader, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;coil3.ImageLoader;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, coil3/ImageLoader, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;coil3.ImageLoader;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, coil3/ImageLoader, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;coil3.ImageLoader;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, coil3/ImageLoader, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;coil3.ImageLoader;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/rememberConstraintsSizeResolver(androidx.compose.runtime/Composer?, kotlin/Int): coil3.compose/ConstraintsSizeResolver // coil3.compose/rememberConstraintsSizeResolver|rememberConstraintsSizeResolver(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
final fun coil3.compose/rememberDrawScopeSizeResolver(androidx.compose.runtime/Composer?, kotlin/Int): coil3.compose/DrawScopeSizeResolver // coil3.compose/rememberDrawScopeSizeResolver|rememberDrawScopeSizeResolver(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
final inline fun coil3.compose/AsyncImagePreviewHandler(crossinline kotlin.coroutines/SuspendFunction1<coil3.request/ImageRequest, coil3/Image>): coil3.compose/AsyncImagePreviewHandler // coil3.compose/AsyncImagePreviewHandler|AsyncImagePreviewHandler(kotlin.coroutines.SuspendFunction1<coil3.request.ImageRequest,coil3.Image>){}[0]
final inline fun coil3.compose/AsyncImagePreviewHandler(crossinline kotlin.coroutines/SuspendFunction1<coil3.request/ImageRequest, coil3/Image?>): coil3.compose/AsyncImagePreviewHandler // coil3.compose/AsyncImagePreviewHandler|AsyncImagePreviewHandler(kotlin.coroutines.SuspendFunction1<coil3.request.ImageRequest,coil3.Image?>){}[0]
+69
View File
@@ -0,0 +1,69 @@
// :coil:coil-compose-core - AsyncImage / rememberAsyncImagePainter internals, vendored
// VERBATIM from coil-kt/coil. Upstream publishes NO desktop-native klibs for the
// compose layer at all (neither mingwX64 nor linux), so this whole module exists to
// give the port its targets. Compose coords substitute to the port's project modules
// on every native configuration (root build's commonization bridge).
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.plugin.compose)
}
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
dependencies {
api(project(":coil:coil-core"))
// Declared DIRECTLY, not leaned on transitively: these coords are
// SUBSTITUTED to the port's project modules on native configurations,
// and KGP's granular-metadata visibility rule then drops the substituted
// module's transitives from the commonMain metadata classpath (CLAUDE.md
// "Common pitfalls"). Every artifact the vendored common code touches
// needs its own declaration + its own bridge rule.
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
api("org.jetbrains.compose.ui:ui:${libs.versions.compose.get()}")
api("org.jetbrains.compose.ui:ui-graphics:${libs.versions.compose.get()}")
api("org.jetbrains.compose.ui:ui-text:${libs.versions.compose.get()}")
api("org.jetbrains.compose.ui:ui-unit:${libs.versions.compose.get()}")
api("org.jetbrains.compose.ui:ui-geometry:${libs.versions.compose.get()}")
api("org.jetbrains.compose.ui:ui-util:${libs.versions.compose.get()}")
api("org.jetbrains.compose.foundation:foundation:${libs.versions.compose.get()}")
api("org.jetbrains.compose.foundation:foundation-layout:${libs.versions.compose.get()}")
}
kotlin.srcDir("src/vendor/common/kotlin")
}
// Upstream's nonAndroid compose layer reaches straight into org.jetbrains.skia
// (Canvas / Bitmap.asComposeImageBitmap), so it needs skiko on its own compile
// classpath - official for macOS/Linux, the bitsycore FORK for mingwX64, on two
// PARALLEL branches. See coil/coil-core/build.gradle.kts.
fun nonAndroidBranch(inName: String, inSkiko: Any) = create("${inName}NonAndroidMain") {
dependsOn(commonMain.get())
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
dependencies { implementation(inSkiko) }
}
val vOfficial = nonAndroidBranch("coilComposeOfficial", libs.skiko)
get("macosArm64Main").dependsOn(vOfficial)
get("linuxX64Main").dependsOn(vOfficial)
get("linuxArm64Main").dependsOn(vOfficial)
if (vHostSupportsMingw) {
val vFork = nonAndroidBranch(
"coilComposeFork",
"com.bitsycore.skiko:skiko:${providers.gradleProperty("skikoMingwVersion").getOrElse(libs.versions.skikoMingw.get())}",
)
get("mingwX64Main").dependsOn(vFork)
}
}
compilerOptions {
freeCompilerArgs.addAll(
"-Xexpect-actual-classes",
"-opt-in=coil3.annotation.InternalCoilApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=coil3.annotation.DelicateCoilApi",
)
}
}
+44
View File
@@ -0,0 +1,44 @@
# :coil:coil-compose-core - AsyncImage / rememberAsyncImagePainter internals.
# Upstream publishes NEITHER mingwX64 NOR linux for the compose layer.
# Upstream source sets collapse onto this port: it has NO android, jvm or js
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
# hierarchy template).
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
SET_FOLDER=coil-compose-core/src
commonMain/kotlin/ -> src/vendor/common/kotlin/
# | commonMain/kotlin/coil3/compose/AsyncImage.kt -> src/vendor/common/kotlin/coil3/compose/AsyncImage.kt
# | commonMain/kotlin/coil3/compose/AsyncImagePainter.kt -> src/vendor/common/kotlin/coil3/compose/AsyncImagePainter.kt
# | commonMain/kotlin/coil3/compose/ConstraintsSizeResolver.kt -> src/vendor/common/kotlin/coil3/compose/ConstraintsSizeResolver.kt
# | commonMain/kotlin/coil3/compose/CrossfadePainter.kt -> src/vendor/common/kotlin/coil3/compose/CrossfadePainter.kt
# | commonMain/kotlin/coil3/compose/DrawScopeSizeResolver.kt -> src/vendor/common/kotlin/coil3/compose/DrawScopeSizeResolver.kt
# | commonMain/kotlin/coil3/compose/ImagePainter.kt -> src/vendor/common/kotlin/coil3/compose/ImagePainter.kt
# | commonMain/kotlin/coil3/compose/LocalAsyncImageModelEqualityDelegate.kt -> src/vendor/common/kotlin/coil3/compose/LocalAsyncImageModelEqualityDelegate.kt
# | commonMain/kotlin/coil3/compose/LocalAsyncImagePreviewHandler.kt -> src/vendor/common/kotlin/coil3/compose/LocalAsyncImagePreviewHandler.kt
# | commonMain/kotlin/coil3/compose/LocalPlatformContext.kt -> src/vendor/common/kotlin/coil3/compose/LocalPlatformContext.kt
# | commonMain/kotlin/coil3/compose/SubcomposeAsyncImage.kt -> src/vendor/common/kotlin/coil3/compose/SubcomposeAsyncImage.kt
# | commonMain/kotlin/coil3/compose/imageRequests.kt -> src/vendor/common/kotlin/coil3/compose/imageRequests.kt
# | commonMain/kotlin/coil3/compose/internal/ContentPainterModifier.kt -> src/vendor/common/kotlin/coil3/compose/internal/ContentPainterModifier.kt
# | commonMain/kotlin/coil3/compose/internal/DeferredDispatch.kt -> src/vendor/common/kotlin/coil3/compose/internal/DeferredDispatch.kt
# | commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt -> src/vendor/common/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt
# | commonMain/kotlin/coil3/compose/internal/utils.kt -> src/vendor/common/kotlin/coil3/compose/internal/utils.kt
nonAndroidMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
# | nonAndroidMain/kotlin/coil3/compose/AsyncImagePainter.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/compose/AsyncImagePainter.nonAndroid.kt
# | nonAndroidMain/kotlin/coil3/compose/ImagePainter.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/compose/ImagePainter.nonAndroid.kt
# | nonAndroidMain/kotlin/coil3/compose/LocalPlatformContext.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/compose/LocalPlatformContext.nonAndroid.kt
# | nonAndroidMain/kotlin/coil3/compose/internal/utils.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/compose/internal/utils.nonAndroid.kt
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
# androidMain (4)
# | androidMain/kotlin/coil3/compose/AsyncImagePainter.android.kt -> src/vendor/android/kotlin/coil3/compose/AsyncImagePainter.android.kt
# | androidMain/kotlin/coil3/compose/ImagePainter.android.kt -> src/vendor/android/kotlin/coil3/compose/ImagePainter.android.kt
# | androidMain/kotlin/coil3/compose/LocalPlatformContext.android.kt -> src/vendor/android/kotlin/coil3/compose/LocalPlatformContext.android.kt
# | androidMain/kotlin/coil3/compose/internal/utils.android.kt -> src/vendor/android/kotlin/coil3/compose/internal/utils.android.kt
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
@@ -0,0 +1,21 @@
// Klib ABI Dump
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
// Rendering settings:
// - Signature version: 2
// - Show manifest properties: true
// - Show declarations: true
// Library unique name: <com.bitsycore.coil3:coil-compose>
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, kotlin/Function3<coil3.compose/SubcomposeAsyncImageScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;kotlin.Function3<coil3.compose.SubcomposeAsyncImageScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, kotlin/Function3<coil3.compose/SubcomposeAsyncImageScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;kotlin.Function3<coil3.compose.SubcomposeAsyncImageScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Loading, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Success, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Error, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Loading,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Success,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Error,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/SubcomposeAsyncImage(kotlin/Any?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Loading, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Success, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function4<coil3.compose/SubcomposeAsyncImageScope, coil3.compose/AsyncImagePainter.State.Error, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality?, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/SubcomposeAsyncImage|SubcomposeAsyncImage(kotlin.Any?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Loading,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Success,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function4<coil3.compose.SubcomposeAsyncImageScope,coil3.compose.AsyncImagePainter.State.Error,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality?;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Loading, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Success, kotlin/Unit>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State.Error, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Loading,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Success,kotlin.Unit>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State.Error,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/rememberAsyncImagePainter(kotlin/Any?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, coil3.compose/AsyncImagePainter.State>?, kotlin/Function1<coil3.compose/AsyncImagePainter.State, kotlin/Unit>?, androidx.compose.ui.layout/ContentScale?, androidx.compose.ui.graphics/FilterQuality?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): coil3.compose/AsyncImagePainter // coil3.compose/rememberAsyncImagePainter|rememberAsyncImagePainter(kotlin.Any?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,coil3.compose.AsyncImagePainter.State>?;kotlin.Function1<coil3.compose.AsyncImagePainter.State,kotlin.Unit>?;androidx.compose.ui.layout.ContentScale?;androidx.compose.ui.graphics.FilterQuality?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun coil3.compose/setSingletonImageLoaderFactory(kotlin/Function1<coil3/PlatformContext, coil3/ImageLoader>, androidx.compose.runtime/Composer?, kotlin/Int) // coil3.compose/setSingletonImageLoaderFactory|setSingletonImageLoaderFactory(kotlin.Function1<coil3.PlatformContext,coil3.ImageLoader>;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
+31
View File
@@ -0,0 +1,31 @@
// :coil:coil-compose - the public compose entry points (AsyncImage, SubcomposeAsyncImage),
// vendored VERBATIM from coil-kt/coil. commonMain only. Upstream publishes no
// desktop-native klibs for the compose layer.
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.plugin.compose)
}
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
dependencies {
api(project(":coil:coil"))
api(project(":coil:coil-compose-core"))
}
kotlin.srcDir("src/vendor/common/kotlin")
}
}
compilerOptions {
freeCompilerArgs.addAll(
"-Xexpect-actual-classes",
"-opt-in=coil3.annotation.InternalCoilApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=coil3.annotation.DelicateCoilApi",
)
}
}
+15
View File
@@ -0,0 +1,15 @@
# :coil:coil-compose - the public compose entry points. commonMain only.
# Upstream publishes NEITHER mingwX64 NOR linux for the compose layer.
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
SET_FOLDER=coil-compose/src
commonMain/kotlin/ -> src/vendor/common/kotlin/
# | commonMain/kotlin/coil3/compose/SingletonAsyncImage.kt -> src/vendor/common/kotlin/coil3/compose/SingletonAsyncImage.kt
# | commonMain/kotlin/coil3/compose/SingletonAsyncImagePainter.kt -> src/vendor/common/kotlin/coil3/compose/SingletonAsyncImagePainter.kt
# | commonMain/kotlin/coil3/compose/SingletonSubcomposeAsyncImage.kt -> src/vendor/common/kotlin/coil3/compose/SingletonSubcomposeAsyncImage.kt
# | commonMain/kotlin/coil3/compose/singletonImageLoaders.kt -> src/vendor/common/kotlin/coil3/compose/singletonImageLoaders.kt
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
# (none - every source set under SET_FOLDER is vendored)
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
// :coil:coil-core - coil3.* core, vendored VERBATIM from coil-kt/coil into src/vendor.
//
// Upstream publishes linux + apple klibs but NOT mingwX64, and the code is
// target-agnostic, so the port mostly just re-declares the targets.
//
// SOURCE-SET COLLAPSE: this module has no android, jvm or js target, so upstream's
// nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all apply to every
// target here and are vendored into ONE tree (src/vendor/native). Only the
// apple-vs-rest split survives: appleMain is macosArm64, nonAppleMain is linux+mingw.
//
// SKIA: upstream's non-android decoder is skiko-based (SkiaImageDecoder), which is
// this port's renderer already. macOS/Linux take the official skiko; mingwX64 takes
// the bitsycore FORK, the same swap :compose:ui:ui-graphics makes.
//
// Provenance = compose-fork.txt + scripts/compose-fork/compose.properties. Never
// hand-edit src/vendor - change the manifest + re-run
// `python scripts/compose-fork/sync.py coil/coil-core`.
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.okio)
}
kotlin.srcDir("src/vendor/common/kotlin")
}
// SKIKO BRANCHES. Upstream's nonAndroid files use org.jetbrains.skia.*, so the
// intermediate source set that holds them needs skiko on its own compile
// classpath (the shared-metadata compilation is a real compile). macOS/Linux
// take the OFFICIAL skiko; mingwX64 takes the bitsycore FORK - and the two
// cannot share an intermediate, so mingw gets a PARALLEL branch over the same
// srcDirs. Same shape as :compose:ui:ui-graphics.
//
// Each branch is two levels deep because upstream's nonAndroid sets declare
// `expect`s whose `actual`s live in nativeMain - siblings in one set is an error.
fun skikoBranch(inName: String, inSkiko: Any): org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet {
val vNonAndroid = create("${inName}NonAndroidMain") {
dependsOn(commonMain.get())
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
dependencies { implementation(inSkiko) }
}
return create("${inName}NativeMain") {
dependsOn(vNonAndroid)
kotlin.srcDir("src/vendor/native/kotlin")
}
}
val vOfficial = skikoBranch("coilOfficial", libs.skiko)
val vAppleMain = create("coilAppleMain") {
dependsOn(vOfficial)
kotlin.srcDir("src/vendor/apple/kotlin")
}
val vLinuxMain = create("coilLinuxMain") {
dependsOn(vOfficial)
kotlin.srcDir("src/vendor/nonApple/kotlin")
}
get("macosArm64Main").dependsOn(vAppleMain)
get("linuxX64Main").dependsOn(vLinuxMain)
get("linuxArm64Main").dependsOn(vLinuxMain)
if (vHostSupportsMingw) {
val vFork = skikoBranch(
"coilFork",
"com.bitsycore.skiko:skiko:${providers.gradleProperty("skikoMingwVersion").getOrElse(libs.versions.skikoMingw.get())}",
)
val vMingwMain = create("coilMingwMain") {
dependsOn(vFork)
kotlin.srcDir("src/vendor/nonApple/kotlin")
}
get("mingwX64Main").dependsOn(vMingwMain)
}
}
compilerOptions {
freeCompilerArgs.addAll(
"-Xexpect-actual-classes",
"-opt-in=kotlinx.cinterop.ExperimentalForeignApi",
"-opt-in=coil3.annotation.InternalCoilApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=coil3.annotation.DelicateCoilApi",
)
}
}
+206
View File
@@ -0,0 +1,206 @@
# :coil:coil-core - coil3.* core, vendored verbatim from coil-kt/coil.
# Upstream publishes linux + apple but NOT mingwX64, so the port re-declares the
# targets and rebuilds the same sources. Decoding is Skia-based upstream
# (SkiaImageDecoder), which is exactly this port's renderer - on mingwX64 the
# skiko dependency is the bitsycore FORK, as in :compose:ui:ui-graphics.
# Upstream source sets collapse onto this port: it has NO android, jvm or js
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
# hierarchy template).
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
SET_FOLDER=coil-core/src
commonMain/kotlin/ -> src/vendor/common/kotlin/
# | commonMain/kotlin/coil3/ColorImage.kt -> src/vendor/common/kotlin/coil3/ColorImage.kt
# | commonMain/kotlin/coil3/ComponentRegistry.kt -> src/vendor/common/kotlin/coil3/ComponentRegistry.kt
# | commonMain/kotlin/coil3/EventListener.kt -> src/vendor/common/kotlin/coil3/EventListener.kt
# | commonMain/kotlin/coil3/Extras.kt -> src/vendor/common/kotlin/coil3/Extras.kt
# | commonMain/kotlin/coil3/Image.kt -> src/vendor/common/kotlin/coil3/Image.kt
# | commonMain/kotlin/coil3/ImageLoader.kt -> src/vendor/common/kotlin/coil3/ImageLoader.kt
# | commonMain/kotlin/coil3/PlatformContext.kt -> src/vendor/common/kotlin/coil3/PlatformContext.kt
# | commonMain/kotlin/coil3/RealImageLoader.kt -> src/vendor/common/kotlin/coil3/RealImageLoader.kt
# | commonMain/kotlin/coil3/Uri.kt -> src/vendor/common/kotlin/coil3/Uri.kt
# | commonMain/kotlin/coil3/annotation/annotations.kt -> src/vendor/common/kotlin/coil3/annotation/annotations.kt
# | commonMain/kotlin/coil3/decode/BlackholeDecoder.kt -> src/vendor/common/kotlin/coil3/decode/BlackholeDecoder.kt
# | commonMain/kotlin/coil3/decode/DataSource.kt -> src/vendor/common/kotlin/coil3/decode/DataSource.kt
# | commonMain/kotlin/coil3/decode/DecodeResult.kt -> src/vendor/common/kotlin/coil3/decode/DecodeResult.kt
# | commonMain/kotlin/coil3/decode/DecodeUtils.kt -> src/vendor/common/kotlin/coil3/decode/DecodeUtils.kt
# | commonMain/kotlin/coil3/decode/Decoder.kt -> src/vendor/common/kotlin/coil3/decode/Decoder.kt
# | commonMain/kotlin/coil3/decode/ImageSource.kt -> src/vendor/common/kotlin/coil3/decode/ImageSource.kt
# | commonMain/kotlin/coil3/disk/DiskCache.kt -> src/vendor/common/kotlin/coil3/disk/DiskCache.kt
# | commonMain/kotlin/coil3/disk/DiskLruCache.kt -> src/vendor/common/kotlin/coil3/disk/DiskLruCache.kt
# | commonMain/kotlin/coil3/disk/FaultHidingSink.kt -> src/vendor/common/kotlin/coil3/disk/FaultHidingSink.kt
# | commonMain/kotlin/coil3/disk/RealDiskCache.kt -> src/vendor/common/kotlin/coil3/disk/RealDiskCache.kt
# | commonMain/kotlin/coil3/disk/utils.kt -> src/vendor/common/kotlin/coil3/disk/utils.kt
# | commonMain/kotlin/coil3/fetch/BitmapFetcher.kt -> src/vendor/common/kotlin/coil3/fetch/BitmapFetcher.kt
# | commonMain/kotlin/coil3/fetch/ByteArrayFetcher.kt -> src/vendor/common/kotlin/coil3/fetch/ByteArrayFetcher.kt
# | commonMain/kotlin/coil3/fetch/DataUriFetcher.kt -> src/vendor/common/kotlin/coil3/fetch/DataUriFetcher.kt
# | commonMain/kotlin/coil3/fetch/FetchResult.kt -> src/vendor/common/kotlin/coil3/fetch/FetchResult.kt
# | commonMain/kotlin/coil3/fetch/Fetcher.kt -> src/vendor/common/kotlin/coil3/fetch/Fetcher.kt
# | commonMain/kotlin/coil3/fetch/FileUriFetcher.kt -> src/vendor/common/kotlin/coil3/fetch/FileUriFetcher.kt
# | commonMain/kotlin/coil3/imageLoaders.kt -> src/vendor/common/kotlin/coil3/imageLoaders.kt
# | commonMain/kotlin/coil3/intercept/EngineInterceptor.kt -> src/vendor/common/kotlin/coil3/intercept/EngineInterceptor.kt
# | commonMain/kotlin/coil3/intercept/Interceptor.kt -> src/vendor/common/kotlin/coil3/intercept/Interceptor.kt
# | commonMain/kotlin/coil3/intercept/RealInterceptorChain.kt -> src/vendor/common/kotlin/coil3/intercept/RealInterceptorChain.kt
# | commonMain/kotlin/coil3/key/FileUriKeyer.kt -> src/vendor/common/kotlin/coil3/key/FileUriKeyer.kt
# | commonMain/kotlin/coil3/key/Keyer.kt -> src/vendor/common/kotlin/coil3/key/Keyer.kt
# | commonMain/kotlin/coil3/key/UriKeyer.kt -> src/vendor/common/kotlin/coil3/key/UriKeyer.kt
# | commonMain/kotlin/coil3/map/Mapper.kt -> src/vendor/common/kotlin/coil3/map/Mapper.kt
# | commonMain/kotlin/coil3/map/PathMapper.kt -> src/vendor/common/kotlin/coil3/map/PathMapper.kt
# | commonMain/kotlin/coil3/map/StringMapper.kt -> src/vendor/common/kotlin/coil3/map/StringMapper.kt
# | commonMain/kotlin/coil3/memory/MemoryCache.kt -> src/vendor/common/kotlin/coil3/memory/MemoryCache.kt
# | commonMain/kotlin/coil3/memory/MemoryCacheService.kt -> src/vendor/common/kotlin/coil3/memory/MemoryCacheService.kt
# | commonMain/kotlin/coil3/memory/RealMemoryCache.kt -> src/vendor/common/kotlin/coil3/memory/RealMemoryCache.kt
# | commonMain/kotlin/coil3/memory/StrongMemoryCache.kt -> src/vendor/common/kotlin/coil3/memory/StrongMemoryCache.kt
# | commonMain/kotlin/coil3/memory/WeakMemoryCache.kt -> src/vendor/common/kotlin/coil3/memory/WeakMemoryCache.kt
# | commonMain/kotlin/coil3/request/CachePolicy.kt -> src/vendor/common/kotlin/coil3/request/CachePolicy.kt
# | commonMain/kotlin/coil3/request/Disposable.kt -> src/vendor/common/kotlin/coil3/request/Disposable.kt
# | commonMain/kotlin/coil3/request/ImageRequest.kt -> src/vendor/common/kotlin/coil3/request/ImageRequest.kt
# | commonMain/kotlin/coil3/request/ImageResult.kt -> src/vendor/common/kotlin/coil3/request/ImageResult.kt
# | commonMain/kotlin/coil3/request/NullRequestData.kt -> src/vendor/common/kotlin/coil3/request/NullRequestData.kt
# | commonMain/kotlin/coil3/request/NullRequestDataException.kt -> src/vendor/common/kotlin/coil3/request/NullRequestDataException.kt
# | commonMain/kotlin/coil3/request/Options.kt -> src/vendor/common/kotlin/coil3/request/Options.kt
# | commonMain/kotlin/coil3/request/RequestDelegate.kt -> src/vendor/common/kotlin/coil3/request/RequestDelegate.kt
# | commonMain/kotlin/coil3/request/RequestService.kt -> src/vendor/common/kotlin/coil3/request/RequestService.kt
# | commonMain/kotlin/coil3/request/imageRequests.kt -> src/vendor/common/kotlin/coil3/request/imageRequests.kt
# | commonMain/kotlin/coil3/size/Dimension.kt -> src/vendor/common/kotlin/coil3/size/Dimension.kt
# | commonMain/kotlin/coil3/size/Precision.kt -> src/vendor/common/kotlin/coil3/size/Precision.kt
# | commonMain/kotlin/coil3/size/RealSizeResolver.kt -> src/vendor/common/kotlin/coil3/size/RealSizeResolver.kt
# | commonMain/kotlin/coil3/size/Scale.kt -> src/vendor/common/kotlin/coil3/size/Scale.kt
# | commonMain/kotlin/coil3/size/Size.kt -> src/vendor/common/kotlin/coil3/size/Size.kt
# | commonMain/kotlin/coil3/size/SizeResolver.kt -> src/vendor/common/kotlin/coil3/size/SizeResolver.kt
# | commonMain/kotlin/coil3/target/Target.kt -> src/vendor/common/kotlin/coil3/target/Target.kt
# | commonMain/kotlin/coil3/transform/Transformation.kt -> src/vendor/common/kotlin/coil3/transform/Transformation.kt
# | commonMain/kotlin/coil3/util/LruCache.kt -> src/vendor/common/kotlin/coil3/util/LruCache.kt
# | commonMain/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt -> src/vendor/common/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt
# | commonMain/kotlin/coil3/util/SystemCallbacks.kt -> src/vendor/common/kotlin/coil3/util/SystemCallbacks.kt
# | commonMain/kotlin/coil3/util/collections.kt -> src/vendor/common/kotlin/coil3/util/collections.kt
# | commonMain/kotlin/coil3/util/contexts.kt -> src/vendor/common/kotlin/coil3/util/contexts.kt
# | commonMain/kotlin/coil3/util/coroutines.kt -> src/vendor/common/kotlin/coil3/util/coroutines.kt
# | commonMain/kotlin/coil3/util/fileSystems.kt -> src/vendor/common/kotlin/coil3/util/fileSystems.kt
# | commonMain/kotlin/coil3/util/logging.kt -> src/vendor/common/kotlin/coil3/util/logging.kt
# | commonMain/kotlin/coil3/util/mimeTypes.kt -> src/vendor/common/kotlin/coil3/util/mimeTypes.kt
# | commonMain/kotlin/coil3/util/utils.kt -> src/vendor/common/kotlin/coil3/util/utils.kt
nonAndroidMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
# | nonAndroidMain/kotlin/coil3/ColorImage.kt -> src/vendor/nonAndroid/kotlin/coil3/ColorImage.kt
# | nonAndroidMain/kotlin/coil3/EventListener.kt -> src/vendor/nonAndroid/kotlin/coil3/EventListener.kt
# | nonAndroidMain/kotlin/coil3/Image.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/Image.nonAndroid.kt
# | nonAndroidMain/kotlin/coil3/PlatformContext.kt -> src/vendor/nonAndroid/kotlin/coil3/PlatformContext.kt
# | nonAndroidMain/kotlin/coil3/RealImageLoader.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/RealImageLoader.nonAndroid.kt
# | nonAndroidMain/kotlin/coil3/decode/SkiaImageDecoder.kt -> src/vendor/nonAndroid/kotlin/coil3/decode/SkiaImageDecoder.kt
# | nonAndroidMain/kotlin/coil3/intercept/EngineInterceptor.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/intercept/EngineInterceptor.nonAndroid.kt
# | nonAndroidMain/kotlin/coil3/request/RequestService.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/request/RequestService.nonAndroid.kt
# | nonAndroidMain/kotlin/coil3/request/imageRequests.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/request/imageRequests.nonAndroid.kt
# | nonAndroidMain/kotlin/coil3/util/SystemCallbacks.kt -> src/vendor/nonAndroid/kotlin/coil3/util/SystemCallbacks.kt
# | nonAndroidMain/kotlin/coil3/util/contexts.kt -> src/vendor/nonAndroid/kotlin/coil3/util/contexts.kt
# | nonAndroidMain/kotlin/coil3/util/mimeTypes.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/util/mimeTypes.nonAndroid.kt
# | nonAndroidMain/kotlin/coil3/util/utils.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/util/utils.nonAndroid.kt
nonJvmCommonMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
# | nonJvmCommonMain/kotlin/coil3/RealImageLoader.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/RealImageLoader.nonJvmCommon.kt
# | nonJvmCommonMain/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt -> src/vendor/nonAndroid/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt
# | nonJvmCommonMain/kotlin/coil3/util/collections.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/util/collections.nonJvmCommon.kt
# | nonJvmCommonMain/kotlin/coil3/util/contexts.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/util/contexts.nonJvmCommon.kt
# | nonJvmCommonMain/kotlin/coil3/util/fileSystems.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/util/fileSystems.nonJvmCommon.kt
nonJsCommonMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
# | nonJsCommonMain/kotlin/coil3/disk/utils.kt -> src/vendor/nonAndroid/kotlin/coil3/disk/utils.kt
# | nonJsCommonMain/kotlin/coil3/imageLoaders.nonJsCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/imageLoaders.nonJsCommon.kt
# | nonJsCommonMain/kotlin/coil3/util/coroutines.nonJsCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/util/coroutines.nonJsCommon.kt
# | nonJsCommonMain/kotlin/coil3/util/fileSystems.nonJsCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/util/fileSystems.nonJsCommon.kt
nativeMain/kotlin/ -> src/vendor/native/kotlin/
# | nativeMain/kotlin/coil3/decode/SkiaImageDecoder.native.kt -> src/vendor/native/kotlin/coil3/decode/SkiaImageDecoder.native.kt
# | nativeMain/kotlin/coil3/util/utils.native.kt -> src/vendor/native/kotlin/coil3/util/utils.native.kt
appleMain/kotlin/ -> src/vendor/apple/kotlin/
# | appleMain/kotlin/coil3/RealImageLoader.apple.kt -> src/vendor/apple/kotlin/coil3/RealImageLoader.apple.kt
# | appleMain/kotlin/coil3/map/NSURLMapper.kt -> src/vendor/apple/kotlin/coil3/map/NSURLMapper.kt
nonAppleMain/kotlin/ -> src/vendor/nonApple/kotlin/
# | nonAppleMain/kotlin/coil3/RealImageLoader.nonApple.kt -> src/vendor/nonApple/kotlin/coil3/RealImageLoader.nonApple.kt
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
# androidMain (49)
# | androidMain/kotlin/coil3/ColorImage.kt -> src/vendor/android/kotlin/coil3/ColorImage.kt
# | androidMain/kotlin/coil3/EventListener.kt -> src/vendor/android/kotlin/coil3/EventListener.kt
# | androidMain/kotlin/coil3/Image.android.kt -> src/vendor/android/kotlin/coil3/Image.android.kt
# | androidMain/kotlin/coil3/PlatformContext.kt -> src/vendor/android/kotlin/coil3/PlatformContext.kt
# | androidMain/kotlin/coil3/RealImageLoader.android.kt -> src/vendor/android/kotlin/coil3/RealImageLoader.android.kt
# | androidMain/kotlin/coil3/Uri.android.kt -> src/vendor/android/kotlin/coil3/Uri.android.kt
# | androidMain/kotlin/coil3/decode/BitmapFactoryDecoder.kt -> src/vendor/android/kotlin/coil3/decode/BitmapFactoryDecoder.kt
# | androidMain/kotlin/coil3/decode/ExifOrientationStrategy.kt -> src/vendor/android/kotlin/coil3/decode/ExifOrientationStrategy.kt
# | androidMain/kotlin/coil3/decode/ExifUtils.kt -> src/vendor/android/kotlin/coil3/decode/ExifUtils.kt
# | androidMain/kotlin/coil3/decode/ImageSource.kt -> src/vendor/android/kotlin/coil3/decode/ImageSource.kt
# | androidMain/kotlin/coil3/decode/StaticImageDecoder.kt -> src/vendor/android/kotlin/coil3/decode/StaticImageDecoder.kt
# | androidMain/kotlin/coil3/fetch/AssetUriFetcher.kt -> src/vendor/android/kotlin/coil3/fetch/AssetUriFetcher.kt
# | androidMain/kotlin/coil3/fetch/ContentUriFetcher.kt -> src/vendor/android/kotlin/coil3/fetch/ContentUriFetcher.kt
# | androidMain/kotlin/coil3/fetch/DrawableFetcher.kt -> src/vendor/android/kotlin/coil3/fetch/DrawableFetcher.kt
# | androidMain/kotlin/coil3/fetch/ResourceUriFetcher.kt -> src/vendor/android/kotlin/coil3/fetch/ResourceUriFetcher.kt
# | androidMain/kotlin/coil3/imageLoaders.android.kt -> src/vendor/android/kotlin/coil3/imageLoaders.android.kt
# | androidMain/kotlin/coil3/intercept/EngineInterceptor.android.kt -> src/vendor/android/kotlin/coil3/intercept/EngineInterceptor.android.kt
# | androidMain/kotlin/coil3/key/AndroidResourceUriKeyer.kt -> src/vendor/android/kotlin/coil3/key/AndroidResourceUriKeyer.kt
# | androidMain/kotlin/coil3/map/AndroidUriMapper.kt -> src/vendor/android/kotlin/coil3/map/AndroidUriMapper.kt
# | androidMain/kotlin/coil3/map/ResourceIntMapper.kt -> src/vendor/android/kotlin/coil3/map/ResourceIntMapper.kt
# | androidMain/kotlin/coil3/request/RequestDelegate.android.kt -> src/vendor/android/kotlin/coil3/request/RequestDelegate.android.kt
# | androidMain/kotlin/coil3/request/RequestService.android.kt -> src/vendor/android/kotlin/coil3/request/RequestService.android.kt
# | androidMain/kotlin/coil3/request/ViewTargetDisposable.kt -> src/vendor/android/kotlin/coil3/request/ViewTargetDisposable.kt
# | androidMain/kotlin/coil3/request/ViewTargetRequestManager.kt -> src/vendor/android/kotlin/coil3/request/ViewTargetRequestManager.kt
# | androidMain/kotlin/coil3/request/imageRequests.android.kt -> src/vendor/android/kotlin/coil3/request/imageRequests.android.kt
# | androidMain/kotlin/coil3/size/RealViewSizeResolver.kt -> src/vendor/android/kotlin/coil3/size/RealViewSizeResolver.kt
# | androidMain/kotlin/coil3/size/ScaleDrawable.kt -> src/vendor/android/kotlin/coil3/size/ScaleDrawable.kt
# | androidMain/kotlin/coil3/size/ViewSizeResolver.kt -> src/vendor/android/kotlin/coil3/size/ViewSizeResolver.kt
# | androidMain/kotlin/coil3/target/GenericViewTarget.kt -> src/vendor/android/kotlin/coil3/target/GenericViewTarget.kt
# | androidMain/kotlin/coil3/target/ImageViewTarget.kt -> src/vendor/android/kotlin/coil3/target/ImageViewTarget.kt
# | androidMain/kotlin/coil3/target/ViewTarget.kt -> src/vendor/android/kotlin/coil3/target/ViewTarget.kt
# | androidMain/kotlin/coil3/transform/CircleCropTransformation.kt -> src/vendor/android/kotlin/coil3/transform/CircleCropTransformation.kt
# | androidMain/kotlin/coil3/transform/RoundedCornersTransformation.kt -> src/vendor/android/kotlin/coil3/transform/RoundedCornersTransformation.kt
# | androidMain/kotlin/coil3/transform/transformations.kt -> src/vendor/android/kotlin/coil3/transform/transformations.kt
# | androidMain/kotlin/coil3/transition/CrossfadeDrawable.kt -> src/vendor/android/kotlin/coil3/transition/CrossfadeDrawable.kt
# | androidMain/kotlin/coil3/transition/CrossfadeTransition.kt -> src/vendor/android/kotlin/coil3/transition/CrossfadeTransition.kt
# | androidMain/kotlin/coil3/transition/NoneTransition.kt -> src/vendor/android/kotlin/coil3/transition/NoneTransition.kt
# | androidMain/kotlin/coil3/transition/Transition.kt -> src/vendor/android/kotlin/coil3/transition/Transition.kt
# | androidMain/kotlin/coil3/transition/TransitionTarget.kt -> src/vendor/android/kotlin/coil3/transition/TransitionTarget.kt
# | androidMain/kotlin/coil3/util/CoilUtils.kt -> src/vendor/android/kotlin/coil3/util/CoilUtils.kt
# | androidMain/kotlin/coil3/util/DrawableUtils.kt -> src/vendor/android/kotlin/coil3/util/DrawableUtils.kt
# | androidMain/kotlin/coil3/util/SystemCallbacks.kt -> src/vendor/android/kotlin/coil3/util/SystemCallbacks.kt
# | androidMain/kotlin/coil3/util/bitmaps.kt -> src/vendor/android/kotlin/coil3/util/bitmaps.kt
# | androidMain/kotlin/coil3/util/contexts.kt -> src/vendor/android/kotlin/coil3/util/contexts.kt
# | androidMain/kotlin/coil3/util/fileSystems.android.kt -> src/vendor/android/kotlin/coil3/util/fileSystems.android.kt
# | androidMain/kotlin/coil3/util/hardwareBitmaps.kt -> src/vendor/android/kotlin/coil3/util/hardwareBitmaps.kt
# | androidMain/kotlin/coil3/util/lifecycles.kt -> src/vendor/android/kotlin/coil3/util/lifecycles.kt
# | androidMain/kotlin/coil3/util/mimeTypes.android.kt -> src/vendor/android/kotlin/coil3/util/mimeTypes.android.kt
# | androidMain/kotlin/coil3/util/utils.android.kt -> src/vendor/android/kotlin/coil3/util/utils.android.kt
# jsCommonMain (7)
# | jsCommonMain/kotlin/coil3/decode/SkiaImageDecoder.jsCommon.kt -> src/vendor/jsCommon/kotlin/coil3/decode/SkiaImageDecoder.jsCommon.kt
# | jsCommonMain/kotlin/coil3/decode/WebWorker.kt -> src/vendor/jsCommon/kotlin/coil3/decode/WebWorker.kt
# | jsCommonMain/kotlin/coil3/disk/utils.kt -> src/vendor/jsCommon/kotlin/coil3/disk/utils.kt
# | jsCommonMain/kotlin/coil3/fetch/BlobUriFetcher.kt -> src/vendor/jsCommon/kotlin/coil3/fetch/BlobUriFetcher.kt
# | jsCommonMain/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.kt -> src/vendor/jsCommon/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.kt
# | jsCommonMain/kotlin/coil3/util/coroutines.kt -> src/vendor/jsCommon/kotlin/coil3/util/coroutines.kt
# | jsCommonMain/kotlin/coil3/util/fileSystems.kt -> src/vendor/jsCommon/kotlin/coil3/util/fileSystems.kt
# jsMain (3)
# | jsMain/kotlin/coil3/fetch/BlobUriFetcher.js.kt -> src/vendor/js/kotlin/coil3/fetch/BlobUriFetcher.js.kt
# | jsMain/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.js.kt -> src/vendor/js/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.js.kt
# | jsMain/kotlin/coil3/util/utils.js.kt -> src/vendor/js/kotlin/coil3/util/utils.js.kt
# jvmCommonMain (10)
# | jvmCommonMain/kotlin/coil3/RealImageLoader.jvmCommon.kt -> src/vendor/jvmCommon/kotlin/coil3/RealImageLoader.jvmCommon.kt
# | jvmCommonMain/kotlin/coil3/decode/ImageSource.kt -> src/vendor/jvmCommon/kotlin/coil3/decode/ImageSource.kt
# | jvmCommonMain/kotlin/coil3/disk/diskCache.kt -> src/vendor/jvmCommon/kotlin/coil3/disk/diskCache.kt
# | jvmCommonMain/kotlin/coil3/fetch/ByteBufferFetcher.kt -> src/vendor/jvmCommon/kotlin/coil3/fetch/ByteBufferFetcher.kt
# | jvmCommonMain/kotlin/coil3/fetch/JarFileFetcher.kt -> src/vendor/jvmCommon/kotlin/coil3/fetch/JarFileFetcher.kt
# | jvmCommonMain/kotlin/coil3/imageLoaders.hidden.kt -> src/vendor/jvmCommon/kotlin/coil3/imageLoaders.hidden.kt
# | jvmCommonMain/kotlin/coil3/map/FileMapper.kt -> src/vendor/jvmCommon/kotlin/coil3/map/FileMapper.kt
# | jvmCommonMain/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt -> src/vendor/jvmCommon/kotlin/coil3/util/ServiceLoaderComponentRegistry.kt
# | jvmCommonMain/kotlin/coil3/util/collections.jvmCommon.kt -> src/vendor/jvmCommon/kotlin/coil3/util/collections.jvmCommon.kt
# | jvmCommonMain/kotlin/coil3/util/utils.jvmCommon.kt -> src/vendor/jvmCommon/kotlin/coil3/util/utils.jvmCommon.kt
# jvmMain (3)
# | jvmMain/kotlin/coil3/decode/SkiaImageDecoder.jvm.kt -> src/vendor/jvm/kotlin/coil3/decode/SkiaImageDecoder.jvm.kt
# | jvmMain/kotlin/coil3/util/contexts.jvm.kt -> src/vendor/jvm/kotlin/coil3/util/contexts.jvm.kt
# | jvmMain/kotlin/coil3/util/fileSystems.jvm.kt -> src/vendor/jvm/kotlin/coil3/util/fileSystems.jvm.kt
# wasmJsMain (3)
# | wasmJsMain/kotlin/coil3/fetch/BlobUriFetcher.wasmJs.kt -> src/vendor/wasmJs/kotlin/coil3/fetch/BlobUriFetcher.wasmJs.kt
# | wasmJsMain/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.wasmJs.kt -> src/vendor/wasmJs/kotlin/coil3/fetch/internal/BlobUriFetcherServiceLoaderTarget.wasmJs.kt
# | wasmJsMain/kotlin/coil3/util/utils.wasmJs.kt -> src/vendor/wasmJs/kotlin/coil3/util/utils.wasmJs.kt
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
@@ -0,0 +1,197 @@
// Klib ABI Dump
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
// Rendering settings:
// - Signature version: 2
// - Show manifest properties: true
// - Show declarations: true
// Library unique name: <com.bitsycore.coil3:coil-network-core>
abstract fun interface coil3.network/ConnectivityChecker { // coil3.network/ConnectivityChecker|null[0]
abstract fun isOnline(): kotlin/Boolean // coil3.network/ConnectivityChecker.isOnline|isOnline(){}[0]
final object Companion { // coil3.network/ConnectivityChecker.Companion|null[0]
final val ONLINE // coil3.network/ConnectivityChecker.Companion.ONLINE|{}ONLINE[0]
final fun <get-ONLINE>(): coil3.network/ConnectivityChecker // coil3.network/ConnectivityChecker.Companion.ONLINE.<get-ONLINE>|<get-ONLINE>(){}[0]
}
}
abstract interface coil3.network/CacheStrategy { // coil3.network/CacheStrategy|null[0]
abstract suspend fun read(coil3.network/NetworkResponse, coil3.network/NetworkRequest, coil3.request/Options): coil3.network/CacheStrategy.ReadResult // coil3.network/CacheStrategy.read|read(coil3.network.NetworkResponse;coil3.network.NetworkRequest;coil3.request.Options){}[0]
abstract suspend fun write(coil3.network/NetworkResponse?, coil3.network/NetworkRequest, coil3.network/NetworkResponse, coil3.request/Options): coil3.network/CacheStrategy.WriteResult // coil3.network/CacheStrategy.write|write(coil3.network.NetworkResponse?;coil3.network.NetworkRequest;coil3.network.NetworkResponse;coil3.request.Options){}[0]
final class ReadResult { // coil3.network/CacheStrategy.ReadResult|null[0]
constructor <init>(coil3.network/NetworkRequest) // coil3.network/CacheStrategy.ReadResult.<init>|<init>(coil3.network.NetworkRequest){}[0]
constructor <init>(coil3.network/NetworkResponse) // coil3.network/CacheStrategy.ReadResult.<init>|<init>(coil3.network.NetworkResponse){}[0]
final val request // coil3.network/CacheStrategy.ReadResult.request|{}request[0]
final fun <get-request>(): coil3.network/NetworkRequest? // coil3.network/CacheStrategy.ReadResult.request.<get-request>|<get-request>(){}[0]
final val response // coil3.network/CacheStrategy.ReadResult.response|{}response[0]
final fun <get-response>(): coil3.network/NetworkResponse? // coil3.network/CacheStrategy.ReadResult.response.<get-response>|<get-response>(){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.network/CacheStrategy.ReadResult.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // coil3.network/CacheStrategy.ReadResult.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // coil3.network/CacheStrategy.ReadResult.toString|toString(){}[0]
}
final class WriteResult { // coil3.network/CacheStrategy.WriteResult|null[0]
constructor <init>(coil3.network/NetworkResponse) // coil3.network/CacheStrategy.WriteResult.<init>|<init>(coil3.network.NetworkResponse){}[0]
final val response // coil3.network/CacheStrategy.WriteResult.response|{}response[0]
final fun <get-response>(): coil3.network/NetworkResponse? // coil3.network/CacheStrategy.WriteResult.response.<get-response>|<get-response>(){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.network/CacheStrategy.WriteResult.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // coil3.network/CacheStrategy.WriteResult.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // coil3.network/CacheStrategy.WriteResult.toString|toString(){}[0]
final object Companion { // coil3.network/CacheStrategy.WriteResult.Companion|null[0]
final val DISABLED // coil3.network/CacheStrategy.WriteResult.Companion.DISABLED|{}DISABLED[0]
final fun <get-DISABLED>(): coil3.network/CacheStrategy.WriteResult // coil3.network/CacheStrategy.WriteResult.Companion.DISABLED.<get-DISABLED>|<get-DISABLED>(){}[0]
}
}
final object Companion { // coil3.network/CacheStrategy.Companion|null[0]
final val DEFAULT // coil3.network/CacheStrategy.Companion.DEFAULT|{}DEFAULT[0]
final fun <get-DEFAULT>(): coil3.network/CacheStrategy // coil3.network/CacheStrategy.Companion.DEFAULT.<get-DEFAULT>|<get-DEFAULT>(){}[0]
}
}
abstract interface coil3.network/ConcurrentRequestStrategy { // coil3.network/ConcurrentRequestStrategy|null[0]
abstract suspend fun apply(kotlin/String, kotlin.coroutines/SuspendFunction0<coil3.fetch/FetchResult>): coil3.fetch/FetchResult // coil3.network/ConcurrentRequestStrategy.apply|apply(kotlin.String;kotlin.coroutines.SuspendFunction0<coil3.fetch.FetchResult>){}[0]
final object Companion { // coil3.network/ConcurrentRequestStrategy.Companion|null[0]
final val UNCOORDINATED // coil3.network/ConcurrentRequestStrategy.Companion.UNCOORDINATED|{}UNCOORDINATED[0]
final fun <get-UNCOORDINATED>(): coil3.network/ConcurrentRequestStrategy // coil3.network/ConcurrentRequestStrategy.Companion.UNCOORDINATED.<get-UNCOORDINATED>|<get-UNCOORDINATED>(){}[0]
}
}
abstract interface coil3.network/NetworkClient { // coil3.network/NetworkClient|null[0]
abstract suspend fun <#A1: kotlin/Any?> executeRequest(coil3.network/NetworkRequest, kotlin.coroutines/SuspendFunction1<coil3.network/NetworkResponse, #A1>): #A1 // coil3.network/NetworkClient.executeRequest|executeRequest(coil3.network.NetworkRequest;kotlin.coroutines.SuspendFunction1<coil3.network.NetworkResponse,0:0>){0§<kotlin.Any?>}[0]
}
abstract interface coil3.network/NetworkRequestBody { // coil3.network/NetworkRequestBody|null[0]
abstract suspend fun writeTo(okio/BufferedSink) // coil3.network/NetworkRequestBody.writeTo|writeTo(okio.BufferedSink){}[0]
}
abstract interface coil3.network/NetworkResponseBody : kotlin/AutoCloseable { // coil3.network/NetworkResponseBody|null[0]
abstract suspend fun writeTo(okio/BufferedSink) // coil3.network/NetworkResponseBody.writeTo|writeTo(okio.BufferedSink){}[0]
abstract suspend fun writeTo(okio/FileSystem, okio/Path) // coil3.network/NetworkResponseBody.writeTo|writeTo(okio.FileSystem;okio.Path){}[0]
}
final class coil3.network/DeDupeConcurrentRequestStrategy : coil3.network/ConcurrentRequestStrategy { // coil3.network/DeDupeConcurrentRequestStrategy|null[0]
constructor <init>() // coil3.network/DeDupeConcurrentRequestStrategy.<init>|<init>(){}[0]
final suspend fun apply(kotlin/String, kotlin.coroutines/SuspendFunction0<coil3.fetch/FetchResult>): coil3.fetch/FetchResult // coil3.network/DeDupeConcurrentRequestStrategy.apply|apply(kotlin.String;kotlin.coroutines.SuspendFunction0<coil3.fetch.FetchResult>){}[0]
}
final class coil3.network/HttpException : kotlin/RuntimeException { // coil3.network/HttpException|null[0]
constructor <init>(coil3.network/NetworkResponse) // coil3.network/HttpException.<init>|<init>(coil3.network.NetworkResponse){}[0]
final val response // coil3.network/HttpException.response|{}response[0]
final fun <get-response>(): coil3.network/NetworkResponse // coil3.network/HttpException.response.<get-response>|<get-response>(){}[0]
}
final class coil3.network/NetworkFetcher : coil3.fetch/Fetcher { // coil3.network/NetworkFetcher|null[0]
constructor <init>(kotlin/String, coil3.request/Options, kotlin/Lazy<coil3.network/NetworkClient>, kotlin/Lazy<coil3.disk/DiskCache?>, kotlin/Lazy<coil3.network/CacheStrategy>, coil3.network/ConnectivityChecker) // coil3.network/NetworkFetcher.<init>|<init>(kotlin.String;coil3.request.Options;kotlin.Lazy<coil3.network.NetworkClient>;kotlin.Lazy<coil3.disk.DiskCache?>;kotlin.Lazy<coil3.network.CacheStrategy>;coil3.network.ConnectivityChecker){}[0]
constructor <init>(kotlin/String, coil3.request/Options, kotlin/Lazy<coil3.network/NetworkClient>, kotlin/Lazy<coil3.disk/DiskCache?>, kotlin/Lazy<coil3.network/CacheStrategy>, kotlin/Lazy<coil3.network/ConnectivityChecker>, kotlin/Lazy<coil3.network/ConcurrentRequestStrategy>) // coil3.network/NetworkFetcher.<init>|<init>(kotlin.String;coil3.request.Options;kotlin.Lazy<coil3.network.NetworkClient>;kotlin.Lazy<coil3.disk.DiskCache?>;kotlin.Lazy<coil3.network.CacheStrategy>;kotlin.Lazy<coil3.network.ConnectivityChecker>;kotlin.Lazy<coil3.network.ConcurrentRequestStrategy>){}[0]
final fun getMimeType(kotlin/String, kotlin/String?): kotlin/String? // coil3.network/NetworkFetcher.getMimeType|getMimeType(kotlin.String;kotlin.String?){}[0]
final suspend fun fetch(): coil3.fetch/FetchResult // coil3.network/NetworkFetcher.fetch|fetch(){}[0]
final class Factory : coil3.fetch/Fetcher.Factory<coil3/Uri> { // coil3.network/NetworkFetcher.Factory|null[0]
constructor <init>(kotlin/Function0<coil3.network/NetworkClient>, kotlin/Function0<coil3.network/CacheStrategy> = ..., kotlin/Function1<coil3/PlatformContext, coil3.network/ConnectivityChecker> = ...) // coil3.network/NetworkFetcher.Factory.<init>|<init>(kotlin.Function0<coil3.network.NetworkClient>;kotlin.Function0<coil3.network.CacheStrategy>;kotlin.Function1<coil3.PlatformContext,coil3.network.ConnectivityChecker>){}[0]
constructor <init>(kotlin/Function0<coil3.network/NetworkClient>, kotlin/Function0<coil3.network/CacheStrategy> = ..., kotlin/Function1<coil3/PlatformContext, coil3.network/ConnectivityChecker> = ..., kotlin/Function0<coil3.network/ConcurrentRequestStrategy> = ...) // coil3.network/NetworkFetcher.Factory.<init>|<init>(kotlin.Function0<coil3.network.NetworkClient>;kotlin.Function0<coil3.network.CacheStrategy>;kotlin.Function1<coil3.PlatformContext,coil3.network.ConnectivityChecker>;kotlin.Function0<coil3.network.ConcurrentRequestStrategy>){}[0]
final fun create(coil3/Uri, coil3.request/Options, coil3/ImageLoader): coil3.fetch/Fetcher? // coil3.network/NetworkFetcher.Factory.create|create(coil3.Uri;coil3.request.Options;coil3.ImageLoader){}[0]
}
}
final class coil3.network/NetworkHeaders { // coil3.network/NetworkHeaders|null[0]
final fun asMap(): kotlin.collections/Map<kotlin/String, kotlin.collections/List<kotlin/String>> // coil3.network/NetworkHeaders.asMap|asMap(){}[0]
final fun get(kotlin/String): kotlin/String? // coil3.network/NetworkHeaders.get|get(kotlin.String){}[0]
final fun getAll(kotlin/String): kotlin.collections/List<kotlin/String> // coil3.network/NetworkHeaders.getAll|getAll(kotlin.String){}[0]
final fun newBuilder(): coil3.network/NetworkHeaders.Builder // coil3.network/NetworkHeaders.newBuilder|newBuilder(){}[0]
final class Builder { // coil3.network/NetworkHeaders.Builder|null[0]
constructor <init>() // coil3.network/NetworkHeaders.Builder.<init>|<init>(){}[0]
constructor <init>(coil3.network/NetworkHeaders) // coil3.network/NetworkHeaders.Builder.<init>|<init>(coil3.network.NetworkHeaders){}[0]
final fun add(kotlin/String, kotlin/String): coil3.network/NetworkHeaders.Builder // coil3.network/NetworkHeaders.Builder.add|add(kotlin.String;kotlin.String){}[0]
final fun build(): coil3.network/NetworkHeaders // coil3.network/NetworkHeaders.Builder.build|build(){}[0]
final fun set(kotlin/String, kotlin.collections/List<kotlin/String>): coil3.network/NetworkHeaders.Builder // coil3.network/NetworkHeaders.Builder.set|set(kotlin.String;kotlin.collections.List<kotlin.String>){}[0]
final fun set(kotlin/String, kotlin/String): coil3.network/NetworkHeaders.Builder // coil3.network/NetworkHeaders.Builder.set|set(kotlin.String;kotlin.String){}[0]
}
final object Companion { // coil3.network/NetworkHeaders.Companion|null[0]
final val EMPTY // coil3.network/NetworkHeaders.Companion.EMPTY|{}EMPTY[0]
final fun <get-EMPTY>(): coil3.network/NetworkHeaders // coil3.network/NetworkHeaders.Companion.EMPTY.<get-EMPTY>|<get-EMPTY>(){}[0]
}
}
final class coil3.network/NetworkRequest { // coil3.network/NetworkRequest|null[0]
constructor <init>(kotlin/String, kotlin/String = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkRequestBody? = ...) // coil3.network/NetworkRequest.<init>|<init>(kotlin.String;kotlin.String;coil3.network.NetworkHeaders;coil3.network.NetworkRequestBody?){}[0]
constructor <init>(kotlin/String, kotlin/String = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkRequestBody? = ..., coil3/Extras = ...) // coil3.network/NetworkRequest.<init>|<init>(kotlin.String;kotlin.String;coil3.network.NetworkHeaders;coil3.network.NetworkRequestBody?;coil3.Extras){}[0]
final val body // coil3.network/NetworkRequest.body|{}body[0]
final fun <get-body>(): coil3.network/NetworkRequestBody? // coil3.network/NetworkRequest.body.<get-body>|<get-body>(){}[0]
final val extras // coil3.network/NetworkRequest.extras|{}extras[0]
final fun <get-extras>(): coil3/Extras // coil3.network/NetworkRequest.extras.<get-extras>|<get-extras>(){}[0]
final val headers // coil3.network/NetworkRequest.headers|{}headers[0]
final fun <get-headers>(): coil3.network/NetworkHeaders // coil3.network/NetworkRequest.headers.<get-headers>|<get-headers>(){}[0]
final val method // coil3.network/NetworkRequest.method|{}method[0]
final fun <get-method>(): kotlin/String // coil3.network/NetworkRequest.method.<get-method>|<get-method>(){}[0]
final val url // coil3.network/NetworkRequest.url|{}url[0]
final fun <get-url>(): kotlin/String // coil3.network/NetworkRequest.url.<get-url>|<get-url>(){}[0]
final fun copy(kotlin/String = ..., kotlin/String = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkRequestBody? = ...): coil3.network/NetworkRequest // coil3.network/NetworkRequest.copy|copy(kotlin.String;kotlin.String;coil3.network.NetworkHeaders;coil3.network.NetworkRequestBody?){}[0]
final fun copy(kotlin/String = ..., kotlin/String = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkRequestBody? = ..., coil3/Extras = ...): coil3.network/NetworkRequest // coil3.network/NetworkRequest.copy|copy(kotlin.String;kotlin.String;coil3.network.NetworkHeaders;coil3.network.NetworkRequestBody?;coil3.Extras){}[0]
}
final class coil3.network/NetworkResponse { // coil3.network/NetworkResponse|null[0]
constructor <init>(kotlin/Int = ..., kotlin/Long = ..., kotlin/Long = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkResponseBody? = ..., kotlin/Any? = ...) // coil3.network/NetworkResponse.<init>|<init>(kotlin.Int;kotlin.Long;kotlin.Long;coil3.network.NetworkHeaders;coil3.network.NetworkResponseBody?;kotlin.Any?){}[0]
final val body // coil3.network/NetworkResponse.body|{}body[0]
final fun <get-body>(): coil3.network/NetworkResponseBody? // coil3.network/NetworkResponse.body.<get-body>|<get-body>(){}[0]
final val code // coil3.network/NetworkResponse.code|{}code[0]
final fun <get-code>(): kotlin/Int // coil3.network/NetworkResponse.code.<get-code>|<get-code>(){}[0]
final val delegate // coil3.network/NetworkResponse.delegate|{}delegate[0]
final fun <get-delegate>(): kotlin/Any? // coil3.network/NetworkResponse.delegate.<get-delegate>|<get-delegate>(){}[0]
final val headers // coil3.network/NetworkResponse.headers|{}headers[0]
final fun <get-headers>(): coil3.network/NetworkHeaders // coil3.network/NetworkResponse.headers.<get-headers>|<get-headers>(){}[0]
final val requestMillis // coil3.network/NetworkResponse.requestMillis|{}requestMillis[0]
final fun <get-requestMillis>(): kotlin/Long // coil3.network/NetworkResponse.requestMillis.<get-requestMillis>|<get-requestMillis>(){}[0]
final val responseMillis // coil3.network/NetworkResponse.responseMillis|{}responseMillis[0]
final fun <get-responseMillis>(): kotlin/Long // coil3.network/NetworkResponse.responseMillis.<get-responseMillis>|<get-responseMillis>(){}[0]
final fun copy(kotlin/Int = ..., kotlin/Long = ..., kotlin/Long = ..., coil3.network/NetworkHeaders = ..., coil3.network/NetworkResponseBody? = ..., kotlin/Any? = ...): coil3.network/NetworkResponse // coil3.network/NetworkResponse.copy|copy(kotlin.Int;kotlin.Long;kotlin.Long;coil3.network.NetworkHeaders;coil3.network.NetworkResponseBody?;kotlin.Any?){}[0]
}
final object coil3.network/CacheNetworkResponse { // coil3.network/CacheNetworkResponse|null[0]
final fun readFrom(okio/BufferedSource): coil3.network/NetworkResponse // coil3.network/CacheNetworkResponse.readFrom|readFrom(okio.BufferedSource){}[0]
final fun writeTo(coil3.network/NetworkResponse, okio/BufferedSink) // coil3.network/CacheNetworkResponse.writeTo|writeTo(coil3.network.NetworkResponse;okio.BufferedSink){}[0]
}
final val coil3.network/httpBody // coil3.network/httpBody|@coil3.Extras.Key.Companion{}httpBody[0]
final fun (coil3/Extras.Key.Companion).<get-httpBody>(): coil3/Extras.Key<coil3.network/NetworkRequestBody?> // coil3.network/httpBody.<get-httpBody>|<get-httpBody>@coil3.Extras.Key.Companion(){}[0]
final val coil3.network/httpBody // coil3.network/httpBody|@coil3.request.ImageRequest{}httpBody[0]
final fun (coil3.request/ImageRequest).<get-httpBody>(): coil3.network/NetworkRequestBody? // coil3.network/httpBody.<get-httpBody>|<get-httpBody>@coil3.request.ImageRequest(){}[0]
final val coil3.network/httpBody // coil3.network/httpBody|@coil3.request.Options{}httpBody[0]
final fun (coil3.request/Options).<get-httpBody>(): coil3.network/NetworkRequestBody? // coil3.network/httpBody.<get-httpBody>|<get-httpBody>@coil3.request.Options(){}[0]
final val coil3.network/httpHeaders // coil3.network/httpHeaders|@coil3.Extras.Key.Companion{}httpHeaders[0]
final fun (coil3/Extras.Key.Companion).<get-httpHeaders>(): coil3/Extras.Key<coil3.network/NetworkHeaders> // coil3.network/httpHeaders.<get-httpHeaders>|<get-httpHeaders>@coil3.Extras.Key.Companion(){}[0]
final val coil3.network/httpHeaders // coil3.network/httpHeaders|@coil3.request.ImageRequest{}httpHeaders[0]
final fun (coil3.request/ImageRequest).<get-httpHeaders>(): coil3.network/NetworkHeaders // coil3.network/httpHeaders.<get-httpHeaders>|<get-httpHeaders>@coil3.request.ImageRequest(){}[0]
final val coil3.network/httpHeaders // coil3.network/httpHeaders|@coil3.request.Options{}httpHeaders[0]
final fun (coil3.request/Options).<get-httpHeaders>(): coil3.network/NetworkHeaders // coil3.network/httpHeaders.<get-httpHeaders>|<get-httpHeaders>@coil3.request.Options(){}[0]
final val coil3.network/httpMethod // coil3.network/httpMethod|@coil3.Extras.Key.Companion{}httpMethod[0]
final fun (coil3/Extras.Key.Companion).<get-httpMethod>(): coil3/Extras.Key<kotlin/String> // coil3.network/httpMethod.<get-httpMethod>|<get-httpMethod>@coil3.Extras.Key.Companion(){}[0]
final val coil3.network/httpMethod // coil3.network/httpMethod|@coil3.request.ImageRequest{}httpMethod[0]
final fun (coil3.request/ImageRequest).<get-httpMethod>(): kotlin/String // coil3.network/httpMethod.<get-httpMethod>|<get-httpMethod>@coil3.request.ImageRequest(){}[0]
final val coil3.network/httpMethod // coil3.network/httpMethod|@coil3.request.Options{}httpMethod[0]
final fun (coil3.request/Options).<get-httpMethod>(): kotlin/String // coil3.network/httpMethod.<get-httpMethod>|<get-httpMethod>@coil3.request.Options(){}[0]
final fun (coil3.request/ImageRequest.Builder).coil3.network/httpBody(coil3.network/NetworkRequestBody): coil3.request/ImageRequest.Builder // coil3.network/httpBody|httpBody@coil3.request.ImageRequest.Builder(coil3.network.NetworkRequestBody){}[0]
final fun (coil3.request/ImageRequest.Builder).coil3.network/httpHeaders(coil3.network/NetworkHeaders): coil3.request/ImageRequest.Builder // coil3.network/httpHeaders|httpHeaders@coil3.request.ImageRequest.Builder(coil3.network.NetworkHeaders){}[0]
final fun (coil3.request/ImageRequest.Builder).coil3.network/httpMethod(kotlin/String): coil3.request/ImageRequest.Builder // coil3.network/httpMethod|httpMethod@coil3.request.ImageRequest.Builder(kotlin.String){}[0]
final fun coil3.network/ConnectivityChecker(coil3/PlatformContext): coil3.network/ConnectivityChecker // coil3.network/ConnectivityChecker|ConnectivityChecker(coil3.PlatformContext){}[0]
final fun coil3.network/NetworkRequestBody(okio/ByteString): coil3.network/NetworkRequestBody // coil3.network/NetworkRequestBody|NetworkRequestBody(okio.ByteString){}[0]
final fun coil3.network/NetworkResponseBody(okio/BufferedSource): coil3.network/NetworkResponseBody // coil3.network/NetworkResponseBody|NetworkResponseBody(okio.BufferedSource){}[0]
+34
View File
@@ -0,0 +1,34 @@
// :coil:coil-network-core - vendored VERBATIM from coil-kt/coil into src/vendor.
// Shared network-fetcher plumbing. Upstream: linux + apple, no mingwX64.
// Upstream's non* source sets sit ABOVE nativeMain here (see coil-core's build file
// and compose-fork.txt for why). Provenance = compose-fork.txt; never hand-edit
// src/vendor - change the manifest + `python scripts/compose-fork/sync.py coil/coil-network-core`.
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
dependencies { api(project(":coil:coil-core")) }
kotlin.srcDir("src/vendor/common/kotlin")
}
val vNonAndroidMain = create("coilNonAndroidMain") {
dependsOn(commonMain.get())
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
}
nativeMain { dependsOn(vNonAndroidMain) }
}
compilerOptions {
freeCompilerArgs.addAll(
"-Xexpect-actual-classes",
"-opt-in=coil3.annotation.InternalCoilApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=coil3.annotation.DelicateCoilApi",
)
}
}
+37
View File
@@ -0,0 +1,37 @@
# :coil:coil-network-core - shared network fetcher plumbing.
# Upstream: linux + apple, no mingwX64.
# Upstream source sets collapse onto this port: it has NO android, jvm or js
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
# hierarchy template).
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
SET_FOLDER=coil-network-core/src
commonMain/kotlin/ -> src/vendor/common/kotlin/
# | commonMain/kotlin/coil3/network/CacheNetworkResponse.kt -> src/vendor/common/kotlin/coil3/network/CacheNetworkResponse.kt
# | commonMain/kotlin/coil3/network/CacheStrategy.kt -> src/vendor/common/kotlin/coil3/network/CacheStrategy.kt
# | commonMain/kotlin/coil3/network/ConcurrentRequestStrategy.kt -> src/vendor/common/kotlin/coil3/network/ConcurrentRequestStrategy.kt
# | commonMain/kotlin/coil3/network/ConnectivityChecker.kt -> src/vendor/common/kotlin/coil3/network/ConnectivityChecker.kt
# | commonMain/kotlin/coil3/network/HttpException.kt -> src/vendor/common/kotlin/coil3/network/HttpException.kt
# | commonMain/kotlin/coil3/network/NetworkClient.kt -> src/vendor/common/kotlin/coil3/network/NetworkClient.kt
# | commonMain/kotlin/coil3/network/NetworkFetcher.kt -> src/vendor/common/kotlin/coil3/network/NetworkFetcher.kt
# | commonMain/kotlin/coil3/network/NetworkHeaders.kt -> src/vendor/common/kotlin/coil3/network/NetworkHeaders.kt
# | commonMain/kotlin/coil3/network/imageRequests.kt -> src/vendor/common/kotlin/coil3/network/imageRequests.kt
# | commonMain/kotlin/coil3/network/internal/DefaultCacheStrategy.kt -> src/vendor/common/kotlin/coil3/network/internal/DefaultCacheStrategy.kt
# | commonMain/kotlin/coil3/network/internal/SingleParameterLazy.kt -> src/vendor/common/kotlin/coil3/network/internal/SingleParameterLazy.kt
# | commonMain/kotlin/coil3/network/internal/utils.kt -> src/vendor/common/kotlin/coil3/network/internal/utils.kt
nonAndroidMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
# | nonAndroidMain/kotlin/coil3/network/ConnectivityChecker.kt -> src/vendor/nonAndroid/kotlin/coil3/network/ConnectivityChecker.kt
# | nonAndroidMain/kotlin/coil3/network/internal/utils.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/network/internal/utils.nonAndroid.kt
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
# androidMain (2)
# | androidMain/kotlin/coil3/network/ConnectivityChecker.kt -> src/vendor/android/kotlin/coil3/network/ConnectivityChecker.kt
# | androidMain/kotlin/coil3/network/internal/utils.android.kt -> src/vendor/android/kotlin/coil3/network/internal/utils.android.kt
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
@@ -0,0 +1,18 @@
// Klib ABI Dump
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
// Rendering settings:
// - Signature version: 2
// - Show manifest properties: true
// - Show declarations: true
// Library unique name: <com.bitsycore.coil3:coil-network-ktor3>
final val coil3.network.ktor3.internal/initHook // coil3.network.ktor3.internal/initHook|{}initHook[0]
final fun <get-initHook>(): kotlin/Any // coil3.network.ktor3.internal/initHook.<get-initHook>|<get-initHook>(){}[0]
final fun (io.ktor.client/HttpClient).coil3.network.ktor3/asNetworkClient(): coil3.network/NetworkClient // coil3.network.ktor3/asNetworkClient|asNetworkClient@io.ktor.client.HttpClient(){}[0]
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(){}[0]
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(io.ktor.client/HttpClient): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(io.ktor.client.HttpClient){}[0]
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(io.ktor.client/HttpClient, coil3.network/ConcurrentRequestStrategy = ...): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(io.ktor.client.HttpClient;coil3.network.ConcurrentRequestStrategy){}[0]
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(kotlin/Function0<io.ktor.client/HttpClient> = ..., kotlin/Function0<coil3.network/CacheStrategy> = ..., kotlin/Function1<coil3/PlatformContext, coil3.network/ConnectivityChecker> = ...): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(kotlin.Function0<io.ktor.client.HttpClient>;kotlin.Function0<coil3.network.CacheStrategy>;kotlin.Function1<coil3.PlatformContext,coil3.network.ConnectivityChecker>){}[0]
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(kotlin/Function0<io.ktor.client/HttpClient> = ..., kotlin/Function0<coil3.network/CacheStrategy> = ..., kotlin/Function1<coil3/PlatformContext, coil3.network/ConnectivityChecker> = ..., kotlin/Function0<coil3.network/ConcurrentRequestStrategy> = ...): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(kotlin.Function0<io.ktor.client.HttpClient>;kotlin.Function0<coil3.network.CacheStrategy>;kotlin.Function1<coil3.PlatformContext,coil3.network.ConnectivityChecker>;kotlin.Function0<coil3.network.ConcurrentRequestStrategy>){}[0]
final fun coil3.network.ktor3/KtorNetworkFetcherFactory(kotlin/Function0<io.ktor.client/HttpClient>): coil3.network/NetworkFetcher.Factory // coil3.network.ktor3/KtorNetworkFetcherFactory|KtorNetworkFetcherFactory(kotlin.Function0<io.ktor.client.HttpClient>){}[0]
+42
View File
@@ -0,0 +1,42 @@
// :coil:coil-network-ktor3 - vendored VERBATIM from coil-kt/coil into src/vendor.
// Ktor 3 network fetcher, on the port's existing ktor pin.
// Upstream's non* source sets sit ABOVE nativeMain here (see coil-core's build file
// and compose-fork.txt for why). Provenance = compose-fork.txt; never hand-edit
// src/vendor - change the manifest + `python scripts/compose-fork/sync.py coil/coil-network-ktor3`.
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
dependencies {
api(project(":coil:coil-core"))
api(project(":coil:coil-network-core"))
api(libs.ktor.client.core)
}
kotlin.srcDir("src/vendor/common/kotlin")
}
val vNonAndroidMain = create("coilNonAndroidMain") {
dependsOn(commonMain.get())
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
dependencies { implementation(libs.kotlinx.io.okio) }
}
nativeMain {
dependsOn(vNonAndroidMain)
kotlin.srcDir("src/vendor/native/kotlin")
}
}
compilerOptions {
freeCompilerArgs.addAll(
"-Xexpect-actual-classes",
"-opt-in=coil3.annotation.InternalCoilApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=coil3.annotation.DelicateCoilApi",
)
}
}
+32
View File
@@ -0,0 +1,32 @@
# :coil:coil-network-ktor3 - Ktor 3 network fetcher. Upstream: linux + apple, no mingwX64.
# Pairs with the port's existing ktor pin (the `ktor` entry in libs.versions.toml).
# Upstream source sets collapse onto this port: it has NO android, jvm or js
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
# hierarchy template).
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
SET_FOLDER=coil-network-ktor3/src
commonMain/kotlin/ -> src/vendor/common/kotlin/
# | commonMain/kotlin/coil3/network/ktor3/KtorNetworkFetcher.kt -> src/vendor/common/kotlin/coil3/network/ktor3/KtorNetworkFetcher.kt
# | commonMain/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.kt -> src/vendor/common/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.kt
# | commonMain/kotlin/coil3/network/ktor3/internal/utils.kt -> src/vendor/common/kotlin/coil3/network/ktor3/internal/utils.kt
nonJvmCommonMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
# | nonJvmCommonMain/kotlin/coil3/network/ktor3/internal/utils.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/network/ktor3/internal/utils.nonJvmCommon.kt
nativeMain/kotlin/ -> src/vendor/native/kotlin/
# | nativeMain/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.native.kt -> src/vendor/native/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.native.kt
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
# jsMain (1)
# | jsMain/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.js.kt -> src/vendor/js/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.js.kt
# jvmCommonMain (1)
# | jvmCommonMain/kotlin/coil3/network/ktor3/internal/utils.jvmCommon.kt -> src/vendor/jvmCommon/kotlin/coil3/network/ktor3/internal/utils.jvmCommon.kt
# wasmJsMain (1)
# | wasmJsMain/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.wasmJs.kt -> src/vendor/wasmJs/kotlin/coil3/network/ktor3/internal/KtorNetworkFetcherServiceLoaderTarget.wasmJs.kt
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
+123
View File
@@ -0,0 +1,123 @@
// Klib ABI Dump
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
// Rendering settings:
// - Signature version: 2
// - Show manifest properties: true
// - Show declarations: true
// Library unique name: <com.bitsycore.coil3:coil-svg>
abstract interface coil3.svg/Svg { // coil3.svg/Svg|null[0]
abstract val height // coil3.svg/Svg.height|{}height[0]
abstract fun <get-height>(): kotlin/Float // coil3.svg/Svg.height.<get-height>|<get-height>(){}[0]
abstract val width // coil3.svg/Svg.width|{}width[0]
abstract fun <get-width>(): kotlin/Float // coil3.svg/Svg.width.<get-width>|<get-width>(){}[0]
abstract var viewBox // coil3.svg/Svg.viewBox|{}viewBox[0]
abstract fun <get-viewBox>(): coil3.svg/Svg.ViewBox? // coil3.svg/Svg.viewBox.<get-viewBox>|<get-viewBox>(){}[0]
abstract fun <set-viewBox>(coil3.svg/Svg.ViewBox?) // coil3.svg/Svg.viewBox.<set-viewBox>|<set-viewBox>(coil3.svg.Svg.ViewBox?){}[0]
abstract fun asImage(kotlin/Int = ..., kotlin/Int = ...): coil3/Image // coil3.svg/Svg.asImage|asImage(kotlin.Int;kotlin.Int){}[0]
abstract fun height(kotlin/String) // coil3.svg/Svg.height|height(kotlin.String){}[0]
abstract fun options(coil3.request/Options) // coil3.svg/Svg.options|options(coil3.request.Options){}[0]
abstract fun width(kotlin/String) // coil3.svg/Svg.width|width(kotlin.String){}[0]
abstract fun interface Parser { // coil3.svg/Svg.Parser|null[0]
abstract fun parse(okio/BufferedSource): coil3.svg/Svg // coil3.svg/Svg.Parser.parse|parse(okio.BufferedSource){}[0]
final object Companion { // coil3.svg/Svg.Parser.Companion|null[0]
final val DEFAULT // coil3.svg/Svg.Parser.Companion.DEFAULT|{}DEFAULT[0]
final fun <get-DEFAULT>(): coil3.svg/Svg.Parser // coil3.svg/Svg.Parser.Companion.DEFAULT.<get-DEFAULT>|<get-DEFAULT>(){}[0]
}
}
final class ViewBox { // coil3.svg/Svg.ViewBox|null[0]
constructor <init>(kotlin/Float, kotlin/Float, kotlin/Float, kotlin/Float) // coil3.svg/Svg.ViewBox.<init>|<init>(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
final val bottom // coil3.svg/Svg.ViewBox.bottom|{}bottom[0]
final fun <get-bottom>(): kotlin/Float // coil3.svg/Svg.ViewBox.bottom.<get-bottom>|<get-bottom>(){}[0]
final val left // coil3.svg/Svg.ViewBox.left|{}left[0]
final fun <get-left>(): kotlin/Float // coil3.svg/Svg.ViewBox.left.<get-left>|<get-left>(){}[0]
final val right // coil3.svg/Svg.ViewBox.right|{}right[0]
final fun <get-right>(): kotlin/Float // coil3.svg/Svg.ViewBox.right.<get-right>|<get-right>(){}[0]
final val top // coil3.svg/Svg.ViewBox.top|{}top[0]
final fun <get-top>(): kotlin/Float // coil3.svg/Svg.ViewBox.top.<get-top>|<get-top>(){}[0]
final fun component1(): kotlin/Float // coil3.svg/Svg.ViewBox.component1|component1(){}[0]
final fun component2(): kotlin/Float // coil3.svg/Svg.ViewBox.component2|component2(){}[0]
final fun component3(): kotlin/Float // coil3.svg/Svg.ViewBox.component3|component3(){}[0]
final fun component4(): kotlin/Float // coil3.svg/Svg.ViewBox.component4|component4(){}[0]
final fun copy(kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ...): coil3.svg/Svg.ViewBox // coil3.svg/Svg.ViewBox.copy|copy(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // coil3.svg/Svg.ViewBox.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // coil3.svg/Svg.ViewBox.hashCode|hashCode(){}[0]
final fun toString(): kotlin/String // coil3.svg/Svg.ViewBox.toString|toString(){}[0]
}
}
final class coil3.svg/SvgDecoder : coil3.decode/Decoder { // coil3.svg/SvgDecoder|null[0]
constructor <init>(coil3.decode/ImageSource, coil3.request/Options, coil3.svg/Svg.Parser = ..., kotlin/Function1<coil3/PlatformContext, kotlin/Float> = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.svg/SvgDecoder.<init>|<init>(coil3.decode.ImageSource;coil3.request.Options;coil3.svg.Svg.Parser;kotlin.Function1<coil3.PlatformContext,kotlin.Float>;kotlin.Boolean;kotlin.Boolean){}[0]
constructor <init>(coil3.decode/ImageSource, coil3.request/Options, kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.svg/SvgDecoder.<init>|<init>(coil3.decode.ImageSource;coil3.request.Options;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0]
final val density // coil3.svg/SvgDecoder.density|{}density[0]
final fun <get-density>(): kotlin/Function1<coil3/PlatformContext, kotlin/Float> // coil3.svg/SvgDecoder.density.<get-density>|<get-density>(){}[0]
final val parser // coil3.svg/SvgDecoder.parser|{}parser[0]
final fun <get-parser>(): coil3.svg/Svg.Parser // coil3.svg/SvgDecoder.parser.<get-parser>|<get-parser>(){}[0]
final val renderToBitmap // coil3.svg/SvgDecoder.renderToBitmap|{}renderToBitmap[0]
final fun <get-renderToBitmap>(): kotlin/Boolean // coil3.svg/SvgDecoder.renderToBitmap.<get-renderToBitmap>|<get-renderToBitmap>(){}[0]
final val scaleToDensity // coil3.svg/SvgDecoder.scaleToDensity|{}scaleToDensity[0]
final fun <get-scaleToDensity>(): kotlin/Boolean // coil3.svg/SvgDecoder.scaleToDensity.<get-scaleToDensity>|<get-scaleToDensity>(){}[0]
final val useViewBoundsAsIntrinsicSize // coil3.svg/SvgDecoder.useViewBoundsAsIntrinsicSize|{}useViewBoundsAsIntrinsicSize[0]
final fun <get-useViewBoundsAsIntrinsicSize>(): kotlin/Boolean // coil3.svg/SvgDecoder.useViewBoundsAsIntrinsicSize.<get-useViewBoundsAsIntrinsicSize>|<get-useViewBoundsAsIntrinsicSize>(){}[0]
final suspend fun decode(): coil3.decode/DecodeResult // coil3.svg/SvgDecoder.decode|decode(){}[0]
final class Factory : coil3.decode/Decoder.Factory { // coil3.svg/SvgDecoder.Factory|null[0]
constructor <init>(coil3.svg/Svg.Parser = ..., kotlin/Function1<coil3/PlatformContext, kotlin/Float> = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.svg/SvgDecoder.Factory.<init>|<init>(coil3.svg.Svg.Parser;kotlin.Function1<coil3.PlatformContext,kotlin.Float>;kotlin.Boolean;kotlin.Boolean){}[0]
constructor <init>(kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // coil3.svg/SvgDecoder.Factory.<init>|<init>(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0]
final val density // coil3.svg/SvgDecoder.Factory.density|{}density[0]
final fun <get-density>(): kotlin/Function1<coil3/PlatformContext, kotlin/Float> // coil3.svg/SvgDecoder.Factory.density.<get-density>|<get-density>(){}[0]
final val parser // coil3.svg/SvgDecoder.Factory.parser|{}parser[0]
final fun <get-parser>(): coil3.svg/Svg.Parser // coil3.svg/SvgDecoder.Factory.parser.<get-parser>|<get-parser>(){}[0]
final val renderToBitmap // coil3.svg/SvgDecoder.Factory.renderToBitmap|{}renderToBitmap[0]
final fun <get-renderToBitmap>(): kotlin/Boolean // coil3.svg/SvgDecoder.Factory.renderToBitmap.<get-renderToBitmap>|<get-renderToBitmap>(){}[0]
final val scaleToDensity // coil3.svg/SvgDecoder.Factory.scaleToDensity|{}scaleToDensity[0]
final fun <get-scaleToDensity>(): kotlin/Boolean // coil3.svg/SvgDecoder.Factory.scaleToDensity.<get-scaleToDensity>|<get-scaleToDensity>(){}[0]
final val useViewBoundsAsIntrinsicSize // coil3.svg/SvgDecoder.Factory.useViewBoundsAsIntrinsicSize|{}useViewBoundsAsIntrinsicSize[0]
final fun <get-useViewBoundsAsIntrinsicSize>(): kotlin/Boolean // coil3.svg/SvgDecoder.Factory.useViewBoundsAsIntrinsicSize.<get-useViewBoundsAsIntrinsicSize>|<get-useViewBoundsAsIntrinsicSize>(){}[0]
final fun create(coil3.fetch/SourceFetchResult, coil3.request/Options, coil3/ImageLoader): coil3.decode/Decoder? // coil3.svg/SvgDecoder.Factory.create|create(coil3.fetch.SourceFetchResult;coil3.request.Options;coil3.ImageLoader){}[0]
}
final object Companion { // coil3.svg/SvgDecoder.Companion|null[0]
final val NO_DENSITY // coil3.svg/SvgDecoder.Companion.NO_DENSITY|{}NO_DENSITY[0]
final fun <get-NO_DENSITY>(): kotlin/Function1<coil3/PlatformContext, kotlin/Float> // coil3.svg/SvgDecoder.Companion.NO_DENSITY.<get-NO_DENSITY>|<get-NO_DENSITY>(){}[0]
final val PLATFORM_DENSITY // coil3.svg/SvgDecoder.Companion.PLATFORM_DENSITY|{}PLATFORM_DENSITY[0]
final fun <get-PLATFORM_DENSITY>(): kotlin/Function1<coil3/PlatformContext, kotlin/Float> // coil3.svg/SvgDecoder.Companion.PLATFORM_DENSITY.<get-PLATFORM_DENSITY>|<get-PLATFORM_DENSITY>(){}[0]
}
}
final class coil3.svg/SvgImage : coil3/Image { // coil3.svg/SvgImage|null[0]
constructor <init>(org.jetbrains.skia.svg/SVGDOM, kotlin/Int, kotlin/Int) // coil3.svg/SvgImage.<init>|<init>(org.jetbrains.skia.svg.SVGDOM;kotlin.Int;kotlin.Int){}[0]
final val height // coil3.svg/SvgImage.height|{}height[0]
final fun <get-height>(): kotlin/Int // coil3.svg/SvgImage.height.<get-height>|<get-height>(){}[0]
final val shareable // coil3.svg/SvgImage.shareable|{}shareable[0]
final fun <get-shareable>(): kotlin/Boolean // coil3.svg/SvgImage.shareable.<get-shareable>|<get-shareable>(){}[0]
final val size // coil3.svg/SvgImage.size|{}size[0]
final fun <get-size>(): kotlin/Long // coil3.svg/SvgImage.size.<get-size>|<get-size>(){}[0]
final val svg // coil3.svg/SvgImage.svg|{}svg[0]
final fun <get-svg>(): org.jetbrains.skia.svg/SVGDOM // coil3.svg/SvgImage.svg.<get-svg>|<get-svg>(){}[0]
final val width // coil3.svg/SvgImage.width|{}width[0]
final fun <get-width>(): kotlin/Int // coil3.svg/SvgImage.width.<get-width>|<get-width>(){}[0]
final fun draw(org.jetbrains.skia/Canvas) // coil3.svg/SvgImage.draw|draw(org.jetbrains.skia.Canvas){}[0]
}
final val coil3.svg.internal/initHook // coil3.svg.internal/initHook|{}initHook[0]
final fun <get-initHook>(): kotlin/Any // coil3.svg.internal/initHook.<get-initHook>|<get-initHook>(){}[0]
final val coil3.svg/height // coil3.svg/height|@coil3.svg.Svg.ViewBox{}height[0]
final fun (coil3.svg/Svg.ViewBox).<get-height>(): kotlin/Float // coil3.svg/height.<get-height>|<get-height>@coil3.svg.Svg.ViewBox(){}[0]
final val coil3.svg/width // coil3.svg/width|@coil3.svg.Svg.ViewBox{}width[0]
final fun (coil3.svg/Svg.ViewBox).<get-width>(): kotlin/Float // coil3.svg/width.<get-width>|<get-width>@coil3.svg.Svg.ViewBox(){}[0]
final fun (coil3.decode/DecodeUtils).coil3.svg/isSvg(okio/BufferedSource): kotlin/Boolean // coil3.svg/isSvg|isSvg@coil3.decode.DecodeUtils(okio.BufferedSource){}[0]
+55
View File
@@ -0,0 +1,55 @@
// :coil:coil-svg - SVG decoding, vendored VERBATIM from coil-kt/coil into src/vendor.
// Upstream: linux + apple, no mingwX64. The non-android decoder goes through skiko's
// SVGDOM, so mingwX64 binds the bitsycore skiko FORK like :compose:ui:ui-graphics.
// Upstream's non* source sets sit ABOVE nativeMain here (see coil-core).
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
dependencies { api(project(":coil:coil-core")) }
kotlin.srcDir("src/vendor/common/kotlin")
}
// Two PARALLEL skiko branches (official for macOS/Linux, the bitsycore fork for
// mingwX64), each two levels deep so nonAndroid `expect`s and nativeMain
// `actual`s are not siblings. See coil/coil-core/build.gradle.kts for the why.
fun skikoBranch(inName: String, inSkiko: Any): org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet {
val vNonAndroid = create("${inName}NonAndroidMain") {
dependsOn(commonMain.get())
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
dependencies { implementation(inSkiko) }
}
return create("${inName}NativeMain") {
dependsOn(vNonAndroid)
kotlin.srcDir("src/vendor/native/kotlin")
}
}
val vOfficial = skikoBranch("coilSvgOfficial", libs.skiko)
get("macosArm64Main").dependsOn(vOfficial)
get("linuxX64Main").dependsOn(vOfficial)
get("linuxArm64Main").dependsOn(vOfficial)
if (vHostSupportsMingw) {
val vFork = skikoBranch(
"coilSvgFork",
"com.bitsycore.skiko:skiko:${providers.gradleProperty("skikoMingwVersion").getOrElse(libs.versions.skikoMingw.get())}",
)
get("mingwX64Main").dependsOn(vFork)
}
}
compilerOptions {
freeCompilerArgs.addAll(
"-Xexpect-actual-classes",
"-opt-in=coil3.annotation.InternalCoilApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=coil3.annotation.DelicateCoilApi",
)
}
}
+44
View File
@@ -0,0 +1,44 @@
# :coil:coil-svg - SVG decoding. Upstream: linux + apple, no mingwX64.
# The non-android decoder goes through skiko's SVGDOM.
# Upstream source sets collapse onto this port: it has NO android, jvm or js
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
# hierarchy template).
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
SET_FOLDER=coil-svg/src
commonMain/kotlin/ -> src/vendor/common/kotlin/
# | commonMain/kotlin/coil3/svg/Svg.kt -> src/vendor/common/kotlin/coil3/svg/Svg.kt
# | commonMain/kotlin/coil3/svg/SvgDecoder.kt -> src/vendor/common/kotlin/coil3/svg/SvgDecoder.kt
# | commonMain/kotlin/coil3/svg/decodeUtils.kt -> src/vendor/common/kotlin/coil3/svg/decodeUtils.kt
# | commonMain/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.kt -> src/vendor/common/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.kt
# | commonMain/kotlin/coil3/svg/internal/parseSvg.kt -> src/vendor/common/kotlin/coil3/svg/internal/parseSvg.kt
# | commonMain/kotlin/coil3/svg/internal/utils.kt -> src/vendor/common/kotlin/coil3/svg/internal/utils.kt
nonAndroidMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
# | nonAndroidMain/kotlin/coil3/svg/SvgImage.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/svg/SvgImage.nonAndroid.kt
# | nonAndroidMain/kotlin/coil3/svg/internal/parseSvg.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/svg/internal/parseSvg.nonAndroid.kt
# | nonAndroidMain/kotlin/coil3/svg/internal/utils.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/svg/internal/utils.nonAndroid.kt
nonJvmCommonMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
# | nonJvmCommonMain/kotlin/coil3/svg/internal/utils.nonJvmCommon.kt -> src/vendor/nonAndroid/kotlin/coil3/svg/internal/utils.nonJvmCommon.kt
nativeMain/kotlin/ -> src/vendor/native/kotlin/
# | nativeMain/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.native.kt -> src/vendor/native/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.native.kt
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
# androidMain (4)
# | androidMain/kotlin/coil3/svg/SvgImage.android.kt -> src/vendor/android/kotlin/coil3/svg/SvgImage.android.kt
# | androidMain/kotlin/coil3/svg/imageRequests.android.kt -> src/vendor/android/kotlin/coil3/svg/imageRequests.android.kt
# | androidMain/kotlin/coil3/svg/internal/parseSvg.android.kt -> src/vendor/android/kotlin/coil3/svg/internal/parseSvg.android.kt
# | androidMain/kotlin/coil3/svg/internal/utils.android.kt -> src/vendor/android/kotlin/coil3/svg/internal/utils.android.kt
# jsMain (1)
# | jsMain/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.js.kt -> src/vendor/js/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.js.kt
# jvmCommonMain (1)
# | jvmCommonMain/kotlin/coil3/svg/internal/utils.jvmCommon.kt -> src/vendor/jvmCommon/kotlin/coil3/svg/internal/utils.jvmCommon.kt
# wasmJsMain (1)
# | wasmJsMain/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.wasmJs.kt -> src/vendor/wasmJs/kotlin/coil3/svg/internal/SvgDecoderServiceLoaderTarget.wasmJs.kt
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
+19
View File
@@ -0,0 +1,19 @@
// Klib ABI Dump
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
// Rendering settings:
// - Signature version: 2
// - Show manifest properties: true
// - Show declarations: true
// Library unique name: <com.bitsycore.coil3:coil>
final object coil3/SingletonImageLoader { // coil3/SingletonImageLoader|null[0]
final fun get(coil3/PlatformContext): coil3/ImageLoader // coil3/SingletonImageLoader.get|get(coil3.PlatformContext){}[0]
final fun reset() // coil3/SingletonImageLoader.reset|reset(){}[0]
final fun setSafe(coil3/SingletonImageLoader.Factory) // coil3/SingletonImageLoader.setSafe|setSafe(coil3.SingletonImageLoader.Factory){}[0]
final fun setUnsafe(coil3/ImageLoader) // coil3/SingletonImageLoader.setUnsafe|setUnsafe(coil3.ImageLoader){}[0]
final fun setUnsafe(coil3/SingletonImageLoader.Factory) // coil3/SingletonImageLoader.setUnsafe|setUnsafe(coil3.SingletonImageLoader.Factory){}[0]
abstract fun interface Factory { // coil3/SingletonImageLoader.Factory|null[0]
abstract fun newImageLoader(coil3/PlatformContext): coil3/ImageLoader // coil3/SingletonImageLoader.Factory.newImageLoader|newImageLoader(coil3.PlatformContext){}[0]
}
}
+34
View File
@@ -0,0 +1,34 @@
// :coil:coil - vendored VERBATIM from coil-kt/coil into src/vendor.
// The singleton ImageLoader facade. Upstream: linux + apple, no mingwX64.
// Upstream's non* source sets sit ABOVE nativeMain here (see coil-core's build file
// and compose-fork.txt for why). Provenance = compose-fork.txt; never hand-edit
// src/vendor - change the manifest + `python scripts/compose-fork/sync.py coil/coil`.
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
dependencies { api(project(":coil:coil-core")) }
kotlin.srcDir("src/vendor/common/kotlin")
}
val vNonAndroidMain = create("coilNonAndroidMain") {
dependsOn(commonMain.get())
kotlin.srcDir("src/vendor/nonAndroid/kotlin")
}
nativeMain { dependsOn(vNonAndroidMain) }
}
compilerOptions {
freeCompilerArgs.addAll(
"-Xexpect-actual-classes",
"-opt-in=coil3.annotation.InternalCoilApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=coil3.annotation.DelicateCoilApi",
)
}
}
+24
View File
@@ -0,0 +1,24 @@
# :coil:coil - the singleton ImageLoader facade. Upstream: linux + apple, no mingwX64.
# Upstream source sets collapse onto this port: it has NO android, jvm or js
# target, so nonAndroidMain / nonJvmCommonMain / nonJsCommonMain / nativeMain all
# apply to EVERY target here. They land in TWO trees, not one: the non* sets keep
# their own level (src/vendor/nonAndroid) ABOVE src/vendor/native, because some of
# them declare `expect`s whose `actual`s live in upstream nativeMain - collapsing
# both into one source set makes expect and actual siblings, which is an error. appleMain is macosArm64
# only and nonAppleMain is linux + mingw, so those two keep their own trees (the
# build file wires a `nonAppleMain` intermediate; appleMain comes from the default
# hierarchy template).
SET_REPO=https://github.com/coil-kt/coil@<COIL_REF>
SET_FOLDER=coil/src
commonMain/kotlin/ -> src/vendor/common/kotlin/
# | commonMain/kotlin/coil3/SingletonImageLoader.kt -> src/vendor/common/kotlin/coil3/SingletonImageLoader.kt
nonAndroidMain/kotlin/ -> src/vendor/nonAndroid/kotlin/
# | nonAndroidMain/kotlin/coil3/SingletonImageLoader.nonAndroid.kt -> src/vendor/nonAndroid/kotlin/coil3/SingletonImageLoader.nonAndroid.kt
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
# androidMain (2)
# | androidMain/kotlin/coil3/SingletonImageLoader.android.kt -> src/vendor/android/kotlin/coil3/SingletonImageLoader.android.kt
# | androidMain/kotlin/coil3/singletonImageLoaders.android.kt -> src/vendor/android/kotlin/coil3/singletonImageLoaders.android.kt
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
@@ -5,7 +5,7 @@
// - Show manifest properties: true
// - Show declarations: true
// Library unique name: <com.bitsycore.compose.sdl:components-resources>
// Library unique name: <com.bitsycore.compose.components:components-resources>
open annotation class org.jetbrains.compose.resources/ExperimentalResourceApi : kotlin/Annotation { // org.jetbrains.compose.resources/ExperimentalResourceApi|null[0]
constructor <init>() // org.jetbrains.compose.resources/ExperimentalResourceApi.<init>|<init>(){}[0]
}
@@ -0,0 +1,54 @@
// :components-resources - the official Compose Multiplatform resources runtime
// (org.jetbrains.compose.components:components-resources), vendored because the
// Maven artifact ships no mingwX64/linux klibs. Public API is byte-for-byte
// upstream (painterResource / stringResource / Font / qualifiers / Res codegen
// compatibility); the platform actuals are this port's: data.kres reading,
// image decode via :ui's Skia decoder, SDL locale/theme environment. Apps' JVM
// targets keep using the official Maven artifact - this module is native-only.
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.plugin.compose)
}
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
linuxArm64()
linuxX64()
macosArm64()
if (vHostSupportsMingw) mingwX64()
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
kotlin.srcDir("src/vendor/common/kotlin")
dependencies {
// foundation (isSystemInDarkTheme in ResourceEnvironment) pulls the
// whole ui surface + loadComposeResourceBytes + the sdl3 cinterop
// types transitively (all edges are api).
api(project(":compose:foundation:foundation"))
implementation(libs.kotlinx.coroutines.core)
}
}
nativeMain {
kotlin.srcDir("src/vendor/native/kotlin")
// src/nativeMain/kotlin also holds the Font / Image resource actuals -
// pure project code that delegates to the project font registry
// (IconFont / NamedFont) and the :ui-graphics Skia decoder
// (decodeEncodedImageBitmap). Skiko-free, so a single native set covers
// every target.
}
}
compilerOptions {
freeCompilerArgs.addAll(
"-Xexpect-actual-classes",
// Upstream's library build opts the whole module into its own
// annotations; the vendored files rely on that.
"-opt-in=org.jetbrains.compose.resources.InternalResourceApi",
"-opt-in=org.jetbrains.compose.resources.ExperimentalResourceApi",
)
}
}
@@ -1,13 +1,13 @@
# Files vendored VERBATIM from JetBrains/compose-multiplatform (the umbrella repo —
# Files vendored VERBATIM from JetBrains/compose-multiplatform (the umbrella repo -
# NOT compose-multiplatform-core) into :components-resources. The official
# org.jetbrains.compose.components:components-resources artifact publishes NO
# mingwX64/linux variants, so the runtime library is vendored and the platform
# actuals are project code:
# src/nativeMain — ResourceReader (data.kres), getSystemEnvironment (SDL),
# pure-Kotlin DomXmlParser (upstream's is Darwin NSXMLParser)
# src/sdlRendererMain — image decode via the :ui SDL3_image hook, SVG unsupported,
# Font via the project font registry
# vendor/skikoRenderer — upstream skikoMain Skia-bound files, verbatim
# src/nativeMain - ResourceReader (data.kres), getSystemEnvironment (SDL),
# pure-Kotlin DomXmlParser (upstream's is Darwin NSXMLParser),
# and the Font/Image resource actuals: renderer-agnostic
# project code (image decode via the :ui-graphics Skia
# decoder, Font via the project font registry)
# Re-sync: python scripts/compose-fork/sync.py components/resources/library
SET_REPO=https://github.com/JetBrains/compose-multiplatform@<COMPOSE_REF>
@@ -43,21 +43,20 @@ blockingMain/kotlin/ -> src/vendor/native/kotlin/
nativeMain/kotlin/ -> src/vendor/native/kotlin/
# | nativeMain/kotlin/org/jetbrains/compose/resources/ImageResources.native.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/ImageResources.native.kt
# | nativeMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt
# Darwin-only (Foundation NSXMLParser) — replaced by a pure-Kotlin project parser
# Darwin-only (Foundation NSXMLParser) - replaced by a pure-Kotlin project parser
# in src/nativeMain so mingwX64/linux compile.
!nativeMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt
# Portable pieces of upstream skikoMain (no Skia references — pure delegation /
# runtime-only) — every native target gets them.
# Portable pieces of upstream skikoMain (no Skia references - pure delegation /
# runtime-only) - every native target gets them.
skikoMain/kotlin/org/jetbrains/compose/resources/ImageDecoders.skiko.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/ImageDecoders.skiko.kt
skikoMain/kotlin/org/jetbrains/compose/resources/ResourceReader.skiko.kt -> src/vendor/native/kotlin/org/jetbrains/compose/resources/ResourceReader.skiko.kt
# Skia-bound pieces — upstream's skikoMain actuals rely on Compose-Desktop Skia
# Skia-bound pieces - upstream's skikoMain actuals rely on Compose-Desktop Skia
# extensions (SystemFont / toComposeImageBitmap / nativeCanvas) this port's :ui
# doesn't expose. Since the sdlRenderer actuals (src/sdlRendererMain/kotlin/) use
# pure project APIs (IconFont / NamedFont / decodeEncodedImageBitmap) that work
# on either renderer, the module's build shares those with skikoRendererMain
# instead of vendoring the upstream files. Opt them out of the sync.
# doesn't expose. The module's own Font/Image actuals (src/nativeMain/kotlin/)
# use pure project APIs (IconFont / NamedFont / decodeEncodedImageBitmap)
# instead, so opt the upstream Skia-bound files out.
!skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt
!skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt
!skikoMain/kotlin/org/jetbrains/compose/resources/SvgPainter.kt
@@ -1,5 +1,11 @@
@file:OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
// VENDOR-REIMPL(COMPOSE_REF): components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt @ v1.12.0
// Project REIMPLEMENTATION, not a derived copy: same package + signatures,
// different body (the port replaces upstream's skiko scene/platform layer).
// Upstream edits to the counterpart do NOT need reconciling here - re-check
// only if the expect/actual SIGNATURES change.
package org.jetbrains.compose.resources
import org.jetbrains.compose.resources.InternalResourceApi
@@ -15,16 +21,16 @@ import com.compose.sdl.icons.IconFont
import com.compose.sdl.text.NamedFont
// ==================
// MARK: Font actuals — SDL renderer
// MARK: Font actuals - Skia text stack via the project font registry
// ==================
/* The SDL text stack resolves fonts by FAMILY NAME through the project font
registry (IconFont.registerIcon — the general byte-font table, not just
/** The Skia text stack resolves fonts by FAMILY NAME through the project font
registry (IconFont.registerIcon - the general byte-font table, not just
icons). A font resource therefore loads its bytes through the resource
reader, registers them under a per-resource family name, and returns a
[NamedFont] — `FontFamily(Font(Res.font.x))` then renders through the
[NamedFont] - `FontFamily(Font(Res.font.x))` then renders through the
standard project pipeline. Weight/style are carried on the NamedFont;
variation axes apply at draw time via the renderer's axis support. */
variation axes apply at draw time via Skia's axis support. */
private val emptyFont: Font = NamedFont("res-font:<loading>")
@@ -0,0 +1,86 @@
@file:OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
// VENDOR-REIMPL(COMPOSE_REF): components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt @ v1.12.0
// Project REIMPLEMENTATION, not a derived copy: same package + signatures,
// different body (the port replaces upstream's skiko scene/platform layer).
// Upstream edits to the counterpart do NOT need reconciling here - re-check
// only if the expect/actual SIGNATURES change.
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.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
// ==================
/** Decoding goes through the :ui-graphics Skia hook the SDL backend registers
at init (decodeEncodedImageBitmap). resourceDensity/targetDensity are ignored:
under this port's Option-B density flow layout runs in physical pixels and
drawables ship at a single density, so no decode-time rescale applies. */
internal actual fun ByteArray.toImageBitmap(resourceDensity: Int, targetDensity: Int): ImageBitmap =
decodeEncodedImageBitmap(this)
?: error("Image decode failed - is the render backend initialised before painterResource ran?")
/** 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)
/** 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 mAlpha: Float = 1f
private var mColorFilter: ColorFilter? = null
private var mCacheKey: Long = -1L
private var mCacheBitmap: ImageBitmap? = null
override fun applyAlpha(alpha: Float): Boolean { mAlpha = alpha; return true }
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { mColorFilter = colorFilter; return true }
private fun rasterFor(inWidthPx: Int, inHeightPx: Int): ImageBitmap? {
val vKey = (inWidthPx.toLong() shl 32) or inHeightPx.toLong()
if (vKey == mCacheKey && mCacheBitmap != null) return mCacheBitmap
val vBitmap = decodeSvgAt(bytes, inWidthPx, inHeightPx) ?: return null
mCacheKey = vKey
mCacheBitmap = 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 = mAlpha,
colorFilter = mColorFilter,
)
}
}
@@ -0,0 +1,33 @@
@file:OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
package org.jetbrains.compose.resources
import org.jetbrains.compose.resources.InternalResourceApi
import org.jetbrains.compose.resources.ExperimentalResourceApi
import com.compose.sdl.res.preferredLocaleProvider
import com.compose.sdl.res.systemThemeIsDarkProvider
// ==================
// MARK: ResourceEnvironment - platform-env-seam actual
// ==================
/** Non-composable system environment for qualifier resolution (values-fr,
drawable-dark, …): locale + theme come from the platform-env seams installed
by the SDL layer, so :components-resources carries no dependency on the sdl3
cinterop. Density is reported as 1f - under this port's Option-B density flow
layout runs in physical pixels and drawables are bundled at a single density,
so the mdpi bucket is always the right one. The COMPOSABLE path
(rememberResourceEnvironment) doesn't use this: it reads LocalDensity /
isSystemInDarkTheme from the composition. */
internal actual fun getSystemEnvironment(): ResourceEnvironment {
val (vLanguage, vRegion) = preferredLocaleProvider?.invoke() ?: ("" to "")
val vDark = systemThemeIsDarkProvider?.invoke() ?: false
return ResourceEnvironment(
language = LanguageQualifier(vLanguage),
// SDL locales carry language+country only - no script subtag.
script = ScriptQualifier(""),
region = RegionQualifier(vRegion),
theme = ThemeQualifier.selectByValue(isDark = vDark),
density = DensityQualifier.selectByDensity(1f),
)
}

Some files were not shown because too many files have changed in this diff Show More