Compare commits

..
142 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
510 changed files with 27522 additions and 33877 deletions
+88 -34
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:
@@ -20,7 +23,7 @@ jobs:
# Each host runs only the publish tasks Gradle actually generated for it.
# 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,
# `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.
@@ -46,7 +49,7 @@ jobs:
# 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
# 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.
@@ -99,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
@@ -120,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.
@@ -144,7 +138,7 @@ jobs:
# 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
# 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
@@ -160,16 +154,35 @@ jobs:
VER="${PUBLISH_VERSION#v}"
OWNER="${GITHUB_REPOSITORY%%/*}"
# Keep in sync with the module list in settings.gradle.kts.
MODULES="ui ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple window material-symbols navigation3-ui components-resources"
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 "com.bitsycore.compose.sdl.$m"; else echo "com.bitsycore.compose.sdl.$m$s"; fi
if [ "$s" = "_ROOT_" ]; then echo "$g.$m"; else echo "$g.$m$s"; fi
done
done
if [ "${{ matrix.host }}" = "windows" ]; then
@@ -183,13 +196,13 @@ jobs:
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" '[.[]? | select(.name==$v) | .id] | first // empty')
| 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) —
# 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" \
@@ -206,7 +219,7 @@ jobs:
fi
echo "::endgroup::"
if [ $attempt -lt 3 ]; then
echo "::warning::Publish attempt $attempt failed — cleaning this host's $VER packages and retrying"
echo "::warning::Publish attempt $attempt failed - cleaning this host's $VER packages and retrying"
cleanup_host_versions || true
sleep 30
fi
@@ -214,11 +227,39 @@ jobs:
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
@@ -233,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}"
@@ -241,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/
+2 -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/
@@ -64,3 +64,4 @@ __pycache__/
# Probe artifacts written to the repo root by demo --nav3test / --searchesctest / --dialoganimtest
/*.bmp
/local.properties
+448 -236
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.
+176 -30
View File
@@ -1,4 +1,4 @@
[![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
@@ -18,20 +18,24 @@ executable for macOS (arm64), Linux (x64/arm64), and Windows (mingwX64), using
- **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.
- **Rendering is pluggable** behind one `RenderBackend`: Skia (via Skiko) on
macOS and Linux, and a from-scratch SDL3 renderer (`SDL3_ttf` +
`SDL_RenderGeometry`) on Windows, or anywhere with `-Prenderer=sdl3`.
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.
- **No runtime dependencies.** SDL3 and its codecs are built as static
libraries and linked in. A distributable is just the executable plus a
`data.kres` resource bundle.
- **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).
| Platform | Gradle target | Default renderer |
|----------|---------------|------------------|
| macOS arm64 | `macosArm64` | Skia (Metal) |
| Linux x64 / arm64 | `linuxX64` / `linuxArm64` | Skia (OpenGL) |
| Windows | `mingwX64` | SDL3 |
| 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 |
## Quickstart
@@ -67,19 +71,39 @@ 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(":window")) // window shell + main loop
implementation(project(":material3")) // Material 3 widgets
implementation(project(":material-symbols")) // icon-font composables (optional)
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 GitHub Packages under `com.bitsycore.compose.sdl:*`. Apply
The klibs publish to [maven.bitsycore.com](https://maven.bitsycore.com/releases)
(no auth) and GitHub Packages (authenticated fallback) under per-area coordinates that mirror
upstream - `com.bitsycore.compose.ui:ui`, `com.bitsycore.compose.foundation:foundation`,
… (the `com.bitsycore` fork of each `org.jetbrains.compose.*`). Apply
the bridge Gradle plugin once, declare the **official** Compose Multiplatform
coordinates, and the plugin swaps in the port's klibs on native desktop targets
while android, jvm, iOS, and wasm keep resolving the official artifacts.
@@ -94,15 +118,29 @@ plugins {
commonMain.dependencies {
// The plugin exposes the exact Compose versions the port tracks, so you
// never hand-match them (material3 is versioned separately upstream).
// 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")
}
```
Repositories, credentials, and version pinning:
**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
@@ -122,7 +160,7 @@ 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 (SDL3)
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (Skia / Skiko fork)
./gradlew :demo:run # JVM Compose Desktop (reference)
```
@@ -138,28 +176,132 @@ client certificates.
./gradlew :apidemo:run # JVM Compose Desktop (reference)
```
Pass `-Prenderer=sdl3` on macOS or Linux to drop Skiko and use the pure SDL3
renderer everywhere.
## Modules
One Gradle module per upstream Compose artifact, mirroring the upstream
`compose/` tree. The renderer and cinterops live in `:ui`; `:window` is the SDL
integration layer. `:material-symbols`, `:components-resources`, and
`:navigation3-ui` are project or vendored modules that the official Maven
artifacts do not cover for Kotlin/Native desktop.
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. The full module map, dependency graph, and the list of
compatible artifacts are in [CLAUDE.md](CLAUDE.md).
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
Build the native libraries once per machine, then build any app target:
```bash
python3 scripts/build-sdl/build-all.py # SDL3, SDL3_ttf, SDL3_image, FreeType (static)
python3 scripts/build-sdl/build-all.py # SDL3 (static)
./gradlew :demo:runDebugExecutableMacosArm64
```
@@ -168,6 +310,10 @@ 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.
## Support
[Bitsycore's Discord](https://discord.gg/kGnraVAtDR)
## Documentation
- [TOOLING.md](TOOLING.md): building the native libs, vendoring, and verification.
-263
View File
@@ -1,263 +0,0 @@
# RENDERER.md
The durable reference for the rendering layer: how it is structured, the
convergence work that shaped it, the decisions worth remembering, and what is
left. Read this before touching anything under `renderer/`, the graphics
actuals, or the layer engine.
For build and verification commands see [TOOLING.md](TOOLING.md). For module
layout, source-set hierarchy, and vendoring rules see [CLAUDE.md](CLAUDE.md).
## 1. The two renderer legs
Rendering is pluggable behind one `RenderBackend` interface. Exactly one leg is
active per target:
- **Skia leg** (`skikoRendererMain`, macOS + Linux). Draws through upstream
Compose Multiplatform's own Skia stack, vendored verbatim: `SkiaBackedCanvas`,
`SkiaBackedPaint`, `SkiaShader`, `actual class GraphicsLayer` backed by
`org.jetbrains.skiko.node.RenderNode`, `SkiaGraphicsContext`. On this leg the
layer and draw engine internals are literally upstream.
- **SDL leg** (`sdlRendererMain`, Windows always; macOS/Linux under
`-Prenderer=sdl3`). A from-scratch renderer on `SDL_RenderGeometry` +
`SDL3_ttf` + FreeType. SDL is a triangle blitter, so this leg carries a
permanent bespoke surface (about 3,800 lines): `Sdl3Canvas`,
`Sdl3TextRenderer`, `FreeTypeIcons`, and the SDL render nodes.
Both legs share one project-owned text and image pipeline (`SdlParagraph`,
`NativeTextMeasurer`, `IconFont`). Text layout, line-breaking, metrics, and
hit-test are the port's engine on both legs; only glyph rasterization differs
(Skia vs SDL3_ttf/FreeType). This is a deliberate axiom, not a gap: see the
text decision in section 4.
The seam is kept as narrow and low as possible. Code flows
`Common (upstream) -> shared native engine -> Skia actual / SDL actual`. What we
minimize is hand-rolled actual surface, not actual-side line count: a fat
vendored-upstream actual (the Skia leg carrying upstream's whole GraphicsLayer
stack) is preferred over a thin hand-written one.
## 2. The retained-layer engine (the model we copied)
Upstream skiko skips work at three levels. The port has all three.
- **L1 frame scheduling.** A frame schedules the next only if still dirty.
Ours: `ComposeWindow.shouldRender()` gates `renderFrame()`; the loop blocks on
`SDL_WaitEventTimeout` when idle.
- **L2 measure/layout only dirty nodes.** Vendored `MeasureAndLayoutDelegate`
used verbatim through `ComposeOwner.measureAndLayout()`.
- **L3 draw record-once / replay** (the expensive one). Each isolated
`LayoutNode` gets an `OwnedLayer` (`GraphicsLayerOwnerLayer`) owning a
`GraphicsLayer`. The display list re-records only when content is dirty;
otherwise it replays.
The critical property: **transform, alpha, and clip changes do NOT re-record.**
Moving, scaling, rotating, or fading a layer replays cached content under a new
transform. Only a genuine content change (a state read inside the draw block, or
a resize) re-records. Upstream replays the whole scene every frame with no
dirty-region present; the entire win is not re-recording clean layers, not
drawing less screen. Dirty-region rendering is therefore an explicit non-goal.
The compositing-strategy contract (`requiresLayer()`, from
`SkiaGraphicsLayer.skiko.kt`) must match on both legs. It decides when a layer
needs an offscreen:
| Condition | Auto | Offscreen | ModulateAlpha |
|---|---|---|---|
| `alpha < 1` | offscreen | offscreen | per-op alpha multiply (no offscreen) |
| `colorFilter != null` | offscreen | offscreen | offscreen |
| `blendMode != SrcOver` | offscreen | offscreen | offscreen |
| `renderEffect != null` | offscreen | offscreen | offscreen |
| none of the above | replay in place | always offscreen | replay in place |
## 3. Current state
- **Skia leg runs upstream's engine.** Canvas (`SkiaBackedCanvas`) and
GraphicsLayer/GraphicsContext (`org.jetbrains.skiko.node.RenderNode`) are
vendored verbatim. The Skia leg gets real display-list caching and correct
clip/shadow/renderEffect for free from upstream. After this landed, Skia
`draw` on LazyColumn fell from 1.75 ms to 0.2 ms.
- **SDL leg ships the geo node as the Windows default.** `SdlDisplayListRenderNode`
records a leaf's layer-local tessellated geometry, text runs, and icon glyphs
by params (not texture pointers), then re-emits through the layer transform.
Crisp under any transform, bit-exact, deterministic (no render-target state),
and eviction-safe (replay re-looks-up via the run LRU / FreeType glyph cache).
0.000% across a 57-screen self-consistency sweep bar a sub-0.06% rotated-edge
AA fringe. LazyColumn steady-state `draw` fell 57% versus the un-cached
baseline. Fallbacks: `CDN_LAYERCACHE=off|texture`.
- **Parity is a golden-master on the Skia leg** (about 2% median, dominated by
the shared text engine's small metric delta) and a bounded, ranked signal on
the SDL leg. See TOOLING.md for how to read it.
- **Memory is stable.** The historical composition leak is fixed (see section 6)
and guarded by the `--soaktest` gate in the verify runbook.
## 4. Decisions to remember
- **Goal is G1, cheap upstream-tracking.** The target is low per-bump
reconciliation cost when following upstream, not Windows pixel-parity or
feature completeness. The JVM Compose Desktop target (`:demo:run`) is the
documented Windows fidelity/feature tier: real Skia, full fidelity, on any
host. That makes SDL-leg fidelity work (B3) and real-Skia-on-Windows (Track A)
optional polish, not obligations.
- **Vendor, do not hand-roll.** Copy upstream verbatim wherever it compiles.
Edit-to-compile becomes a manual vendor with a `// VENDOR-BASE:` header so the
drift tripwire can track it. The litmus test for any divergence: "Is this what
upstream does? If not, what real platform constraint forces the difference?"
Valid answers name a constraint (no Skiko on Windows K/N; SDL is a triangle
blitter). "It was easier" is not valid.
- **B2 convergence is complete.** The Skia leg was migrated off the port's
hand-rolled GraphicsLayer onto upstream's own. This was a source-set migration,
not a file-flip: the SDL node cluster relocated `nativeMain -> sdlRendererMain`,
`GraphicsContext` forked per-leg behind a `createGraphicsContext()` seam, and
the upstream skiko files were un-refused in `compose-fork.txt`. Measured
sync-tax reduction on the skiko path: about 8.5x (roughly 1240 hand-rolled
lines down to about 145 lines of drift-tracked edits; roughly 1006 lines now
auto-sync verbatim on a ref bump). The beta02 -> beta03 bump proved it: zero
reconciliation, all manual-vendor bases unchanged.
- **Text stays the port's engine (B6.3 skipped).** Making Skia-leg text truly
upstream (`SkiaParagraph`) is a font-subsystem replacement, not a canvas swap:
two coexisting font-identity models (the port's name-to-bytes `IconFont` +
no-op resolver vs upstream `PlatformFont`/`FontCache`/`FontCollection`), about
13 skiko files to re-vendor, about 10 shared actuals to split, a
`data.kres`-to-`FontCache` bridge, no green intermediate, and it discards the
P3.1 metrics work. ROI is low: the Skia leg already measures text with skiko
`Font` metrics (via `currentTextMeasurer` -> `SkiaTextRenderer`), which is how
parity reached about 2%. Revisit only if complex-script/bidi fidelity on the
native Skia leg becomes a hard requirement.
- **`GraphicsLayerOwnerLayer` stays shared.** The fork point is `GraphicsLayer` /
`GraphicsContext`, not the owner layer. The shared owner layer compiles
against both `GraphicsLayer` actuals (upstream skiko + port SDL) only if both
satisfy the commonMain `expect class GraphicsLayer` API. That API-parity
invariant is enforced by `:ui:compileCommonMainKotlinMetadata` on every build.
- **Lifetime model.** The SDL leg uses GC / release-queue for layer lifetime and
does NOT vendor `ChildLayerDependenciesTracker`. The Skia leg uses upstream's
`SkiaGraphicsLayer`, which inherently uses the tracker (already vendored in
commonMain). A per-leg lifetime divergence is the class of bug that caused an
early navigation crash, so any change here goes to both legs plus a soak gate.
- **Module split (`:ui-graphics` / `:ui-text`) is shelved as infeasible.** Not
cosmetic churn as originally scoped: the `sdl3` cinterop is a shared substrate
used by 11 graphics-side files AND 12 platform/windowing/node/resources files
that stay in `:ui`. Kotlin/Native cannot cleanly share a cinterop klib across a
module boundary, which is exactly why they were merged. A real split needs a
new non-upstream `:ui-cinterop` base module plus cross-module cinterop
api-exposure, which is bigger and more divergent than the "match upstream
artifacts" goal it was meant to serve. The one keeper from the attempt:
`RenderBackend.drawRoot` now takes `(Canvas) -> Unit`, decoupling the backends
from `ComposeRootHost` (a genuine improvement, retained).
- **Track A (real Skia on Windows K/N) is shelved.** Kotlin/Native mingwX64 is
GNU-ABI; skia-pack Windows is MSVC-ABI, which cannot be statically fused into a
GNU-ABI binary. Building current C++20 Skia to a GNU/mingw static archive is an
open-ended fork (only abandoned Mozilla-era precedent). The only working route
is a runtime DLL, which breaks the no-DLL invariant and adds tens of MB on the
one platform it targets. The SDL leg is the permanent Windows renderer; JVM is
the fidelity escape hatch.
## 5. Convergence status
| Item | Status |
|------|--------|
| Guardrails (parity gate, verify-mac runbook, drift + vendor-clean checks) | Done |
| B2: Skia leg on upstream GraphicsLayer/GraphicsContext | Done |
| B6.1: Skia leg on upstream `SkiaBackedCanvas` + paint/shader | Done |
| B6.2: upstream `GraphicsLayer` + delete transient port cluster | Done |
| B6.3: upstream text (`SkiaParagraph`) on the Skia leg | Skipped (decision) |
| B5: engine-convergence deltas audit | Done (clean wins spent by B6) |
| P2.2: composition memory leak | Fixed + soak-gated |
| P2.3: outsets / blur / renderEffect | Done via upstream `SkiaGraphicsLayer` |
| P3.1: SDL text metrics parity (17% median -> 2%) | Done |
| B3: further SDL fidelity | Capped (parity-ranked wins only) |
| Module split | Shelved (infeasible as specified) |
| Track A: real Skia on Windows | Shelved |
## 6. Remaining and future work
Nothing here blocks day-to-day work. These are the open threads worth
remembering.
- **WIN-SMOKE (pre-ship, Windows only).** The Mac runbook covers both renderers
but cannot cover the shipped mingwX64 binary, the Windows-only `PrintWindow`
probe, or the common-metadata publish job. Run these on a Windows host before
any release. This is the only outstanding verification.
- **Stabilization at Compose 1.12.0 stable.** The vendored refs are pinned to
`v1.12.0-beta03+dev4483` (no clean beta03 tag exists yet, and it is not on
Maven). The native side leads the JVM parity leg (forced to beta02, the latest
published) by a documented skew. When 1.12.0 stable ships: re-pin both refs to
the clean tag, bump the `vComposeJvmVersion` forcing in demo/apidemo/
material-symbols to close the skew, then cut the release. The concrete
step-by-step (the version map, the ref-bump flow, and the release flow) lives
in [TOOLING.md](TOOLING.md#versioning-and-releasing).
- **Deferred engine deltas (D2-D6, all low ROI under G1).**
- D2: split `GraphicsLayerOwnerLayer` per-leg to restore upstream's exact
`setLightingInfo` shadow lighting. Feasible (the old RootNodeOwner-coupling
blocker was outdated) but high blast radius for a marginal gain; elevation
shadows already match JVM.
- D3: dedupe `LayerTransformationMatrix.kt` against the now-vendored
`Matrices.skiko`. Blocked on D2 (the shared owner-layer hit-test needs a
both-legs matrix fn).
- D4/D5: `SemanticsRegion` intersect/difference are stubs, and `CharHelpers`
is naive grapheme/bidi vs upstream ICU. Real fidelity gaps, gated on an
accessibility or complex-script roadmap.
- D6: `Focusability` / `PlatformVelocityTracker` are byte-equal to upstream;
vendoring them saves nothing.
- **SDL performance (opportunistic).** Shared glyph atlas to replace per-run
textures; `drawImageRect` capture in the geo node (journal rates it low-ROI).
- **SDL_GPU backend (long-term).** Real stencil clipping, pipelined batching,
shader gradients. The `NativeRenderNode` seam makes a GPU node a clean
drop-in.
- **Native-resource lifecycle.** Wire `GraphicsLayer`/RenderNode +
`SdlImageBitmap.close()` fully into cache eviction and the renderer
`destroy()` chain; demote the periodic GC nudge once ownership covers it.
## 7. Hard-won learnings (do not relearn)
- **Minimize-divergence is load-bearing.** A hand-written `notifyLayerIsDirty`
that diverged from upstream `OwnedLayerManagerImpl` removed a layer from
`dirtyLayers` mid-loop and crashed on any navigation. Screenshots missed it;
the interaction probe caught it. Match upstream verbatim; do not hand-roll
engine plumbing from a summary.
- **The composition memory leak (root cause).** The port never called
`OwnerSnapshotObserver.clearInvalidObservations()`, which upstream
`RootNodeOwner` runs after every measure. Snapshot read-observations for
scopes invalidated on dispose lingered forever, each pinning its observed
object graph via a Kotlin/Native `ExternalRCRef` (a K/N-heap leak, vmmap
Memory Tag 246). Most visible on ripple/indication draws. Fix:
`ComposeRootHost.measureAndLayout()` sweeps `clearInvalidObservations()` after
layout, plus `ComposeOwner.onDetach` clears the detached node's observations.
Found by exact live-counters, static-mode isolation, macOS `leaks`/`heap`/
`vmmap`, and component bisection. RSS alone cannot pinpoint a referenced leak;
budget heap tooling for this class.
- **Offscreen-texture caching is timing-nondeterministic on complex screens.**
This is why the geo (no-render-target) node is the robust default, not the
texture node.
- **Rounded/path layer clips must be applied or deferred on the fast path.** The
geo path submits raw geometry, which clips only to a rect; rounded clips are a
lazy offscreen mask the fast replay bypasses. Rect clip on the fast path;
rounded/generic clips fall back to block-replay.
- **Screenshots miss crashes and settle-timing.** Free-running screenshots on
animated/settling screens give false signals. Use render-to-quiescence +
virtual frame time for parity, the probe for interaction/crash coverage.
- **The profiler `present` phase is vsync-capped by the display refresh.**
Profile on the target monitor before concluding a frame-rate gap.
## 8. Explicit non-goals
- **Dirty-region / partial present.** Upstream replays the whole scene; the win
is not re-recording, not redrawing less.
- **A custom `cacheKey` API.** Superseded by the real per-node display list.
- **Vendoring `RootNodeOwner` / the `ComposeScene` stack.** Coupled to skiko's
`SkiaLayer` and windowing; it fights the `:window` SDL loop. The port borrows
only the layer engine.
## 9. Key files
- `compose/ui/ui/src/nativeMain/.../RenderBackend.kt`: the interface.
- `compose/ui/ui/src/nativeMain/.../GpuMode.kt`: renderer / driver picker.
- `compose/ui/ui/src/skikoRendererMain/.../renderer/skia/SkiaRenderBackend.kt`.
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3RenderBackend.kt`.
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/FreeTypeIcons.kt`:
variable-font axis rasterization.
- `compose/ui/ui/src/commonMain/.../node/ComposeRootHost.kt`: root host,
hit-test, event dispatch, snapshot observer sweep.
- `compose/ui/ui/src/commonMain/.../node/impl/ComposeOwner.kt`: the project
`Owner` + `GraphicsLayerOwnerLayer` bridge.
- `compose/ui/ui/src/nativeMain/.../ui/text/SdlParagraph.native.kt`: the bridged
`Paragraph` (measurement, hit-test, line metrics, span painting).
-160
View File
@@ -1,160 +0,0 @@
# TODO.md
Everything the port currently leaves as a no-op, stub, hardcode, or partial
implementation, plus the gaps worth closing before a stable 1.12 release. This
is an audit of PROJECT code only (the `*.native.kt` / `*.sdl.kt` /
`com.compose.sdl.*` actuals); vendored upstream under `src/vendor/` is out of
scope.
Severity is a rough guide, not a mandate:
- **Blocker** for stable: a capability a typical desktop app expects that is
currently absent or broken.
- **Nice-to-have**: real gap, but the app works without it.
- **Cosmetic**: minor fidelity or edge-case behavior.
Context for prioritizing: the project goal is **G1, cheap upstream-tracking**
(see [RENDERER.md](RENDERER.md)). The **JVM Compose Desktop target is the
documented Windows fidelity/feature tier**, so SDL-leg visual gaps (section J)
are lower priority than the cross-cutting platform gaps (input, locale, a11y,
cursor) that affect every backend including Skia on macOS/Linux.
Renderer-internal cleanups (the deferred D2 shadow-lighting split, D3 matrix
dedupe) live in [RENDERER.md](RENDERER.md#6-remaining-and-future-work), not
here.
---
## Release-blocking shortlist for stable 1.12
The cross-cutting items to weigh first (all detailed below):
1. **Locale is hardcoded to `en-US`** (section E). One fix unlocks the 40+
vendored Material 3 translations. Cheapest high-value win in this file.
2. **No IME / text composition** (section B). Only committed Latin text works;
CJK, dead keys, and accented input do not.
3. **No cursor changes** (section D). The OS cursor never becomes an I-beam over
text or a hand over clickables.
4. **No copy/paste context menu** (section C). Keyboard copy works; there is no
right-click or selection toolbar.
5. **Accessibility is entirely absent** (section A). Decide whether stable
requires any screen-reader support at all.
6. **Standard `Font(...)` / resource-font loading is a no-op** (section F). Only
pre-registered named fonts render. A blocker only if apps use the standard
font APIs.
---
## A. Accessibility (entirely absent)
No accessibility bridge exists. A real `SemanticsOwner` is built but never
surfaced to any OS accessibility API (no NSAccessibility / UIA / AT-SPI, no SDL
a11y). Screen readers get nothing.
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:340` · `semanticsOwner` is instantiated but never read; no traversal into a platform a11y tree. A real a11y controller would walk the semantics tree and map roles/labels/actions to a native AT bridge. **Blocker for a11y.**
- `compose/ui/ui/src/nativeMain/.../platform/CompositionLocals.native.kt:31` · `LocalPlatformScreenReader` default throws (`error(...)`); never provided by any window. Screen-reader-active queries are unavailable. **Blocker for a11y.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:297` · `accessibilityManager` implements only `calculateRecommendedTimeoutMillis` (passthrough); no announce/focus/event surface. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../semantics/SemanticsRegion.native.kt:14` · `StubSemanticsRegion.intersect()`/`difference()` hardcode `false`; only `set`/`bounds` work. Region math for semantics culling is inert. **Nice-to-have.**
## B. Text input / IME
Committed text, arrow/backspace/enter editing, and mouse-drag selection work.
Composition (preedit) does not.
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:407` · `textInputSession` awaits cancellation forever (`awaitCancellation()` at :414); no real `PlatformTextInputSession`. The modern `PlatformTextInputModifierNode` pipeline (BasicTextField2) has no IME start/stop, composition, or cursor-rect reporting. **Blocker.**
- `compose/ui/ui/src/nativeMain/.../SDL3EventMapper.kt:126` · maps `SDL_EVENT_TEXT_INPUT` (committed) only; `SDL_EVENT_TEXT_EDITING` (preedit/composition) is never mapped, so there is no underlined composing region for dead keys / CJK. **Blocker for CJK and dead-key input.**
- `compose/ui/ui/src/nativeMain/.../SDL3Backend.kt:111` · `SDL_StartTextInput` is called once at window init and never scoped to the focused field; `SDL_SetTextInputArea` is never called, so the IME candidate window renders at the wrong location. **Nice-to-have.**
- `compose/ui/ui/src/commonMain/.../text/input/NoOpPlatformTextInputService.kt:24,32` · legacy `PlatformTextInputService` `startInput`/`stopInput`/`updateState` + software-keyboard toggles are all NOP. Superseded by the modern path above. **Nice-to-have / cosmetic (desktop).**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:328` · `softwareKeyboardController.show/hide` NOP. **Cosmetic (desktop).**
## C. Text toolbar and context menus (copy / paste / select-all)
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:311` · `textToolbar` is a stub: `showMenu`/`hide` NOP, `status` always `Hidden`. No selection or right-click copy/paste popup anywhere. Keyboard copy still works. **Blocker for text UX.**
- `compose/foundation/.../text/selection/SelectionActuals.native.kt:42` · `addSelectionContainerTextContextMenuComponents` NOP (upstream TODO CMP-7819). No copy/select-all menu for `SelectionContainer`. **Nice-to-have.**
- `compose/foundation/.../text/selection/TextFieldSelectionManager.native.kt:24` and `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:36` · `addBasicTextFieldTextContextMenuComponents` NOP for both the legacy and state-based `BasicTextField`. **Nice-to-have.**
## D. Pointer / cursor icons
The OS cursor never changes. No SDL cursor API is called anywhere in the port.
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:333` · `pointerIconService.getIcon()` always returns `Default`; `setIcon()` body is empty. This is the hook `Modifier.pointerHoverIcon` drives, so hover cursor changes are swallowed. Should store the requested icon and call `SDL_SetCursor`. **Blocker for UX.**
- `compose/ui/ui/src/nativeMain/.../input/pointer/PointerIcon.native.kt:18` · `pointerIconDefault|Crosshair|Text|Hand` are inert marker objects carrying only a name; nothing maps them to `SDL_SYSTEM_CURSOR_*`. Needs `SDL_CreateSystemCursor` + `SDL_SetCursor` (and cursor caching in the `:window` loop). **Blocker (paired with above).**
## E. Locale and internationalization
System-locale plumbing already exists in exactly one place:
`components/resources/.../ResourceEnvironment.native.kt:38` reads
`SDL_GetPreferredLocales()` for resource-qualifier resolution. Every other i18n
consumer ignores it and hardcodes English. Wiring `Locale.current` to that same
call is the flagship cheap win.
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt:57` · `DefaultLocale` is a fixed `Locale("en-US")`, so `Locale.current` can never change. This one value keys Material 3 string translation selection, stranding the 40+ vendored `material3/internal/l10n/*.kt` tables (they are present and reachable by tag, but the key is always en-US). Fix by querying `SDL_GetPreferredLocales()` (already bound in the `sdl3` cinterop). **Blocker; fixing it transitively localizes all of Material 3.**
- `compose/ui/ui/src/nativeMain/.../text/intl/PlatformLocale.native.kt:18` · `createPlatformLocaleDelegate().current` hardcodes `LocaleList(listOf(Locale("en-US")))`; should reflect the full ordered preferred-locale list SDL returns (the resources module currently reads only `[0]`). **Blocker.**
- `compose/material3/.../CalendarLocale.native.kt:20` · locale-agnostic stub: `toString()` fixed `"en"`, single shared instance, `equals` treats all instances as equal. A separate type from `ui.text.intl.Locale`, so it needs its own wiring so `CalendarModel`/`PlatformDateFormat` can vary by locale. **Blocker (DatePicker/TimePicker).**
- `compose/material3/.../internal/PlatformDateFormat.native.kt` · multiple hardcodes:
- `:27` `weekdayNames` hardcoded English full/short names (DatePicker shows English day initials regardless of locale). **Blocker.**
- `:32` `formatWithPattern`/`formatWithSkeleton` ignore the pattern/skeleton and always emit ISO `yyyy-MM-dd`. **Blocker.**
- `:25` `firstDayOfWeek` hardcoded `1` (Sunday); locale-dependent. **Nice-to-have.**
- `:44` `parse()` only accepts ISO `yyyy-MM-dd`, ignoring pattern/locale. **Nice-to-have.**
- `:62` `getDateInputFormat()` hardcoded `yyyy-MM-dd` / `'-'`. **Nice-to-have.**
- `:65` `is24HourFormat()` hardcoded `true`. **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../text/platform/NativeStringDelegate.native.kt:17` · `toUpperCase`/`toLowerCase` use locale-independent stdlib casing; the `locale` param is ignored (wrong for Turkish dotted/dotless i, etc.). **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt:17` · `Locale.isRtl()` not implemented; the pipeline is LTR-only (`SdlParagraph.native.kt:333` `getBidiRunDirection` always returns `Ltr`; owners/DrawScopes default `LayoutDirection.Ltr`). No RTL shaping. **Nice-to-have.**
- No `NumberFormat` / currency / decimal-grouping actual exists; numbers render via `toString()` with `.` decimal and no grouping regardless of locale. **Cosmetic.**
**Beyond a real `getLocale`:** SDL reports language + country only (no script subtag, no calendar/clock/number preferences), and Kotlin/Native bundles no ICU. Localized month/weekday name tables, date/number/currency patterns, first-day-of-week, locale-aware casing, and RTL all still require a bundled CLDR subset or a third-party K/N i18n library. The SDL wiring is the cheap 80%; these are the expensive tail.
## F. Font resolution
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:32` · `SdlPlatformFontLoader.loadBlocking`/`awaitLoad` are NOP (return `Unit`); font bytes for `ResourceFont` / `Font(bytes)` / `LoadedFontFamily` are never loaded. Only pre-registered `NamedFont` names reach the renderer. **Blocker if the app uses standard `Font(...)` / resource-font APIs; otherwise nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:38` · `PlatformFontFamilyTypefaceAdapter.resolve` returns `TypefaceResult.Immutable(Unit)`; any code reading the resolved typeface gets `Unit` (the renderer bypasses this via name lookup, so usually harmless). **Nice-to-have.**
- `compose/ui/ui/src/commonMain/.../text/NamedFont.kt:62` · `FontFamily.projectFontName` resolves only a `FontListFontFamily` whose first font is a `NamedFont`; returns `null` for `Serif`/`SansSerif`/`Monospace`/`Cursive`/`Default`, `LoadedFontFamily`, and resource-backed families, so all of those collapse to the single default font (no serif/mono/cursive distinction). **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:69` · deprecated `createFontFamilyResolver(fontResourceLoader)` ignores the supplied loader. **Cosmetic.**
## G. Text rendering details
- `compose/ui/ui/src/nativeMain/.../text/SdlParagraph.native.kt:376` · `drawNativeText` accepts and ignores text `Shadow`, stroke `DrawStyle`, and non-`SrcOver` blend, so those never render on text. **Cosmetic.**
- `compose/foundation/.../text/StringHelpers.native.kt:31` and `compose/ui/ui/src/nativeMain/.../text/CharHelpers.native.kt:14` · `findPrecedingBreak`/`findFollowingBreak` (and `offsetByCodePoints`) do a codepoint/surrogate walk only, with no ICU grapheme clusters, so caret movement and backspace split combining marks and emoji ZWJ sequences. **Nice-to-have.**
## H. Drag and drop
Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:38` · `requestDragAndDropTransfer` is a NOP and `isRequestDragAndDropTransferRequired` returns `false`; `Modifier.dragAndDropSource` compiles but no OS-level drag leaves the window. Needs per-OS native work (NSDraggingSession / DoDragDrop / XDND); SDL3 has no portable start-drag. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:119` · incoming transfer carries only file paths + a text blob; no image/URL/custom-MIME payloads. **Cosmetic.**
- `compose/foundation/.../draganddrop/DragAndDropSource.native.kt:36` · no drag-shadow / drag-image feedback for internal DnD (upstream skiko renders one). **Cosmetic.**
## I. Window and platform info
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:354` · `windowInfo.containerSize`/`containerDpSize` return `IntSize.Zero`/`DpSize.Zero`; `LocalWindowInfo.containerSize` readers (popup sizing, window-size-aware layout) get 0. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:353` · `windowInfo.isWindowFocused` hardcoded `true`; not fed by SDL focus events (which the window already tracks). Focus-reactive UI will not react. **Nice-to-have.**
- Multi-monitor: no display enumeration or per-monitor placement anywhere (`SDL_GetDisplays`/`SDL_GetDisplayBounds`/`SDL_GetDisplayForWindow` unused). **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../platform/PrefetchLocals.native.kt:18` · `LocalPlatformPrefetchScheduler` default is a NOP scheduler; lazy lists skip ahead-of-time item composition (correctness fine, possible scroll-in jank). **Nice-to-have (perf).**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:324` · `autofill`/`autofillManager` are null; `requestAutofill` NOP. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:279` · `hapticFeedBack.performHapticFeedback` NOP (no desktop pointer haptics API). **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:287` · deprecated `clipboardManager` `setText`/`getText` NOP, and `clipboard.getClipEntry` null. Harmless: the real `LocalClipboard` (text + PNG image via SDL3) works. **Cosmetic.**
- `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:44` · `ClipboardPasteState.hasClip` aliased to `hasText`, so image-only clipboard is not detected for the paste affordance. **Cosmetic.**
## J. SDL renderer graphics (Windows / `-Prenderer=sdl3` only)
These affect only the SDL leg. On macOS/Linux the Skia leg implements them
correctly, and JVM is the documented Windows fidelity tier, so these are lower
priority under G1. Listed because SDL is the shipped Windows renderer.
- `compose/ui/ui/src/sdlRendererMain/.../graphics/PathEffect.sdl.kt:15` · `dash`/`corner`/`chain`/`stamped` path effects are all NOP; dashed/dotted strokes render solid. **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../graphics/RenderEffect.sdl.kt:16` · `RenderEffect.isSupported()` is `false`; `Modifier.blur()` and `graphicsLayer{renderEffect=...}` do nothing. `compose/ui/ui/src/sdlRendererMain/.../graphics/shadow/Blur.native.kt:33` · `Paint.setBlurFilter` NOP. **Nice-to-have (Windows visual parity).**
- Gradients render real ramps, but `TileMode` is dropped: `t` is clamped, so `Repeated`/`Mirror`/`Decal` all behave as `Clamp` (`Sdl3DrawScope.kt:1070` samplers; `CanvasPaintActuals.native.kt:66`). **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../graphics/CanvasPaintActuals.native.kt:106` · `ActualImageShader`/`ActualCompositeShader` are stubs; image/composite `ShaderBrush` degrades to solid fill (white/black). **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../graphics/ColorFilter.sdl.kt:13` · tint/color-matrix/lighting color filters are empty stubs; only `BlendModeColorFilter` tint on image blits is honored (`Sdl3Canvas.kt:1251`). ColorMatrix (grayscale/saturation) and all filters on shapes are inert. **Nice-to-have.**
- BlendMode: only `SrcOver` (plus a `Clear` special case) composites; `Multiply`/`Screen`/`Plus`/etc. are ignored (`Sdl3DrawScope.kt:206`, `Sdl3Canvas.kt:926`). Note `BlendMode.isSupported()` misleadingly returns `true`. **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:436` · `saveLayer` has no offscreen buffer; layer alpha is multiplied into each primitive, so overlapping content double-composites (wrong group opacity) and the layer paint's colorFilter/blendMode/renderEffect are dropped. **Nice-to-have (blocker for correct group-alpha over overlapping content).**
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:509` · `clipPath` degrades an arbitrary path to its bounding box; rotated/sheared `clipRect` collapses to an AABB. Non-rect clips leak. (Rounded/difference clips are real, with feathered AA.) **Nice-to-have.**
- Stroke joins (`StrokeJoin`) are never applied, so polyline corners notch; `StrokeCap.Square` falls back to butt (`Sdl3DrawScope.kt:380,342`). **Nice-to-have / cosmetic.**
- `PathFillType` (NonZero vs EvenOdd) is mostly ignored beyond a 2-contour border case; interior holes and self-intersections are not cut out, and concave fills self-overlap (`Sdl3DrawScope.kt:366,717`). **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:1276` · `drawPoints`/`drawRawPoints`/`drawVertices` are NOP; point-mode drawing and custom vertex meshes render nothing. **Nice-to-have.**
- Images under a rotated layer are not rotated (`SDL_RenderTexture` is axis-aligned, `Sdl3Canvas.kt:1264`); stroked non-square ovals stroke as a circular ring (`Sdl3DrawScope.kt:527`); `SdlImageBitmap.readPixels` is a no-op (`Sdl3Offscreen.kt:117`). **Cosmetic / nice-to-have.**
- Drop shadow is approximated (9-slice / stacked rings, not a gaussian blur; ambient vs spot largely collapsed) at `Sdl3Canvas.kt:785`; AA is a ~1px geometry fringe rather than analytic coverage. **Cosmetic.**
## K. GraphicsLayer / image
- `compose/ui/ui/src/sdlRendererMain/.../graphics/CanvasPaintActuals.native.kt:151` · `createImageBitmap(bytes)` throws `UnsupportedOperationException`. (`GraphicsLayer.toImageBitmap` and `snapshotBgra` do work on both renderers.) **Nice-to-have.**
+82 -26
View File
@@ -8,18 +8,27 @@ For architecture, source-set layout, and vendoring rules, see
## Native libraries
SDL3, SDL3_ttf, SDL3_image, and FreeType are built from source as static
libraries and linked straight into the executable. One script does it on every
OS. Output lands in the gitignored `libs/`; versions are pinned in
`scripts/build-sdl/build-sdl.properties`.
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 # freetype, sdl3, sdl3-image, sdl3-ttf
python3 scripts/build-sdl/build-all.py sdl3-ttf # rebuild one step
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 versions.
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
@@ -41,13 +50,22 @@ 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). Run both on
every ref bump.
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
@@ -63,16 +81,16 @@ python3 scripts/compose-coverage.py --missing ui-text # list uncovered decl
### One-command gate
`verify-mac.sh` is the runbook to run after any renderer or layout change. On
macOS or Linux it exercises BOTH renderer legs (Skia by default, then
`-Prenderer=sdl3`) and exits non-zero on any failure:
macOS or Linux it exercises the Skia renderer and exits non-zero on any
failure:
```bash
scripts/verify-mac.sh
```
It runs, per leg: 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).
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
@@ -110,6 +128,21 @@ CDN_PROFILE=1 CDN_FORCERENDER=1 ./demo/build/bin/macosArm64/debugExecutable/demo
Note: `present` is vsync-blocking, so profile on the target refresh rate before
drawing conclusions about a frame-rate gap.
### Text metrics diagnostic
Set `CDN_TEXT_METRICS=1` and run any app to dump, per built paragraph, the font
scaler's `ascent`/`descent`/`leading`, the resolved `familyName` + `fontPx`, and
the resulting paragraph line box (`paraHeight`, `line0[ascent/descent/baseline/
height]`). Its job is the mac-vs-Windows vertical-spacing question (PLAN.md §1b):
the SAME `NotoSans.ttf` bytes go through a different Skia `FontMgr` scaler per
host (CoreText / fontconfig-FreeType / DirectWrite), which can pick different
metric tables. Run it on native AND on the JVM parity app on the SAME host and
diff the lines - Windows-native must match skiko-JVM-Windows.
```bash
CDN_TEXT_METRICS=1 demo.kexe --screen=Buttons --screenshot=/tmp/b.bmp
```
### Demo probes
`:demo` ships headless regression probes driven by CLI flags, used by the
@@ -121,6 +154,7 @@ 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)
```
@@ -135,6 +169,13 @@ 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
@@ -151,25 +192,31 @@ 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`. Group is `com.bitsycore.compose.sdl`. | Set by the tag, not edited by hand. A non-publish build is `0.0.0-SNAPSHOT`. |
| 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. |
| JVM parity forcing | `vComposeJvmVersion` in `demo`, `apidemo`, and `material-symbols` `build.gradle.kts`. | Must be a version PUBLISHED to Maven Central. It may lag the vendored native ref (a documented skew) until the matching version is published. |
| Skiko | `skiko` in `gradle/libs.versions.toml`. | Must expose the `org.jetbrains.skiko.node` `RenderNode` / `GraphicsContext` API the vendored compose-core uses. Verify with a throwaway `skikoRendererMain` compile if unsure. |
| SDL3 / SDL3_ttf / SDL3_image / FreeType | `scripts/build-sdl/build-sdl.properties`. | Rebuild `libs/` with `build-all.py` after any change. |
| 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 `COMPOSE_CORE_REF` / `COMPOSE_REF` in `compose.properties` (and `compose`
in `libs.versions.toml` if the coordinate version moved).
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.
4. Build both legs and fix any fallout.
5. `scripts/verify-mac.sh` (both legs green, including the soak and parity gates).
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.
@@ -180,16 +227,25 @@ Run this on each upstream bump; it is the flow that keeps the sync tax low.
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 :window:compileCommonMainKotlinMetadata`.
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. `git tag vX.Y.Z && git push origin vX.Y.Z`. This triggers `.github/workflows/publish.yml`.
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 `com.bitsycore.compose.sdl:*`. Windows additionally publishes the root
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
+64 -282
View File
@@ -1,111 +1,59 @@
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
// JVM parity target's Compose version — the dev build matching the pinned
// COMPOSE_CORE_REF (see scripts/compose-fork/compose.properties); mirrors
// :demo's forcing (Gradle orders "+dev" BELOW the plain version, so the
// plugin's beta01 would win conflict resolution without it).
val vComposeJvmVersion = libs.versions.compose.get()
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
val vComposeJvmForced = mapOf(
"org.jetbrains.compose.runtime" to vComposeJvmVersion,
"org.jetbrains.compose.ui" to vComposeJvmVersion,
"org.jetbrains.compose.foundation" to vComposeJvmVersion,
"org.jetbrains.compose.animation" to vComposeJvmVersion,
"org.jetbrains.compose.material" to vComposeJvmVersion,
"org.jetbrains.compose.material3" to vComposeM3JvmVersion,
)
configurations.configureEach {
if (name.startsWith("jvm")) {
resolutionStrategy.eachDependency {
vComposeJvmForced[requested.group]?.let { useVersion(it) }
}
}
}
// ==================
// MARK: Targets
// ==================
kotlin {
jvm()
linuxArm64()
linuxX64()
macosArm64()
if (vHostSupportsMingw) mingwX64()
applyDefaultHierarchyTemplate()
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")
}
}
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…).
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",
// 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`.
// GUI subsystem (no console window), keeping the C `main` entry.
"-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(
target.name.startsWith("linux") -> linkerOpts(
"-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib/aarch64-linux-gnu",
"-lfontconfig", "-lGL", "-lX11",
)
}
}
}
}
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
dependencies {
// Common API on both stacks — usable from shared code.
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,
// so commonMain metadata (and the IDE's common analysis)
// resolve. Metadata + jvm resolve them from Maven; the NATIVE
// configurations substitute each for its port module (root
// build's FULL-COMMONIZATION BRIDGE). Every artifact must be
// declared DIRECTLY: transitives of a substituted module are
// invisible to the granular-metadata visibility check. The
// runtime is the official klib everywhere, never substituted
// (jvm configs force the +dev build above).
// 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")
@@ -119,6 +67,9 @@ kotlin {
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)
@@ -130,27 +81,18 @@ kotlin {
}
nativeMain {
dependencies {
// The SDL window shell + main loop (native-only).
implementation(project(":window"))
// Single HTTP engine on every native 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. Curl everywhere (not
// WinHttp/Darwin) so the 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.
implementation(project(":compose:desktop:native:desktop-native-window"))
implementation(libs.ktor.client.curl)
}
}
jvmMain {
dependencies {
// Upstream Compose Desktop — the parity stack.
implementation(compose.desktop.currentOs)
// Coroutine-based engine on JVM; native targets keep Curl.
// 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)
// Dispatchers.Main provider on desktop JVM (must match
// kotlinx-coroutines-core's version — the catalog pins both).
implementation(libs.kotlinx.coroutines.swing)
}
}
@@ -168,200 +110,40 @@ compose.desktop {
}
}
// 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"),
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"),
)
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)
}
}
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,
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")
}
/* 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
}
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")
}
}
}
// 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()
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) }
}
)
embedWindowsIcon = providers.gradleProperty("embedWindowsIcon").map { it.toBoolean() }
}
}
// ==================
// MARK: Stage fonts onto the JVM classpath
// MARK: Fonts (shared pipeline - buildSrc ComposeFontBundling.kt)
// ==================
// The JVM analog of the data.kres font/ entries: :material-symbols' JVM actual
// loads each style's font from the classpath at font/<Style>.ttf, and the
// jvm Fonts actual loads the mono body font at font/NotoSansMono.ttf.
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(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<*>
from(vFontFile) { into("font") }
dependsOn(vDownloadTask)
}
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

+18 -17
View File
@@ -2,15 +2,16 @@ 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,
@@ -21,11 +22,11 @@ actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
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.
/** 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). */
@@ -41,8 +42,8 @@ actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<Ch
// 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
/** 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. */
@@ -52,7 +53,7 @@ private fun osChainApple(
inServerCount: Int,
inServerCerts: List<List<Pair<String, String>>>,
): List<ChainCert> {
// Parse every cert the server presented, not just the leaf — Apple's
// 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 {
@@ -81,7 +82,7 @@ private fun osChainApple(
}
val vTrust = vTrustVar.value!!
// Evaluate even if it fails — we still want the partial chain back so
// 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>()
@@ -124,7 +125,7 @@ private fun osChainApple(
return vResolved
}
/* SecCertificate from DER bytes. Returns null if the bytes aren't a
/** 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? {
@@ -136,7 +137,7 @@ private fun createSecCertificate(inDer: ByteArray): SecCertificateRef? {
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)
@@ -146,7 +147,7 @@ private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
return vArr
}
/* Apple's "subject summary" — basically the CN, but falls back to the
/** 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)
@@ -156,7 +157,7 @@ private fun secSubjectSummary(inCert: SecCertificateRef): String? {
return vNs?.toString()
}
/* PEM string for a SecCertificate — base64 of the DER bytes wrapped
/** 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? {
@@ -166,17 +167,17 @@ private fun secCertPem(inCert: SecCertificateRef): String? {
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 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()
return runCatching { Base64.decode(vBody) }.getOrNull()
}
File diff suppressed because it is too large Load Diff
+7 -18
View File
@@ -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
}
+49 -24
View File
@@ -1,19 +1,21 @@
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
/** 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) {
@@ -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,71 +38,92 @@ 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
/** 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) {
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
/** 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
/** 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
/** 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
/** 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
@@ -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)
+44 -51
View File
@@ -1,18 +1,17 @@
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,
// 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. */
/** 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
@@ -25,15 +24,12 @@ class CertDetails(
// ============
// DER reader: each next() returns [tag, contentStart, contentEnd] and advances.
private class DerReader(val b: ByteArray, var pos: Int, val end: Int)
{
private class DerReader(val b: ByteArray, var pos: Int, val end: Int) {
fun hasMore() = pos < end
fun next(): IntArray
{
fun next(): IntArray {
val vTag = b[pos++].toInt() and 0xFF
var vLen = b[pos++].toInt() and 0xFF
if (vLen and 0x80 != 0)
{
if (vLen and 0x80 != 0) {
val vN = vLen and 0x7F
vLen = 0
repeat(vN) { vLen = (vLen shl 8) or (b[pos++].toInt() and 0xFF) }
@@ -44,13 +40,11 @@ private class DerReader(val b: ByteArray, var pos: Int, val end: Int)
}
}
/* Parse the PEM into the fields the chain dialog shows. Returns null on any
/** 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?
{
fun parseCertDetails(inPem: String): CertDetails? {
val vDer = pemToDer(inPem) ?: return null
return try
{
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
@@ -77,44 +71,43 @@ fun parseCertDetails(inPem: String): CertDetails?
// 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())
{
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
}
catch (e: Throwable) { null }
}
// ============
// Helpers
@OptIn(ExperimentalEncodingApi::class)
private fun pemToDer(inPem: String): ByteArray?
{
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 }
return try {
Base64.decode(vBody)
} catch (_: Throwable) {
null
}
}
/* The friendly name (or dotted OID) of the first child OID of a SEQUENCE — used
/** 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?
{
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>)
{
/** 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())
{
while (vEs.hasMore()) {
val vExtn = vEs.next()
val vEr = DerReader(inB, vExtn[1], vExtn[2])
val vOid = vEr.next() // extnID
@@ -129,8 +122,7 @@ private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<Strin
{
val vGnSeq = DerReader(inB, vVal[1], vVal[2]).next()
val vGn = DerReader(inB, vGnSeq[1], vGnSeq[2])
while (vGn.hasMore())
{
while (vGn.hasMore()) {
val vName = vGn.next()
if (vName[0] == 0x82) inOut.add(inB.copyOfRange(vName[1], vName[2]).decodeToString()) // [2] dNSName
}
@@ -138,54 +130,55 @@ private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<Strin
}
}
/* ASN.1 UTCTime (YYMMDD…) / GeneralizedTime (YYYYMMDD…) → readable UTC string. */
private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String?
{
/** 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
{
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
{
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 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
}
catch (e: Throwable) { vS }
}
/* Colon-separated hex of a byte range (serial number rendering). */
/** 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
{
/** 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)
{
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 }
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)
{
/** 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"
+50 -2
View File
@@ -1,5 +1,6 @@
package apidemo
import androidx.compose.material3.Typography
import androidx.compose.ui.text.font.FontFamily
// ==================
@@ -11,10 +12,57 @@ import androidx.compose.ui.text.font.FontFamily
// back to the default proportional font.
const val kMonoFamily = "noto-mono"
/* The raw family name string — used by wrappedRowCount, which measures via the
/** 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.
/** 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),
)
}
+4 -3
View File
@@ -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 {
+18 -20
View File
@@ -1,28 +1,28 @@
package apidemo
import io.ktor.client.*
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
/** 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. */
run() is a suspend fun - call it off the UI dispatcher. */
class HttpRunner {
private val fClient = createApiHttpClient()
private val mClient = createApiHttpClient()
init {
// Clear any temporary client certs a prior crash left in the Windows
@@ -32,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() }
@@ -44,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()
@@ -72,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,
@@ -82,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,
@@ -98,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(),
@@ -115,10 +113,10 @@ class HttpRunner {
}
}
fun close() = fClient.close()
fun close() = mClient.close()
}
/* Heuristic for a non-text body: textual content types (text, json, xml, html,
/** 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 {
@@ -131,17 +129,17 @@ internal fun isBinaryBody(inContentType: String?, inBytes: ByteArray): Boolean {
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
/** 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())
/* 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 =
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()
+92 -29
View File
@@ -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
/** 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. */
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
/** 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"
/** 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,7 +131,7 @@ internal fun SourceTag(inLabel: String, inPath: String) {
}
}
/* The tiny marker shown on an own value that shadows an inherited one — hover for
/** 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) {
@@ -120,7 +150,7 @@ internal fun OverrideMark(inKey: String, inPath: String) {
}
}
/* Read-only list of inherited key/values, each tagged with its source. When not
/** 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
@@ -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
/** 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()) }
+1 -1
View File
@@ -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
+154 -76
View File
@@ -6,8 +6,8 @@ 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,
/** 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(). */
@@ -26,8 +26,8 @@ data class Pack(
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
/** 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(
@@ -38,8 +38,8 @@ data class CertConfig(
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
/** 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
@@ -47,7 +47,7 @@ data class CertConfig(
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 ──
// ── Session level - the base of every inheritance ladder ──
globalEnv = listOf(
KeyVal("baseUrl", "https://httpbin.org"),
KeyVal("token", "session-token-abc"),
@@ -56,83 +56,167 @@ fun defaultSession(): Session = Session(
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)
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(
// ── 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,
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,
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 = "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(
)
)
),
// ── 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.
)
)
),
// ── 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"),
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"),
))),
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")),
SavedPack(
pack = Pack(
name = "Methods · httpbingo", color = 6, requests = emptyList(),
variables = listOf(KeyVal("baseUrl", "https://httpbingo.org")), linkedTo = "pk-methods"
)
),
),
)
@@ -159,24 +243,26 @@ data class ApiRequest(
val certPassword: String = "",
)
/* Whether this request carries a client certificate (and so is sent via the
/** 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). */
/** 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. */
/** 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)
copy(
certPath = inCert.certPath, certFormat = inCert.certFormat,
keyPath = inCert.keyPath, keyFormat = inCert.keyFormat, certPassword = inCert.certPassword
)
/* True when this config actually selects a certificate. */
/** 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
/** 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. */
@@ -187,24 +273,17 @@ enum class CertFormat(val curlName: String, val label: String) {
PKCS12("P12", "PKCS#12"),
}
/* A toggleable key/value row, used for both query params and headers. */
/** 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. */
/** Title-case label for the body-type picker. */
val BodyType.label: String
get() = when (this) {
BodyType.NONE -> "None"
@@ -213,7 +292,7 @@ val BodyType.label: String
BodyType.FILE -> "File"
}
/* The Content-Type the request body implies (null when there's no body). For a
/** 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) {
@@ -224,11 +303,10 @@ fun ApiRequest.bodyContentType(): String? = when (bodyType) {
}
// ==================
// MARK: Response (runtime only — not part of a pack)
// MARK: Response (runtime only - not part of a pack)
// ==================
class ApiResponse(
val ok: Boolean,
val status: Int,
val statusText: String,
val timeMs: Long,
@@ -242,6 +320,6 @@ class ApiResponse(
val error: String? = null,
)
/* True when the response carries an image payload (rendered, not shown as text). */
/** True when the response carries an image payload (rendered, not shown as text). */
val ApiResponse.isImage: Boolean
get() = contentType?.startsWith("image/", ignoreCase = true) == true
+16 -16
View File
@@ -1,59 +1,59 @@
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
/** Write the pack as pretty JSON to inPath. Returns null on success, else the
error message. */
fun exportPack(inPack: Pack, inPath: String): String? = try {
systemFileSystem.write(inPath.trim().toPath()) {
writeUtf8(fJson.encodeToString(inPack))
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 = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
Result.success(fJson.decodeFromString<Pack>(vText))
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 {
systemFileSystem.write(inPath.trim().toPath()) {
writeUtf8(fJson.encodeToString(inSession))
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 = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
Result.success(fJson.decodeFromString<Session>(vText))
Result.success(mJson.decodeFromString<Session>(vText))
} catch (e: Throwable) {
Result.failure(e)
}
/* Rename inPath to inNewName within the same directory (adding a .json
/** 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()
@@ -65,7 +65,7 @@ fun renameFile(inPath: String, inNewName: String): Result<String> = try {
Result.failure(e)
}
/* Write arbitrary text (a response body / headers dump) to inPath. Returns null
/** 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 {
systemFileSystem.write(inPath.trim().toPath()) { writeUtf8(inText) }
@@ -74,7 +74,7 @@ fun writeTextFile(inPath: String, inText: String): String? = try {
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 {
systemFileSystem.write(inPath.trim().toPath()) { write(inBytes) }
null
@@ -82,10 +82,10 @@ fun writeBytesFile(inPath: String, inBytes: ByteArray): String? = try {
e.message ?: e.toString()
}
/* Re-indent a JSON response for display; returns the input unchanged if it
/** 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
}
+19 -16
View File
@@ -1,15 +1,17 @@
package apidemo
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,
/** 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. */
@@ -17,6 +19,7 @@ import okio.Path.Companion.toPath
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)
@@ -34,7 +37,7 @@ data class AppState(
val activeReq: Int = -1,
)
/* A pack as stored in the app state: the pack content plus where it was last
/** 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(
@@ -43,8 +46,8 @@ 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
/** 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(
@@ -61,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
@@ -72,39 +75,39 @@ 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
/** 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
/** 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 = systemFileSystem.read(vPath.toPath()) { readUtf8() }
fStateJson.decodeFromString<AppState>(vText)
} catch (e: Throwable) {
mStateJson.decodeFromString<AppState>(vText)
} catch (_: Throwable) {
AppState()
}
}
/* Write the app state. Best-effort — failures are swallowed (it's a cache of
/** 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 {
systemFileSystem.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
/** 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
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
+54 -14
View File
@@ -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,7 +86,7 @@ internal fun OptionsMenu(
// MARK: Method picker (dropdown)
// ==================
/* Panel 2 — one unified bar: a method dropdown (coloured label + unfold arrows),
/** 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(
@@ -96,10 +126,12 @@ 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)
@@ -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,14 +15,16 @@ 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 apidemo.compat.editorTabWidth
import apidemo.compat.showOpenFileDialog
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// ==================
// 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,
/** 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(
@@ -40,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()
@@ -53,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,
@@ -98,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()
@@ -117,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) {
@@ -145,7 +145,7 @@ internal fun RequestBuilder(
}
}
/* The body editing area for the current body type (driven by the bottom menu). A
/** 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) {
@@ -157,19 +157,20 @@ 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 /
/** 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
/** 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
@@ -202,14 +203,16 @@ 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
/** 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() {
@@ -227,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),
@@ -235,7 +241,11 @@ 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)) {
@@ -248,7 +258,7 @@ internal fun TabSizeSelector() {
}
}
/* 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
@@ -261,12 +271,17 @@ internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) ->
}
}
/* Client-certificate (mTLS) editor — certificate + optional separate 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)) {
@@ -291,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)
@@ -302,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,
)
}
@@ -313,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
/** 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)) {
@@ -341,20 +377,32 @@ internal fun InheritedCertCard(inInherited: InheritedCert, inOwnSet: Boolean, in
}
}
/* The request's Cert tab: the inherited cert (from pack / session) shown read-only
/** 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
/** 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. */
@@ -367,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(),
@@ -388,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
@@ -23,6 +23,7 @@ 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
@@ -30,9 +31,9 @@ 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
/** 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
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. */
@@ -56,7 +57,7 @@ internal fun RequestTabStrip(
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).
// 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) }
@@ -88,12 +89,13 @@ internal fun RequestTabStrip(
.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
// `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)
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))
@@ -101,7 +103,7 @@ internal fun RequestTabStrip(
.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
// 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.
@@ -130,7 +132,10 @@ internal fun RequestTabStrip(
val vd = vDragKey
if (vd != null) {
val vFrom = inTabs.indexOfFirst { it.tabKey == vd }
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(vFrom, vDragTarget)
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(
vFrom,
vDragTarget
)
}
vDragKey = null; vDragDx = 0f; vDragTarget = -1
},
@@ -138,7 +143,7 @@ internal fun RequestTabStrip(
)
}
// Middle-click closes the tab; right-click opens the tab menu. Compose
// has no first-class match for either — inline pointerInput checks the
// has no first-class match for either - inline pointerInput checks the
// pressed PointerButton at each Press.
.pointerInput(vTab) {
awaitPointerEventScope {
@@ -150,6 +155,7 @@ internal fun RequestTabStrip(
vEv.changes.firstOrNull()?.consume()
inOnClose(vTab)
}
PointerButton.Secondary -> {
vEv.changes.firstOrNull()?.consume()
vMenuTab = vTab; vMenu = true
@@ -169,12 +175,27 @@ internal fun RequestTabStrip(
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)
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)))
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)
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) }
}
@@ -190,9 +211,15 @@ internal fun RequestTabStrip(
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() })
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() })
}
}
}
@@ -201,11 +228,19 @@ internal fun RequestTabStrip(
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)) {
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)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val vSel = vTab.tabKey == inActiveKey
val vRs = vTab.req
if (vRs == null) {
@@ -214,11 +249,28 @@ internal fun RequestTabStrip(
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))
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)
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) })
@@ -230,11 +282,14 @@ internal fun RequestTabStrip(
}
}
/* Thin accent bar marking where a dragged tab will be inserted. Rendered in-flow
/** 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)))
Box(
modifier = Modifier.width(3.dp).height(24.dp).clip(RoundedCornerShape(2.dp))
.background(c.accent, RoundedCornerShape(2.dp))
)
}
+141 -50
View File
@@ -1,5 +1,4 @@
@file:OptIn(
androidx.compose.material3.ExperimentalMaterial3Api::class,
androidx.compose.ui.ExperimentalComposeUiApi::class,
)
@@ -31,6 +30,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
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
@@ -38,7 +40,7 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// MARK: Pack colour (palette dot + picker)
// ==================
/* A small rounded square in the pack's palette colour (neutral when unset).
/** 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) {
@@ -47,7 +49,7 @@ 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).
/** 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) {
@@ -77,7 +79,7 @@ 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
/** 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) {
@@ -85,7 +87,11 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
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,
@@ -93,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,
@@ -108,11 +116,11 @@ 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
/** 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 {
@@ -122,8 +130,8 @@ 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
/** 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
@@ -152,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,
@@ -166,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,
@@ -174,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,
@@ -190,7 +207,8 @@ internal fun SessionMenu(
)
},
onClick = { vOpen = false; inOnRename() },
enabled = vSaved)
enabled = vSaved
)
DropdownMenuItem(
text = {
MenuRow(
@@ -200,7 +218,8 @@ internal fun SessionMenu(
)
},
onClick = { vOpen = false; inOnReveal() },
enabled = vSaved)
enabled = vSaved
)
HorizontalDivider(color = c.border)
DropdownMenuItem(text = {
MenuRow(
@@ -208,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,
@@ -219,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)
@@ -242,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(),
) {
@@ -280,7 +303,7 @@ internal fun SessionMenu(
// MARK: Pack tree (recursive sidebar rendering)
// ==================
/* The per-pack operations a PackSection needs, bundled so PackTree can bind them
/** 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,
@@ -307,11 +330,18 @@ internal class PackOps(
val packDropEnd: () -> Unit,
)
/* Renders a pack then its sub-packs recursively, each indented by its depth.
/** 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. */
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
@@ -351,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() }
}
@@ -359,7 +398,7 @@ 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
/** 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
@@ -410,7 +449,8 @@ internal fun PackSection(
.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 {
@@ -462,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),
) {
@@ -473,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(
@@ -485,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) {
@@ -512,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 }
@@ -522,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
@@ -536,9 +613,10 @@ 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 —
// Same graphicsLayer feedback concern as the pack drag -
// accumulate dragAmount instead of reading change.position.
detectDragGestures(
onDragStart = { offset ->
@@ -551,7 +629,8 @@ internal fun PackSection(
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 && kotlin.math.abs(inDrag.dy) >= kDragSlop) inDrag.engaged =
true
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
},
onDragEnd = { inOnReqDropEnd() },
@@ -627,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)) {
@@ -637,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() })
}
}
}
@@ -7,17 +7,17 @@ import androidx.compose.ui.text.buildAnnotatedString
import kotlinx.serialization.Serializable
// ==================
// MARK: BodyFormat — viewer/highlighter selection
// 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
/** 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). */
/** Short label for the format / "type" picker (RAW = plain text, no highlighting). */
val BodyFormat.label: String
get() = when (this) {
BodyFormat.RAW -> "RAW"
@@ -27,7 +27,7 @@ val BodyFormat.label: String
BodyFormat.HTML -> "HTML"
}
/* The Content-Type a TEXT body of this format is sent with. */
/** The Content-Type a TEXT body of this format is sent with. */
val BodyFormat.contentType: String
get() = when (this) {
BodyFormat.RAW -> "text/plain"
@@ -37,7 +37,7 @@ val BodyFormat.contentType: String
BodyFormat.HTML -> "text/html"
}
/* Pick a format from a Content-Type header. JSON / XML / YAML / 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 {
@@ -55,7 +55,7 @@ fun autoFormatFor(inContentType: String?): BodyFormat {
// MARK: Highlight palette
// ==================
/* Per-token colours. Two preset themes: a dark-on-dark and a dark-on-
/** 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(
@@ -68,7 +68,7 @@ data class SyntaxPalette(
val punct: Color,
) {
companion object {
/* Light-on-dark — VS Code Dark+. */
/** Light-on-dark - VS Code Dark+. */
val Dark = SyntaxPalette(
key = Color(0xFF9CDCFE),
string = Color(0xFFCE9178),
@@ -78,7 +78,8 @@ data class SyntaxPalette(
comment = Color(0xFF6A9955),
punct = Color(0xFFCCCCCC),
)
/* Dark-on-light — VS Code Light+. Higher contrast on white. */
/** Dark-on-light - VS Code Light+. Higher contrast on white. */
val Light = SyntaxPalette(
key = Color(0xFF0451A5),
string = Color(0xFFA31515),
@@ -89,7 +90,7 @@ data class SyntaxPalette(
punct = Color(0xFF333333),
)
/* Pick a palette by the host theme's background brightness. */
/** Pick a palette by the host theme's background brightness. */
fun forDark(inIsDark: Boolean): SyntaxPalette = if (inIsDark) Dark else Light
}
}
@@ -98,7 +99,7 @@ data class SyntaxPalette(
// MARK: Entry point
// ==================
/* Render the given text with per-token colour spans for the requested
/** Render the given text with per-token colour spans for the requested
format. RAW returns an unstyled AnnotatedString. */
fun highlight(
inText: String,
@@ -112,13 +113,13 @@ fun highlight(
}
// ==================
// MARK: JSON tokeniser — single-pass, error-tolerant
// MARK: JSON tokeniser - single-pass, error-tolerant
// ==================
/* Tokeniser for JSON. Walks the string once and emits style spans for
/** 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
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
@@ -132,8 +133,12 @@ private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString =
val vStart = vI
var vJ = vI + 1
while (vJ < vN) {
if (inText[vJ] == '\\' && vJ + 1 < vN) { vJ += 2; continue }
if (inText[vJ] == '"') { vJ++; break }
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.
@@ -146,6 +151,7 @@ private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString =
pop()
vI = vJ
}
vC.isDigit() || (vC == '-' && vI + 1 < vN && inText[vI + 1].isDigit()) -> {
val vStart = vI
if (vC == '-') vI++
@@ -154,6 +160,7 @@ private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString =
append(inText.substring(vStart, vI))
pop()
}
vC.isLetter() -> {
val vStart = vI
while (vI < vN && inText[vI].isLetter()) vI++
@@ -166,13 +173,17 @@ private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString =
append(vWord)
}
}
vC in "{}[],:" -> {
pushStyle(SpanStyle(color = inP.punct))
append(vC)
pop()
vI++
}
else -> { append(vC); vI++ }
else -> {
append(vC); vI++
}
}
}
}
@@ -181,29 +192,30 @@ private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString =
// MARK: XML / HTML tokeniser
// ==================
/* Tokeniser for XML / HTML. Distinguishes tag names, attribute names,
/** 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) -> {
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
}
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++ }
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++
@@ -212,13 +224,22 @@ private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString =
append(inText.substring(vTagStart, vJ))
pop()
}
// Attributes — name="value" pairs.
// 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.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))
@@ -226,6 +247,7 @@ private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString =
pop()
vJ = vEnd
}
else -> {
val vAttrStart = vJ
while (vJ < vGt - 1 && !inText[vJ].isWhitespace() && inText[vJ] != '=' && inText[vJ] != '>' && inText[vJ] != '/') vJ++
@@ -240,16 +262,19 @@ private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString =
}
vI = vGt
}
else -> { append(vC); vI++ }
else -> {
append(vC); vI++
}
}
}
}
// ==================
// MARK: YAML tokeniser — line-based
// MARK: YAML tokeniser - line-based
// ==================
/* Tokeniser for YAML. Walks line by line because YAML's grammar is
/** 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
@@ -270,7 +295,9 @@ private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString =
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++ }
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])
@@ -293,18 +320,21 @@ private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString =
}
}
/* Render the value portion of a YAML line — applies string / number /
/** 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.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 }
@@ -313,9 +343,11 @@ private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPa
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++
@@ -325,38 +357,37 @@ private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPa
pop()
append(vTrimmed.substring(vJ))
}
else -> append(vTrimmed)
}
}
/* Find the index of the first '#' that isn't inside a quoted string,
/** 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
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
/** 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 -> {
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
}
+6 -12
View File
@@ -4,22 +4,16 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// ==================
// MARK: Theme — switchable dark / light palette
// 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 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 }
internal val LocalAppColors = staticCompositionLocalOf { appColorsFromScheme(VolticPalette.Purple.scheme.dark) }
+51 -17
View File
@@ -15,22 +15,25 @@ 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 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
/** 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) {
@@ -38,16 +41,28 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
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),
@@ -55,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) }
@@ -66,7 +84,7 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
}
}
/* One certificate's summary card (subject / issuer / validity). Server-presented
/** 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. */
@@ -86,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(", "))
@@ -115,7 +141,7 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
internal val kSelfSignedColor = Color(0xFF3FB950L)
/* Copy button — rounded, hover overlay, real click that copies inText and shows
/** 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. */
@@ -130,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 }
@@ -152,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) {
@@ -175,16 +209,16 @@ 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
/** 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
default, with spaces - what
libcurl on macOS emits)
Null when there's no CN. */
internal fun cnOf(inDn: String): String? {
@@ -192,6 +226,6 @@ internal fun cnOf(inDn: String): String? {
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 }
+8 -8
View File
@@ -4,26 +4,26 @@ 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
// 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 /
/** 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. */
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
/** 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. */
/** 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). */
/** 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
/** 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()
+37 -18
View File
@@ -4,11 +4,11 @@ package apidemo
// MARK: Variable substitution ({{name}})
// ==================
/* Matches a {{ name }} token. Names may contain letters, digits, _, . and -.
/** 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
/** 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 {
@@ -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
/** 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,7 +82,7 @@ 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
/** 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. */
@@ -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
+29 -23
View File
@@ -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
/** 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,7 +45,7 @@ internal fun MethodTag(inMethod: ReqMethod) {
}
}
/* Thin horizontal accent line showing where a dragged request row will drop
/** Thin horizontal accent line showing where a dragged request row will drop
(the open-tab strip uses the vertical DropBar). */
@Composable
internal fun RowDropBar() {
@@ -53,7 +53,7 @@ internal fun RowDropBar() {
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
/** 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) {
@@ -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
/** 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,
@@ -128,10 +137,17 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
}
}
/* Small icon-only button (burger / pack / options / save / close). inPadding
/** 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,16 +168,16 @@ 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).
/** 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
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
@@ -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) {
+148 -143
View File
@@ -29,7 +29,6 @@ import androidx.compose.ui.input.pointer.isShiftPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.*
@@ -37,6 +36,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import apidemo.compat.*
import apidemo.compat.ScrollbarStyle
import apidemo.compat.rememberScrollbarAdapter
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
import kotlinx.coroutines.launch
@@ -45,7 +47,7 @@ import kotlinx.coroutines.launch
// MARK: Response
// ==================
/* Panel 4 — Request / Response viewer. Each tab stacks a HEADERS section over a
/** Panel 4 - Request / Response viewer. Each tab stacks a HEADERS section over a
BODY section. "Request" shows the resolved request that would be sent (the
Preview); "Response" shows the result (image-aware). A copy / save toolbar
acts on whichever view is showing. */
@@ -59,7 +61,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
val vPreview = inRs.preview
val vShowRequest = vPreview || inRs.viewTab == 0
// Pretty-print once per received body (not every recomposition) — the whole
// Pretty-print once per received body (not every recomposition) - the whole
// body is shown; the renderer line-culls + wrap-caches so even a 20k-line
// payload stays cheap. (Previously capped at 20000 chars, which silently cut
// long responses off around line ~1100.)
@@ -85,9 +87,9 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
Text("PREVIEW", color = c.accent, fontSize = 13.sp)
}
Spacer(Modifier.weight(1f))
Text("not sent", color = kWarnColor, fontSize = 11.sp)
Text("not sent", color = VolticTheme.extended.warning, fontSize = 11.sp)
} else {
// Request tab — adds the coloured method only once the
// Request tab - adds the coloured method only once the
// request has actually been sent (sentReq != null).
val vSent = inRs.sentReq
ViewerTab(
@@ -97,7 +99,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
inSelected = inRs.viewTab == 0,
inOnClick = { inRs.viewTab = 0 },
)
// Response tab — adds the coloured status only when a
// Response tab - adds the coloured status only when a
// response (or error) is in. No placeholder during the
// pre-send / loading state.
val (vRespAccent, vRespColor) = when {
@@ -113,7 +115,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
inOnClick = { inRs.viewTab = 1 },
)
Spacer(Modifier.weight(1f))
// Spinner only — the Cancel control already lives next to
// Spinner only - the Cancel control already lives next to
// Send in the URL bar; no need for a duplicate here.
if (inRs.viewTab == 1 && vLoading) {
CircularProgressIndicator(modifier = Modifier.size(14.dp), color = c.accent, strokeWidth = 2.dp)
@@ -129,12 +131,14 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
vShowRequest && vReqShown == null -> ViewerEmpty(MaterialSymbols.Send, "Not sent")
vShowRequest -> {
val vR = vReqShown!!
val vSentHeaders = if (!vPreview) inRs.response?.requestHeaders?.takeIf { it.isNotEmpty() } else null
val vSentHeaders =
if (!vPreview) inRs.response?.requestHeaders?.takeIf { it.isNotEmpty() } else null
// Synthesize wire-level headers (Host, Content-Length, User-Agent)
// the engine adds without telling Ktor — same set httpie shows.
val vRawHeaders = if (vSentHeaders != null) vSentHeaders else parseHeaderLines(requestHeadersText(vR))
// the engine adds without telling Ktor - same set httpie shows.
val vRawHeaders =
if (vSentHeaders != null) vSentHeaders else parseHeaderLines(requestHeadersText(vR))
val vHeaders = synthesizeRequestHeaders(vR, vRawHeaders)
val vBody = if (!vR.method.allowsBody || vR.bodyType == BodyType.NONE) null else requestBodyText(vR)
val vBody = if (!true || vR.bodyType == BodyType.NONE) null else requestBodyText(vR)
HttpFlowView(
inStatusLine = formatRequestLine(vR, inRs.response?.httpVersion ?: "HTTP/1.1", c),
inStatusLineAccentColor = methodColor(vR.method),
@@ -148,6 +152,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
inSoftWrap = inRs.bodyWrap,
)
}
vResp == null && !vLoading -> ViewerEmpty(MaterialSymbols.Download, "Not received")
else -> {
val vHeaders = vResp?.headers ?: emptyList()
@@ -161,7 +166,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
inBody = if (vRespImage) null else vBody,
inIsImage = vRespImage && !vLoading,
inImagePainter = if (vRespImage && !vLoading) {
val vSvg = vResp?.contentType?.contains("svg", ignoreCase = true) == true
val vSvg = vResp.contentType?.contains("svg", ignoreCase = true) == true
memoryImagePainter(inRs.imageKey!!, vSvg)
} else null,
inHeadersCollapsed = vHeadersCollapsed,
@@ -188,7 +193,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
) {
// Response viewer: format selector for the body. Auto-detected
// from Content-Type until the user overrides, then pinned.
if (vRespHasContent && vResp != null && !vRespImage) {
if (vRespHasContent && !vRespImage) {
val vAuto = autoFormatFor(vResp.contentType)
val vCurrent = inRs.respFormatOverride ?: vAuto
BodyFormatSelector(
@@ -199,10 +204,10 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
inAutoLabel = if (inRs.respFormatOverride == null) vAuto.label else null,
)
}
// Wrap toggle — soft-wrap long lines (default) vs. no wrap +
// Wrap toggle - soft-wrap long lines (default) vs. no wrap +
// horizontal scroll (one source line per row). Shown for any text
// body (request preview or response), hidden for an image.
val vHasTextBody = (vRespHasContent && vResp != null && !vRespImage) || vReqHasContent
val vHasTextBody = (vRespHasContent && !vRespImage) || vReqHasContent
if (vHasTextBody) {
val vWrapOn = inRs.bodyWrap
Box(
@@ -226,7 +231,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
}
vMsg?.let { Text(it, color = c.dim, fontSize = 11.sp) }
Spacer(Modifier.weight(1f))
if (vRespHasContent && vResp != null && vResp.error == null) {
if (vRespHasContent && vResp.error == null) {
Text(formatTimingSize(vResp), color = c.dim, fontSize = 11.sp)
Spacer(Modifier.width(8.dp))
}
@@ -252,10 +257,10 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
}
// ==================
// MARK: HttpFlowView — flat httpie-style request/response layout
// MARK: HttpFlowView - flat httpie-style request/response layout
// ==================
/* Renders the body of a Request or Response tab in a httpie-flavoured
/** Renders the body of a Request or Response tab in a httpie-flavoured
layout: status line (with collapse arrow) + headers table + raw body
inline, no card / rounded-rect chrome around each section. The
headers section can be collapsed via the arrow. */
@@ -275,12 +280,12 @@ internal fun HttpFlowView(
) {
val c = LocalAppColors.current
val vState = rememberLazyListState()
// Editor-style selection state — anchor + caret in absolute character offsets
// Editor-style selection state - anchor + caret in absolute character offsets
// into inBody. Resets when the body changes so a stale range never highlights
// a shorter/different response. Lives outside any SelectionContainer so it
// survives scroll and spans off-screen chunks (see the BodySel comment).
var vSel: BodySel? by remember(inBody) { mutableStateOf(null) }
// Selection background from the app theme — same colour that
// Selection background from the app theme - same colour that
// LocalTextSelectionColors publishes to Compose text selection elsewhere.
val vSelColor = LocalTextSelectionColors.current.backgroundColor
val vFocus = remember { FocusRequester() }
@@ -292,12 +297,12 @@ internal fun HttpFlowView(
// auto-scroll effect that scrolls the LazyColumn while the pointer sits in
// the top / bottom edge zone.
var vDragPos: Offset? by remember { mutableStateOf(null) }
// The body is highlighted ONCE, then sliced into BLOCKS of lines — one BasicText
// The body is highlighted ONCE, then sliced into BLOCKS of lines - one BasicText
// per block, NOT one per line. Far fewer nodes / selectables / paragraph set-ups
// churn through the viewport while scrolling (the smoothness win), and the
// O(total-spans) AnnotatedString.subSequence runs once per block at build time
// (memoised) instead of per visible line every frame. LazyColumn still virtualizes,
// so only the ~2-3 on-screen blocks are composed/measured — 12k lines stay cheap.
// so only the ~2-3 on-screen blocks are composed/measured - 12k lines stay cheap.
val vDark = isDarkBg(c.bg)
val vChunks = remember(inBody, inBodyFormat, vDark) {
if (inBody == null) emptyList()
@@ -310,26 +315,31 @@ internal fun HttpFlowView(
// Per-visible-chunk TextLayoutResult, keyed by chunk index. Populated as each
// BodyChunkRow's BasicText lays out, dropped implicitly when the chunks list
// changes (new response) via the remember key. Off-screen chunks aren't in
// this map — we can't hit-test them via mouse either since they're not painted.
// this map - we can't hit-test them via mouse either since they're not painted.
val vChunkLayouts = remember(vChunks) { mutableStateMapOf<Int, TextLayoutResult>() }
val vTotalLines = remember(inBody) { if (inBody == null) 0 else inBody.count { it == '\n' } + 1 }
// No-wrap mode pans horizontally; every block shares this one scroll state so the
// whole body pans together (the gutter stays pinned — not scrolled).
// whole body pans together (the gutter stays pinned - not scrolled).
val vHScroll = rememberScrollState()
val vGutterWidth = (vTotalLines.coerceAtLeast(1).toString().length * 7 + 4).dp
// Per-line styles hoisted once (not allocated per line per frame). The rows use
// BasicText with these fixed styles instead of material3 Text — no per-node
// LocalTextStyle merge / LocalContentColor read — which is the main scroll-
// BasicText with these fixed styles instead of material3 Text - no per-node
// LocalTextStyle merge / LocalContentColor read - which is the main scroll-
// smoothness win when thousands of line items churn through the viewport.
val vBodyStyle = remember(c.text) { TextStyle(color = c.text, fontSize = 12.sp, fontFamily = monoFontFamily) }
val vNumStyle = remember(c.dim) {
TextStyle(color = c.dim.copy(alpha = 0.45f), fontSize = 12.sp, fontFamily = monoFontFamily, textAlign = TextAlign.End)
TextStyle(
color = c.dim.copy(alpha = 0.45f),
fontSize = 12.sp,
fontFamily = monoFontFamily,
textAlign = TextAlign.End
)
}
// Body-relative x offset (px) of the block's BasicText inside the outer Box.
// Mirrors BodyChunkRow's layout: 4dp Row start padding + gutter width + 6dp spacer.
val vTextStartPx = with(density) { (4.dp + vGutterWidth + 6.dp).toPx() }
// LazyColumn item index of chunk index 0 — status row + optional header
// LazyColumn item index of chunk index 0 - status row + optional header
// table + divider. Used to scroll a specific chunk into view when the caret
// moves off-screen via keyboard. Only stable while we're on the "have body"
// branch, but that's the only branch that renders chunks in the first place.
@@ -348,7 +358,7 @@ internal fun HttpFlowView(
if (!vVisible) vScope.launch { vState.scrollToItem(vChunkItemOffset + vCi) }
}
// Hit-test — turns a pointer position (outer Box local frame) into a global
// Hit-test - turns a pointer position (outer Box local frame) into a global
// character offset into inBody, or null if the pointer isn't over any chunk.
// Clamps past-viewport pointers to the nearest visible chunk boundary so a
// drag that overshoots still keeps extending toward that end.
@@ -377,14 +387,14 @@ internal fun HttpFlowView(
// Drag auto-scroll: while a drag is in progress (vDragPos != null), tick
// every frame and scroll the LazyColumn when the pointer sits in a 40dp
// edge zone at the top / bottom. Speed ramps linearly with depth into the
// zone up to 24dp per frame — roughly a page per second at 60fps at max
// zone up to 24dp per frame - roughly a page per second at 60fps at max
// depth. After each scroll we re-hit-test the current pointer position so
// the selection keeps extending onto the newly revealed chunks.
val vEdgePx = with(density) { 40.dp.toPx() }
val vMaxScrollPx = with(density) { 24.dp.toPx() }
LaunchedEffect(Unit) {
while (true) {
androidx.compose.runtime.withFrameNanos { }
withFrameNanos { }
val vPos = vDragPos ?: continue
val vVpH = vState.layoutInfo.viewportSize.height.toFloat()
val vDelta = when {
@@ -402,7 +412,7 @@ internal fun HttpFlowView(
// contentAlignment pins the narrow scrollbar to the right edge; the
// fillMaxSize content fills the rest (this project's Box has no BoxScope, so
// there's no Modifier.align — alignment is via contentAlignment).
// there's no Modifier.align - alignment is via contentAlignment).
Box(
modifier = Modifier
.fillMaxSize()
@@ -411,7 +421,7 @@ internal fun HttpFlowView(
// Mouse: press starts a selection, drag extends it, Shift+press extends
// from the existing anchor. We drive this by hand instead of going
// through SelectionContainer because SC only sees composed selectables
// (visible chunks) — Ctrl+A and Shift+Arrow can't reach off-screen text
// (visible chunks) - Ctrl+A and Shift+Arrow can't reach off-screen text
// through it. Header items above the chunks stay non-selectable, which
// matches the previous DisableSelection wrappers.
.pointerInput(vChunks) {
@@ -427,7 +437,7 @@ internal fun HttpFlowView(
val vDown = awaitPointerEvent()
if (vDown.type != PointerEventType.Press) continue
val vChange = vDown.changes.firstOrNull() ?: continue
// Skip presses a child already handled — most importantly the
// Skip presses a child already handled - most importantly the
// VerticalScrollbar sibling in this Box. Without this, dragging
// the scrollbar would also start (and continuously extend) a
// body selection because my hit-test still finds a chunk under
@@ -455,10 +465,12 @@ internal fun HttpFlowView(
val vR = wordRangeAt(vBody, vOffset)
BodySel(anchor = vR.first, caret = vR.last)
}
vPressCount >= 3 -> BodySel(
anchor = lineStartAt(vBody, vOffset),
caret = lineEndAt(vBody, vOffset),
)
else -> BodySel(anchor = vOffset, caret = vOffset)
}
vChange.consume()
@@ -496,6 +508,7 @@ internal fun HttpFlowView(
vMod && ev.key == Key.A -> {
vSel = BodySel(0, vBody.length); true
}
vMod && ev.key == Key.C -> {
val vS = vSel
if (vS != null && !vS.isEmpty) {
@@ -504,39 +517,49 @@ internal fun HttpFlowView(
true
} else false
}
ev.key == Key.Escape -> {
if (vSel != null) { vSel = null; true } else false
if (vSel != null) {
vSel = null; true
} else false
}
vShift && ev.key == Key.DirectionRight -> {
val vCur = vSel ?: BodySel(0, 0)
vSel = vCur.copy(caret = (vCur.caret + 1).coerceAtMost(vBody.length))
vScrollCaretIntoView(); true
}
vShift && ev.key == Key.DirectionLeft -> {
val vCur = vSel ?: BodySel(0, 0)
vSel = vCur.copy(caret = (vCur.caret - 1).coerceAtLeast(0))
vScrollCaretIntoView(); true
}
vShift && ev.key == Key.DirectionDown -> {
val vCur = vSel ?: BodySel(0, 0)
vSel = vCur.copy(caret = moveDown(vBody, vCur.caret))
vScrollCaretIntoView(); true
}
vShift && ev.key == Key.DirectionUp -> {
val vCur = vSel ?: BodySel(0, 0)
vSel = vCur.copy(caret = moveUp(vBody, vCur.caret))
vScrollCaretIntoView(); true
}
vShift && ev.key == Key.MoveHome -> {
val vCur = vSel ?: BodySel(0, 0)
vSel = vCur.copy(caret = if (vMod) 0 else lineStartAt(vBody, vCur.caret))
vScrollCaretIntoView(); true
}
vShift && ev.key == Key.MoveEnd -> {
val vCur = vSel ?: BodySel(0, 0)
vSel = vCur.copy(caret = if (vMod) vBody.length else lineEndAt(vBody, vCur.caret))
vScrollCaretIntoView(); true
}
else -> false
}
},
@@ -561,7 +584,7 @@ internal fun HttpFlowView(
if (inShowSecureLock) {
MaterialSymbolsOutlined(
icon = MaterialSymbols.Lock,
tint = Color(0xFF36B37E), // green — TLS verified by OS
tint = Color(0xFF36B37E), // green - TLS verified by OS
size = 14.dp,
)
Spacer(Modifier.width(6.dp))
@@ -569,7 +592,7 @@ internal fun HttpFlowView(
Text(inStatusLine, color = c.text, fontSize = 13.sp)
}
}
// Headers as a key/value table — only when not collapsed.
// Headers as a key/value table - only when not collapsed.
if (!inHeadersCollapsed && inHeaders.isNotEmpty()) {
item {
HeaderTable(inHeaders, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
@@ -588,7 +611,7 @@ internal fun HttpFlowView(
}
}
} else if (vChunks.isNotEmpty()) {
// One BasicText per BLOCK of lines (not per line) — far fewer
// One BasicText per BLOCK of lines (not per line) - far fewer
// nodes to churn while scrolling. Chunk key lets the pointer
// hit-test map a visible item back to its chunk index.
items(vChunks.size, key = { "chunk_$it" }, contentType = { "chunk" }) { vCi ->
@@ -629,13 +652,13 @@ internal fun HttpFlowView(
// MARK: Body selection (editor-style, spans virtualized chunks)
// ==================
/* A response body's selection is a pair of character offsets in the whole body
/** A response body's selection is a pair of character offsets in the whole body
string: [anchor] is where the user pressed / started the selection, [caret] is
where they've moved to. When they differ, the selected range is [ min..max ].
When equal, it's a zero-width caret still useful as a starting point for
subsequent Shift+Arrow / Shift+Click extensions.
Note this lives OUTSIDE the SelectionContainer machinery — that machinery only
Note this lives OUTSIDE the SelectionContainer machinery - that machinery only
tracks selectables composed by the LazyColumn (visible chunks), which is why
Ctrl+A and drag-select cannot reach off-screen text through it. This state is
plain integer offsets so it survives scroll and covers the whole body. */
@@ -645,7 +668,7 @@ private data class BodySel(val anchor: Int, val caret: Int) {
val isEmpty get() = anchor == caret
}
/* Character offset of the start of the line containing [inOffset]. */
/** Character offset of the start of the line containing [inOffset]. */
private fun lineStartAt(inText: String, inOffset: Int): Int {
if (inOffset <= 0) return 0
val vClamped = inOffset.coerceAtMost(inText.length)
@@ -653,7 +676,7 @@ private fun lineStartAt(inText: String, inOffset: Int): Int {
return if (vPrev < 0) 0 else vPrev + 1
}
/* Character offset of the end of the line containing [inOffset] — the position of
/** Character offset of the end of the line containing [inOffset] - the position of
its '\n', or the length of [inText] if this is the last line. */
private fun lineEndAt(inText: String, inOffset: Int): Int {
val vClamped = inOffset.coerceIn(0, inText.length)
@@ -661,7 +684,7 @@ private fun lineEndAt(inText: String, inOffset: Int): Int {
return if (vNext < 0) inText.length else vNext
}
/* Move a caret one visual "row" down while trying to keep its column — i.e. the
/** Move a caret one visual "row" down while trying to keep its column - i.e. the
number of chars past the current line's start. If the target line is shorter,
the caret snaps to its end. Off-by-one at EOF collapses to the body length. */
private fun moveDown(inText: String, inOffset: Int): Int {
@@ -674,7 +697,7 @@ private fun moveDown(inText: String, inOffset: Int): Int {
return (vNextStart + vCol).coerceAtMost(vNextEnd)
}
/* The Up counterpart to [moveDown]. */
/** The Up counterpart to [moveDown]. */
private fun moveUp(inText: String, inOffset: Int): Int {
val vLineStart = lineStartAt(inText, inOffset)
if (vLineStart == 0) return 0
@@ -684,7 +707,7 @@ private fun moveUp(inText: String, inOffset: Int): Int {
return (vPrevStart + vCol).coerceAtMost(vPrevEnd)
}
/* Word boundary rule for double-click selection: contiguous run of "word chars"
/** Word boundary rule for double-click selection: contiguous run of "word chars"
(letters, digits, underscore) containing [inOffset], or an empty range at the
click point when it lands on non-word text (whitespace, punctuation). Matches
the boundary editors like VS Code / IntelliJ use for double-click select. */
@@ -704,8 +727,8 @@ private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
return vStart..vEnd
}
/* The index of the chunk in [inChunks] whose character range contains
[inOffset], or null when the body is empty. Uses a linear scan — the chunk
/** The index of the chunk in [inChunks] whose character range contains
[inOffset], or null when the body is empty. Uses a linear scan - the chunk
count is O(body / kLinesPerChunk) which stays modest even for huge bodies. */
private fun chunkContaining(inChunks: List<BodyChunk>, inOffset: Int): Int? {
if (inChunks.isEmpty()) return null
@@ -721,7 +744,7 @@ private fun chunkContaining(inChunks: List<BodyChunk>, inOffset: Int): Int? {
// measure at the viewport edges, large enough to slash node/selectable count.
private const val kLinesPerChunk = 48
/* One BLOCK of a read-only body: a right-aligned column of line numbers in the
/** One BLOCK of a read-only body: a right-aligned column of line numbers in the
gutter + the block's (syntax-coloured) text, each a single BasicText. Rendering
blocks (not one-BasicText-per-line) keeps the node / selectable / paragraph-set-up
count low as blocks scroll through the viewport. The gutter is pinned outside
@@ -768,7 +791,7 @@ private fun BodyChunkRow(
// Precompute this block's slice of the selection in LOCAL offsets. `null`
// when the selection is empty or doesn't touch this block. Also computes the
// caret's local offset when the caret itself falls inside this block (drawn
// even when the range is empty — that's the read-only viewer's cursor).
// even when the range is empty - that's the read-only viewer's cursor).
val vChunkStart = inChunk.startCharOffset
val vChunkEnd = vChunkStart + inChunk.body.length
val vLocalRange: IntRange? = if (inSel == null || inSel.isEmpty) null else {
@@ -822,7 +845,8 @@ private fun BodyChunkRow(
vSb.append(inChunk.firstLine + j)
if (vLen > 0) {
val vStart = inChunk.lineStartsInBlock[j].coerceIn(0, vMaxOff)
val vEnd = (if (j + 1 < inChunk.lineCount) inChunk.lineStartsInBlock[j + 1] - 1 else vMaxOff)
val vEnd =
(if (j + 1 < inChunk.lineCount) inChunk.lineStartsInBlock[j + 1] - 1 else vMaxOff)
.coerceIn(vStart, vMaxOff)
val vRows = layout.getLineForOffset(vEnd) - layout.getLineForOffset(vStart) + 1
repeat((vRows - 1).coerceAtLeast(0)) { vSb.append('\n') }
@@ -847,7 +871,7 @@ private fun BodyChunkRow(
}
}
/* A block of consecutive source lines: 1-based number of the first line, the line
/** A block of consecutive source lines: 1-based number of the first line, the line
count, each source line's start offset WITHIN the block text (to map a wrapped body
layout back to line numbers), the character offset of the block's first char in the
full body (used to map local text offsets to global ones for cross-chunk selection),
@@ -860,9 +884,9 @@ private class BodyChunk(
val body: AnnotatedString,
)
/* Slice a highlighted body into blocks of [inLinesPerChunk] lines. The O(total-spans)
/** Slice a highlighted body into blocks of [inLinesPerChunk] lines. The O(total-spans)
subSequence runs once per block HERE (build time, memoised) instead of once per
visible line every frame — the difference between smooth and janky on a big body. */
visible line every frame - the difference between smooth and janky on a big body. */
private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerChunk: Int): List<BodyChunk> {
val vStarts = lineStartOffsets(inText)
val vN = vStarts.size
@@ -882,9 +906,9 @@ private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerCh
return vOut
}
/* Offsets where each source line starts (line i spans [starts[i], starts[i+1]-1),
/** Offsets where each source line starts (line i spans [starts[ i], starts[i+1]-1),
the -1 dropping the '\n'; the last line runs to the string end). One cheap O(n)
scan, memoised — replaces the old gutter pre-pass that ran measurer.wrap() on
scan, memoised - replaces the old gutter pre-pass that measured wrapping on
every one of N lines on each width change (the other half of the freeze). */
private fun lineStartOffsets(inText: String): IntArray {
val vStarts = ArrayList<Int>(64)
@@ -893,7 +917,7 @@ private fun lineStartOffsets(inText: String): IntArray {
return vStarts.toIntArray()
}
/* Header table: each row is (key | value) where the key column has a
/** Header table: each row is (key | value) where the key column has a
fixed width so values line up. Long keys wrap; long values wrap too.
Keys render in accent colour, values in regular text. */
@Composable
@@ -919,7 +943,7 @@ internal fun HeaderTable(inHeaders: List<Pair<String, String>>, modifier: Modifi
}
}
/* Body view with line numbers in the gutter — mimics the code panel
/** Body view with line numbers in the gutter - mimics the code panel
in httpie. Numbers are dim; body text uses the regular colour. */
@Composable
internal fun BodyView(
@@ -939,25 +963,25 @@ internal fun BodyView(
val vGutterWidth = (vDigits * 7 + 4).dp
// Body wrap width (physical px, from onSizeChanged), reported by the body
// Box once it's laid out (0 on the first frame). The gutter numbering
// depends on it — see vNumbers. NativeTextMeasurer.wrap expects the font
// depends on it - see vNumbers. Wrap measurement expects the font
// size + max-width in the SAME unit (both physical px on Retina), so we
// scale 12.sp through the current density here, matching what SdlParagraph
// does when it builds the body's real paragraph.
// scale 12.sp through the current density here, matching what the paragraph
// engine does when it builds the body's real paragraph.
var vBodyWidthPx by remember { mutableStateOf(0) }
val vFontPx = with(LocalDensity.current) { 12.sp.toPx().toInt() }
// Horizontal scroll for no-wrap mode (read-only body only); dormant when wrapping.
val vHScroll = rememberScrollState()
Row(modifier = modifier) {
// Numbers are reference-only — half-alpha so they stay legible without
// Numbers are reference-only - half-alpha so they stay legible without
// competing with the body. Rendered as ONE '\n'-joined multi-line Text
// (not one Text per line): a 1000-line gutter is then a single node the
// renderer line-culls + wrap-caches exactly like the body, instead of
// 1000 leaf nodes — which lagged and tripped SDL's ~16384px draw-
// 1000 leaf nodes - which lagged and tripped SDL's ~16384px draw-
// coordinate limit (line numbers vanished past ~955).
//
// When the body soft-wraps, a source line can span several visual rows.
// To keep the numbers aligned we emit each line's number once (on its
// first row) followed by one blank row per wrapped continuation — by
// first row) followed by one blank row per wrapped continuation - by
// re-running the SAME wrap the body uses (same measurer, font, width),
// so the gutter ends up with exactly as many rows as the body. Without
// this, a line wrapping into 3 rows would read 10/11/12 instead of
@@ -973,7 +997,7 @@ internal fun BodyView(
append(vIdx + 1)
if (vWrapRows) {
// Pass the density-scaled font size so this wrap matches the body's
// actual glyph pixel widths — passing 12 (sp) here counted each
// actual glyph pixel widths - passing 12 (sp) here counted each
// char as half its rendered size, so long lines that visually
// wrapped into 3 rows in the body were estimated as 1 or 2 rows
// here and the gutter numbers drifted upward.
@@ -1000,7 +1024,8 @@ internal fun BodyView(
// anywhere (not just on the text line) focuses it and starts writing.
// Read-only (response) keeps wrap-height so it grows with content.
val vEditable = inOnChange != null
Box(modifier = Modifier.weight(1f)
Box(
modifier = Modifier.weight(1f)
.onSizeChanged { vBodyWidthPx = it.width }
.then(if (vEditable) Modifier.fillMaxHeight() else Modifier)) {
if (inText.isEmpty() && inPlaceholder.isNotEmpty()) {
@@ -1008,7 +1033,7 @@ internal fun BodyView(
}
if (inOnChange != null) {
// EDITABLE request body: BasicTextField with a colour-only
// syntax-highlight visualTransform — cursor / selection map to
// syntax-highlight visualTransform - cursor / selection map to
// the plain text. RAW = no highlight. Remembered per (format,
// theme) so typing and cursor-blink reuse it.
val vDark = isDarkBg(c.bg)
@@ -1023,7 +1048,7 @@ internal fun BodyView(
}
}
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
LocalTextSelectionColors provides
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
@@ -1032,7 +1057,7 @@ internal fun BodyView(
BasicTextField(
value = inText,
onValueChange = inOnChange,
textStyle = androidx.compose.ui.text.TextStyle(
textStyle = TextStyle(
color = c.text,
fontSize = 12.sp,
fontFamily = monoFontFamily,
@@ -1054,7 +1079,7 @@ internal fun BodyView(
}
SelectionContainer {
// No-wrap mode: one source line per row, long lines overflow
// and the body pans horizontally (the gutter stays pinned —
// and the body pans horizontally (the gutter stays pinned -
// it's outside this scroll). The scroll must sit on a PARENT
// of the Text: a node's own scroll offset shifts its children,
// not itself. Wrap mode: plain soft-wrapping Text.
@@ -1071,7 +1096,7 @@ internal fun BodyView(
}
}
/* "Request" + colored "GET" tab. inAccent is shown next to the label
/** "Request" + colored "GET" tab. inAccent is shown next to the label
when non-null (e.g. method name once sent, status code once received);
when null the tab is just the label, no placeholder dash. Selected
tab gets full-strength labels and a 2dp underline; unselected tabs
@@ -1095,7 +1120,11 @@ internal fun ViewerTab(
) {
Text(inLabel, color = if (inSelected) c.dim else c.dim.copy(alpha = 0.6f), fontSize = 14.sp)
if (inAccent != null) {
Text(inAccent, color = if (inSelected) inAccentColor else inAccentColor.copy(alpha = 0.6f), fontSize = 14.sp)
Text(
inAccent,
color = if (inSelected) inAccentColor else inAccentColor.copy(alpha = 0.6f),
fontSize = 14.sp
)
}
}
Spacer(Modifier.height(4.dp))
@@ -1109,12 +1138,12 @@ internal fun ViewerTab(
}
// ==================
// MARK: Helpers — header parsing / formatting
// MARK: Helpers - header parsing / formatting
// ==================
/* "HTTP/1.1 200 OK" status line for a Response, with the protocol
/** "HTTP/1.1 200 OK" status line for a Response, with the protocol
token dimmed (it's structural) and the status code + reason in the
status colour. Just "HTTP/1.1" pre-response — no placeholder. Triple-
status colour. Just "HTTP/1.1" pre-response - no placeholder. Triple-
spaced to match the request-line formatting. */
internal fun formatStatusLine(inResp: ApiResponse?, inColors: AppColors): AnnotatedString {
if (inResp == null) return buildAnnotatedString {
@@ -1142,14 +1171,15 @@ internal fun formatStatusLine(inResp: ApiResponse?, inColors: AppColors): Annota
}
}
/* "GET /path HTTP/1.1" status line for a Request. Each part takes
/** "GET /path HTTP/1.1" status line for a Request. Each part takes
its own colour: method in its canonical method colour, URL in the
default text colour, protocol token dimmed so the eye lands on the
request target first. Triple-spaced so the columns read at a glance.
The protocol string comes from the matching response when we have
one (so HTTP/2 servers show "HTTP/2"); fallback "HTTP/1.1" before
send. */
internal fun formatRequestLine(inReq: ApiRequest, inHttpVersion: String, inColors: AppColors): AnnotatedString = buildAnnotatedString {
internal fun formatRequestLine(inReq: ApiRequest, inHttpVersion: String, inColors: AppColors): AnnotatedString =
buildAnnotatedString {
pushStyle(SpanStyle(color = methodColor(inReq.method), fontWeight = FontWeight.Bold))
append(inReq.method.name)
pop()
@@ -1166,8 +1196,8 @@ internal fun formatRequestLine(inReq: ApiRequest, inHttpVersion: String, inColor
pop()
}
/* Pull the path (+ query) out of a URL, dropping scheme + host. The
request line in HTTP is "METHOD path HTTP/x.y" — the host lives on a
/** Pull the path (+ query) out of a URL, dropping scheme + host. The
request line in HTTP is "METHOD path HTTP/x.y" - the host lives on a
separate Host: header line, not in the path. Falls back to the input
string when no "://" is present (relative URL). */
internal fun urlPathOnly(inUrl: String): String {
@@ -1178,7 +1208,7 @@ internal fun urlPathOnly(inUrl: String): String {
return if (vSlash < 0) "/" else vAfterScheme.substring(vSlash)
}
/* Extract the host (+ port) from a URL — used to synthesize a Host
/** Extract the host (+ port) from a URL - used to synthesize a Host
header for display when the engine didn't surface one. */
internal fun urlHost(inUrl: String): String? {
val vIdx = inUrl.indexOf("://")
@@ -1189,12 +1219,12 @@ internal fun urlHost(inUrl: String): String? {
return vAuthority.ifEmpty { null }
}
/* The user-agent string our Darwin engine sends is opaque to Ktor —
/** The user-agent string our Darwin engine sends is opaque to Ktor -
NSURLSession picks the default. Match what httpie does: identify
ourselves so the wire log isn't missing the field entirely. */
internal const val kUserAgent: String = "compose-apidemo/1.0"
/* Combine Ktor's reported request headers with the ones the engine
/** Combine Ktor's reported request headers with the ones the engine
adds at the wire level (Host, Content-Length, User-Agent) so the
Request tab shows the actual on-the-wire header set rather than
just the subset Ktor sees. Sorted alphabetically. */
@@ -1202,23 +1232,23 @@ internal fun synthesizeRequestHeaders(
inReq: ApiRequest,
inReported: List<Pair<String, String>>,
): List<Pair<String, String>> {
val vByKey = inReported.associate { it.first.lowercase() to it }.toMutableMap()
val vByKey = inReported.associateBy { it.first.lowercase() }.toMutableMap()
urlHost(inReq.url)?.let { vHost ->
vByKey.getOrPut("host") { "Host" to vHost }
}
if (!vByKey.containsKey("user-agent")) {
vByKey["user-agent"] = "User-Agent" to kUserAgent
}
if (inReq.method.allowsBody && inReq.bodyType != BodyType.NONE && !vByKey.containsKey("content-length")) {
if (true && inReq.bodyType != BodyType.NONE && !vByKey.containsKey("content-length")) {
val vLen = computedBodyLength(inReq)
if (vLen != null) vByKey["content-length"] = "Content-Length" to vLen.toString()
}
return vByKey.values.sortedBy { it.first.lowercase() }
}
/* Body length in bytes for the headers synthesis. JSON / TEXT use the
/** Body length in bytes for the headers synthesis. JSON / TEXT use the
raw UTF-8 byte count; FORM serialises and counts; FILE skips
(loading the file just for the count would be wasteful — the engine
(loading the file just for the count would be wasteful - the engine
sets the field anyway). */
internal fun computedBodyLength(inReq: ApiRequest): Int? = when (inReq.bodyType) {
BodyType.TEXT -> inReq.body.encodeToByteArray().size
@@ -1226,7 +1256,7 @@ internal fun computedBodyLength(inReq: ApiRequest): Int? = when (inReq.bodyType)
BodyType.FILE, BodyType.NONE -> null
}
/* Format the timing / size footer text shown bottom-right. */
/** Format the timing / size footer text shown bottom-right. */
internal fun formatTimingSize(inResp: ApiResponse): String {
val vSize = when {
inResp.sizeBytes < 1024 -> "${inResp.sizeBytes} B"
@@ -1237,12 +1267,12 @@ internal fun formatTimingSize(inResp: ApiResponse): String {
}
// ==================
// MARK: ViewerOverflowMenu — 3-dot menu in the bottom-right of the viewer
// MARK: ViewerOverflowMenu - 3-dot menu in the bottom-right of the viewer
// ==================
/* Replaces the inline Copy / Save chips with a single MoreHoriz menu.
/** Replaces the inline Copy / Save chips with a single MoreHoriz menu.
Copy actions target whichever tab is showing (request or response);
Clear is global — wipes the response, sentReq, preview state, and any
Clear is global - wipes the response, sentReq, preview state, and any
memory-backed image resource for the current request. */
@Composable
internal fun ViewerOverflowMenu(
@@ -1333,12 +1363,14 @@ internal fun ViewerOverflowMenu(
vOpen = false
})
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = c.border)
DropdownMenuItem(text = { Text("Clear", color = Color(0xFFFF5630)) }, onClick = { inOnClear(); vOpen = false })
DropdownMenuItem(
text = { Text("Clear", color = Color(0xFFFF5630)) },
onClick = { inOnClear(); vOpen = false })
}
}
}
/* Perceptual luminance check — true when the colour reads as "dark"
/** Perceptual luminance check - true when the colour reads as "dark"
(background gets a light foreground). Standard Rec. 709 weights. */
internal fun isDarkBg(inColor: Color): Boolean {
val vY = 0.299f * inColor.red + 0.587f * inColor.green + 0.114f * inColor.blue
@@ -1346,10 +1378,10 @@ internal fun isDarkBg(inColor: Color): Boolean {
}
// ==================
// MARK: BodyFormatSelector — small dropdown for RAW / JSON / XML / YAML / HTML
// MARK: BodyFormatSelector - small dropdown for RAW / JSON / XML / YAML / HTML
// ==================
/* Format / "type" picker. inBordered renders it as a full dropdown matching
/** Format / "type" picker. inBordered renders it as a full dropdown matching
BodyTypeMenu (the Body-tab Text-type picker); otherwise it's the compact inline
control used by the response viewer. */
@Composable
@@ -1373,21 +1405,26 @@ internal fun BodyFormatSelector(
horizontalArrangement = Arrangement.spacedBy(if (inBordered) 5.dp else 4.dp),
) {
Text(vLabel, color = if (inBordered) c.text else c.dim, fontSize = if (inBordered) 13.sp else 11.sp)
MaterialSymbolsOutlined(if (inBordered) MaterialSymbols.UnfoldMore else MaterialSymbols.ArrowDropDown, tint = c.dim, size = if (inBordered) 15.dp else 14.dp)
MaterialSymbolsOutlined(
if (inBordered) MaterialSymbols.UnfoldMore else MaterialSymbols.ArrowDropDown,
tint = c.dim,
size = if (inBordered) 15.dp else 14.dp
)
}
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
for (vF in BodyFormat.values()) {
DropdownMenuItem(text = { Text(vF.label, color = if (vF == inSelected) c.accent else c.text) },
for (vF in BodyFormat.entries) {
DropdownMenuItem(
text = { Text(vF.label, color = if (vF == inSelected) c.accent else c.text) },
onClick = { inOnChange(vF); vOpen = false })
}
}
}
}
/* TLS validation indicator: only true when the URL is https AND we got
/** TLS validation indicator: only true when the URL is https AND we got
a real response back (i.e. the OS engine completed the TLS handshake
without an error). The engines we ship — NSURLSession on macOS,
WinHttp on Windows, libcurl on Linux — all reject an untrusted
without an error). The engines we ship - NSURLSession on macOS,
WinHttp on Windows, libcurl on Linux - all reject an untrusted
certificate by default, so a non-error response is implicit proof
that the OS validated the chain. */
internal fun isTlsValidated(inUrl: String, inResp: ApiResponse?): Boolean {
@@ -1397,7 +1434,7 @@ internal fun isTlsValidated(inUrl: String, inResp: ApiResponse?): Boolean {
return inResp.status > 0
}
/* Parse "Key: value\nKey2: value2" into a list of pairs so the headers
/** Parse "Key: value\nKey2: value2" into a list of pairs so the headers
table renderer can lay them out as a key/value grid. */
internal fun parseHeaderLines(inText: String): List<Pair<String, String>> {
if (inText.isBlank() || inText == "(no headers)") return emptyList()
@@ -1407,7 +1444,7 @@ internal fun parseHeaderLines(inText: String): List<Pair<String, String>> {
}
}
/* "content-type" → "Content-Type"; preserves single-word keys; leaves
/** "content-type" → "Content-Type"; preserves single-word keys; leaves
already-correct ones unchanged. */
internal fun titleCaseHeader(inKey: String): String =
inKey.split('-').joinToString("-") { vWord ->
@@ -1415,7 +1452,7 @@ internal fun titleCaseHeader(inKey: String): String =
else vWord[0].uppercaseChar() + vWord.drop(1).lowercase()
}
/* Centered icon + label placeholder for the viewer (Not sent / Not received /
/** Centered icon + label placeholder for the viewer (Not sent / Not received /
No Body). Fills its parent unless a sized modifier is supplied. */
@Composable
internal fun ViewerEmpty(inIcon: Int, inText: String, inModifier: Modifier = Modifier.fillMaxSize()) {
@@ -1428,64 +1465,32 @@ internal fun ViewerEmpty(inIcon: Int, inText: String, inModifier: Modifier = Mod
}
}
/* A labelled, read-only (selectable) code block used by the viewer sections. */
@Composable
internal fun CodeSection(inLabel: String, inText: String) {
val c = LocalAppColors.current
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(inLabel, color = c.dim, fontSize = 11.sp)
Box(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
.background(c.bg, RoundedCornerShape(8.dp)).border(1.dp, c.border, RoundedCornerShape(8.dp)).padding(12.dp),
) {
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),
),
) {
BasicTextField(
value = inText.ifEmpty { "(empty)" }, onValueChange = {}, readOnly = true,
textStyle = androidx.compose.ui.text.TextStyle(color = c.text, fontSize = 12.sp),
cursorBrush = androidx.compose.ui.graphics.SolidColor(c.accent),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
/* Format an actual header list (key: value per line). */
/** Format an actual header list (key: value per line). */
internal fun headersText(inHeaders: List<Pair<String, String>>): String =
inHeaders.joinToString("\n") { (vK, vV) -> "$vK: $vV" }.ifEmpty { "(no headers)" }
/* The headers that *would* be sent — explicit enabled headers plus the inferred
/** The headers that *would* be sent - explicit enabled headers plus the inferred
Content-Type for the body type (unless one is already set). Used for Preview;
a sent request shows the real headers via headersText(response.requestHeaders). */
internal fun requestHeadersText(inReq: ApiRequest): String {
val vLines = mutableListOf<String>()
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { vLines.add("${it.key}: ${it.value}") }
val vCt = inReq.bodyContentType()
if (inReq.method.allowsBody && vCt != null && inReq.headers.none { it.enabled && it.key.equals("content-type", ignoreCase = true) }) {
if (true && vCt != null && inReq.headers.none {
it.enabled && it.key.equals(
"content-type",
ignoreCase = true
)
}) {
vLines.add("Content-Type: $vCt")
}
return vLines.joinToString("\n").ifEmpty { "(no headers)" }
}
/* The body that would be sent, rendered as text for the preview. */
/** The body that would be sent, rendered as text for the preview. */
internal fun requestBodyText(inReq: ApiRequest): String = when (inReq.bodyType) {
BodyType.NONE -> "(no body)"
BodyType.TEXT -> inReq.body.ifEmpty { "(empty)" }
BodyType.FORM -> formEncode(inReq.form).ifEmpty { "(empty form)" }
BodyType.FILE -> if (inReq.body.isBlank()) "(no file)" else "(file) ${inReq.body}"
}
@Composable
internal fun StatusPill(inStatus: Int, inLabel: String) {
val vC = statusColor(inStatus)
Box(modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(vC.copy(alpha = 0.20f), RoundedCornerShape(6.dp)).padding(horizontal = 12.dp, vertical = 6.dp)) {
Text(inLabel, color = vC, fontSize = 14.sp)
}
}
@@ -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),
),
)
+18 -18
View File
@@ -1,60 +1,60 @@
package apidemo
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.HttpClient
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.
// 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. */
/** 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. */
/** 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. */
/** "Explorer" / "Finder" / "Files" - for menu labels. */
expect fun fileManagerName(): String
/* Async save-file dialog; the callback gets the chosen absolute path or null. */
/** 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. */
/** 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). */
/** 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). */
/** 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. */
/** Drop a registered image's bytes. */
expect fun removeMemoryImage(inKey: String)
/* Painter for a registered image key (raster or SVG). */
/** 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
/** 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
/** 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
@@ -62,15 +62,15 @@ expect var editorTabWidth: Int
// ============
// Infrastructure
/* okio's FileSystem.SYSTEM is declared per-platform, not in its common
metadata — surface it through a seam. */
/** 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 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
/** 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
@@ -1,4 +1,4 @@
package apidemo
package apidemo.compat
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.positionInWindow
@@ -9,8 +9,8 @@ import androidx.compose.ui.layout.positionInWindow
// its node is mid-recycle, and positionInWindow() throws on a detached
// coordinator.
/* Logical-point X of this layout in the window. */
/** 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. */
/** Logical-point Y of this layout in the window. */
val LayoutCoordinates.y: Int get() = if (isAttached) positionInWindow().y.toInt() else 0
@@ -1,42 +1,34 @@
// 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
// 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
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
@@ -46,10 +38,10 @@ import kotlin.math.roundToInt
// MARK: Scrollbar (Compose Desktop-style)
// ==================
/* A faithful subset of Compose Desktop's scrollbar (androidx.compose.foundation).
/** 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
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) {
@@ -75,7 +67,7 @@ class ScrollbarStyle(
val hoverColor: Color,
)
/* Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
/** 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,
@@ -92,7 +84,7 @@ val LocalScrollbarStyle = compositionLocalOf { defaultScrollbarStyle() }
// MARK: ScrollbarAdapter
// ==================
/* Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
/** 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 {
@@ -106,9 +98,9 @@ interface ScrollbarAdapter {
// internals (ScrollState.contentSize/viewportSize) that a copy outside
// :foundation cannot access. apidemo only scrollbars a LazyColumn.)
/* Adapter for a virtualized LazyColumn / LazyRow. A lazy list only knows the
/** 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
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. */
@@ -139,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) {
@@ -171,16 +163,7 @@ 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,
/** 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
@@ -201,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,13 +230,13 @@ private fun Scrollbar(
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
@@ -263,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
@@ -279,7 +262,7 @@ private fun Scrollbar(
// 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).
@@ -325,7 +308,7 @@ private fun Scrollbar(
}
}
/* Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
/** 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 =
@@ -1,32 +1,21 @@
// 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
// 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
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -77,7 +66,7 @@ fun HorizontalSplitPane(
Box(modifier = Modifier.width(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxHeight()) { first() }
// Solid divider that fills its own slot — what you see is exactly what
// 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).
@@ -105,68 +94,6 @@ fun HorizontalSplitPane(
}
}
@Composable
fun VerticalSplitPane(
modifier: Modifier = Modifier,
initialFirstSize: Dp = 150.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 vTotalHeightPx by remember { mutableStateOf(0) }
var vFirstHeightPx by remember { mutableStateOf(vInitialFirstPx) }
val vDividerSource = remember { MutableInteractionSource() }
val vDividerHover by vDividerSource.collectIsHoveredAsState()
var vDragging by remember { mutableStateOf(false) }
var vPressStartFirstPx by remember { mutableStateOf(0) }
Column(
modifier = modifier
.fillMaxSize()
.onSizeChanged { vTotalHeightPx = it.height },
) {
val vClampedFirstPx = if (vTotalHeightPx > 0)
vFirstHeightPx.coerceIn(vMinFirstPx, (vTotalHeightPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx))
else vFirstHeightPx
val vSecondHeightPx = (vTotalHeightPx - vClampedFirstPx - vDividerPx).coerceAtLeast(0)
Box(modifier = Modifier.height(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxWidth()) { first() }
// Solid divider that fills its slot; hover/drag only changes the colour
// (no size change → no layout shift, no flicker mid-drag).
val vActive = vDividerHover || vDragging
Box(
modifier = Modifier
.height(SplitPaneDefaults.DividerThickness)
.fillMaxWidth()
.background(if (vActive) dividerHoverColor else dividerColor)
.hoverable(vDividerSource)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { vDragging = true },
onDragEnd = { vDragging = false },
onDragCancel = { vDragging = false },
onDrag = { _, delta ->
val vMax = (vTotalHeightPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx)
vFirstHeightPx = (vFirstHeightPx + delta.y.toInt()).coerceIn(vMinFirstPx, vMax)
},
)
},
)
Box(modifier = Modifier.height(with(vDensity) { vSecondHeightPx.toDp() }).fillMaxWidth()) { second() }
}
}
object SplitPaneDefaults {
val DividerThickness: Dp = 4.dp // divider width = hit/drag area (solid; what you see is what you grab)
}
-162
View File
@@ -1,162 +0,0 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package apidemo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.res.loadSvgPainter
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 java.awt.Desktop
import java.awt.EventQueue
import java.awt.FileDialog
import java.awt.Frame
import java.awt.datatransfer.StringSelection
import java.io.File
import org.jetbrains.skia.Image
// ==================
// 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) loadSvgPainter(vBytes.inputStream(), 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 fTabWidth by mutableStateOf(4)
actual var editorTabWidth: Int
get() = fTabWidth
set(value) { fTabWidth = value }
+14 -2
View File
@@ -4,14 +4,14 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.platform.Font
// ==================
// MARK: Monospace body font — JVM actuals (classpath font/ staged by
// 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")
println("apidemo: NotoSansMono.ttf not on the classpath - body uses the default font")
null
} else {
FontFamily(Font(identity = kMonoFamily, data = vBytes))
@@ -21,3 +21,15 @@ actual val monoFontFamily: FontFamily? by lazy {
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))
}
}
+88 -3
View File
@@ -1,22 +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.
fun main() = application {
//
// --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)",
state = rememberWindowState(width = 1240.dp, height = 820.dp),
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()
}
}
+2 -3
View File
@@ -1,19 +1,18 @@
package apidemo
// ==================
// MARK: TLS chain / client-certificate — JVM actuals (native-only feature)
// 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."
"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(
ok = false,
status = 0,
statusText = "",
timeMs = 0,
@@ -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
}
@@ -1,9 +1,9 @@
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,
@@ -14,9 +14,9 @@ actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
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)
@@ -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,20 +31,18 @@ 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
/** 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
{
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert {
val vCertBytes = readFileBytes(inReq.certPath)
val (vThumb, vContainer) = when (inReq.certFormat)
{
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
sslCertType = null, // store reference - not a file type
sslKey = null,
sslKeyType = null,
keyPassword = null,
@@ -57,60 +54,69 @@ actual fun prepareClientCert(inReq: ApiRequest): PreparedCert
// PEM / DER (RSA) → store
// ============
/* Build a cert context from PEM/DER, import its RSA private key into a named
/** 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?>
{
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") }
?: 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")
try
{
try {
val vThumb = thumbprintHex(vCert)
val vContainer = kTagPrefix + vThumb
importRsaIntoContainer(vContainer, vBlob)
try
{
try {
linkKeyToCert(vCert, vContainer)
tagFriendlyName(vCert, kTagPrefix + vThumb)
addToStore(vCert)
return vThumb to vContainer
} catch (e: Throwable) {
deleteContainer(vContainer); throw e
}
catch (e: Throwable) { deleteContainer(vContainer); throw e }
} finally {
CertFreeCertificateContext(vCert)
}
finally { CertFreeCertificateContext(vCert) }
}
/* Create a fresh named key container and import the RSA blob into it. */
/** 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
{
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)
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)
}
finally { CryptReleaseContext(vProv.value, 0u) }
}
/* Attach the named container to the cert so Schannel can find its key. */
/** 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>()
@@ -129,63 +135,86 @@ private fun linkKeyToCert(inCert: CPointer<CERT_CONTEXT>, inContainer: String) =
// RSA key ASN.1 → CryptoAPI PRIVATEKEYBLOB
// ============
/* Minimal DER reader for the RSA key structures. */
private class Der(val b: ByteArray)
{
/** 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
{
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 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 } }
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
{
fun integer(): ByteArray {
require(tag() == 0x02) { "INTEGER expected" }
val vL = len(); val vV = b.copyOfRange(p, p + vL); p += vL
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.
/** 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
{
private fun rsaKeyDerToBlob(inDer: ByteArray): ByteArray {
val vR = Der(inDer)
vR.sequence(); vR.integer() // outer version
return if (vR.peek() == 0x30)
{
return if (vR.peek() == 0x30) {
// PKCS#8 PrivateKeyInfo: skip AlgorithmIdentifier, parse the inner key.
vR.skip()
parsePkcs1(vR.octetString())
}
else
} 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())
buildBlob(
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer()
)
}
private fun parsePkcs1(inDer: ByteArray): ByteArray
{
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())
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. */
/** 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
{
): ByteArray {
val vMod = inN.size // modulus length in bytes
val vHalf = vMod / 2
val vOut = ArrayList<Byte>(20 + vMod * 2 + vHalf * 5)
@@ -204,19 +233,22 @@ private fun buildBlob(
return vOut.toByteArray()
}
/* A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
private fun leField(inBe: ByteArray, inSize: Int): ByteArray
{
/** 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
{
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
@@ -226,7 +258,7 @@ private fun beToUInt(inBe: ByteArray): UInt
// PKCS#12 → store
// ============
/* Import a .p12/.pfx into a memory store, then copy its cert (with the key
/** 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 {
@@ -236,27 +268,40 @@ private fun importPkcs12(inBytes: ByteArray, inPassword: String): Pair<String, S
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.")
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)
}
finally { CertCloseStore(vMem, 0u) }
}
}
/* The key-container name PFXImportCertStore assigned to a cert (from its
/** 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
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
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()
}
@@ -264,7 +309,7 @@ private fun containerNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped
// 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>()
@@ -273,38 +318,40 @@ private fun tagFriendlyName(inCert: CPointer<CERT_CONTEXT>, inName: String) = me
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>)
{
private fun addToStore(inCert: CPointer<CERT_CONTEXT>) {
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
try
{
try {
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
winError("CertAddCertificateContextToStore")
} finally {
CertCloseStore(vStore, 0u)
}
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')) }
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
/** 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?)
{
private fun deleteTempCert(inThumbHex: String, inContainer: String?) {
val vStore = openMyStore()
if (vStore != null) memScoped {
val vHash = hexToBytes(inThumbHex)
@@ -320,23 +367,21 @@ private fun deleteTempCert(inThumbHex: String, inContainer: String?)
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())
}
/* Remove every temp cert we ever added (prefix-tagged) plus its container — for
/** 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()
{
actual fun sweepTempClientCerts() {
val vStore = openMyStore() ?: return
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
var vCert = CertEnumCertificatesInStore(vStore, null)
while (vCert != null)
{
while (vCert != null) {
val vFriendly = friendlyNameOf(vCert)
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
@@ -346,13 +391,25 @@ actual fun sweepTempClientCerts()
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
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
if (CertGetCertificateContextProperty(
inCert,
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
vBuf,
vLen.ptr
) == 0
) return@memScoped null
vBuf.reinterpret<UShortVar>().toKStringFromUtf16()
}
@@ -360,61 +417,58 @@ private fun friendlyNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped
// Chain extension via the OS certificate store
// ============
/* Continue the server's chain by asking the OS to build it from the leaf — this
/** 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>
{
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()) }
val vLeaf =
vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
?: return@memScoped emptyList()
try
{
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
{
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())
{
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)
{
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 { CertFreeCertificateChain(vChain) }
} finally {
CertFreeCertificateContext(vLeaf)
}
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>>
{
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) }
@@ -422,7 +476,7 @@ private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<Stri
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() }
@@ -433,15 +487,29 @@ private fun certNameString(inCtx: CPointer<CERT_CONTEXT>, inFlags: UInt): String
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
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
if (CryptBinaryToStringA(
vData,
vSize,
CRYPT_STRING_BASE64HEADER.toUInt(),
vBuf,
vLen.ptr
) == 0
) return@memScoped null
vBuf.toKString()
}
@@ -449,47 +517,43 @@ private fun certPem(inCtx: CPointer<CERT_CONTEXT>): String? = memScoped {
// 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
{
/** 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.Default.decode(pemBody(vBlock))
return Base64.decode(pemBody(vBlock))
}
/* Extract + DER-decode the first PRIVATE KEY block from PEM text, or pass DER
/** 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?
{
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))
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() }
private fun hexToBytes(inHex: String): ByteArray =
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
{
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++ }
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)})")
@@ -1,63 +0,0 @@
package apidemo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.ClipEntry
import com.compose.sdl.LocalComposeNativeWindow
import com.compose.sdl.TextLayoutConfig
import com.compose.sdl.registerMemoryResource
import com.compose.sdl.removeMemoryResource
import com.compose.sdl.res.ResourceKind
import com.compose.sdl.text.currentTextMeasurer
// ==================
// MARK: Native actuals — pure delegation to the port's SDL-backed APIs
// ==================
actual fun appDataDir(inOrg: String, inApp: String): String? =
com.compose.sdl.appDataDir(inOrg, inApp)
actual fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)?) =
com.compose.sdl.revealInFileManager(inPath, inOnResult)
actual fun fileManagerName(): String = com.compose.sdl.fileManagerName()
actual fun showSaveFileDialog(inDefaultName: String?, inOnResult: (String?) -> Unit) =
com.compose.sdl.showSaveFileDialog(inDefaultName, inOnResult)
actual fun showOpenFileDialog(inOnResult: (String?) -> Unit) =
com.compose.sdl.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 =
com.compose.sdl.res.painterResource(inKey, if (inSvg) ResourceKind.Svg else ResourceKind.Raster)
actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int =
currentTextMeasurer.wrap(inText, inFontPx, inMaxWidthPx, inFamilyName).lines.size
actual var editorTabWidth: Int
get() = TextLayoutConfig.tabWidth
set(value) { TextLayoutConfig.tabWidth = value }
internal actual val systemFileSystem: okio.FileSystem = okio.FileSystem.SYSTEM
actual fun createApiHttpClient(): io.ktor.client.HttpClient =
io.ktor.client.HttpClient(io.ktor.client.engine.curl.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) }
}
}
+119 -80
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,9 +19,9 @@ 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
/** 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
@@ -35,34 +35,32 @@ class PreparedCert(
val cleanup: () -> Unit,
)
/* Ready the request's client certificate for libcurl (platform-specific).
/** 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
// ChainCert / TlsChain and the sweepTempClientCerts / inspectTlsChain /
// curlSendWithClientCert expects live in commonMain (TlsCommon.kt) — the UI
// 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.
/* Continue a server-presented chain with its issuer(s). Where possible the
/** 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
/* Server certs as ChainCerts; if the last one isn't self-signed, append a
/** 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>
{
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)
{
if (vLast != null) {
val vSubject = certFieldOf(vLast, "Subject")
val vIssuer = certFieldOf(vLast, "Issuer")
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
@@ -71,51 +69,65 @@ internal fun serverChainWithIssuerName(inServer: List<List<Pair<String, String>>
return vOut
}
/* Per-transfer accumulators handed to the C callbacks through a StableRef. */
private class CurlSink
{
/** 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. */
/** 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
{
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. */
/** 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
{
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
/** 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 =
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
/** 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
{
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)
@@ -133,17 +145,18 @@ actual fun inspectTlsChain(inReq: ApiRequest): TlsChain
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).")
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
{
} finally {
curl_easy_cleanup(vCurl)
vCert?.cleanup()
}
}
/* Read CURLINFO_CERTINFO off a performed handle into per-cert field lists. */
/** 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>()
@@ -151,15 +164,12 @@ private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>
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)
{
for (vI in 0 until vInfo.num_of_certs) {
val vFields = ArrayList<Pair<String, String>>()
var vNode = vArr[vI]
while (vNode != null)
{
while (vNode != null) {
val vData = vNode.pointed.data?.toKString()
if (vData != null)
{
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)
@@ -171,23 +181,29 @@ private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>
vResult
}
/* Send a client-certificate request through libcurl and adapt the result into
/** 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
{
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 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) }
?: run {
vCert.cleanup(); return errorResponse(
"Could not initialize libcurl",
vMark.elapsedNow().inWholeMilliseconds
)
}
val vSink = CurlSink()
val vSinkRef = StableRef.create(vSink)
var vHeaders: CPointer<curl_slist>? = null
try
{
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
@@ -199,8 +215,7 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
else
curl_easy_setopt(vCurl, CURLOPT_CUSTOMREQUEST, inReq.method.name)
if (vBody != null && vBody.bytes.isNotEmpty())
{
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)) }
}
@@ -240,9 +255,27 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
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 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)
@@ -254,7 +287,6 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
}.sortedBy { it.first.lowercase() }
return ApiResponse(
ok = true,
status = vStatus,
statusText = runCatching { HttpStatusCode.fromValue(vStatus).description }.getOrDefault(""),
timeMs = vMs,
@@ -262,7 +294,7 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
headers = parseRawHeaders(vSink.headerRaw.toString()),
body = when {
vIsImage -> ""
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes — not shown; use Save as…)"
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes - not shown; use Save as…)"
else -> vBytes.decodeToString()
},
bytes = vBytes,
@@ -270,9 +302,7 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
requestHeaders = vSent,
httpVersion = vVer,
)
}
finally
{
} finally {
vHeaders?.let { curl_slist_free_all(it) }
curl_easy_cleanup(vCurl)
vSinkRef.dispose()
@@ -280,37 +310,47 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
}
}
/* A failed-request ApiResponse with a message (mirrors HttpRunner's catch). */
/** 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
/** 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?
{
private fun requestBodyBytes(inReq: ApiRequest): CurlBody? {
if (inReq.bodyType == BodyType.NONE) return null
return when (inReq.bodyType)
{
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.FILE -> if (inReq.body.isBlank()) null else CurlBody(
inReq.bodyContentType(),
readFileBytes(inReq.body)
)
BodyType.NONE -> null
}
}
/* 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>>
{
/** 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"))
{
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 }
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())
@@ -318,10 +358,9 @@ private fun parseRawHeaders(inRaw: String): List<Pair<String, String>>
return vCurrent.sortedBy { it.first.lowercase() }
}
/* libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
/** libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
@OptIn(ExperimentalForeignApi::class)
private fun httpVersionName(inCode: Long): String = when (inCode)
{
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"
+15 -2
View File
@@ -6,13 +6,13 @@ import com.compose.sdl.loadComposeResourceBytes
import com.compose.sdl.text.namedFontFamily
// ==================
// MARK: Monospace body font — native actuals (data.kres + IconFont)
// MARK: Monospace body font - native actuals (data.kres + IconFont)
// ==================
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)
@@ -23,3 +23,16 @@ actual val monoFontFamilyName: String? 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)
}
}
+69 -3
View File
@@ -1,11 +1,77 @@
package apidemo
import com.compose.sdl.AppWindowIcon
import com.compose.sdl.disableInfiniteAnimations
import com.compose.sdl.nativeComposeWindow
import com.compose.sdl.useVirtualFrameTime
import com.compose.sdl.windowHasInvalidations
import kotlinx.cinterop.ExperimentalForeignApi
import utils.encodeBmpBgra32
import utils.writeFile
// ==================
// MARK: Entry point (native — SDL window shell)
// MARK: Entry point (native - SDL window shell)
// ==================
fun main() {
nativeComposeWindow(title = "API Manager", width = 1240, height = 820) { App() }
// The voltic window/taskbar icon - pre-decoded .rgba blobs bundled into data.kres
// under icon/ by the bridge plugin (compose.desktop.native { icon {} }); the
// backend uses the largest as the base and the rest as alternate sizes.
private val kAppIcon = AppWindowIcon(
light = listOf("icon/voltic-icon-128.rgba", "icon/voltic-icon-32.rgba"),
dark = listOf("icon/voltic-icon-dark-128.rgba", "icon/voltic-icon-dark-32.rgba")
)
private const val kWidth = 1240
private const val kHeight = 820
/** Safety net for content that never stops invalidating. */
private const val kMaxFrames = 300
/**
* Entry point.
*
* `--screenshot=<path.bmp>` renders to quiescence and dumps the framebuffer, then
* exits - the native half of the JVM-vs-native drift check (the JVM half is
* `./gradlew :apidemo:run --args=--screenshot=<path.png>`, which goes through
* ImageComposeScene). The capture is a GPU READBACK, not a screen grab, so it
* does not care whether the window is visible or occluded.
*/
@OptIn(ExperimentalForeignApi::class)
fun main(args: Array<String>) {
val screenshot = args.firstOrNull { it.startsWith("--screenshot=") }?.substringAfter('=')
if (screenshot != null) {
// Match the JVM leg: freeze infinite animations and drive a virtual clock so
// entrance animations settle deterministically instead of run-to-run.
disableInfiniteAnimations = true
useVirtualFrameTime = true
}
nativeComposeWindow(
title = "API Manager",
width = kWidth,
height = kHeight,
icon = kAppIcon,
onFrame = if (screenshot != null) {
// Capture once the window reports no pending invalidations for a few
// consecutive frames, or at the cap. Mirrors :demo's parity path.
var quietFrames = 0
{ backend, frameIndex ->
quietFrames = if (windowHasInvalidations()) 0 else quietFrames + 1
if (quietFrames >= 3 || frameIndex >= kMaxFrames) {
if (frameIndex >= kMaxFrames) {
println("Screenshot: quiescence not reached by frame $frameIndex - capturing anyway")
}
val snap = backend.snapshotBgra()
if (snap != null) {
val (w, h, bgra) = snap
writeFile(screenshot, encodeBmpBgra32(w, h, bgra))
println("Wrote screenshot: $screenshot (${w}x$h, settled at frame $frameIndex)")
} else {
println("Screenshot snapshot was null")
}
false // quit
} else true
}
} else null,
) { App() }
}
@@ -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
}
+176 -53
View File
@@ -1,45 +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 }
// compose-desktop-native-bridge is a plain JVM Gradle plugin — no klib ABI to track.
ignoredProjects.addAll(listOf("demo", "apidemo", "compose-desktop-native-bridge"))
// 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
@@ -48,38 +95,73 @@ allprojects {
val kAppModules = setOf(":demo", ":apidemo")
val kPublishedLibs = setOf(
":ui", ":ui-util", ":ui-geometry", ":ui-unit", ":ui-backhandler",
":ui-tooling-preview",
":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> {
@@ -93,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 {
@@ -114,43 +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"))
// ui's split artifacts: ui-graphics + ui-text are merged INTO
// :ui in the port; ui-unit / ui-geometry have their own modules.
substitute(module("org.jetbrains.compose.ui:ui-graphics")).using(project(":ui"))
substitute(module("org.jetbrains.compose.ui:ui-text")).using(project(":ui"))
substitute(module("org.jetbrains.compose.ui:ui-unit")).using(project(":ui-unit"))
substitute(module("org.jetbrains.compose.ui:ui-geometry")).using(project(":ui-geometry"))
substitute(module("org.jetbrains.compose.ui:ui-util")).using(project(":ui-util"))
substitute(module("org.jetbrains.compose.ui:ui-tooling-preview")).using(project(":ui-tooling-preview"))
substitute(module("org.jetbrains.compose.foundation:foundation")).using(project(":foundation"))
substitute(module("org.jetbrains.compose.foundation:foundation-layout")).using(project(":foundation-layout"))
substitute(module("org.jetbrains.compose.animation:animation")).using(project(":animation"))
substitute(module("org.jetbrains.compose.animation:animation-core")).using(project(":animation-core"))
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-ui"))
// 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),
)
}
@@ -8,11 +8,11 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi
import com.compose.sdl.loadComposeResourceBytes
// ==================
// MARK: ResourceReader — data.kres actual
// MARK: ResourceReader - data.kres actual
// ==================
/* The platform reader for this port: every app ships its composeResources
content inside data.kres (a STORED zip next to the executable — see the
/** The platform reader for this port: every app ships its composeResources
content inside data.kres (a STORED zip next to the executable - see the
apps' Zip tasks), and :ui's ResourceIO serves entries by exact path with an
fseek+fread. The paths the generated Res accessors produce
("composeResources/<package>/drawable/x.png") are stored verbatim in

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