Compare commits

...
159 Commits
Author SHA1 Message Date
Bitsy 83c1902b08 refactor(text): delete the hand-rolled text renderer/seam — one skiko path
Now that Compose text draws through the skiko paragraph engine, migrate the last
consumer (IconText / Material Symbols) onto the standard path and delete the
parallel hand-rolled machinery:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Dead code removed:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified on Windows: repo-wide common+native metadata sweep green for
all library modules, full publishKotlinMultiplatformPublicationToMavenLocal
(root .module variants now cover linux_x64/linux_arm64/macos_arm64/
mingw_x64/jvm), demo links + boots, and the bubble-wrap consumer example
resolves and links mingwX64 through the bridge against the local roots.
2026-07-14 10:34:50 +02:00
291 changed files with 12297 additions and 12142 deletions
+22 -20
View File
@@ -18,9 +18,8 @@ jobs:
strategy:
fail-fast: false
# Each host runs only the publish tasks Gradle actually generated for it.
# macOS also publishes the shared kotlinMultiplatform metadata module.
# No library module declares jvm() today — apps do their own JVM parity
# build against upstream Compose, so JVM isn't part of the publication.
# WINDOWS also publishes the shared kotlinMultiplatform metadata modules
# (see its matrix entry) + the jvm jar + the bridge plugin.
# `target` is the Gradle K/N target token (Capitalised) — one per host,
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
# skipped for the app build because SDL3 static libs on the ubuntu runner
@@ -31,16 +30,8 @@ jobs:
host: macos
target: MacosArm64
exe_ext: kexe
# jvm publication: only :material-symbols declares a jvm() target
# (its common API renders via upstream Compose on JVM) — publish
# its host-independent jar from the same host as the metadata.
# :compose-desktop-native-bridge (the consumer-side bridge Gradle plugin +
# its plugin marker) is host-independent too — same job.
tasks: >-
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
publishMacosArm64PublicationToGitHubPackagesRepository
publishJvmPublicationToGitHubPackagesRepository
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
- runner: ubuntu-22.04
host: linux
target: LinuxX64
@@ -52,8 +43,18 @@ jobs:
host: windows
target: MingwX64
exe_ext: exe
# WINDOWS owns the root KotlinMultiplatform metadata modules: it is
# the ONLY host that declares every target (vHostSupportsMingw is
# host-gated), so only its generated .module files carry the full
# variant table — the macOS-published roots of v0.1.15 had NO
# mingwX64 variants and Windows consumers could not resolve them.
# The host-independent jvm jar (:material-symbols) and the bridge
# plugin ride along on the same job.
tasks: >-
publishMingwX64PublicationToGitHubPackagesRepository
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
publishJvmPublicationToGitHubPackagesRepository
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
runs-on: ${{ matrix.runner }}
@@ -148,8 +149,8 @@ jobs:
# already completed; you can't tell from the 409 alone. The fix: between
# attempts, DELETE this host's package versions for the current release
# and re-upload from a clean slate. Each host owns DISJOINT package
# names (roots / -macosarm64 / -jvm / bridge on macOS, -mingwx64 on
# Windows, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
# names (roots / -mingwx64 / -jvm / bridge on Windows, -macosarm64 on
# macOS, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
# each other's uploads. Uses the run's GITHUB_TOKEN (packages: write);
# a 403 on delete just degrades to the old blind-retry behaviour.
- name: Publish to GitHub Packages
@@ -161,9 +162,9 @@ jobs:
# 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"
case "${{ matrix.host }}" in
macos) SUFFIXES="_ROOT_ -macosarm64" ;;
macos) SUFFIXES="-macosarm64" ;;
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
windows) SUFFIXES="-mingwx64" ;;
windows) SUFFIXES="_ROOT_ -mingwx64" ;;
esac
host_packages() {
for m in $MODULES; do
@@ -171,7 +172,7 @@ jobs:
if [ "$s" = "_ROOT_" ]; then echo "com.bitsycore.compose.sdl.$m"; else echo "com.bitsycore.compose.sdl.$m$s"; fi
done
done
if [ "${{ matrix.host }}" = "macos" ]; then
if [ "${{ matrix.host }}" = "windows" ]; then
echo "com.bitsycore.compose.sdl.material-symbols-jvm"
echo "com.bitsycore.compose.sdl.compose-desktop-native-bridge"
echo "com.bitsycore.compose-desktop-native.bridge.com.bitsycore.compose-desktop-native.bridge.gradle.plugin"
@@ -214,10 +215,11 @@ jobs:
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 —
# (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
+3
View File
@@ -61,3 +61,6 @@ bin/
### Python ###
__pycache__/
*.pyc
# Probe artifacts written to the repo root by demo --nav3test / --searchesctest / --dialoganimtest
/*.bmp
+179 -133
View File
@@ -3,17 +3,37 @@
Guidance for Claude Code working in this repository. This is the primary
context — read it first, then look at the files it points to.
## Documentation map
- [README.md](README.md) — public overview + quickstart (bridge plugin,
`nativeComposeWindow`, sample apps).
- [RENDERER.md](RENDERER.md) — the rendering layer: architecture, the retained-
layer engine, convergence decisions, learnings, and open work. Read it before
touching any renderer, graphics-actual, or layer-engine code.
- [TOOLING.md](TOOLING.md) — build/vendor/verify scripts and workflows
(build-sdl, sync + drift checks, parity, probe, profiler, coverage,
verify-mac) + the version map and the ref-bump / release runbooks.
- [TODO.md](TODO.md) — audited list of no-ops, stubs, and hardcodes left in the
port, with severity, plus what's missing for a stable 1.12 release.
- This file — architecture, module layout, vendoring rules, source-set
hierarchy, density flow, conventions, and common pitfalls.
## What this project is
**ComposeNativeSDL3** — a Kotlin/Native port of Compose Multiplatform running
on SDL3, no JVM. Compiles to native binaries for macOS (arm64), Linux
(x64/arm64), Windows (mingwX64).
Rendering is pluggable behind one `RenderBackend`:
Rendering is **Skia everywhere** behind one `RenderBackend` — Metal / OpenGL
/ CPU raster:
- **Skia** (via Skiko klibs) on macOS + Linux — Metal / OpenGL / CPU raster.
- **SDL3** (`SDL3_ttf` + `SDL_RenderGeometry`) on Windows, and on macOS/Linux
when `-Prenderer=sdl3` is passed.
- **macOS + Linux** link the OFFICIAL Skiko klibs from Maven.
- **Windows (mingwX64)** links the bitsycore skiko FORK — skiko+Skia compiled
into `skiko-windows-x64.dll` with a flat extern-C surface, bound from K/N via
an embedded GNU import lib, published to GitHub Packages as
`org.jetbrains.skiko:skiko:0.150.1-mingw.1` (override with
`-PskikoMingwVersion`). The runtime DLL is auto-provisioned next to the exe by
the bridge plugin (`installWindowsSkiaDll`). See SKIKO-MINGW-FEASIBILITY.md.
Windowing, input, audio, filesystem access, and the OS-integration surface
(file dialogs, clipboard, "open in Finder/Explorer"…) all go through
@@ -36,9 +56,9 @@ One Gradle module per upstream artifact; the directory mirrors the upstream
compose/
├── ui/
│ ├── ui/ → :ui — androidx.compose.ui.* (ui + ui-graphics + ui-text) +
│ │ com.compose.sdl.* — cinterops + BOTH renderer pipelines live
│ │ here. ui-graphics/ui-text can't split off: their Canvas /
│ │ Paragraph `expect`s are the renderers' `actual`s (same-module).
│ │ com.compose.sdl.* — the sdl3 cinterop + the Skia renderer
│ │ pipeline live here. ui-graphics/ui-text can't split off: their
│ │ Canvas / Paragraph `expect`s are the renderer's `actual`s (same-module).
│ ├── ui-util/ → :ui-util — androidx.compose.ui.util.* (+ Experimental/InternalComposeUiApi)
│ ├── ui-geometry/ → :ui-geometry — androidx.compose.ui.geometry.*
│ ├── ui-unit/ → :ui-unit — androidx.compose.ui.unit.*
@@ -67,14 +87,14 @@ utils/
└── material-symbols/ → :material-symbols — codepoints + all three style objects
(Outlined / Rounded / Sharp). COMMON API (usable from
shared app code) + per-stack actuals: native renders
via :foundation IconFontIcon (IconFont handles the
SDL3/Skia split), jvm() via Skiko directly
via :foundation IconFontIcon (Skia), jvm() via Skiko directly
(Typeface.makeClone per axes — upstream's FontCache
drops variationSettings from its key). Its commonMain
declares official Maven compose coords — the root
build's FULL-COMMONIZATION BRIDGE substitutes the
whole ui / foundation / animation / material3 /
nav3-ui family to project modules on native configs
nav3-ui / components-resources family to project
modules on native configs
(:demo and :apidemo commonMains rely on the same
bridge; :compose-desktop-native-bridge ships it to
consumers). Apps get one dep; the consumer Zip task
@@ -90,8 +110,8 @@ components/
no mingwX64/linux klibs. Platform layer is project
code: data.kres ResourceReader, pure-Kotlin
DomXmlParser (upstream's is Darwin NSXMLParser),
SDL3_image decode via the :ui EncodedImageDecoder
hook, NamedFont registration, SDL locale/theme env.
image decode via the :ui EncodedImageDecoder hook
(Skia), NamedFont registration, SDL locale/theme env.
Apps' JVM targets keep the Maven artifact — the
generated Res accessors work against BOTH.
@@ -117,22 +137,48 @@ apidemo/ → :apidemo — Postman-style REST API manager. MULTIPLA
expect/actual seams (compat/Compat.kt — native actuals delegate
to com.compose.sdl, jvm actuals use AWT + upstream desktop).
mTLS / TLS-chain inspection stays native-only (bundled libcurl).
DOGFOODS the bridge plugin: data.kres packaging + app icon come
from compose.desktop.native {}; the font pipeline (Noto +
Material Symbols subsetting) comes from the shared buildSrc
helper (registerComposeFontBundling).
buildSrc/ → shared build logic for the app modules: ComposeFontBundling.kt —
registerComposeFontBundling { } (everything OPT-IN; no flag = no-op)
registers downloadNotoFonts (bundleNotoSans / bundleNotoSansMono /
autoDetectNotoSansMono), detects the Material Symbols styles the
sources use (bundleMaterialSymbols), wires the font/ entries into
every data.kres Zip (the bridge plugin's package* tasks; hand-rolled
copy*ComposeResources* tasks also match) and stages the same fonts on
the JVM classpath; hb-subset pipeline behind enableIconSubsetting
(findMaterialSymbolsUsage + subsetMaterialSymbols<Style>).
gradle-plugin/
└── compose-desktop-native-bridge/ → :compose-desktop-native-bridge — the CONSUMER-side bridge as a
published Gradle plugin (id com.bitsycore.compose-desktop-native
.bridge, applies to Settings or Project): substitution rules
can't ship inside Maven artifacts, so third-party apps apply the
plugin, declare OFFICIAL CMP coords in commonMain, and native
configurations swap in the published com.bitsycore.compose.sdl
klibs. Substituted version defaults to the plugin's own
(override: composeDesktopNative.version property). Published by
the macOS publish job.
└── compose-desktop-native-bridge/ → the CONSUMER-side bridge as a published Gradle plugin
(id com.bitsycore.compose-desktop-native.bridge, applies to
Settings or Project). An INCLUDED build (pluginManagement.
includeBuild in settings.gradle.kts), NOT a subproject — a
plugins{} block can only resolve plugins from repositories or
included builds; :demo and :apidemo apply it from source
(dogfooding: data.kres packaging — and apidemo's app icon —
run through the plugin).
Three halves: (1) substitution — third-party apps declare
OFFICIAL CMP coords in commonMain and native configurations
swap in the published com.bitsycore.compose.sdl klibs
(version defaults to the plugin's own; override:
composeDesktopNative.version property; disable:
composeDesktopNative.substitution=false — set repo-wide in
gradle.properties since the root build's FULL-COMMONIZATION
BRIDGE substitutes to project modules in-repo); (2) data.kres
packaging (package<Variant>ComposeResources<Target> per native
executable, honours -PcompressResources); (3) the
compose.desktop.native { entryPoint / icon {} } DSL (.rgba
runtime icons + windres .exe embed — injected into
hand-declared executables too). Published by the WINDOWS
publish job.
scripts/ → vendor-sync + python helper scripts (compose-coverage = API
coverage/fidelity vs upstream, material-symbols generate/subset)
+ compose-fork/;
scripts/build-sdl/ = static-lib build script (python)
libs/ → gitignored per-host static SDL3 / SDL3_ttf / SDL3_image / FreeType
output of scripts/build-sdl/build-all.py on Windows
libs/ → gitignored per-host static SDL3 output of
scripts/build-sdl/build-all.py on Windows
```
Module PATHS stay short (`:ui`, `:foundation`, `:window`, …) —
@@ -160,7 +206,7 @@ sees the split modules. Full DAG: `:ui-util → collection`; `:ui-geometry → :
`:material3 → :foundation, :material-ripple, :animation-core, :foundation-layout`.
`:ui` is the renderer/cinterop hub (the ui-graphics + ui-text `expect`s resolve to
its SDL/Skia renderer `actual`s). The pure lower artifacts (`:ui-util`,
its Skia renderer `actual`s). The pure lower artifacts (`:ui-util`,
`:ui-geometry`, `:ui-unit`, `:ui-backhandler`) are split out below it. Everything
above `:ui` can only touch renderer / cinterop internals via its public surface.
`:window` depends on `:ui` + `:foundation` (needs `LazyList`-style scaffolding to
@@ -180,7 +226,7 @@ compose-multiplatform umbrella repo that `:components-resources` vendors from).
There is no implicit default — `SET_REPO` is required. `scripts/compose-fork/sync.sh`
walks all manifests and copies each selected file byte-for-byte from the pinned
checkout (each distinct repo sparse-cloned to `../cmp-ref[-<name>]`) into
`<module>/src/vendor/{common,native,skikoRenderer,sdlRenderer}/kotlin/`. The
`<module>/src/vendor/{common,native,skikoRenderer}/kotlin/`. The
`src/vendor/` tree is **gitignored** — you don't check it in, you re-sync
on demand.
@@ -205,8 +251,8 @@ Two categories of code live in each module:
Never hand-edit these. If upstream diverges, adjust the manifest or the
pinned ref and re-sync. This is the bulk of the codebase (~1500 files
across the modules).
2. **Project code (in `src/commonMain/`, `src/nativeMain/`, `src/skikoRendererMain/`,
`src/sdlRendererMain/`)** — code we author (project actuals, glue between
2. **Project code (in `src/commonMain/`, `src/nativeMain/`,
`src/skikoRendererMain/`)** — code we author (project actuals, glue between
Compose and SDL3, project-specific extensions).
### The 5 rules for adding upstream Compose surface
@@ -228,19 +274,30 @@ Two categories of code live in each module:
NON-IDEMPOTENT.** Move it OUT of `src/vendor/` into the corresponding
`src/{commonMain,nativeMain,…}` tree, add a header comment noting
which upstream file it derived from and what changed, and comment
its line out of `compose-fork.txt`. Now it's a project file — the
next sync won't overwrite it, and future upstream changes to that
file need to be reconciled by hand. This is fine; do it when the
edit is small and the file is unlikely to churn upstream.
its line out of `compose-fork.txt`. **ALWAYS also add the
machine-readable provenance line** so the copy can be diffed against
its base and against later upstream versions:
4. **Skiko-specific things go in `:ui/src/skikoRendererMain/`, with
an SDL3 equivalent in `:ui/src/sdlRendererMain/`.** When upstream
ships a `.skiko.kt` file that uses Skiko's Canvas / Paragraph / …,
the `.skiko.kt` variant is fine to vendor into `skikoRendererMain`.
Then hand-roll (or minimally vendor) the SDL3 counterpart in
`sdlRendererMain` — `SkiaCanvas.kt` ↔ `Sdl3Canvas.kt`,
`SkiaTextRenderer.kt` ↔ `Sdl3TextRenderer.kt` (using SDL3_ttf +
FreeType), `SkiaImageCache.kt` ↔ `Sdl3ImageCache.kt` (using SDL3_image).
```
// VENDOR-BASE: <upstream-repo-relative-path> @ <pinned-tag>
// VENDOR-BASE(COMPOSE_REF): <path> @ <tag> ← for umbrella-repo files
```
`scripts/compose-fork/check-vendor-drift.py` reads these at every ref
bump: it flags any file whose recorded base lags the current pin and
(with the local clone) reports whether the upstream base ACTUALLY
changed base..pin — i.e. whether the copy needs hand-reconciling or
just a ref re-stamp. Now it's a project file — the next sync won't
overwrite it, and future upstream changes to that file need to be
reconciled by hand. This is fine; do it when the edit is small and
the file is unlikely to churn upstream.
4. **Skiko-specific things go in `:ui/src/skikoRendererMain/`.** When upstream
ships a `.skiko.kt` file that uses Skiko's Canvas / Paragraph / …, the
`.skiko.kt` variant is fine to vendor into `skikoRendererMain` (the Skia
drawing pipeline — `SkiaCanvas.kt`, `SkiaTextRenderer.kt`,
`SkiaImageCache.kt`, …). All native targets attach this source set;
mingwX64 layers its fork actuals on top under `skikoRendererMingwMain`.
5. **Multi-OS project code goes through SDL3, not hand-rolled per-target
ifdefs.** SDL3 already handles the platform differences for filesystem
@@ -258,48 +315,36 @@ call sites don't care.
## Source-set hierarchy (:ui only)
`:ui` owns cinterops + both renderer pipelines. Its source-set tree:
`:ui` owns the `sdl3` cinterop + the Skia renderer pipeline. Its source-set tree:
```
commonMain
└── nativeMain (vendored .native.kt + project native code)
├── skikoRendererMain (Skia drawing pipeline; Skiko on classpath)
├── skikoRendererMain (Skia drawing pipeline; official Skiko on classpath)
│ ├── skikoRendererMacosMain (macOS-only Skia actuals — Metal bridge)
│ └── skikoRendererLinuxMain (Linux-only Skia actuals — OpenGL)
│ attached to: macosArm64Main / linuxX64Main / linuxArm64Main
│ ONLY when the Skia renderer is active for the target.
└── sdlRendererMain (SDL3 drawing pipeline + TTF/image/FreeType)
├── sdlRendererMacosMain (macOS-only SDL3 driver hint)
├── sdlRendererLinuxMain (Linux-only SDL3 driver hint)
└── sdlRendererMingwMain (mingwX64-only SDL3 driver hint)
attached to: mingwX64Main always; macOS/Linux when -Prenderer=sdl3.
│ attached to: macosArm64Main / linuxX64Main / linuxArm64Main.
└── skikoRendererMingwSharedMain
└── skikoRendererMingwMain (mingwX64-only Skia actuals — the bitsycore
skiko FORK: skiko-windows-x64.dll bound via
an embedded GNU import lib; OpenGL context)
attached to: mingwX64Main.
```
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are declared identically
in both `skikoRendererMain` and `sdlRendererMain`. `:window` calls them straight
from `:ui` — no `expect`/`actual`, no factory layer — and the right impl
resolves because **only one of the two renderer source sets is attached to a
given target**. Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
**not even created**, so Gradle has nothing to warn about and Skiko is never
pulled in.
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are `expect`s in
`:ui`'s nativeMain with `actual`s in `skikoRendererMain` (shared by every native
target). `rendererPreferredGpuMode()` picks Metal on macOS, OpenGL on
Linux + Windows, with a Software (CPU raster) auto-fallback if the GPU context
fails to come up.
### Cinterop sibling-dependency gotcha
### Cinterop
`:ui` owns four cinterops in `src/nativeInterop/cinterop/`:
`sdl3`, `sdl3_ttf`, `sdl3_image`, `freetype`. The `.def` files for `sdl3_ttf` /
`sdl3_image` carry `depends = sdl3` so their `SDL_Surface` / `SDL_Color`
references resolve to the *same* types `sdl3` produces (not duplicates inside
`sdl3_image.SDL_Surface`). **Gradle does not automatically add a sibling
cinterop's klib to a cinterop task's `-library` list**, so the manifest
directive silently fails and you get cryptic
`expected CPointer<sdl3.SDL_Surface>?, actual CPointer<sdl3_image.SDL_Surface>?`
errors.
`:ui/build.gradle.kts` works around this by passing the sdl3 cinterop output
klib path explicitly via `extraOpts("-library", vSdl3Klib)` on `sdl3_ttf` /
`sdl3_image`, plus a task dependency
(`cinteropSdl3_ttf*Target.dependsOn(cinteropSdl3*Target)`). If you ever add
another `depends = sdl3` cinterop, add it to that list too.
`:ui` owns ONE cinterop in `src/nativeInterop/cinterop/`: `sdl3` (SDL_Window /
SDL_Event / SDL_GetBasePath / clipboard / dialogs / GL+Metal context /
SDL_Renderer-for-CPU-raster). It has no siblings, so there's no
`depends = <sibling>` propagation to worry about — cinterop `.def` files list
each other under `depends =` and Gradle does NOT auto-add a sibling cinterop's
klib to the `-library` list, but with a single cinterop that gotcha is moot.
## Density flow (Option B — layout in physical pixels)
@@ -333,13 +378,13 @@ Passing raw `px.dp` will double-scale on Retina.
./gradlew :demo:runDebugExecutableLinuxX64
./gradlew :apidemo:runDebugExecutableLinuxX64
# Windows (from Windows — mingw cross-build from macOS/Linux fails at cinterop)
# Windows (from Windows — mingw cross-build from macOS/Linux fails at cinterop).
# Links the bitsycore skiko FORK from GitHub Packages (org.jetbrains.skiko:skiko:
# 0.150.1-mingw.1, override -PskikoMingwVersion); the bridge plugin drops
# skiko-windows-x64.dll next to the exe (installWindowsSkiaDll).
gradlew.bat :demo:runDebugExecutableMingwX64
gradlew.bat :apidemo:runDebugExecutableMingwX64
# Skiko-free build on macOS/Linux — SDL3 renderer everywhere
./gradlew :demo:runDebugExecutableMacosArm64 -Prenderer=sdl3
# Stock JVM Compose Desktop (any host) — the parity reference: the SAME shared
# screens on upstream Compose; differences vs the native build = port bugs.
./gradlew :demo:run
@@ -348,21 +393,16 @@ gradlew.bat :apidemo:runDebugExecutableMingwX64
### System dependencies
SDL3 + SDL3_ttf + SDL3_image + FreeType (+ image codecs) are **built from
source as static libraries on every OS** and linked straight into the
executable — no brew/apt SDL packages, no runtime .dll/.so/.dylib;
a distributable is `<app>` + `data.kres`. One script does it all:
`python scripts/build-sdl/build-all.py` (plain Python 3, no Git Bash needed)
builds into the gitignored, in-repo `libs/` folder. Versions/URLs pinned in
`scripts/build-sdl/build-sdl.properties`. Steps run in order freetype → sdl3
→ sdl3-image → sdl3-ttf; pass step names to rebuild a subset
(`python scripts/build-sdl/build-all.py sdl3-ttf`).
SDL3 is **built from source as a static library on every OS** and linked
straight into the executable — no brew/apt SDL packages. Build it once per host
with `python scripts/build-sdl/build-all.py`. Per-host toolchain requirements
and the step breakdown are in [TOOLING.md](TOOLING.md#native-libraries).
Needs everywhere: git, cmake, python 3 (ninja fetched automatically). Per
host: macOS = Xcode CLT (Skia default; Skiko klibs from Maven); Linux =
gcc/g++ + the X11/Wayland/audio dev headers SDL3's configure detects (apt
list in .github/workflows/publish.yml); Windows = mingw-w64 g++ on PATH
(C compiles with K/N's bundled mingw).
Skia comes in through the Skiko klibs: the official Maven ones on macOS/Linux
(no runtime .so/.dylib — Skia is statically inside the klib), and the bitsycore
fork on Windows, whose `skiko-windows-x64.dll` the bridge plugin provisions next
to the exe. So a macOS/Linux distributable is `<app>` + `data.kres`; a Windows
one is `<app>.exe` + `data.kres` + `skiko-windows-x64.dll`.
## Runtime bundling — data.kres
@@ -370,37 +410,38 @@ Every app ships `<app>.exe` + `data.kres` (a STORED zip alongside the
executable, loaded via `SDL_GetBasePath()`). Contents:
- App drawables + files under `composeResources/{drawable,files}/`
- `font/NotoSans.ttf` — the default variable font, downloaded once by
`:ui:downloadNotoFonts` into `compose/ui/build/fonts/`. Pass
`-PbundleDefaultFont=false` to skip.
- `font/NotoSansMono.ttf` — mono body font (apidemo only)
- Material Symbols fonts for the styles the app **actually uses** — the
Zip task scans the app's Kotlin sources for `MaterialSymbolsOutlined` /
`Rounded` / `Sharp` and only bundles the fonts referenced.
- `-PsubsetIcons=true` (default on): `scripts/subset-material-symbols.py`
scans app sources for `MaterialSymbols.<Name>` usage and hb-subsets each
bundled font down to just those glyphs. Needs `hb-subset` on PATH
(`brew install harfbuzz` / `apt install harfbuzz-utils`) — falls back
to the full font if absent.
- `font/NotoSans.ttf` — the default variable font. Bundling it is the APP's
job: each app opts in via `registerComposeFontBundling { bundleNotoSans =
true; … }` (buildSrc — every flag is opt-in, no flag = no-op), which
registers its `downloadNotoFonts` task (into `<app>/build/fonts/`); the
library ships no download task. Pass `-PbundleDefaultFont=false` to skip.
- `font/NotoSansMono.ttf` — `autoDetectNotoSansMono = true` bundles it when
`FontFamily.Monospace` appears in the app sources (demo);
`bundleNotoSansMono = true` forces it (apidemo's body font, loaded through
its own seam)
- Material Symbols fonts for the styles the app **actually uses**
(`bundleMaterialSymbols = true`) — the Zip task scans the app's Kotlin
sources for `MaterialSymbolsOutlined` / `Rounded` / `Sharp` and only
bundles the fonts referenced.
- `-PsubsetIcons=true` (default on, opted into per app via
`enableIconSubsetting = true` — apidemo yes, demo no):
`scripts/subset-material-symbols.py` scans app sources for
`MaterialSymbols.<Name>` usage and hb-subsets each bundled font down to
just those glyphs. Needs `hb-subset` on PATH (`brew install harfbuzz` /
`apt install harfbuzz-utils`) — falls back to the full font if absent.
## Vendor sync workflow
```bash
# Sync every module's compose-fork.txt against the pinned upstream ref.
scripts/compose-fork/sync.sh
# Sync one module by direct manifest path (module names use ':' → '/' → skip
# 'compose/' prefix — nested modules like compose/sdl/window pass the full path).
scripts/compose-fork/sync.sh compose/ui/compose-fork.txt
# Re-format a manifest (align columns, group by upstream folder).
scripts/compose-fork/format-manifest.py --discover ../cmp-ref \
--manifest compose/ui/compose-fork.txt
```
Upstream ref: `scripts/compose-fork/compose.properties` — set to a specific commit
Upstream ref: `scripts/compose-fork/compose.properties` — set to a durable tag
of `JetBrains/compose-multiplatform-core`. Bump the ref → re-sync → let the
build tell you what broke.
build tell you what broke. Per-module sync, manifest re-formatting, and the
drift / vendor-clean guardrails are in
[TOOLING.md](TOOLING.md#vendoring-upstream-compose).
## Key files by area — start here when you need to find something
@@ -408,12 +449,12 @@ build tell you what broke.
- `compose/sdl/window/src/nativeMain/…/ComposeWindow.kt` — main loop,
recomposer lifecycle, SDL event dispatch, composition-local seeding.
- `compose/ui/ui/src/nativeMain/…/RenderBackend.kt` — the interface.
- `compose/ui/ui/src/nativeMain/…/GpuMode.kt` — sealed 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 rasterisation (SDL3_ttf has no axis-set API; we go
to FreeType directly for icon families).
- `compose/ui/ui/src/nativeMain/…/GpuMode.kt` — sealed driver picker
(`Auto` / `Software` / `Skia.OpenGL` / `Skia.Metal`).
- `compose/ui/ui/src/skikoRendererMain/…/renderer/skia/SkiaRenderBackend.kt` —
the Skia render backend (shared by every native target).
- `compose/ui/ui/src/skikoRendererMingwMain/…` — mingwX64-only Skia actuals
bound against the skiko fork's flat extern-C surface.
### Layout / composition wiring
- `compose/ui/ui/src/commonMain/…/node/ComposeRootHost.kt` — root LayoutNode
@@ -430,8 +471,7 @@ build tell you what broke.
- `compose/ui/ui/src/nativeMain/…/ui/text/ParagraphFactories.native.kt` —
actuals for the `Paragraph(…)` / `ParagraphIntrinsics(…)` factory family.
- `compose/ui/ui/src/commonMain/…/text/TextMeasurer.kt` — `NativeTextMeasurer`
interface (per-renderer implementations: `SkiaTextRenderer` /
`Sdl3TextRenderer`).
interface (implemented by `SkiaTextRenderer`).
### Icons
- `compose/foundation/foundation/src/nativeMain/…/icons/IconFontIcon.kt` —
@@ -454,6 +494,21 @@ build tell you what broke.
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
ship drop-in equivalents for our anchor / scrim patterns).
## Tooling
The full tooling reference is [TOOLING.md](TOOLING.md): the static-lib build,
vendor sync + drift/clean guardrails, API coverage, the `verify-mac` runbook,
the native-vs-JVM parity harness, the interaction probe, and the frame
profiler. Quick rules of thumb:
- Whole-project renderer/layout change → run **parity** (`scripts/parity/parity.py`),
the broad net.
- Chasing one reported interaction → run the **probe** (`scripts/probe/`), targeted.
- Slow frame → **profiler** first (`CDN_PROFILE=1`), optimize second.
- Any renderer change → the **`verify-mac`** runbook gates it before commit.
See [RENDERER.md](RENDERER.md) for the renderer work these support.
## Conventions
Kotlin standard style — plain `camelCase` for parameters, local variables,
@@ -499,12 +554,6 @@ that should surface in tooling.
`Modifier.alpha(x)` desugars to `graphicsLayer(alpha = x, clip = true)`.
For a drag ghost that also translates, put `alpha` and `translationX` on
the SAME `graphicsLayer(...)` so clip stays false.
- **Cinterop `depends = sdl3`** — silently doesn't propagate; see the
gotcha section above.
- **Configuration cache + `-Prenderer=`** — Gradle caches configuration;
toggling the renderer property may not invalidate it. Delete
`.gradle/configuration-cache/` between switches if you see weird
"couldn't find sdl3_ttf" errors.
- **Substituted Maven modules hide their transitives from common metadata** —
when the bridge swaps an official coord for a project module on native
configs, KGP's granular-metadata visibility check drops that Maven module's
@@ -512,19 +561,16 @@ that should surface in tooling.
'Color'` / `Cannot access class ...` in `compileCommonMainKotlinMetadata`,
while per-target compilation is fine). Declare EVERY artifact the common
code touches DIRECTLY (ui-graphics, ui-text, ui-unit, …) and give each its
own bridge rule. Note only the macOS publish job compiles common metadata —
own bridge rule. Note only the WINDOWS publish job compiles common metadata
(it owns the root KotlinMultiplatform publications — the only host that
declares every target, so only its .module files carry the full variant
table; macOS-published roots left v0.1.15 without mingwX64 variants) —
test with `gradlew :<module>:compileCommonMainKotlinMetadata` before tagging.
- **`Path()` in commonMain returns different actuals per renderer** —
the Skia renderer produces a `SkiaBackedPath` (wraps
`org.jetbrains.skia.Path`), the SDL renderer produces a project
`ProjectPath` (command-list based). `SkiaCanvas.toSkiaPath` handles
both — never assume the type without checking.
## Useful Gradle tricks
- `--args="--gpu=sdl3.opengl --screen=Buttons"` — pass CLI to the demo.
- `--args="--gpu=skia.opengl --screen=Buttons"` — pass CLI to the demo.
- `--info` — see cinterop classpath + include paths actually used.
- `--rerun-tasks` — force rebuild after toggling `-Prenderer=`.
- After a module rename or IC-cache mismatch: nuke
`demo/build/kotlin-native-ic-cache` (or `apidemo/build/…`). Kotlin/Native
pins module IDs into its klib metadata; a stale cache surfaces as
+134 -221
View File
@@ -1,265 +1,178 @@
[![Kotlin](https://img.shields.io/badge/Kotlin-2.4.0-blue.svg?style=flat&logo=kotlin)](https://kotlinlang.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md)
# Compose Desktop Native
A Kotlin/Native port of **Compose Multiplatform** running on **SDL3** for
windowing and input — **no JVM**. Compiles to native binaries for macOS
(arm64), Linux (x64/arm64), Windows (mingwX64).
Run [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform)
as a native binary, with no JVM. Compose Desktop Native compiles to a single
executable for macOS (arm64), Linux (x64/arm64), and Windows (mingwX64), using
[SDL3](https://libsdl.org) for windowing and input.
Rendering is pluggable behind one `RenderBackend`:
<img src="screenshots/demo.png" width="100%" alt="Compose Desktop Native demo" />
- **Skia** (via Skiko klibs) on macOS + Linux — Metal / OpenGL / CPU raster.
- **SDL3** (`SDL3_ttf` + `SDL_RenderGeometry`) on Windows, and on macOS/Linux
when `-Prenderer=sdl3` is passed.
## How it works
The Compose **runtime** is the official
`org.jetbrains.compose.runtime:runtime` klibs from Maven — this project
re-implements the layers on top of it (`androidx.compose.ui.*`,
`.foundation.*`, `.animation.*`, `.material3.*`) by **vendoring
upstream Compose Multiplatform verbatim** whenever possible and hand-rolling
project actuals + SDL3 / Skia glue only where needed.
- **The runtime is the real one.** Composition, snapshots, and the recomposer
are the official `org.jetbrains.compose.runtime` klibs from Maven, never
reimplemented.
- **The UI layers are vendored upstream.** `androidx.compose.ui`, `foundation`,
`animation`, and `material3` are copied from Compose Multiplatform verbatim
wherever they compile as is, with project code filling in only the native glue.
- **Rendering is Skia everywhere.** macOS and Linux link the official Skiko
klibs (Metal / OpenGL); Windows links the bitsycore Skiko fork, which ships
Skiko and Skia together in `skiko-windows-x64.dll`.
- **The platform is SDL3.** Windowing, input, audio, filesystem, file dialogs,
and clipboard all go through SDL3, so one code path covers every OS.
- **Lean distributables.** SDL3 is built as a static library and linked in. On
macOS and Linux a distributable is just the executable plus a `data.kres`
resource bundle; on Windows it also ships `skiko-windows-x64.dll` next to the
exe (auto-provisioned by the bridge plugin).
## Module layout
| Platform | Gradle target | Renderer |
|----------|---------------|----------|
| macOS arm64 | `macosArm64` | Skia (Metal) — official Skiko |
| Linux x64 / arm64 | `linuxX64` / `linuxArm64` | Skia (OpenGL) — official Skiko |
| Windows | `mingwX64` | Skia — bitsycore Skiko fork |
One Gradle module per upstream Compose artifact — the directory tree mirrors
upstream's `compose/` layout, gradle paths stay short via `projectDir`
redirects.
## Quickstart
```
compose/
├── ui/
│ ├── ui/ → :ui (androidx.compose.ui.* + renderers + cinterops)
│ ├── ui-util/ → :ui-util
│ ├── ui-geometry/ → :ui-geometry
│ ├── ui-unit/ → :ui-unit
│ ├── ui-backhandler/ → :ui-backhandler
│ └── ui-tooling-preview/ → :ui-tooling-preview (common @Preview + PreviewParameterProvider —
│ IDE-only metadata; previews render via the apps' jvm targets)
├── animation/
│ ├── animation-core/ → :animation-core
│ ├── animation/ → :animation
│ └── animation-graphics/ → :animation-graphics
├── foundation/
│ ├── foundation/ → :foundation
│ └── foundation-layout/ → :foundation-layout
├── material3/material3/ → :material3
├── material/material-ripple/ → :material-ripple
└── sdl/ (project modules — the SDL integration layer)
└── window/ → :window (nativeComposeApp { Window(...) } + SDL3 main loop,
per-window Lifecycle/ViewModelStore/SavedState owners)
utils/
└── material-symbols/ → :material-symbols (Material Symbols: 4200+ codepoints + Outlined/Rounded/
Sharp composables with the variable-font axes as
parameters (FILL/wght/GRAD/opsz). Common API — native
draws via the port's icon-font engine, the jvm target
via Skiko, so shared app code uses it on both stacks)
components/
└── resources/library/ → :components-resources (vendored official compose resources runtime —
painterResource/stringResource/Res codegen; the Maven
artifact ships no mingwX64/linux klibs)
navigation3/
└── navigation3-ui/ → :navigation3-ui (vendored NavDisplay + scene machinery — the one
Navigation 3 layer without a K/N desktop artifact)
gradle-plugin/
└── compose-desktop-native-bridge/ → :compose-desktop-native-bridge (the consumer-side bridge, published
as a Gradle plugin — see "Using it from your own build")
demo/ → :demo (showcase + CLI probe suite; multiplatform — the same screens
also build for stock JVM Compose Desktop as a parity reference)
apidemo/ → :apidemo (Postman-style REST client; multiplatform — same UI on JVM too)
```
## demo — widget & feature showcase
<img src="screenshots/demo.png" width="100%" alt="ComposeNativeSDL3 demo" />
`:demo` is a full tour of the re-implemented Compose + Material 3 surface —
30+ sidebar screens covering buttons, text fields, layout, modifiers, shapes,
images, state & recomposition, scrolling & lazy lists, dialogs, icons, canvas,
graphics layers, custom layout, animation and gestures.
```bash
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (SDL3)
./gradlew :demo:run # stock JVM Compose Desktop (parity reference)
```
The same shared screens drive both stacks — the JVM target runs them on
upstream Compose Desktop unchanged, so any visual or behavioural difference
against the native build is a porting bug, not a demo artifact.
CLI: `--gpu=…`, `--screen=<Name>` (one screen, no sidebar),
`--screenshot=out.bmp --frames=N`, `--width=W --height=H`.
## apidemo — API Manager
<img src="screenshots/apidemo.png" width="100%" alt="ComposeNativeSDL3 API Manager" />
`:apidemo` is a Postman-style REST client built entirely on the library:
request collections (**packs**, nested sub-packs, linked copies), a
**Session → Pack → Request** inheritance ladder for variables / headers /
query params / client certs, syntax-highlighted JSON / XML / YAML / HTML body
editors, a response viewer with timing, size and TLS-chain inspection, mTLS
client certificates, drag-and-drop tree management, request history and
file-based sessions.
```bash
./gradlew :apidemo:runDebugExecutableMacosArm64
./gradlew :apidemo:runDebugExecutableLinuxX64
gradlew.bat :apidemo:runDebugExecutableMingwX64
./gradlew :apidemo:run # stock JVM Compose Desktop (parity reference)
```
Like the demo, the whole UI lives in `commonMain` and also runs on upstream
Compose Desktop (JVM); only the mTLS / TLS-chain features are native-only —
they drive the bundled libcurl directly.
Add `-Prenderer=sdl3` on macOS/Linux to drop Skiko and use the pure-SDL3
renderer everywhere.
## Minimal app
### A window
```kotlin
import androidx.compose.material3.Text
import com.compose.sdl.nativeComposeWindow
fun main() = nativeComposeWindow(title = "Hello") {
Text("Hello from ComposeNativeSDL3")
Text("Hello from Compose Desktop Native")
}
```
The lambda runs with a `ComposeWindowScope` receiver exposing
`window: ComposeNativeWindow` (`setTitle` / `setSize` / `minimize` /
`maximize` / `setFullscreen` / `close` / …); the same handle is reachable
from any nested composable via `LocalComposeNativeWindow.current`.
The content lambda runs with a `ComposeWindowScope` receiver that exposes
`window: ComposeNativeWindow` (`setTitle`, `setSize`, `minimize`, `maximize`,
`setFullscreen`, `close`). The same handle is reachable from any nested
composable via `LocalComposeNativeWindow.current`.
Add these to your module's `commonMain.dependencies`:
For multiple windows, wrap them in `nativeComposeApp`, where the set of
`Window(...)` calls is state driven (Compose Desktop style):
```kotlin
implementation(project(":window")) // window + main loop
implementation(project(":material3")) // Material 3 widgets
implementation(project(":material-symbols")) // icon-font composables (optional)
fun main() = nativeComposeApp {
Window(title = "Main", onCloseRequest = ::exitApplication) { /* ... */ }
if (showInspector) {
Window(title = "Inspector", onCloseRequest = { showInspector = false }) { /* ... */ }
}
}
```
## Using it from your own build — the bridge plugin
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.
The klibs publish to GitHub Packages under `com.bitsycore.compose.sdl:*`, and
a companion Gradle plugin ships the "redirect trick" this repo uses
internally: apply **`com.bitsycore.compose-desktop-native.bridge`** once in
`settings.gradle.kts` and declare the **official Compose Multiplatform
coordinates** in `commonMain` — the plugin substitutes the port's klibs on
every native desktop configuration (mingwX64 / linuxX64 / linuxArm64 /
macosArm64), while android / jvm / iOS / wasm keep resolving the official
artifacts. One dependency list, all CMP platforms + the port's new ones.
### Building in this repo
```kotlin
// settings.gradle.kts
plugins { id("com.bitsycore.compose-desktop-native.bridge") version "<release>" }
// build.gradle.kts — official coords, everywhere
commonMain.dependencies {
implementation("org.jetbrains.compose.runtime:runtime:<runtime-version>")
implementation("org.jetbrains.compose.ui:ui:<cmp-version>")
implementation("org.jetbrains.compose.foundation:foundation:<cmp-version>")
implementation("org.jetbrains.compose.material3:material3:<cmp-m3-version>")
implementation(project(":window")) // window shell + main loop
implementation(project(":material3")) // Material 3 widgets
implementation(project(":material-symbols")) // icon-font composables (optional)
}
```
Setup details (repositories, credentials, version pinning):
### Building from your own project: the bridge plugin
The klibs publish to GitHub Packages under `com.bitsycore.compose.sdl:*`. Apply
the bridge Gradle plugin once, declare the **official** Compose Multiplatform
coordinates, and the plugin swaps in the port's klibs on native desktop targets
while android, jvm, iOS, and wasm keep resolving the official artifacts.
```kotlin
// build.gradle.kts, official coordinates everywhere
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
id("com.bitsycore.compose-desktop-native.bridge") version "<release>"
}
commonMain.dependencies {
// The plugin exposes the exact Compose versions the port tracks, so you
// never hand-match them (material3 is versioned separately 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}")
}
```
Repositories, credentials, and version pinning:
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
For a complete project that applies the bridge and builds one shared UI for
Android, JVM, and native desktop, see the example repo:
[bitsycore/compose-desktop-native-bridge-example](https://github.com/bitsycore/compose-desktop-native-bridge-example).
## Sample apps
Both live in `commonMain` and also build for stock JVM Compose Desktop, which
serves as the visual and behavioural reference: any difference against the
native build is a porting bug.
**`:demo`** is a tour of the re-implemented Compose and Material 3 surface,
30-plus screens covering text, layout, shapes, images, state, lazy lists,
dialogs, canvas, graphics layers, animation, and gestures.
```bash
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (Skia / Skiko fork)
./gradlew :demo:run # JVM Compose Desktop (reference)
```
**`:apidemo`** is a Postman-style REST client built entirely on the library:
request collections, a session inheritance ladder, syntax-highlighted body
editors, a response viewer with timing and TLS-chain inspection, and mTLS
client certificates.
<img src="screenshots/apidemo.png" width="100%" alt="Compose Desktop Native API Manager" />
```bash
./gradlew :apidemo:runDebugExecutableMacosArm64
./gradlew :apidemo:run # JVM Compose Desktop (reference)
```
## Modules
One Gradle module per upstream 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.
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).
## Building
All four native dependencies — **SDL3**, **SDL3_ttf** (the in-house
variable-font-axes fork), **SDL3_image** (+ vendored PNG/WEBP codecs) and
**FreeType** — are built from source as **static** libraries by one script,
on every OS, and linked straight into the executable. No `brew install`, no
`apt install libsdl3-dev`, no runtime `.dll` / `.so` / `.dylib` next to the
binary — a distributable is just `<app>` + `data.kres`.
Build the native libraries once per machine, then build any app target:
```bash
python3 scripts/build-sdl/build-all.py # freetype → sdl3 → sdl3-image → sdl3-ttf
python3 scripts/build-sdl/build-all.py sdl3-ttf # rebuild a subset
python3 scripts/build-sdl/build-all.py # SDL3 (static)
./gradlew :demo:runDebugExecutableMacosArm64
```
Library versions / URLs are pinned in `scripts/build-sdl/build-sdl.properties`;
output lands in the gitignored in-repo `libs/`. Required on every host: `git`,
`cmake`, Python 3 (`ninja` is fetched automatically when absent). Per host:
Every host needs `git`, `cmake`, and Python 3. Per host: macOS needs the Xcode
command line tools; Linux needs gcc/g++ and the X11 / Wayland / audio dev
headers; Windows needs a mingw-w64 g++ on PATH. See [TOOLING.md](TOOLING.md) for
the full build and verification workflow.
- **macOS** — Xcode command line tools. Skia is the default renderer; Skiko
klibs come from Maven.
- **Linux** — gcc/g++ plus the X11 / Wayland / audio dev headers SDL3's
configure detects (the exact apt list is in
`.github/workflows/publish.yml`).
- **Windows** — a mingw-w64 g++ on PATH (the C side compiles with
Kotlin/Native's bundled mingw).
## Documentation
Then build any app target — see the demo / apidemo commands above.
## Vendoring
The bulk of the `androidx.compose.*` code is vendored byte-for-byte from
`JetBrains/compose-multiplatform-core` and lives under
`<module>/src/vendor/` (gitignored — you re-sync on demand). Each module
carries a `compose-fork.txt` manifest listing which upstream files it pulls
in.
```bash
scripts/compose-fork/sync.sh # sync every module
scripts/compose-fork/sync.sh compose/ui/compose-fork.txt # one module
```
Upstream ref pinned in `scripts/compose-fork/compose.properties`; manifests can
pin additional upstream repos inline (`SET_REPO=<url>@<ref>` — the resources
runtime vendors from the compose-multiplatform umbrella repo this way).
How much of upstream's public API the port actually covers is measured, not
guessed — `scripts/compose-coverage.py` diffs this repo's klib ABI dumps
against upstream's, per vendored module:
```bash
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
python3 scripts/compose-coverage.py --missing ui-text # list the exact uncovered decls
```
See [CLAUDE.md](CLAUDE.md) for the full architecture, source-set hierarchy,
vendoring rules, density flow (physical-pixel Option B), and per-area file
map.
## Known Compatible — official artifacts that just work
A surprising amount of the androidx architecture stack ships real
Kotlin/Native desktop klibs (mingwX64, linuxX64/arm64, macosArm64) and runs
on this port **unmodified** — no reimplementation, no vendoring. Sometimes
the Google coordinates (`androidx.*`) carry the K/N variant, sometimes the
JetBrains ones (`org.jetbrains.*`) — check both.
Verified working in-tree:
| Artifact | Notes |
|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| `org.jetbrains.compose.runtime:runtime`, `runtime-saveable` | the Compose runtime itself — never reimplemented |
| `androidx.compose.runtime:runtime-retain` | google coordinates |
| `androidx.lifecycle:lifecycle-runtime-compose` | `LocalLifecycleOwner`, `rememberLifecycleOwner`, `repeatOnLifecycle` |
| `androidx.lifecycle:lifecycle-viewmodel(-compose, -savedstate)` | full ViewModel + SavedStateHandle stack |
| `androidx.lifecycle:lifecycle-viewmodel-navigation3` | per-entry ViewModel scoping for Navigation 3 |
| `androidx.savedstate:savedstate`, `savedstate-compose` | |
| `androidx.navigation3:navigation3-runtime` | backstack / NavEntry / decorators (only the UI layer needed vendoring → `:navigation3-ui`) |
| `androidx.navigationevent:navigationevent-compose` | predictive back / BackHandler plumbing |
| `androidx.collection:collection` | |
The window layer supplies what these expect from a host: per-window
`LifecycleOwner` (driven by real SDL focus/visibility events — focused →
RESUMED, unfocused → STARTED, minimized → CREATED), `ViewModelStoreOwner`
(the `activityViewModels()` analog, `SavedStateHandle`-enabled) and
`SavedStateRegistryOwner`, plus a `Dispatchers.Main` with a true `immediate`.
ViewModels scope per nav entry, per window, or anywhere in between — same
semantics as Android.
- [TOOLING.md](TOOLING.md): building the native libs, vendoring, and verification.
- [CLAUDE.md](CLAUDE.md): architecture, source-set hierarchy, vendoring rules,
density flow, and a per-area file map.
- [gradle-plugin/compose-desktop-native-bridge](gradle-plugin/compose-desktop-native-bridge/README.md):
consuming the port from another build.
## License
+219
View File
@@ -0,0 +1,219 @@
# RENDERER.md
The durable reference for the rendering layer: how it is structured, the
convergence work that shaped it, the decisions worth remembering, and what is
left. Read this before touching anything under `renderer/`, the graphics
actuals, or the layer engine.
For build and verification commands see [TOOLING.md](TOOLING.md). For module
layout, source-set hierarchy, and vendoring rules see [CLAUDE.md](CLAUDE.md).
## 1. The renderer
> **This branch is Skia-only.** The from-scratch SDL renderer leg
> (`sdlRendererMain`: `Sdl3Canvas` / `Sdl3TextRenderer` / `FreeTypeIcons` / the
> SDL render nodes, ~3,800 lines) and its `SDL3_ttf` / `SDL3_image` / FreeType
> cinterops were removed once Skiko-on-mingwX64 landed (Route 1a — see
> [SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md)). SDL3 remains as the
> windowing / input / platform layer only. Below, references to "the SDL leg", a
> second `RenderBackend` actual, `-Prenderer=sdl3`, and the SDL-leg fidelity work
> are **historical** — kept because the convergence decisions still explain the
> Skia leg's shape.
Rendering goes through one `RenderBackend`, implemented once: the **Skia leg**
(`skikoRendererMain`). It draws through upstream Compose Multiplatform's own Skia
stack, vendored verbatim: `SkiaBackedCanvas`, `SkiaBackedPaint`, `SkiaShader`,
`actual class GraphicsLayer` backed by `org.jetbrains.skiko.node.RenderNode`,
`SkiaGraphicsContext` — the layer and draw engine internals are literally
upstream. macOS/Linux link the official Skiko klibs; mingwX64 links the
bitsycore skiko **fork** (`skikoRendererMingwMain`, Route 1a).
The text and image pipeline (`SdlParagraph`, `NativeTextMeasurer`, `IconFont`)
is the port's own engine; glyph rasterization is Skia. GPU path per platform:
Metal (macOS), OpenGL (Linux + Windows), with a CPU-raster `Software` fallback.
The seam is kept as narrow and low as possible. Code flows
`Common (upstream) -> shared native engine -> Skia actual / 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 Skia leg has all three.
- **L1 frame scheduling.** A frame schedules the next only if still dirty.
Ours: `ComposeWindow.shouldRender()` gates `renderFrame()`; the loop blocks on
`SDL_WaitEventTimeout` when idle.
- **L2 measure/layout only dirty nodes.** Vendored `MeasureAndLayoutDelegate`
used verbatim through `ComposeOwner.measureAndLayout()`.
- **L3 draw record-once / replay** (the expensive one). Each isolated
`LayoutNode` gets an `OwnedLayer` (`GraphicsLayerOwnerLayer`) owning a
`GraphicsLayer`. The display list re-records only when content is dirty;
otherwise it replays.
The critical property: **transform, alpha, and clip changes do NOT re-record.**
Moving, scaling, rotating, or fading a layer replays cached content under a new
transform. Only a genuine content change (a state read inside the draw block, or
a resize) re-records. Upstream replays the whole scene every frame with no
dirty-region present; the entire win is not re-recording clean layers, not
drawing less screen. Dirty-region rendering is therefore an explicit non-goal.
The compositing-strategy contract (`requiresLayer()`, from
`SkiaGraphicsLayer.skiko.kt`) decides when a layer needs an offscreen:
| Condition | Auto | Offscreen | ModulateAlpha |
|---|---|---|---|
| `alpha < 1` | offscreen | offscreen | per-op alpha multiply (no offscreen) |
| `colorFilter != null` | offscreen | offscreen | offscreen |
| `blendMode != SrcOver` | offscreen | offscreen | offscreen |
| `renderEffect != null` | offscreen | offscreen | offscreen |
| none of the above | replay in place | always offscreen | replay in place |
## 3. Current state
- **Skia leg runs upstream's engine.** Canvas (`SkiaBackedCanvas`) and
GraphicsLayer/GraphicsContext (`org.jetbrains.skiko.node.RenderNode`) are
vendored verbatim. The Skia leg gets real display-list caching and correct
clip/shadow/renderEffect for free from upstream. After this landed, Skia
`draw` on LazyColumn fell from 1.75 ms to 0.2 ms.
- **Parity is a golden-master against JVM** (about 2% median, dominated by the
shared text engine's small metric delta). See TOOLING.md for how to read it.
- **Memory is stable.** The historical composition leak is fixed (see section 6)
and guarded by the `--soaktest` gate in the verify runbook.
## 4. Decisions to remember
- **Goal is G1, cheap upstream-tracking.** The target is low per-bump
reconciliation cost when following upstream. All platforms now render through
real Skia — mingwX64 via the bitsycore skiko fork (Route 1a; see
[SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md)) — so there is no
fidelity tier below the JVM cross-check. The JVM Compose Desktop target
(`:demo:run`) remains the fidelity/parity reference on any host.
- **Vendor, do not hand-roll.** Copy upstream verbatim wherever it compiles.
Edit-to-compile becomes a manual vendor with a `// VENDOR-BASE:` header so the
drift tripwire can track it. The litmus test for any divergence: "Is this what
upstream does? If not, what real platform constraint forces the difference?"
Valid answers name a constraint (e.g. no windowing/input toolkit in K/N ->
SDL3). "It was easier" is not valid.
- **Text 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 becomes
a hard requirement.
- **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).
- **Real Skia on Windows K/N shipped (Route 1a).** mingwX64 links the bitsycore
skiko fork against `skiko-windows-x64.dll` (with an embedded GNU import lib),
published to GitHub Packages and auto-provisioned by the bridge plugin. This
replaced the SDL renderer as the Windows path. See
[SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md) for the ABI details
and the route trade-offs.
## 5. Convergence status
| Item | Status |
|------|--------|
| Guardrails (parity gate, verify-mac runbook, drift + vendor-clean checks) | Done |
| B2: Skia leg on upstream GraphicsLayer/GraphicsContext | Done |
| B6.1: Skia leg on upstream `SkiaBackedCanvas` + paint/shader | Done |
| B6.2: upstream `GraphicsLayer` + delete transient port cluster | Done |
| B6.3: upstream text (`SkiaParagraph`) on the Skia leg | 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: text metrics parity (17% median -> 2%) | Done |
| Module split | Shelved (infeasible as specified) |
| Track A: real Skia on Windows (Route 1a) | Done |
## 6. Remaining and future work
Nothing here blocks day-to-day work. These are the open threads worth
remembering.
- **WIN-SMOKE (pre-ship, Windows only).** The Mac runbook cannot cover the
shipped mingwX64 binary, the Windows-only `PrintWindow` probe, or the
common-metadata publish job. Run these on a Windows host before any release.
This is the only outstanding verification.
- **Stabilization at Compose 1.12.0 stable.** The vendored refs are pinned to
`v1.12.0-beta03+dev4483` (no clean beta03 tag exists yet, and it is not on
Maven). The native side leads the JVM parity leg (forced to beta02, the latest
published) by a documented skew. When 1.12.0 stable ships: re-pin both refs to
the clean tag, bump the `vComposeJvmVersion` forcing in demo/apidemo/
material-symbols to close the skew, then cut the release. The concrete
step-by-step (the version map, the ref-bump flow, and the release flow) lives
in [TOOLING.md](TOOLING.md#versioning-and-releasing).
- **Deferred engine deltas (D2-D6, all low ROI under G1).**
- D2: split `GraphicsLayerOwnerLayer` per-leg to restore upstream's exact
`setLightingInfo` shadow lighting. Feasible (the old RootNodeOwner-coupling
blocker was outdated) but high blast radius for a marginal gain; elevation
shadows already match JVM.
- D3: dedupe `LayerTransformationMatrix.kt` against the now-vendored
`Matrices.skiko`. Blocked on D2 (the shared owner-layer hit-test needs a
matrix fn usable from the owner layer).
- D4/D5: `SemanticsRegion` intersect/difference are stubs, and `CharHelpers`
is naive grapheme/bidi vs upstream ICU. Real fidelity gaps, gated on an
accessibility or complex-script roadmap.
- D6: `Focusability` / `PlatformVelocityTracker` are byte-equal to upstream;
vendoring them saves nothing.
- **Native-resource lifecycle.** Wire `GraphicsLayer`/RenderNode +
`SdlImageBitmap.close()` fully into cache eviction and the renderer
`destroy()` chain; demote the periodic GC nudge once ownership covers it.
## 7. Hard-won learnings (do not relearn)
- **Minimize-divergence is load-bearing.** A hand-written `notifyLayerIsDirty`
that diverged from upstream `OwnedLayerManagerImpl` removed a layer from
`dirtyLayers` mid-loop and crashed on any navigation. Screenshots missed it;
the interaction probe caught it. Match upstream verbatim; do not hand-roll
engine plumbing from a summary.
- **The composition memory leak (root cause).** The port never called
`OwnerSnapshotObserver.clearInvalidObservations()`, which upstream
`RootNodeOwner` runs after every measure. Snapshot read-observations for
scopes invalidated on dispose lingered forever, each pinning its observed
object graph via a Kotlin/Native `ExternalRCRef` (a K/N-heap leak, vmmap
Memory Tag 246). Most visible on ripple/indication draws. Fix:
`ComposeRootHost.measureAndLayout()` sweeps `clearInvalidObservations()` after
layout, plus `ComposeOwner.onDetach` clears the detached node's observations.
Found by exact live-counters, static-mode isolation, macOS `leaks`/`heap`/
`vmmap`, and component bisection. RSS alone cannot pinpoint a referenced leak;
budget heap tooling for this class.
- **Screenshots miss crashes and settle-timing.** Free-running screenshots on
animated/settling screens give false signals. Use render-to-quiescence +
virtual frame time for parity, the probe for interaction/crash coverage.
- **The profiler `present` phase is vsync-capped by the display refresh.**
Profile on the target monitor before concluding a frame-rate gap.
## 8. Explicit non-goals
- **Dirty-region / partial present.** Upstream replays the whole scene; the win
is not re-recording, not redrawing less.
- **A custom `cacheKey` API.** Superseded by the real per-node display list.
- **Vendoring `RootNodeOwner` / the `ComposeScene` stack.** Coupled to skiko's
`SkiaLayer` and windowing; it fights the `:window` SDL loop. The port borrows
only the layer engine.
## 9. Key files
- `compose/ui/ui/src/nativeMain/.../RenderBackend.kt`: the interface.
- `compose/ui/ui/src/nativeMain/.../GpuMode.kt`: renderer / driver picker.
- `compose/ui/ui/src/skikoRendererMain/.../renderer/skia/SkiaRenderBackend.kt`.
- `compose/ui/ui/src/commonMain/.../node/ComposeRootHost.kt`: root host,
hit-test, event dispatch, snapshot observer sweep.
- `compose/ui/ui/src/commonMain/.../node/impl/ComposeOwner.kt`: the project
`Owner` + `GraphicsLayerOwnerLayer` bridge.
- `compose/ui/ui/src/nativeMain/.../ui/text/SdlParagraph.native.kt`: the bridged
`Paragraph` (measurement, hit-test, line metrics, span painting).
+397
View File
@@ -0,0 +1,397 @@
# Skiko on mingwX64 — Feasibility & Effort Assessment
Investigation of the effort to run the real Skia leg (`org.jetbrains.skiko`)
on the Kotlin/Native **mingwX64** target, under the relaxed constraint that a
**DLL is acceptable** (the static / no-DLL invariant may be dropped). Companion
to [RENDERER.md](RENDERER.md) §4 "Track A" — which this assessment **partly
supersedes** (see [Corrections](#corrections-to-renderermd-4) below).
Pins at time of writing: skiko `0.150.1` (Skia m150), kotlin `2.4.0`,
compose `1.12.0-beta02`.
---
## Bottom line
Feasible — and **more tractable than RENDERER.md §4 claims**. Two of that
section's load-bearing premises are now factually stale:
1. **"Building C++20 Skia to a GNU/mingw archive is an open-ended fork (only
abandoned Mozilla-era precedent)."** — **False.** MSYS2 maintains
`mingw-w64-skia` at **Skia milestone 143 (C++20)** for `mingw64` (GCC),
`ucrt64`, and `clang64` (clang → `x86_64-w64-windows-gnu`), producing
`libskia.dll` + a GNU import lib `libskia.dll.a`. Last rebuilt **2026-07-24**.
The GNU-ABI recipe is ~8 small GN patches (the key one adds
`is_mingw = is_win && (cxx=="g++"||cxx=="clang++")`).
2. **"The only working route is a runtime DLL, which breaks the no-DLL
invariant."** — The DLL route is real, but the reason it works is **not**
what the section implies. See the crux below.
**Effort:** ~**1–2 weeks** to a proven spike + a CPU-raster Skia-on-Windows
prototype; ~**1–2 months** to a tested, GPU-accelerated Windows Skia leg with
published fork klibs and repo wiring — **plus** permanent
`bitsycore/skiko`-fork maintenance (re-sync at every skiko bump). Difficulty:
**research-grade / high**, because no working prior art exists (the two GitHub
`skiko-mingw64` forks are vaporware — 0 commits ahead of upstream, no mingw
source set). Confidence: **medium** (high on mechanism, medium on timeline).
---
## Spike result — EXECUTED, GREEN ✅ (2026-07-24)
The core de-risking experiment has been **run on this machine** and passed. The
whole Route 1a link mechanism works on the exact target toolchain (Kotlin
**2.4.0**, mingwX64, LLVM **21** `ld.lld`, `x86_64-pc-windows-gnu`).
**What was built** (`spike-skiko-mingw/` in this repo):
- A tiny DLL reproducing skiko's binding shape — an **opaque handle over a real
C++/STL object** (`std::string`), **alloc+free paired inside the DLL**, flat
`extern "C"` surface. Compiled **MSVC-ABI via `clang-cl /LD /MT /EHsc`**
(static CRT). Exports verified undecorated: `spike_make/size/free`.
- A **GNU import library** (`libspike.dll.a`) generated with `dlltool`.
- A **Kotlin/Native mingwX64 executable** binding it via cinterop (plain C
header) + `linkerOpts("-L… -lspike")`.
**Results:**
- `linkReleaseExecutableMingwX64` **linked clean** — **KT-65671 did NOT bite**
(the DLL path puts zero C++ objects in the K/N link graph, so the
`--allow-multiple-definition` / `.drectve __clang_call_terminate` failures
never arise). LLVM 21 `ld.lld` is also far newer than the 1.9.x-era reports.
- Runtime: `SPIKE-RESULT size=7 expected=7 ok=true`, exit 0 — the C++
`std::string(7,'x').size()` round-trips through the opaque handle correctly.
- `objdump -p` confirms the exe imports `spike.dll`, and `spike.dll` depends on
**`KERNEL32.dll` only** → **self-contained, no VC++ redist needed**. The exe
carries K/N's `msvcrt.dll` while the DLL carries its own static MSVC CRT —
**two CRTs coexist in one process** with no issue (ownership paired inside the
DLL), empirically confirming the CRT-boundary discipline.
**Implication:** the one load-bearing unknown (does the DLL route link on
2.4.0?) is resolved **in favour**. Route 1a is de-risked at the mechanism level;
remaining effort is engineering the real `skiko.dll` (compile skiko's shim +
skia-pack MSVC Skia into one DLL) and repo wiring — not proving feasibility.
Feasibility confidence is now **high**; residual medium confidence is on
timeline only.
Reproduce: `./gradlew -p spike-skiko-mingw linkReleaseExecutableMingwX64`, then
run the exe with `spike-skiko-mingw/native` on `PATH`.
---
## The crux — and why "just use a DLL" is only half the story
**The blocker is a C++ ABI mismatch at the binding boundary, and it is
orthogonal to static-vs-dynamic linkage.**
- **K/N mingwX64 is GNU/Itanium ABI.** `konan.properties`:
`targetTriple.mingw_x64 = x86_64-pc-windows-gnu`,
`linker.mingw_x64 = ld.lld.exe`,
`linkerKonanFlags.mingw_x64 = -static-libgcc -static-libstdc++ … -lwinpthread`
— GCC libstdc++/libgcc, Itanium mangling (`_Z3fooi`).
- **skia-pack's only Windows Skia is MSVC-ABI** — built with `clang-cl`,
`is_trivial_abi=false`, shipped as MSVC `.lib`; C++ exports are MSVC-mangled
(`?foo@@YAHH@Z`).
LLVM issue #60847 ("LLD cannot link against MSVC libraries in MinGW toolchain",
closed *not planned*) confirms the two mangling schemes never reconcile.
**Wrapping the same MSVC C++ Skia in a DLL does not change this** — a DLL's C++
exports are still MSVC-mangled. So dynamic-vs-static is a red herring; **the C++
ABI at whatever boundary the mangled C++ crosses is the wall.**
What makes it tractable is *where that boundary sits in skiko*:
- **skiko's Kotlin↔native boundary is already a flat, unmangled C symbol
boundary.** Kotlin declares
`@ExternalSymbolName("org_jetbrains_skia_Data__1nSize") external fun _nSize(…)`;
`ExternalSymbolName` is a typealias for `kotlin.native.SymbolName`, which
resolves to a *plain named symbol* at link. The generated `.def` is
header-less (only `linkerOpts`). **No C++ type ever crosses into Kotlin.**
- **The C++ ABI coupling lives one layer down**, at the *shim↔Skia* link:
~78 `extern "C"` (`SKIKO_EXPORT`) `.cc` files under `src/nativeJsMain/cpp/`
that `reinterpret_cast<SkData*>(ptr)->size()` etc. This shim **must share one
C++ ABI with Skia** — today they're one GNU-ABI island fused via
`-include-binary`.
**Consequence:** a DLL is viable **iff** the *entire* C++ island (shim + Skia)
is one consistent ABI internally, and only the **flat extern-C export surface**
crosses to K/N via a generated import library. On x86_64 there is a single
Windows calling convention and extern-C exports are undecorated on both MSVC and
mingw, so `ld.lld` links such a DLL via `gendef`+`dlltool` (or directly, LLD
13+). **The repo already does exactly this for system DLLs** (`sdl3.def`:
`linkerOpts.mingw_x64 = -lkernel32 -luser32 -lgdi32 …`).
Crucially the extern-C façade **already exists** (the `nativeJsMain/cpp` shim
exports precisely the `org_jetbrains_skia_*` symbols the K/N side binds) — it is
**reused, not authored anew**. The catch: no ownership / exceptions / STL may
cross the C line — skiko is already handle/RefCnt-based, so this holds if
authored carefully. (The JVM `jvmMain/cpp` bridge exports `Java_…` JNI symbols
and is **not** reusable — the *native* shim is the right one.)
The non-negotiable that survives all of this: **upstream skiko ships no
mingwX64 K/N target at all** (`throw GradleException("$os not yet supported")`
for non-{Mac,iOS,tvOS,Linux}; "Windows" in skiko means the JVM/AWT clang-cl
path). So this is a **from-scratch skiko build-system port**, not a flag flip.
---
## The dominant risk: KT-65671
`KT-65671` ("Kotlin/Native: Failed linking cinterop static library for
mingwX64", **Open**, filed against 1.9.x, status on 2.4.0 unverified): K/N's
mingw link step chokes when **fusing an external C++ static archive** —
`ld.lld` rejects `--allow-multiple-definition`, and
`-exclude-symbols:__clang_call_terminate` in the objects is rejected in
`.drectve`. This is skiko's **exact static-fusion pattern**.
Key implication for route choice: a **pure extern-C DLL boundary sidesteps this
bug class entirely** — no C++ objects enter the K/N link graph; only a flat
import surface crosses. So, counter-intuitively, the **DLL route is the
risk-*reducing* route** w.r.t. KT-65671, and it is exactly what the user is
willing to accept.
---
## Routes
### ★ Recommended — DLL with a flat extern-C export, K/N binds via import lib
Put **all** the C++ (skiko's existing shim + Skia) inside one DLL; export only
the flat `org_jetbrains_skia_*` symbols; bind from a new mingwX64 skiko target
via a generated import lib. K/N resolves at link; the Windows loader binds
`skiko.dll` at process start — **no dlopen/`staticLoad` code needed** (the
shipped `staticLoad()` no-op is fine for the import-lib path).
Two sub-choices for the DLL internals (K/N neither knows nor cares which):
- **1a — MSVC internals (minimal fork).** Recompile skiko's `nativeJsMain/cpp`
shim with `clang-cl` against **skia-pack's existing MSVC Windows `.lib`**
(`windows-x64` archives are already published). **Zero Skia fork.** Net-new:
add `__declspec(dllexport)` to `SKIKO_EXPORT` on this build, a mingw/DLL
branch in skiko's build logic, import-lib generation. CRT discipline (MSVC CRT
in the DLL vs K/N's static libgcc) is a correctness constraint on the C API
(handles + primitives only), not a linkability blocker.
- **1b — GNU internals.** Build the shim + Skia GNU-ABI (reuse MSYS2's
`mingw-w64-skia` patch set) into the DLL. Slightly more Skia-side work, but a
single toolchain end-to-end.
**Effort:** ~2–4 weeks once the spike is green. **Risk: medium** (sidesteps
KT-65671). **"Just works" preserved:** none — ships `skiko.dll` (tens of MB)
next to `data.kres` (accepted). **Sub-choice 1a is the least-fork option.**
### Route 2 — GNU-ABI static Skia + real mingwX64 skiko target (purist)
Fork skia-pack (or reuse MSYS2's patches) to emit **GNU-ABI static** Skia;
fork skiko to add a mingwX64 target that recompiles the existing extern-C shim
GNU-ABI and **static-fuses** exactly as macOS/Linux do today.
**Effort:** Skia build itself is now **days** (MSYS2 proves it). skiko port +
link reconciliation ~3–6 weeks. **Risk: medium-high** — this route hits
**KT-65671 head-on** (static C++ archive fusion). **Preserves the static /
no-DLL invariant** — the only reason to prefer it, and the user said they don't
need it.
### Route 3 — CPU-raster-only (a scope reduction, not an ABI shortcut)
The repo's `SkiaSurfaceBridge` (`Surface.makeRasterDirect` → `SDL_UpdateTexture`
→ `SDL_RenderTexture`) needs **no GPU context** and compiles on mingwX64 the
moment a skiko klib exists. Use it to **skip authoring the Windows GPU bridge**
for a first cut. Still requires solving Route 1/2's binding problem first.
**Saves ~1–2 weeks** of GPU-bridge work → the natural **milestone 1** of
whichever route you pick.
### Baseline — do nothing (already shipping)
Windows renders via the from-scratch **SDL leg** (`SDL_RenderGeometry` +
SDL3_ttf + FreeType); the **JVM parity target** is the full-fidelity Skia
reference. The bar any Skia-on-Windows work must clear is "materially better
than the SDL leg's fidelity, worth the DLL + maintenance cost."
---
## Recommended work breakdown (Route 1a, CPU-raster first)
**A. Skia — none (1a) / days (1b).**
Route 1a reuses skia-pack's published `windows-x64` MSVC `.lib`. (1b: reuse the
MSYS2 `mingw-w64-skia` PKGBUILD + patches at skiko's pinned milestone; backends
CPU-raster + `skia_use_gl`, `skia_use_freetype`.)
**B. `bitsycore/skiko` fork — the bulk, medium risk.**
1. Add `mingwX64()` + a `configureNativeTarget(OS.Windows, Arch.X64, …)` branch;
replace the `throw GradleException("$os not yet supported")` / empty
`linkerFlags` fall-throughs in `NativeTasksConfiguration.kt` with a Windows
branch (PE/COFF, `llvm-ar`).
2. Compile the **existing** `nativeJsMain/cpp` shim (not the JVM JNI bridge)
into `skiko.dll`; make `SKIKO_EXPORT` export the flat symbols
(`__declspec(dllexport)` or `--export-all-symbols`/def file).
3. Generate the GNU import lib (`gendef` + `dlltool` → `libskiko.dll.a`).
4. cinterop `.def` stays header-less (`linkerOpts`/`libraryPaths`), matching the
repo's own idiom. Publish mingwX64 klibs + the DLL.
**C. Windows context actual — deferred behind milestone 1.**
5. **Milestone 1:** wire only the CPU-raster `SkiaSurfaceBridge` (already
Windows-ready).
6. **Milestone 2:** author `SkiaD3D11Bridge` / `SkiaVulkanBridge` — extract HWND
via `SDL_GetWindowProperties`, wrap `DirectContext.makeD3D11`/`makeVulkan`,
mirror the Metal/GL bridges' per-frame acquire/present.
**D. This repo's wiring — small, low risk (integration points confirmed).**
7. `compose/ui/ui/build.gradle.kts` — flip `isSkiaTarget("mingwX64")` to `true`
(currently hardcoded `false`, ~line 58); create a `skikoRendererMingwMain`
and `mingwX64Main.dependsOn(...)` it (today mingw attaches only to
`sdlRendererMingwMain`, lines ~203–206; the skiko tree, lines ~220–235, is
created only under `!useSdl3Everywhere` and never for mingw). Add the fork's
coords to that source set.
8. **No new expect/actual needed:** `createRenderBackend` /
`rendererPreferredGpuMode` are `expect`s in `nativeMain` with `actual`s in
`skikoRendererMain` and `sdlRendererMain`; attaching mingwX64 to the skiko
source set makes the Skia actual resolve for Windows (one renderer source set
per target — the existing invariant).
9. Add a Windows GPU-bridge branch to `rendererPreferredGpuMode()`; verify with
the demo/apidemo (`--gpu`, `--screenshot`) and the parity harness.
Repo surface to cover is narrow: **~67 `org.jetbrains.skia` classes + 5
`org.jetbrains.skiko.node` APIs** (`RenderNode`, `RenderNodeContext`,
`SkikoRenderDelegate`, `SystemTheme`, `currentSystemTheme`).
---
## The one spike to run first (~1–2 days)
Before any fork work, **empirically test the K/N-mingw link path**: build a
trivial `extern "C"` C++ DLL (one function returning a handle + one that uses
it) — or consume MSYS2's `mingw-w64-x86_64-skia` DLL + `libskia.dll.a`
directly — from a throwaway mingwX64 K/N project via cinterop + import lib, and
**run it**. This answers, in a day instead of a month:
- Does K/N mingw link + call a GNU-ABI DLL via an import lib at all?
- Does **KT-65671** reproduce on Kotlin 2.4.0 (import-lib vs static fusion)?
- DLL vs static-fusion behaviour on this toolchain.
If green, Routes 1/2 are de-risked. (Caveat: MSYS2 Skia exports Skia's *mangled
C++* symbols, so a direct cinterop of it tests only the *link mechanism*, not
the skiko API — for the API you still need the extern-C shim. The trivial-C-DLL
variant is the cleaner mechanism test.)
---
## Biggest risks / unknowns
1. **KT-65671** — whether K/N's mingw link works on 2.4.0 for external C++;
the DLL route sidesteps it, the static route hits it. *The single fact that
swings the effort.*
2. **Toolchain matching** — Konan bundles LLVM 16 clang + `ld.lld`; the C++20
Skia + shim must be built by a toolchain whose objects/imports LLD accepts.
3. **CRT / ownership discipline** across the C boundary (only matters for 1a).
4. **Maintenance** — SKIKO-446 / SKIKO-611 are Open, unassigned → almost
certainly a **permanent `bitsycore/skiko` fork** with per-bump re-sync tax.
---
## Recommendation
Pursue **Route 1a** (skiko fork producing a DLL with a flat extern-C export over
skia-pack's existing MSVC Windows Skia), **scoped CPU-raster first** (Route 3
milestone), GPU bridge second. It matches the user's accepted DLL tolerance,
requires **zero Skia fork**, and **dodges KT-65671**. Keep Route 2 (static,
GNU-ABI) in reserve only if reclaiming the no-DLL invariant later becomes
valuable. **Run the MSYS2/trivial-DLL spike before committing to any fork.**
---
## Implementation status — WORKING END-TO-END ✅ (2026-07-24)
**`demo.exe` (mingwX64) renders the Material 3 Buttons screen through real Skia**
and wrote a 1000×700 screenshot ("settled at frame 3"), using the CPU-raster
`SkiaSurfaceBridge` backed by the forked `skiko-windows-x64.dll`. Route 1a is
complete. Build/run:
```
# fork (C:/Dev/skiko), one-time env: SKIKO_VSBT_PATH=<VS BuildTools dir>
./gradlew :skiko:publishKotlinMultiplatformPublicationToMavenLocal \
:skiko:publishMingwX64PublicationToMavenLocal -Pskiko.native.windows.enabled=true
# compose repo
./gradlew :demo:linkDebugExecutableMingwX64 -PwindowsSkia=true
cp <fork>/skiko/build/out/link/Release-windows-native-x64/skiko-windows-x64.dll <demo exe dir>/
<demo exe dir>/demo.exe --screenshot=out.bmp --screen=Buttons
```
Fork fixes needed beyond the DLL build (skiko uses a CUSTOM hierarchy template,
`applyDefaultHierarchyTemplate=false`):
1. `sourceHierarchy.kt` — add `group("windows"){ withMingwX64() }` under `native`
(else the mingwX64 klib compiles EMPTY — missing all of `org.jetbrains.skia`).
2. `SkikoProjectContext` — add `supportNativeWindows` into `supportAnyNative` so
the `@SymbolName` opt-in reaches the mingw compile.
3. `Resources.native.kt` — `ftell` is 32-bit `Int` on Windows (LLP64); `==` fix.
4. `windowsMain` actuals for `SkiaLayer` / `currentSystemTheme` (linux is a stub).
5. Force-export the 2 ICU symbols (`uloc_getDefault_skiko`,
`uloc_toLanguageTag_skiko`) that Kotlin binds via `@SymbolName`.
Compose-repo wiring (all behind `-PwindowsSkia=true`): `mavenLocal()` in
settings, `isSkiaTarget("mingwX64")`, a separate `skikoRendererMingwMain` tree
on the fork's **root** coord (`org.jetbrains.skiko:skiko:0.0.0-SNAPSHOT` — the
platform artifact alone doesn't expose api-elements), `PlatformGpu` Windows →
`Software`, and a one-line `PlatformGpu.mingw.kt`.
## Earlier milestone — fork BUILDS ✅ (2026-07-24)
Route 1a is not just designed — the `bitsycore/skiko` fork now **builds the
native Windows Skia DLL + a mingwX64 klib** on this machine (`C:/Dev/skiko`,
skiko `v0.150.1`).
Edits made (all in the fork):
- `src/nativeJsMain/cpp/common.h` — `SKIKO_EXPORT` gains `__declspec(dllexport)`
under a `SKIKO_WINDOWS_DLL` guard.
- `skiko/build.gradle.kts` — `configureNativeTarget(OS.Windows, Arch.X64,
mingwX64())`, gated behind `-Pskiko.native.windows.enabled=true`.
- `buildSrc/.../NativeTasksConfiguration.kt` — a Windows compile branch
(clang-cl + `-DSKIKO_WINDOWS_DLL`) and a new `configureWindowsNativeTarget`
that links `skiko-windows-x64.dll` via `lld-link` (reusing the JVM template +
`resolveBinaryInputs(…, TargetEnv.JVM, …)`), auto-generates the GNU import lib
(`dumpbin`→`.def`→`dlltool`), and wires the K/N cinterop `linkerOpts`.
Results:
- The native bridge (`nativeJsMain/cpp`) **compiled on Windows with clang-cl,
zero source changes.** The only link fix was adding `d3d12.lib
d3dcompiler.lib dxgi.lib` for Skia's `SK_DIRECT3D` backend.
- Produced **`skiko-windows-x64.dll` (14.2 MB, 1005 exported
`org_jetbrains_skia_*` symbols)**, `libskiko-windows-x64.dll.a` (GNU import
lib), and the **mingwX64 klib** (`build/classes/kotlin/mingwX64/main/klib`).
Prerequisite for any skiko Windows build on this box:
`SKIKO_VSBT_PATH='C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools'`
(Gradle's VS locator doesn't auto-find VS 18 BuildTools).
Remaining: publish the fork to `mavenLocal` → a tiny K/N mingw consumer that
calls a real Skia function (links the import lib, runs with the DLL on `PATH`)
for the definitive end-to-end proof → then wire the compose repo (flip
`isSkiaTarget("mingwX64")`, add `skikoRendererMingwMain`, ship the DLL next to
the exe, CPU-raster `SkiaSurfaceBridge` first). One known TODO: the klib's
`linkerOpts` use an absolute import-lib path (fine same-machine; make relative /
bundled for distribution).
## Corrections to RENDERER.md §4
The shelving note should be updated — its two central technical claims are stale
as of 2026-07:
- "GNU-ABI Skia is an open-ended fork / only abandoned Mozilla-era precedent" —
**false**: MSYS2 maintains a modern (m143, C++20) GNU-ABI Skia, rebuilt
routinely.
- The accurate residual blocker is **the K/N-side link/binding port**
(KT-65671 + authoring the mingwX64 skiko target), **not** building Skia, and
the DLL route is *risk-reducing*, not merely "the only working route."
---
## Sources (primary, load-bearing)
- skiko v0.150.1: `skiko/build.gradle.kts`, `buildSrc/.../NativeTasksConfiguration.kt`,
`src/commonMain/kotlin/org/jetbrains/skia/Data.kt`,
`src/nativeMain/kotlin/org/jetbrains/skia/Actuals.native.kt`,
`src/nativeJsMain/cpp/common.h`, `.../impl/Library.native.kt`
- skia-pack `script/build.py`; skia.org build docs
- Kotlin `konan.properties` (v2.2.0); kotlinlang native-c-interop / target-support
- LLVM issue #60847; **KT-65671**; **SKIKO-446**, **SKIKO-611**
- MSYS2 `mingw-w64-skia` (packages.msys2.org; MINGW-packages PKGBUILD + patches)
- Vaporware forks: github.com/Cdm2883/skiko-mingw64, crowforkotlin/skiko-mingw64
- Repo: `compose/ui/ui/build.gradle.kts`, the `SkiaMetalBridge` / `SkiaGLBridge`
/ `SkiaSurfaceBridge` / `RenderBackendFactory` seam
+166
View File
@@ -0,0 +1,166 @@
# TODO.md
Everything the port currently leaves as a no-op, stub, hardcode, or partial
implementation, plus the gaps worth closing before a stable 1.12 release. This
is an audit of PROJECT code only (the `*.native.kt` / `com.compose.sdl.*`
actuals); vendored upstream under `src/vendor/` is out of scope.
Severity is a rough guide, not a mandate:
- **Blocker** for stable: a capability a typical desktop app expects that is
currently absent or broken.
- **Nice-to-have**: real gap, but the app works without it.
- **Cosmetic**: minor fidelity or edge-case behavior.
Context for prioritizing: the project goal is **G1, cheap upstream-tracking**
(see [RENDERER.md](RENDERER.md)). There is now ONE renderer — **Skia**
(macOS/Linux official Skiko; mingwX64 the bitsycore skiko fork), so Windows
renders through real Skia. The priorities below are the cross-cutting platform
gaps that affect every OS. JVM Compose Desktop remains a parity cross-check.
Renderer-internal cleanups (the deferred D2 shadow-lighting split, D3 matrix
dedupe) live in [RENDERER.md](RENDERER.md#6-remaining-and-future-work), not
here.
## Completed
Platform / cross-cutting:
- **Locale / i18n** (`5d03711f`) — `Locale.current` / `LocaleList.current` read the
OS locale via SDL, unlocking the 40+ Material 3 translations. The date/number
format tail remains (section C).
- **IME text input** (`500c49e4`) — a real `PlatformTextInputSession` routes SDL
committed + composing text to the focused field (commit replaces the
composition); `SDL_SetTextInputArea` positions the OS candidate window.
- **Cursor icons** (`1fcc0828`) — `Modifier.pointerHoverIcon` drives the OS cursor
via SDL (I-beam over text, hand over links).
- **WindowInfo** (`bad23073`) — `isWindowFocused` + `containerSize` / `containerDpSize`
report real values (were hardcoded `true` / `Zero`).
- **ImageBitmap from bytes** — `ByteArray.decodeToImageBitmap()` /
`createImageBitmap(bytes)` decodes encoded image bytes; was
`UnsupportedOperationException`. Verified `demo --imagebytestest`.
- **`FontFamily.Monospace`** — generic families map to `"generic:<name>"`;
`registerGenericFonts` registers bundled NotoSansMono under `generic:monospace`
(bundled only when the app references it). Verified `demo --fonttest`.
---
## Release-blocking shortlist for stable 1.12
The cross-cutting items to weigh first (all detailed below):
1. **No copy/paste context menu** (section B). Keyboard copy works; there is no
right-click or selection toolbar (the desktop path is the right-click context
menu, upstream TODO CMP-7819).
2. **Accessibility is entirely absent** (section A). Decide whether stable
requires any screen-reader support at all.
3. Fonts (section D): compose-resources `Font(Res.font.x)` works, and
`FontFamily.Monospace` now renders NotoSansMono. Remaining: `Serif`/`Cursive`
generics (no bundled font) and the androidx `Font(bytes)`/`PlatformFontLoader`
path. Nice-to-have, not a blocker.
---
## A. Accessibility (entirely absent)
No accessibility bridge exists. A real `SemanticsOwner` is built but never
surfaced to any OS accessibility API (no NSAccessibility / UIA / AT-SPI, no SDL
a11y). Screen readers get nothing.
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:340` · `semanticsOwner` is instantiated but never read; no traversal into a platform a11y tree. A real a11y controller would walk the semantics tree and map roles/labels/actions to a native AT bridge. **Blocker for a11y.**
- `compose/ui/ui/src/nativeMain/.../platform/CompositionLocals.native.kt:31` · `LocalPlatformScreenReader` default throws (`error(...)`); never provided by any window. Screen-reader-active queries are unavailable. **Blocker for a11y.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:297` · `accessibilityManager` implements only `calculateRecommendedTimeoutMillis` (passthrough); no announce/focus/event surface. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../semantics/SemanticsRegion.native.kt:14` · `StubSemanticsRegion.intersect()`/`difference()` hardcode `false`; only `set`/`bounds` work. Region math for semantics culling is inert. **Nice-to-have.**
## B. Text toolbar and context menus (copy / paste / select-all)
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:311` · `textToolbar` is a stub: `showMenu`/`hide` NOP, `status` always `Hidden`. NOTE: the floating toolbar is gated on `isInTouchMode`, so on desktop (mouse) it never shows anyway — the desktop path is the right-click context menu below. **Nice-to-have (touch only).**
- `compose/foundation/.../text/selection/SelectionActuals.native.kt:42` · `addSelectionContainerTextContextMenuComponents` NOP (upstream TODO CMP-7819). No right-click copy/select-all menu for `SelectionContainer`. **Blocker for desktop text UX.**
- `compose/foundation/.../text/selection/TextFieldSelectionManager.native.kt:24` and `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:36` · `addBasicTextFieldTextContextMenuComponents` NOP for both the legacy and state-based `BasicTextField`. **Blocker for desktop text UX.**
## C. Locale / i18n tail (needs locale-aware DATA, no ICU on K/N)
`Locale.current` / `LocaleList.current` are wired to the OS (see Completed). The
rest needs month/weekday name tables and date/number patterns, which Kotlin/Native
has no ICU for; SDL reports language + country only (no script subtag, no
calendar/clock/number prefs). These require a bundled CLDR subset or a K/N i18n lib.
- `compose/material3/.../CalendarLocale.native.kt:20` · locale-agnostic stub: `toString()` fixed `"en"`, single shared instance, `equals` treats all instances as equal. A separate type from `ui.text.intl.Locale`; needs its own wiring so `CalendarModel`/`PlatformDateFormat` can vary by locale. Inert until `PlatformDateFormat` is localized. **Nice-to-have (DatePicker/TimePicker).**
- `compose/material3/.../internal/PlatformDateFormat.native.kt` · multiple hardcodes:
- `:27` `weekdayNames` hardcoded English full/short names (DatePicker shows English day initials regardless of locale). **Blocker for localized DatePicker.**
- `:32` `formatWithPattern`/`formatWithSkeleton` ignore the pattern/skeleton and always emit ISO `yyyy-MM-dd`. **Blocker for localized DatePicker.**
- `:25` `firstDayOfWeek` hardcoded `1` (Sunday); locale-dependent. **Nice-to-have.**
- `:44` `parse()` only accepts ISO `yyyy-MM-dd`, ignoring pattern/locale. **Nice-to-have.**
- `:62` `getDateInputFormat()` hardcoded `yyyy-MM-dd` / `'-'`. **Nice-to-have.**
- `:65` `is24HourFormat()` hardcoded `true`. **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../text/platform/NativeStringDelegate.native.kt:17` · `toUpperCase`/`toLowerCase` use locale-independent stdlib casing; the `locale` param is ignored (wrong for Turkish dotted/dotless i, etc.). **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt` · `Locale.isRtl()` not implemented; the pipeline is LTR-only (`SdlParagraph.native.kt` `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.**
## D. Font resolution
**Custom fonts via compose-resources work.** `org.jetbrains.compose.resources.Font(Res.font.x)`
loads the bytes and registers them with the project font registry (IconFont → NamedFont),
so the standard CMP way to bundle a font renders correctly.
- `FontFamily.Serif` / `FontFamily.Cursive` still collapse to the default sans — no bundled
serif/cursive font yet (`downloadNotoFonts` fetches Sans + SansMono only). Register one under
`generic:serif` / `generic:cursive` the same way to enable them. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:32` · `SdlPlatformFontLoader.loadBlocking`/`awaitLoad` are NOP; the androidx `PlatformFontLoader` path (`ResourceFont` / `Font(bytes)` / `LoadedFontFamily`) doesn't load. Use the working compose-resources `Font()` instead. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:38` · `PlatformFontFamilyTypefaceAdapter.resolve` returns `TypefaceResult.Immutable(Unit)` (the renderer resolves by name, so harmless). **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:69` · deprecated `createFontFamilyResolver(fontResourceLoader)` ignores the supplied loader. **Cosmetic.**
## E. Text rendering details
- `compose/ui/ui/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.**
## F. Drag and drop
Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:38` · `requestDragAndDropTransfer` is a NOP and `isRequestDragAndDropTransferRequired` returns `false`; `Modifier.dragAndDropSource` compiles but no OS-level drag leaves the window. Needs per-OS native work (NSDraggingSession / DoDragDrop / XDND); SDL3 has no portable start-drag. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:119` · incoming transfer carries only file paths + a text blob; no image/URL/custom-MIME payloads. **Cosmetic.**
- `compose/foundation/.../draganddrop/DragAndDropSource.native.kt:36` · no drag-shadow / drag-image feedback for internal DnD (upstream skiko renders one). **Cosmetic.**
## G. Window and platform info
- Multi-monitor: no display enumeration or per-monitor placement anywhere (`SDL_GetDisplays`/`SDL_GetDisplayBounds`/`SDL_GetDisplayForWindow` unused). **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../platform/PrefetchLocals.native.kt:18` · `LocalPlatformPrefetchScheduler` default is a NOP scheduler; lazy lists skip ahead-of-time item composition (correctness fine, possible scroll-in jank). **Nice-to-have (perf).**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:324` · `autofill`/`autofillManager` are null; `requestAutofill` NOP. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:279` · `hapticFeedBack.performHapticFeedback` NOP (no desktop pointer haptics API). **Cosmetic.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:287` · deprecated `clipboardManager` `setText`/`getText` NOP, and `clipboard.getClipEntry` null. Harmless: the real `LocalClipboard` (text + PNG image via SDL3) works. **Cosmetic.**
- `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:44` · `ClipboardPasteState.hasClip` aliased to `hasText`, so image-only clipboard is not detected for the paste affordance. **Cosmetic.**
## H. Skia renderer anomalies
Skia is the renderer everywhere, so its bugs are rarely tracked here. This one
surfaced while adding BlendMode coverage (`demo --blendtest`):
- `BlendMode.Multiply` renders wrong. A filled `drawRect` with opaque
`Color.Cyan` over opaque `Color.Yellow` reads back `(0,0,255)` blue; the
multiply formula can only yield `(0,255,0)` green for opaque cyan×yellow.
`Plus` and `Modulate` composite correctly on the same path, so
`paint.blendMode` IS applied — the fault is specific to `MULTIPLY` (likely a
Metal-backend / premultiply interaction in the graphics-layer flatten, not
the `BlendMode.toSkia()` map, which is correct). Needs isolating on the Skia
draw path. **Nice-to-have.**
## J. Needs further review (2026-07 bug audit)
An audit (parallel passes over the renderer and the text/input/event actuals)
surfaced these. The clear, contained bugs were fixed in the same pass; the items
here are left open because they are either invasive, risky, or a judgement call.
(The renderer-side fixes from that pass predate the single-Skia consolidation and
are no longer tracked here.)
Fixed in the audit (for reference, not open work): `Locale.region` no longer
returns the language for a single-subtag tag; and a `MOUSE_LEAVE` now clears
hover so a widget doesn't stay highlighted after the cursor exits.
Open — platform / input:
- **Pointer coords are truncated to Int before DPR scaling** (`SDL3EventMapper.kt` ~78-97, `LegacyPointerEvent.kt` x/y are `Int`, consumed in `ComposeWindow.kt:756`). On a 2× display a click at logical (100.9, 50.4) becomes physical (200,100) instead of (~201,~100), so hit-testing / caret placement quantizes to 2-physical-px steps near glyph boundaries. Fix means widening the pointer event to `Float` through the pipeline — invasive, hence deferred. **Review.**
- **A window created unfocused is still promoted to RESUMED** (`ComposeWindow.kt:575-576, 713-719`). `windowFocused`/`windowVisible` default `true`, so a second `Window {}` opened while another holds focus (or one opened minimized) reports `RESUMED` until SDL later delivers a focus/minimize event. Should query `SDL_GetWindowFlags` at creation. **Review.**
- **IME text-input area is only pushed on the first `TEXT_EDITING`** (`ComposeWindow.kt:803`; `updateImeArea()` not called on focus gain / first `StartTextInput` / caret move). The first candidate popup can appear at (0,0) before correcting. The rect math itself is correct. **Review.**
- **`Snapshot.sendApplyNotifications()` is called synchronously inside the global write observer** (`ComposeWindow.kt:143-146`). Compose Desktop's `GlobalSnapshotManager` deliberately defers it to a separate dispatch; calling it per-write is redundant (the main loop already calls it each frame) and risks re-entrancy during snapshot application. Verify against the runtime's guard before changing — behaviour-sensitive. **Review (risky).**
+209
View File
@@ -0,0 +1,209 @@
# Tooling
Helper scripts and workflows used to build, vendor, and verify Compose Desktop
Native. Each has a focused job; the deeper ones link to their own README.
For architecture, source-set layout, and vendoring rules, see
[CLAUDE.md](CLAUDE.md).
## Native libraries
SDL3 is built from source as a static library and linked straight into the
executable — the windowing, input, and platform-integration layer. One script
does it on every OS. Output lands in the gitignored `libs/`; the version is
pinned in `scripts/build-sdl/build-sdl.properties`.
```bash
python3 scripts/build-sdl/build-all.py # sdl3
python3 scripts/build-sdl/build-all.py sdl3 # rebuild the step
```
Needs `git`, `cmake`, and Python 3 on every host (`ninja` is fetched when
absent). Run it once per machine, or after bumping the pinned version.
## Vendoring upstream Compose
Most `androidx.compose.*` code is copied byte for byte from
`JetBrains/compose-multiplatform-core` into each module's gitignored
`src/vendor/`. A `compose-fork.txt` manifest per module lists which upstream
files it pulls; the pinned ref lives in `scripts/compose-fork/compose.properties`.
```bash
scripts/compose-fork/sync.sh # re-sync every module
scripts/compose-fork/sync.sh compose/ui/ui/compose-fork.txt # one module
```
Reach for this after a fresh checkout or a ref bump. Details:
[scripts/compose-fork/README.md](scripts/compose-fork/README.md).
Guardrails that keep the vendor tree honest:
```bash
python3 scripts/compose-fork/check-vendor-clean.py # src/vendor matches the pinned refs
python3 scripts/compose-fork/check-vendor-drift.py # hand-edited "manual vendors" match their pin
```
`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.
### Coverage against upstream
How much of upstream's public API the port covers is measured, not guessed.
```bash
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
python3 scripts/compose-coverage.py --missing ui-text # list uncovered declarations
```
## Verification
### One-command gate
`verify-mac.sh` is the runbook to run after any renderer or layout change. On
macOS or Linux it exercises the Skia renderer and exits non-zero on any
failure:
```bash
scripts/verify-mac.sh
```
It runs: the vendor drift and clean checks, a build of `:demo` and `:apidemo`,
the interaction probes, the parity sweep, a memory soak, and a frame-time spot
check. The Windows target is verified separately (see below).
### Parity: native vs JVM
`:demo` renders the same shared screens on two stacks: this port, and stock JVM
Compose Desktop. `parity.py` screenshots every screen on both and pixel-diffs
them, so a screen that diverges is a porting bug. It gates against per-screen
golden baselines.
```bash
python3 scripts/parity/parity.py # all screens
python3 scripts/parity/parity.py Buttons Shapes # a subset
python3 scripts/parity/parity.py --no-build # reuse the last renders
```
How to read the diff and the percentage: [scripts/parity/README.md](scripts/parity/README.md).
### Interaction probe
`probe.py` launches a native window, sends window-relative input (click, hover,
hold), and captures the client area. It packages the rigs used to reproduce
interaction bugs. Windows-only. Details:
[scripts/probe/README.md](scripts/probe/README.md).
### Frame profiler
Set `CDN_PROFILE=1` (or `CDN_PROFILE=<path>`) and run any app. Every couple of
seconds it writes per-phase main-loop timings (events, app pump, render, with
draw sub-phases and per-frame draw counters) to a file. `CDN_FORCERENDER=1`
renders every frame so steady-state timings read on an otherwise idle screen.
```bash
CDN_PROFILE=1 CDN_FORCERENDER=1 ./demo/build/bin/macosArm64/debugExecutable/demo.kexe
```
Note: `present` is vsync-blocking, so profile on the target refresh rate before
drawing conclusions about a frame-rate gap.
### Demo probes
`:demo` ships headless regression probes driven by CLI flags, used by the
verify runbook and in isolation:
```bash
demo.kexe --nav3test # Navigation 3 + ViewModel + lifecycle
demo.kexe --backtest # predictive-back / BackHandler
demo.kexe --clicktest # pointer -> upstream clickable
demo.kexe --scrolltest # wheel -> scrollable
demo.kexe --multiwintest # multi-window lifecycle
demo.kexe --soaktest # memory soak (CDN_SOAK_SCREEN, CDN_SOAK_STATIC, CDN_SOAK_CYCLES)
```
### Windows smoke
The Mac runbook cannot cover the Windows target. Before shipping, on a Windows
host: build the mingwX64 executables, run `scripts/probe/`, and run a publish
dry run (only Windows compiles the full common-metadata variant table).
```bat
gradlew.bat :demo:runDebugExecutableMingwX64
gradlew.bat :apidemo:runDebugExecutableMingwX64
```
mingwX64 renders through Skia via the **bitsycore skiko fork**, consumed from
GitHub Packages as `org.jetbrains.skiko:skiko:0.150.1-mingw.1` (macOS/Linux use
official Skiko). The runtime `skiko-windows-x64.dll` is auto-provisioned next
to the executable by the bridge plugin — no manual copy. The fork itself is
published by a separate GitHub Actions workflow in the fork repo, out of band
from this repo's release flow.
## Consuming the port
To build a third-party app against the published klibs, apply the bridge Gradle
plugin and declare official Compose Multiplatform coordinates; the plugin swaps
in the port's klibs on native desktop targets. Setup:
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
## Versioning and releasing
### Version map
Versions live in a few places, some of which move in lockstep. There is no
single file to edit; know which axis you are changing.
| Version | Where | Notes |
|---------|-------|-------|
| Project release version | The git tag `vX.Y.Z`. `PUBLISH_VERSION` (from the tag) feeds `vPublishVersion` in `build.gradle.kts`, which strips the leading `v`. Group is `com.bitsycore.compose.sdl`. | 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`. | macOS/Linux use official Skiko; mingwX64 uses the bitsycore fork (`0.150.1-mingw.1` from GitHub Packages), 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. 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).
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 and fix any fallout.
5. `scripts/verify-mac.sh` (green, including the soak and parity gates).
6. If a matching Compose version is now published to Maven, bump
`vComposeJvmVersion` in demo/apidemo/material-symbols to close the skew.
7. Run WIN-SMOKE on a Windows host.
8. Re-seed parity baselines only if metrics legitimately moved
(`parity.py --update-baselines`), and record why.
### Cut a release
1. Complete the ref-bump flow above and confirm it is green. For a STABLE
release, `vComposeJvmVersion` should match the vendored ref (no skew).
2. On a Windows host, run `gradlew :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`.
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
KotlinMultiplatform metadata, the `jvm` publication, and the bridge plugin
(only Windows declares every target).
- The `MODULES` list in the workflow must stay in sync with the modules in
`settings.gradle.kts`.
- A partial publish (for example a dropped connection mid-upload) deletes this
host's half-published versions for the tag and retries, so a rerun is safe.
- The demo and apidemo release binaries are zipped and attached to the GitHub
Release for the tag.
4. Verify a consumer build against the new version resolves through the bridge.
+67 -289
View File
@@ -1,112 +1,60 @@
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 = "1.12.0-beta01+dev4324"
val vComposeM3JvmVersion = "1.12.0-alpha03+dev4324"
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()
buildList {
add(linuxArm64())
add(linuxX64())
add(macosArm64())
if (isHostMingwCompatible) add(mingwX64())
}.forEach {
it.binaries {
executable {
when {
target.name == "mingwX64" -> linkerOpts(
// crypt32: client-cert (mTLS) import into the Windows cert store.
"-lcrypt32",
"-Wl,--gc-sections", "-Wl,-s",
// GUI subsystem (no console window), keeping the C `main` entry.
"-Wl,--subsystem,windows", "-Wl,-e,mainCRTStartup",
)
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")
target.name.startsWith("linux") -> linkerOpts(
"-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib/aarch64-linux-gnu",
"-lfontconfig", "-lGL", "-lX11",
)
}
}
entryPoint = "apidemo.main"
// SDL3 + SDL3_ttf + SDL3_image + FreeType + image codecs come from
// :ui's cinterop klibs (staticLibraries directive in each .def); the
// system libs they need are in sdl3.def's linkerOpts.<target>. Only
// shell-level flags and app-specific extras (crypt32 for mTLS) live
// here. Ktor's Curl engine bundles its own libcurl + TLS stack.
if (isMingw) linkerOpts(
// crypt32: client-cert (mTLS) import into the Windows cert store
// (CertOpenStore / PFXImportCertStore / CertAddCertificateContextToStore…).
"-lcrypt32",
// Code-shrink: drop unused sections and strip symbols.
"-Wl,--gc-sections", "-Wl,-s",
// GUI subsystem so launching the .exe pops no console window.
// Keep the standard C `main` entry — ld would otherwise default
// a GUI-subsystem PE to WinMainCRTStartup (needs WinMain) and
// fail to link, since Kotlin/Native emits `main`.
"-Wl,--subsystem,windows", "-Wl,-e,mainCRTStartup",
)
// Skia (default renderer) references the system graphics stack.
// K/N's LLD sysroot on linuxX64 doesn't include the host's multi-arch
// dir, so add the -L for both x86_64 and aarch64 (harmless if either
// is absent — LLD skips missing dirs).
if (isLinux) linkerOpts(
"-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib/aarch64-linux-gnu",
"-lfontconfig", "-lGL", "-lX11",
)
}
}
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
dependencies {
// 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).
implementation("org.jetbrains.compose.runtime:runtime:1.11.1")
// Official Maven coords for everything the shared UI touches; native
// configurations substitute them for the port modules automatically
// (root bridge in-repo, the bridge plugin for consumers).
implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
implementation("org.jetbrains.compose.ui:ui:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-graphics:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-text:$vComposeJvmVersion")
@@ -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(":material-symbols"))
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
@@ -130,27 +81,14 @@ 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(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.
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 +106,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"),
)
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,
)
}
/* Register `subsetMaterialSymbols<Style>` for a style — hb-subsets the style's
downloaded TTF (owned by :material-symbols) to only the codepoints in
usage-codepoint.txt and writes the output under this app's build/icons/ dir. */
fun registerSubsetTask(inStyle: String): TaskProvider<*> {
val vSymbolsProject = rootProject.project(":material-symbols")
return tasks.register("subsetMaterialSymbols$inStyle") {
description = "hb-subset the $inStyle Material Symbols font to icons actually used."
@Suppress("UNCHECKED_CAST")
val vInputProvider = vSymbolsProject.extra["iconFontFile$inStyle"] as org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$inStyle"] as TaskProvider<*>
val vOut = iconsBuildDir.get().file("MaterialSymbols$inStyle.subset.ttf").asFile
val vUsage = iconsBuildDir.get().file("usage-codepoint.txt").asFile
inputs.file(vInputProvider)
inputs.file(vUsage)
outputs.file(vOut)
dependsOn(findMaterialSymbolsUsage)
dependsOn(vDownloadTask)
doLast {
val vCodepoints = vUsage.readLines()
.filter { it.isNotBlank() && !it.startsWith("#") && it.contains("=") }
.map { it.substringAfter("=").trim().removePrefix("0x").removePrefix("0X") }
if (vCodepoints.isEmpty()) throw GradleException(
"usage-codepoint.txt has no entries — refusing to subset to an empty font.")
val vUnicodes = vCodepoints.joinToString(",") { "U+$it" }
vOut.parentFile.mkdirs()
val vInputFile = vInputProvider.get().asFile
val vBefore = vInputFile.length()
// ProcessBuilder rather than project.exec — the latter isn't
// available inside doLast on Gradle 9 (only ExecOperations via
// an injected service is, which would mean a buildSrc plugin).
// hb-subset (HarfBuzz) is optional: if it isn't on PATH — common on
// Windows — bundle the full font instead of failing the build.
val vProc = try {
ProcessBuilder(
"hb-subset",
vInputFile.absolutePath,
"-o", vOut.absolutePath,
"--unicodes=$vUnicodes",
).redirectErrorStream(true).start()
} catch (e: java.io.IOException) {
vInputFile.copyTo(vOut, overwrite = true)
logger.warn("[subset $inStyle] hb-subset not found on PATH — bundling the full font " +
"(${vBefore / 1024}KB). Install harfbuzz to shrink it (brew install harfbuzz / " +
"pacman -S mingw-w64-x86_64-harfbuzz / apt install harfbuzz-utils).")
return@doLast
compose.desktop.native {
entryPoint = "apidemo.main"
icon {
light.from(
layout.projectDirectory.file("icons/voltic-icon-32.png"),
layout.projectDirectory.file("icons/voltic-icon-128.png"),
)
dark.from(
layout.projectDirectory.file("icons/voltic-icon-dark-32.png"),
layout.projectDirectory.file("icons/voltic-icon-dark-128.png"),
)
exeIcon.from(
listOf(16, 32, 48, 64, 128, 256).map {
layout.projectDirectory.file("icons/voltic-icon-$it.png")
}
val vOutput = vProc.inputStream.bufferedReader().readText()
val vCode = vProc.waitFor()
if (vCode != 0) throw GradleException("hb-subset failed (exit $vCode):\n$vOutput")
val vAfter = vOut.length()
val vPct = if (vBefore == 0L) 0 else ((100 - 100 * vAfter / vBefore)).coerceAtLeast(0)
logger.lifecycle("[subset $inStyle] ${vBefore / 1024}KB → ${vAfter / 1024}KB (-$vPct%) · ${vCodepoints.size} glyphs kept")
}
}
}
// 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

+129 -128
View File
@@ -2,7 +2,8 @@ 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
@@ -10,173 +11,173 @@ 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.
/* Point libcurl straight at the certificate / key files. */
/** Point libcurl straight at the certificate / key files. */
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
PreparedCert(
sslCert = inReq.certPath,
sslCertType = inReq.certFormat.curlName,
sslKey = inReq.keyPath.ifBlank { null },
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
keyPassword = inReq.certPassword.ifEmpty { null },
cleanup = {},
)
PreparedCert(
sslCert = inReq.certPath,
sslCertType = inReq.certFormat.curlName,
sslKey = inReq.keyPath.ifBlank { null },
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
keyPassword = inReq.certPassword.ifEmpty { null },
cleanup = {},
)
/* No temporary certificate store off Windows. */
/** No temporary certificate store off Windows. */
actual fun sweepTempClientCerts() {}
/* Continue the server's chain with intermediates / roots pulled from the
macOS Keychain — same UX as the Windows path does via the trust store.
Falls back to the name-only placeholder if Apple's APIs can't resolve
the chain (e.g. the server's leaf doesn't parse, or its issuer isn't
in any keychain). */
/** Continue the server's chain with intermediates / roots pulled from the
macOS Keychain — same UX as the Windows path does via the trust store.
Falls back to the name-only placeholder if Apple's APIs can't resolve
the chain (e.g. the server's leaf doesn't parse, or its issuer isn't
in any keychain). */
@OptIn(ExperimentalForeignApi::class)
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> {
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
?: return serverChainWithIssuerName(inServerCerts)
val vOs = runCatching { osChainApple(vLeafPem, inServerCerts.size, inServerCerts) }.getOrNull()
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
?: return serverChainWithIssuerName(inServerCerts)
val vOs = runCatching { osChainApple(vLeafPem, inServerCerts.size, inServerCerts) }.getOrNull()
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
}
// ============
// Chain walk via Security.framework
// ============
/* Build the chain by handing the leaf to SecTrust + evaluating against
an SSL policy — Apple then walks Keychain, pulling intermediates and
the root in. Returns the resolved chain with fromServer=true for the
first inServerCount entries (those came down on the wire) and false
for anything the OS added. */
/** Build the chain by handing the leaf to SecTrust + evaluating against
an SSL policy — Apple then walks Keychain, pulling intermediates and
the root in. Returns the resolved chain with fromServer=true for the
first inServerCount entries (those came down on the wire) and false
for anything the OS added. */
@OptIn(ExperimentalForeignApi::class)
private fun osChainApple(
inLeafPem: String,
inServerCount: Int,
inServerCerts: List<List<Pair<String, String>>>,
inLeafPem: String,
inServerCount: Int,
inServerCerts: List<List<Pair<String, String>>>,
): List<ChainCert> {
// Parse every cert the server presented, not just the leaf — Apple's
// chain builder uses them as candidate intermediates when stitching to
// a root, instead of relying solely on AIA-fetch which is off by default.
val vServerCerts = inServerCerts.mapNotNull {
certFieldOf(it, "Cert")?.let { vPem -> derFromPem(vPem) }
}
if (vServerCerts.isEmpty()) return emptyList()
// Parse every cert the server presented, not just the leaf — Apple's
// chain builder uses them as candidate intermediates when stitching to
// a root, instead of relying solely on AIA-fetch which is off by default.
val vServerCerts = inServerCerts.mapNotNull {
certFieldOf(it, "Cert")?.let { vPem -> derFromPem(vPem) }
}
if (vServerCerts.isEmpty()) return emptyList()
val vCertRefs = vServerCerts.map { createSecCertificate(it) ?: return emptyList() }
val vCertArray = createCFArray(vCertRefs)
val vCertRefs = vServerCerts.map { createSecCertificate(it) ?: return emptyList() }
val vCertArray = createCFArray(vCertRefs)
// SSL policy lets evaluateTrust pull a root out of Keychain.
val vPolicy = SecPolicyCreateSSL(true, null) ?: run {
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
return emptyList()
}
// SSL policy lets evaluateTrust pull a root out of Keychain.
val vPolicy = SecPolicyCreateSSL(true, null) ?: run {
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
return emptyList()
}
val vTrustVar = nativeHeap.alloc<SecTrustRefVar>()
val vCreateStatus = SecTrustCreateWithCertificates(vCertArray, vPolicy, vTrustVar.ptr)
CFRelease(vPolicy)
if (vCreateStatus != errSecSuccess || vTrustVar.value == null) {
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
nativeHeap.free(vTrustVar)
return emptyList()
}
val vTrust = vTrustVar.value!!
val vTrustVar = nativeHeap.alloc<SecTrustRefVar>()
val vCreateStatus = SecTrustCreateWithCertificates(vCertArray, vPolicy, vTrustVar.ptr)
CFRelease(vPolicy)
if (vCreateStatus != errSecSuccess || vTrustVar.value == null) {
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
nativeHeap.free(vTrustVar)
return emptyList()
}
val vTrust = vTrustVar.value!!
// Evaluate even if it fails — we still want the partial chain back so
// the UI can show what it could resolve. SecTrustEvaluateWithError
// stops at the highest cert it found.
val vErr = nativeHeap.alloc<CFErrorRefVar>()
SecTrustEvaluateWithError(vTrust, vErr.ptr)
vErr.value?.let { CFRelease(it) }
nativeHeap.free(vErr)
// Evaluate even if it fails — we still want the partial chain back so
// the UI can show what it could resolve. SecTrustEvaluateWithError
// stops at the highest cert it found.
val vErr = nativeHeap.alloc<CFErrorRefVar>()
SecTrustEvaluateWithError(vTrust, vErr.ptr)
vErr.value?.let { CFRelease(it) }
nativeHeap.free(vErr)
val vCount = SecTrustGetCertificateCount(vTrust).toInt()
val vResolved = ArrayList<ChainCert>(vCount)
val vSubjects = ArrayList<String>(vCount)
val vCount = SecTrustGetCertificateCount(vTrust).toInt()
val vResolved = ArrayList<ChainCert>(vCount)
val vSubjects = ArrayList<String>(vCount)
for (vI in 0 until vCount) {
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
val vSubject = secSubjectSummary(vCert)
vSubjects.add(vSubject ?: "")
}
for (vI in 0 until vCount) {
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
val vSubject = vSubjects[vI].ifBlank { null }
// The issuer is the next cert in the chain's subject (or this
// cert's own subject if it's the self-signed root).
val vIssuer = when {
vI + 1 < vCount -> vSubjects[vI + 1].ifBlank { null }
vSubject != null -> vSubject // root: self-signed
else -> null
}
val vPem = secCertPem(vCert)
val vFields = buildList {
vSubject?.let { add("Subject" to "CN=$it") }
vIssuer?.let { add("Issuer" to "CN=$it") }
vPem?.let { add("Cert" to it) }
}
vResolved.add(ChainCert(vFields, vI < inServerCount))
}
for (vI in 0 until vCount) {
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
val vSubject = secSubjectSummary(vCert)
vSubjects.add(vSubject ?: "")
}
for (vI in 0 until vCount) {
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
val vSubject = vSubjects[vI].ifBlank { null }
// The issuer is the next cert in the chain's subject (or this
// cert's own subject if it's the self-signed root).
val vIssuer = when {
vI + 1 < vCount -> vSubjects[vI + 1].ifBlank { null }
vSubject != null -> vSubject // root: self-signed
else -> null
}
val vPem = secCertPem(vCert)
val vFields = buildList {
vSubject?.let { add("Subject" to "CN=$it") }
vIssuer?.let { add("Issuer" to "CN=$it") }
vPem?.let { add("Cert" to it) }
}
vResolved.add(ChainCert(vFields, vI < inServerCount))
}
CFRelease(vTrust)
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
nativeHeap.free(vTrustVar)
return vResolved
CFRelease(vTrust)
vCertRefs.forEach { CFRelease(it) }
CFRelease(vCertArray)
nativeHeap.free(vTrustVar)
return vResolved
}
/* SecCertificate from DER bytes. Returns null if the bytes aren't a
valid X.509 cert. */
/** SecCertificate from DER bytes. Returns null if the bytes aren't a
valid X.509 cert. */
@OptIn(ExperimentalForeignApi::class)
private fun createSecCertificate(inDer: ByteArray): SecCertificateRef? {
val vData = inDer.usePinned { vPin ->
CFDataCreate(null, vPin.addressOf(0).reinterpret(), inDer.size.convert())
} ?: return null
val vCert = SecCertificateCreateWithData(null, vData)
CFRelease(vData)
return vCert
val vData = inDer.usePinned { vPin ->
CFDataCreate(null, vPin.addressOf(0).reinterpret(), inDer.size.convert())
} ?: return null
val vCert = SecCertificateCreateWithData(null, vData)
CFRelease(vData)
return vCert
}
/* CFArray of SecCertificateRef — the input to SecTrustCreateWithCertificates. */
/** CFArray of SecCertificateRef — the input to SecTrustCreateWithCertificates. */
@OptIn(ExperimentalForeignApi::class)
private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
for (vI in inCerts.indices) vPtrs[vI] = inCerts[vI]
val vArr = CFArrayCreate(null, vPtrs, inCerts.size.convert(), kCFTypeArrayCallBacks.ptr)!!
nativeHeap.free(vPtrs)
return vArr
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
for (vI in inCerts.indices) vPtrs[vI] = inCerts[vI]
val vArr = CFArrayCreate(null, vPtrs, inCerts.size.convert(), kCFTypeArrayCallBacks.ptr)!!
nativeHeap.free(vPtrs)
return vArr
}
/* Apple's "subject summary" — basically the CN, but falls back to the
organisation when no CN is present (matches what Keychain Access
displays in its cert list). */
/** Apple's "subject summary" — basically the CN, but falls back to the
organisation when no CN is present (matches what Keychain Access
displays in its cert list). */
@OptIn(ExperimentalForeignApi::class)
private fun secSubjectSummary(inCert: SecCertificateRef): String? {
val vCf = SecCertificateCopySubjectSummary(inCert) ?: return null
val vNs = CFBridgingRelease(vCf) as? NSString
return vNs?.toString()
val vCf = SecCertificateCopySubjectSummary(inCert) ?: return null
val vNs = CFBridgingRelease(vCf) as? NSString
return vNs?.toString()
}
/* PEM string for a SecCertificate — base64 of the DER bytes wrapped
with -----BEGIN CERTIFICATE-----/-----END----- headers. */
/** PEM string for a SecCertificate — base64 of the DER bytes wrapped
with -----BEGIN CERTIFICATE-----/-----END----- headers. */
@OptIn(ExperimentalForeignApi::class, ExperimentalEncodingApi::class)
private fun secCertPem(inCert: SecCertificateRef): String? {
val vData = SecCertificateCopyData(inCert) ?: return null
val vLen = CFDataGetLength(vData).toInt()
val vBytes = ByteArray(vLen)
val vPtr = CFDataGetBytePtr(vData)
if (vPtr != null) for (vI in 0 until vLen) vBytes[vI] = vPtr[vI].toByte()
CFRelease(vData)
val vB64 = Base64.Default.encode(vBytes)
val vWrapped = vB64.chunked(64).joinToString("\n")
return "-----BEGIN CERTIFICATE-----\n$vWrapped\n-----END CERTIFICATE-----"
val vData = SecCertificateCopyData(inCert) ?: return null
val vLen = CFDataGetLength(vData).toInt()
val vBytes = ByteArray(vLen)
val vPtr = CFDataGetBytePtr(vData)
if (vPtr != null) for (vI in 0 until vLen) vBytes[vI] = vPtr[vI].toByte()
CFRelease(vData)
val vB64 = Base64.encode(vBytes)
val vWrapped = vB64.chunked(64).joinToString("\n")
return "-----BEGIN CERTIFICATE-----\n$vWrapped\n-----END CERTIFICATE-----"
}
/* Decode a PEM block to DER bytes, or pass DER bytes through. */
/** Decode a PEM block to DER bytes, or pass DER bytes through. */
@OptIn(ExperimentalEncodingApi::class)
private fun derFromPem(inPem: String): ByteArray? {
if (!inPem.contains("-----BEGIN")) return inPem.encodeToByteArray()
val vBody = inPem.lineSequence()
.filterNot { it.startsWith("-----") }
.joinToString("") { it.trim() }
return runCatching { Base64.Default.decode(vBody) }.getOrNull()
if (!inPem.contains("-----BEGIN")) return inPem.encodeToByteArray()
val vBody = inPem.lineSequence()
.filterNot { it.startsWith("-----") }
.joinToString("") { it.trim() }
return runCatching { Base64.decode(vBody) }.getOrNull()
}
File diff suppressed because it is too large Load Diff
+2 -13
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"
+60 -35
View File
@@ -1,21 +1,23 @@
package apidemo
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import kotlinx.coroutines.Job
// ==================
// MARK: Working state (session / pack / request / tabs / drag)
// ==================
internal class HistoryEntry(val method: ReqMethod, val url: String, val status: Int, val timeMs: Long, val request: ApiRequest)
internal class HistoryEntry(
val method: ReqMethod,
val url: String,
val status: Int,
val timeMs: Long,
val request: ApiRequest
)
/* One request plus its live session state — response, in-flight job (for
cancel) and which sub-tabs show. Stable identity lets the sidebar and the
open-tab strip reference the same request without index juggling. */
/** One request plus its live session state — response, in-flight job (for
cancel) and which sub-tabs show. Stable identity lets the sidebar and the
open-tab strip reference the same request without index juggling. */
internal class ReqState(inInitial: ApiRequest) {
var req by mutableStateOf(inInitial)
var response by mutableStateOf<ApiResponse?>(null)
@@ -26,6 +28,7 @@ internal class ReqState(inInitial: ApiRequest) {
var preview by mutableStateOf(false) // panel 4 showing the resolved, not-yet-sent request
var sentReq by mutableStateOf<ApiRequest?>(null) // resolved request actually sent (Request tab)
var imageKey: String? = null // memory-resource key when the response is an image
// null = auto-detect from Content-Type each frame; non-null = user-pinned override.
var respFormatOverride by mutableStateOf<BodyFormat?>(null)
var bodyWrap by mutableStateOf(true) // body view: true = soft-wrap, false = no wrap + horizontal scroll
@@ -35,14 +38,21 @@ internal class ReqState(inInitial: ApiRequest) {
var chainUrl by mutableStateOf("") // resolved URL the chain was fetched from
}
/* One open pack: its file path (null = never saved), a dirty flag (edits since
last file save), and its own requests, variables and open-tab strip. Switching
packs swaps the whole working set. */
internal class PackState(inPack: Pack, inPath: String?, inDirty: Boolean, inOpenTabs: List<Int> = emptyList(), inActive: Int = -1) {
/** One open pack: its file path (null = never saved), a dirty flag (edits since
last file save), and its own requests, variables and open-tab strip. Switching
packs swaps the whole working set. */
internal class PackState(
inPack: Pack,
inPath: String?,
inDirty: Boolean,
inOpenTabs: List<Int> = emptyList(),
inActive: Int = -1
) {
var name by mutableStateOf(inPack.name)
var path by mutableStateOf(inPath)
var dirty by mutableStateOf(inDirty)
var color by mutableStateOf(inPack.color)
// A linked copy mirrors its source's requests read-only; non-linked packs own
// their requests. `requests` resolves to whichever applies.
var linkedSource by mutableStateOf<PackState?>(null)
@@ -52,16 +62,21 @@ internal class PackState(inPack: Pack, inPath: String?, inDirty: Boolean, inOpen
val requests: androidx.compose.runtime.snapshots.SnapshotStateList<ReqState>
get() = linkedSource?.requests ?: fOwnRequests
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) } }
@@ -73,39 +88,48 @@ internal class PackState(inPack: Pack, inPath: String?, inDirty: Boolean, inOpen
fun toPack(): Pack = Pack(
name = name,
requests = if (isLinked) emptyList() else requests.map { it.req }, // linked: requests live in the source
variables = variables.toList(), color = color, headers = headers.toList(), params = params.toList(), cert = cert,
id = id, linkedTo = linkedSource?.id, isRoot = isRoot,
variables = variables.toList(),
color = color,
headers = headers.toList(),
params = params.toList(),
cert = cert,
id = id,
linkedTo = linkedSource?.id,
isRoot = isRoot,
subPacks = subPacks.map { it.toPack() },
)
}
/* A fresh random id for a pack (used to wire linked-copy packs to their source
across save / load). */
/** A fresh random id for a pack (used to wire linked-copy packs to their source
across save / load). */
internal fun newPackId(): String = "pk-" + kotlin.random.Random.nextLong().toULong().toString(16)
/* One entry in the unified tab strip: a request tab, a pack's settings tab when
req == null, or the session settings tab when isSession (pack == null). The
underlying ReqState / PackState (or the session key) is the stable identity. */
/** One entry in the unified tab strip: a request tab, a pack's settings tab when
req == null, or the session settings tab when isSession (pack == null). The
underlying ReqState / PackState (or the session key) is the stable identity. */
internal const val kSessionTabKey = "session-settings"
internal class StripTab(val pack: PackState?, val req: ReqState?, val isSession: Boolean = false)
/* Identity of a strip tab. A linked pack shares the source's ReqState objects,
so the key must include the pack — otherwise the source's and linked's tabs
for the same request collide (double selection, duplicate key()). Data class →
structural equality, so compare keys with == (not ===). */
/** Identity of a strip tab. A linked pack shares the source's ReqState objects,
so the key must include the pack — otherwise the source's and linked's tabs
for the same request collide (double selection, duplicate key()). Data class →
structural equality, so compare keys with == (not ===). */
internal data class TabKey(val pack: PackState?, val req: ReqState?)
internal val StripTab.tabKey: Any get() = if (isSession) kSessionTabKey else TabKey(pack, req)
// ==================
// MARK: Cross-pack drag (sidebar tree)
// ==================
/* Shared state for dragging within and across packs in the sidebar tree. One
instance is hoisted to App so any PackSection can be the drag source or the
drop target. Two drag kinds: a request row (dragReq, owned by dragReqOwner) or
a whole pack header (dragPack). Every rendered row / header registers its
absolute window position + height here so onDrag can hit-test across packs,
even into collapsed or empty ones. The resolved drop target is published back
so the target section draws the indicator. */
/** Shared state for dragging within and across packs in the sidebar tree. One
instance is hoisted to App so any PackSection can be the drag source or the
drop target. Two drag kinds: a request row (dragReq, owned by dragReqOwner) or
a whole pack header (dragPack). Every rendered row / header registers its
absolute window position + height here so onDrag can hit-test across packs,
even into collapsed or empty ones. The resolved drop target is published back
so the target section draws the indicator. */
internal class TreeDrag {
// What's being dragged (at most one is non-null while a drag is live).
var dragReq by mutableStateOf<ReqState?>(null)
@@ -113,6 +137,7 @@ 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
// plain click (which carries a pixel or two of jitter) still selects / opens.
var engaged by mutableStateOf(false)
+165 -172
View File
@@ -1,7 +1,6 @@
package apidemo
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
// ==================
// MARK: Minimal X.509 / DER parser
@@ -12,191 +11,185 @@ import kotlin.io.encoding.ExperimentalEncodingApi
// 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
val sigAlg: String?, // signature algorithm (friendly name or OID)
val keyAlg: String?, // public-key algorithm (friendly name or OID)
val notBefore: String?, // "YYYY-MM-DD HH:MM:SS UTC"
val notAfter: String?,
val sans: List<String>, // dNSName SANs
)
val version: Int?, // X.509 version (1/2/3)
val serial: String?, // colon-separated hex
val sigAlg: String?, // signature algorithm (friendly name or OID)
val keyAlg: String?, // public-key algorithm (friendly name or OID)
val notBefore: String?, // "YYYY-MM-DD HH:MM:SS UTC"
val notAfter: String?,
val sans: List<String>, // dNSName SANs
)
// ============
// DER reader: each next() returns [tag, contentStart, contentEnd] and advances.
private class DerReader(val b: ByteArray, var pos: Int, val end: Int)
{
fun hasMore() = pos < end
fun next(): IntArray
{
val vTag = b[pos++].toInt() and 0xFF
var vLen = b[pos++].toInt() and 0xFF
if (vLen and 0x80 != 0)
{
val vN = vLen and 0x7F
vLen = 0
repeat(vN) { vLen = (vLen shl 8) or (b[pos++].toInt() and 0xFF) }
}
val vStart = pos
pos += vLen
return intArrayOf(vTag, vStart, vStart + vLen)
}
}
private class DerReader(val b: ByteArray, var pos: Int, val end: Int) {
fun hasMore() = pos < end
fun next(): IntArray {
val vTag = b[pos++].toInt() and 0xFF
var vLen = b[pos++].toInt() and 0xFF
if (vLen and 0x80 != 0) {
val vN = vLen and 0x7F
vLen = 0
repeat(vN) { vLen = (vLen shl 8) or (b[pos++].toInt() and 0xFF) }
}
val vStart = pos
pos += vLen
return intArrayOf(vTag, vStart, vStart + vLen)
}
}
/* Parse the PEM into the fields the chain dialog shows. Returns null on any
structural surprise (the UI just falls back to whatever the backend gave). */
fun parseCertDetails(inPem: String): CertDetails?
{
val vDer = pemToDer(inPem) ?: return null
return try
{
val vCert = DerReader(vDer, 0, vDer.size).next() // Certificate SEQUENCE
val vCertInner = DerReader(vDer, vCert[1], vCert[2])
val vTbs = vCertInner.next() // tbsCertificate
val vSigAlg = firstOidName(vDer, vCertInner.next()) // signatureAlgorithm
/** Parse the PEM into the fields the chain dialog shows. Returns null on any
structural surprise (the UI just falls back to whatever the backend gave). */
fun parseCertDetails(inPem: String): CertDetails? {
val vDer = pemToDer(inPem) ?: return null
return try {
val vCert = DerReader(vDer, 0, vDer.size).next() // Certificate SEQUENCE
val vCertInner = DerReader(vDer, vCert[1], vCert[2])
val vTbs = vCertInner.next() // tbsCertificate
val vSigAlg = firstOidName(vDer, vCertInner.next()) // signatureAlgorithm
val vT = DerReader(vDer, vTbs[1], vTbs[2])
var vE = vT.next()
var vVersion: Int? = null
if (vE[0] == 0xA0) // [0] EXPLICIT version
{
val vVi = DerReader(vDer, vE[1], vE[2]).next()
if (vVi[0] == 0x02 && vVi[2] > vVi[1]) vVersion = (vDer[vVi[1]].toInt() and 0xFF) + 1
vE = vT.next()
}
val vSerial = if (vE[0] == 0x02) bytesToHex(vDer, vE[1], vE[2]) else null
vT.next() // signature
vT.next() // issuer
val vValidity = vT.next() // validity SEQUENCE
val vVr = DerReader(vDer, vValidity[1], vValidity[2])
val vNotBefore = parseAsn1Time(vDer, vVr.next())
val vNotAfter = parseAsn1Time(vDer, vVr.next())
vT.next() // subject
val vSpki = vT.next() // subjectPublicKeyInfo SEQUENCE
// SPKI = SEQUENCE { algorithm AlgorithmIdentifier SEQUENCE { OID … }, key BIT STRING }
val vKeyAlg = firstOidName(vDer, DerReader(vDer, vSpki[1], vSpki[2]).next())
val vSans = ArrayList<String>()
while (vT.hasMore())
{
val vX = vT.next()
if (vX[0] == 0xA3) parseSans(vDer, vX, vSans) // extensions [3]
}
CertDetails(vVersion, vSerial, vSigAlg, vKeyAlg, vNotBefore, vNotAfter, vSans)
}
catch (e: Throwable) { null }
}
val vT = DerReader(vDer, vTbs[1], vTbs[2])
var vE = vT.next()
var vVersion: Int? = null
if (vE[0] == 0xA0) // [0] EXPLICIT version
{
val vVi = DerReader(vDer, vE[1], vE[2]).next()
if (vVi[0] == 0x02 && vVi[2] > vVi[1]) vVersion = (vDer[vVi[1]].toInt() and 0xFF) + 1
vE = vT.next()
}
val vSerial = if (vE[0] == 0x02) bytesToHex(vDer, vE[1], vE[2]) else null
vT.next() // signature
vT.next() // issuer
val vValidity = vT.next() // validity SEQUENCE
val vVr = DerReader(vDer, vValidity[1], vValidity[2])
val vNotBefore = parseAsn1Time(vDer, vVr.next())
val vNotAfter = parseAsn1Time(vDer, vVr.next())
vT.next() // subject
val vSpki = vT.next() // subjectPublicKeyInfo SEQUENCE
// SPKI = SEQUENCE { algorithm AlgorithmIdentifier SEQUENCE { OID … }, key BIT STRING }
val vKeyAlg = firstOidName(vDer, DerReader(vDer, vSpki[1], vSpki[2]).next())
val vSans = ArrayList<String>()
while (vT.hasMore()) {
val vX = vT.next()
if (vX[0] == 0xA3) parseSans(vDer, vX, vSans) // extensions [3]
}
CertDetails(vVersion, vSerial, vSigAlg, vKeyAlg, vNotBefore, vNotAfter, vSans)
} catch (_: Throwable) {
null
}
}
// ============
// Helpers
@OptIn(ExperimentalEncodingApi::class)
private fun pemToDer(inPem: String): ByteArray?
{
val vBody = inPem.lineSequence().filterNot { it.contains("-----") }.joinToString("") { it.trim() }
if (vBody.isBlank()) return null
return try { Base64.decode(vBody) } catch (e: Throwable) { null }
}
private fun pemToDer(inPem: String): ByteArray? {
val vBody = inPem.lineSequence().filterNot { it.contains("-----") }.joinToString("") { it.trim() }
if (vBody.isBlank()) return null
return try {
Base64.decode(vBody)
} catch (_: Throwable) {
null
}
}
/* The friendly name (or dotted OID) of the first child OID of a SEQUENCE — used
for signatureAlgorithm and subjectPublicKeyInfo's AlgorithmIdentifier. */
private fun firstOidName(inB: ByteArray, inSeq: IntArray): String?
{
val vR = DerReader(inB, inSeq[1], inSeq[2])
val vOid = vR.next()
if (vOid[0] != 0x06) return null
return oidName(oidToString(inB, vOid[1], vOid[2]))
}
/** The friendly name (or dotted OID) of the first child OID of a SEQUENCE — used
for signatureAlgorithm and subjectPublicKeyInfo's AlgorithmIdentifier. */
private fun firstOidName(inB: ByteArray, inSeq: IntArray): String? {
val vR = DerReader(inB, inSeq[1], inSeq[2])
val vOid = vR.next()
if (vOid[0] != 0x06) return null
return oidName(oidToString(inB, vOid[1], vOid[2]))
}
/* Pull dNSName entries out of the SubjectAltName extension under [3]. */
private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<String>)
{
val vSeq = DerReader(inB, inExt3[1], inExt3[2]).next() // SEQUENCE OF Extension
val vEs = DerReader(inB, vSeq[1], vSeq[2])
while (vEs.hasMore())
{
val vExtn = vEs.next()
val vEr = DerReader(inB, vExtn[1], vExtn[2])
val vOid = vEr.next() // extnID
// SAN OID 2.5.29.17 == bytes 55 1D 11
val vIsSan = vOid[2] - vOid[1] == 3 &&
(inB[vOid[1]].toInt() and 0xFF) == 0x55 &&
(inB[vOid[1] + 1].toInt() and 0xFF) == 0x1D &&
(inB[vOid[1] + 2].toInt() and 0xFF) == 0x11
var vVal = vEr.next()
if (vVal[0] == 0x01) vVal = vEr.next() // skip critical BOOLEAN
if (vIsSan && vVal[0] == 0x04) // OCTET STRING wrapping GeneralNames
{
val vGnSeq = DerReader(inB, vVal[1], vVal[2]).next()
val vGn = DerReader(inB, vGnSeq[1], vGnSeq[2])
while (vGn.hasMore())
{
val vName = vGn.next()
if (vName[0] == 0x82) inOut.add(inB.copyOfRange(vName[1], vName[2]).decodeToString()) // [2] dNSName
}
}
}
}
/** Pull dNSName entries out of the SubjectAltName extension under [3]. */
private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<String>) {
val vSeq = DerReader(inB, inExt3[1], inExt3[2]).next() // SEQUENCE OF Extension
val vEs = DerReader(inB, vSeq[1], vSeq[2])
while (vEs.hasMore()) {
val vExtn = vEs.next()
val vEr = DerReader(inB, vExtn[1], vExtn[2])
val vOid = vEr.next() // extnID
// SAN OID 2.5.29.17 == bytes 55 1D 11
val vIsSan = vOid[2] - vOid[1] == 3 &&
(inB[vOid[1]].toInt() and 0xFF) == 0x55 &&
(inB[vOid[1] + 1].toInt() and 0xFF) == 0x1D &&
(inB[vOid[1] + 2].toInt() and 0xFF) == 0x11
var vVal = vEr.next()
if (vVal[0] == 0x01) vVal = vEr.next() // skip critical BOOLEAN
if (vIsSan && vVal[0] == 0x04) // OCTET STRING wrapping GeneralNames
{
val vGnSeq = DerReader(inB, vVal[1], vVal[2]).next()
val vGn = DerReader(inB, vGnSeq[1], vGnSeq[2])
while (vGn.hasMore()) {
val vName = vGn.next()
if (vName[0] == 0x82) inOut.add(inB.copyOfRange(vName[1], vName[2]).decodeToString()) // [2] dNSName
}
}
}
}
/* ASN.1 UTCTime (YYMMDD…) / GeneralizedTime (YYYYMMDD…) → readable UTC string. */
private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String?
{
val vS = inB.copyOfRange(inTlv[1], inTlv[2]).decodeToString()
return try
{
val vYear: String
val vRest: String
if (inTlv[0] == 0x18) // GeneralizedTime
{ vYear = vS.substring(0, 4); vRest = vS.substring(4) }
else // UTCTime — RFC 5280 pivot at 50
{
val vYy = vS.substring(0, 2).toInt()
vYear = (if (vYy >= 50) "19" else "20") + vS.substring(0, 2)
vRest = vS.substring(2)
}
val vMo = vRest.substring(0, 2); val vDa = vRest.substring(2, 4)
val vHh = vRest.substring(4, 6); val vMi = vRest.substring(6, 8)
val vSs = if (vRest.length >= 10 && vRest[8].isDigit()) vRest.substring(8, 10) else "00"
"$vYear-$vMo-$vDa $vHh:$vMi:$vSs UTC"
}
catch (e: Throwable) { vS }
}
/** ASN.1 UTCTime (YYMMDD…) / GeneralizedTime (YYYYMMDD…) → readable UTC string. */
private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String? {
val vS = inB.copyOfRange(inTlv[1], inTlv[2]).decodeToString()
return try {
val vYear: String
val vRest: String
if (inTlv[0] == 0x18) // GeneralizedTime
{
vYear = vS.substring(0, 4); vRest = vS.substring(4)
} else // UTCTime — RFC 5280 pivot at 50
{
val vYy = vS.substring(0, 2).toInt()
vYear = (if (vYy >= 50) "19" else "20") + vS.substring(0, 2)
vRest = vS.substring(2)
}
val vMo = vRest.substring(0, 2)
val vDa = vRest.substring(2, 4)
val vHh = vRest.substring(4, 6)
val vMi = vRest.substring(6, 8)
val vSs = if (vRest.length >= 10 && vRest[8].isDigit()) vRest.substring(8, 10) else "00"
"$vYear-$vMo-$vDa $vHh:$vMi:$vSs UTC"
} catch (_: Throwable) {
vS
}
}
/* Colon-separated hex of a byte range (serial number rendering). */
/** 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') }
(inFrom until inTo).joinToString(":") { (inB[it].toInt() and 0xFF).toString(16).padStart(2, '0') }
/* Decode an OID's content bytes to dotted-decimal. */
private fun oidToString(inB: ByteArray, inFrom: Int, inTo: Int): String
{
if (inTo <= inFrom) return ""
val vSb = StringBuilder()
val vFirst = inB[inFrom].toInt() and 0xFF
vSb.append(vFirst / 40).append('.').append(vFirst % 40)
var vAcc = 0L
for (vI in (inFrom + 1) until inTo)
{
val vByte = inB[vI].toInt() and 0xFF
vAcc = (vAcc shl 7) or (vByte and 0x7F).toLong()
if (vByte and 0x80 == 0) { vSb.append('.').append(vAcc); vAcc = 0 }
}
return vSb.toString()
}
/** Decode an OID's content bytes to dotted-decimal. */
private fun oidToString(inB: ByteArray, inFrom: Int, inTo: Int): String {
if (inTo <= inFrom) return ""
val vSb = StringBuilder()
val vFirst = inB[inFrom].toInt() and 0xFF
vSb.append(vFirst / 40).append('.').append(vFirst % 40)
var vAcc = 0L
for (vI in (inFrom + 1) until inTo) {
val vByte = inB[vI].toInt() and 0xFF
vAcc = (vAcc shl 7) or (vByte and 0x7F).toLong()
if (vByte and 0x80 == 0) {
vSb.append('.').append(vAcc); vAcc = 0
}
}
return vSb.toString()
}
/* Friendly name for the common signature / public-key OIDs, else the OID. */
private fun oidName(inOid: String): String = when (inOid)
{
"1.2.840.113549.1.1.1" -> "RSA"
"1.2.840.113549.1.1.5" -> "SHA1withRSA"
"1.2.840.113549.1.1.11" -> "SHA256withRSA"
"1.2.840.113549.1.1.12" -> "SHA384withRSA"
"1.2.840.113549.1.1.13" -> "SHA512withRSA"
"1.2.840.113549.1.1.10" -> "RSASSA-PSS"
"1.2.840.10045.2.1" -> "EC"
"1.2.840.10045.4.3.2" -> "ECDSAwithSHA256"
"1.2.840.10045.4.3.3" -> "ECDSAwithSHA384"
"1.2.840.10045.4.3.4" -> "ECDSAwithSHA512"
"1.3.101.112" -> "Ed25519"
"1.3.101.113" -> "Ed448"
else -> inOid
}
/** Friendly name for the common signature / public-key OIDs, else the OID. */
private fun oidName(inOid: String): String = when (inOid) {
"1.2.840.113549.1.1.1" -> "RSA"
"1.2.840.113549.1.1.5" -> "SHA1withRSA"
"1.2.840.113549.1.1.11" -> "SHA256withRSA"
"1.2.840.113549.1.1.12" -> "SHA384withRSA"
"1.2.840.113549.1.1.13" -> "SHA512withRSA"
"1.2.840.113549.1.1.10" -> "RSASSA-PSS"
"1.2.840.10045.2.1" -> "EC"
"1.2.840.10045.4.3.2" -> "ECDSAwithSHA256"
"1.2.840.10045.4.3.3" -> "ECDSAwithSHA384"
"1.2.840.10045.4.3.4" -> "ECDSAwithSHA512"
"1.3.101.112" -> "Ed25519"
"1.3.101.113" -> "Ed448"
else -> inOid
}
+4 -4
View File
@@ -11,10 +11,10 @@ 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
platform text pipeline (accepts a name string). Null when not bundled. */
/** The raw family name string — used by wrappedRowCount, which measures via the
platform text pipeline (accepts a name string). Null when not bundled. */
expect val monoFontFamilyName: String?
/* The material3 Text / BasicTextField-shaped FontFamily for the mono family.
Null when not bundled. */
/** The material3 Text / BasicTextField-shaped FontFamily for the mono family.
Null when not bundled. */
expect val monoFontFamily: FontFamily?
+1
View File
@@ -25,6 +25,7 @@ fun formatBody(inText: String, inFormat: BodyFormat): String =
BodyFormat.JSON -> runCatching {
fPrettyJson.encodeToString(JsonElement.serializer(), fPrettyJson.parseToJsonElement(inText))
}.getOrDefault(inText)
BodyFormat.XML -> runCatching { formatXml(inText) }.getOrDefault(inText)
else -> inText
}
+17 -19
View File
@@ -1,12 +1,12 @@
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
@@ -16,10 +16,10 @@ import kotlin.time.TimeSource
// MARK: HttpRunner — executes an ApiRequest through Ktor
// ==================
/* Holds one Ktor HttpClient for the app's lifetime. One engine on every desktop
target: Ktor's Curl engine (bundled libcurl — Schannel on Windows, OpenSSL on
macOS/Linux). Same TLS stack as the client-cert path in CurlMtls.kt.
run() is a suspend fun — call it off the UI dispatcher. */
/** Holds one Ktor HttpClient for the app's lifetime. One engine on every desktop
target: Ktor's Curl engine (bundled libcurl — Schannel on Windows, OpenSSL on
macOS/Linux). Same TLS stack as the client-cert path in CurlMtls.kt.
run() is a suspend fun — call it off the UI dispatcher. */
class HttpRunner {
private val fClient = createApiHttpClient()
@@ -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,
@@ -103,7 +102,6 @@ class HttpRunner {
throw e
} catch (e: Throwable) {
ApiResponse(
ok = false,
status = 0,
statusText = "—",
timeMs = vMark.elapsedNow().inWholeMilliseconds,
@@ -118,30 +116,30 @@ class HttpRunner {
fun close() = fClient.close()
}
/* Heuristic for a non-text body: textual content types (text, json, xml, html,
javascript, csv, form) are shown as text; everything else, or bytes with
embedded NULs, is treated as binary. */
/** Heuristic for a non-text body: textual content types (text, json, xml, html,
javascript, csv, form) are shown as text; everything else, or bytes with
embedded NULs, is treated as binary. */
internal fun isBinaryBody(inContentType: String?, inBytes: ByteArray): Boolean {
val vCt = inContentType?.lowercase() ?: ""
val vTextual = vCt.startsWith("text/") ||
vCt.contains("json") || vCt.contains("xml") || vCt.contains("html") ||
vCt.contains("javascript") || vCt.contains("csv") || vCt.contains("x-www-form-urlencoded")
vCt.contains("json") || vCt.contains("xml") || vCt.contains("html") ||
vCt.contains("javascript") || vCt.contains("csv") || vCt.contains("x-www-form-urlencoded")
if (vTextual) return false
if (vCt.isNotEmpty()) return true // a non-textual content type → binary
return inBytes.take(1024).any { it.toInt() == 0 } // no type: sniff for NUL bytes
}
/* True when the bytes are gzip — either the response says so or they carry the
gzip magic number (1f 8b). */
/** True when the bytes are gzip — either the response says so or they carry the
gzip magic number (1f 8b). */
private fun isGzip(inEncoding: String?, inBytes: ByteArray): Boolean =
inEncoding?.contains("gzip", ignoreCase = true) == true ||
(inBytes.size >= 2 && inBytes[0] == 0x1f.toByte() && inBytes[1] == 0x8b.toByte())
(inBytes.size >= 2 && inBytes[0] == 0x1f.toByte() && inBytes[1] == 0x8b.toByte())
/* Read a file's raw bytes (used for a FILE request body). */
/** Read a file's raw bytes (used for a FILE request body). */
internal fun readFileBytes(inPath: String): ByteArray =
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()
+103 -40
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
request's own editable list: Override copies an inherited entry down, and own
rows that shadow an inherited one get an OverrideMark. Used by Query / Var /
Headers — the only differences are the key case-sensitivity and the labels. */
/** A request tab pairing an inherited (read-only, source-tagged) list with the
request's own editable list: Override copies an inherited entry down, and own
rows that shadow an inherited one get an OverrideMark. Used by Query / Var /
Headers — the only differences are the key case-sensitivity and the labels. */
@Composable
internal fun InheritedEditableTab(
inInherited: List<InheritedKv>,
@@ -40,8 +38,10 @@ internal fun InheritedEditableTab(
fun norm(inKey: String) = if (inCaseInsensitive) inKey.lowercase() else inKey
val vOwnKeys = inOwn.filter { it.key.isNotBlank() }.map { norm(it.key) }.toSet()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
InheritedKvSection(inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
inOnOverride = if (inReadOnly) null else inOnOverride)
InheritedKvSection(
inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
inOnOverride = if (inReadOnly) null else inOnOverride
)
if (inInherited.any { it.kv.key.isNotBlank() }) {
HorizontalDivider(color = c.border)
Text(inOwnTitle, color = c.dim, fontSize = 11.sp)
@@ -52,26 +52,52 @@ internal fun InheritedEditableTab(
}
}
/* Request Headers tab: inherited headers (case-insensitive) + the request's own. */
/** Request Headers tab: inherited headers (case-insensitive) + the request's own. */
@Composable
internal fun HeadersTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
InheritedEditableTab(inInherited, inReq.headers, inCaseInsensitive = true, inReadOnly = inReadOnly, inOwnTitle = "Request headers",
internal fun HeadersTab(
inReq: ApiRequest,
inInherited: List<InheritedKv>,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) =
InheritedEditableTab(
inInherited, inReq.headers, inCaseInsensitive = true, inReadOnly = inReadOnly, inOwnTitle = "Request headers",
inOnOverride = { vKv -> inEdit { it.copy(headers = it.headers + vKv) } },
inOnChange = { v -> inEdit { it.copy(headers = v) } })
/* Request Query tab: inherited query params (case-sensitive) + the request's own. */
/** Request Query tab: inherited query params (case-sensitive) + the request's own. */
@Composable
internal fun QueryTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
InheritedEditableTab(inInherited, inReq.params, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request query params",
internal fun QueryTab(
inReq: ApiRequest,
inInherited: List<InheritedKv>,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) =
InheritedEditableTab(
inInherited,
inReq.params,
inCaseInsensitive = false,
inReadOnly = inReadOnly,
inOwnTitle = "Request query params",
inOnOverride = { vKv -> inEdit { it.copy(params = it.params + vKv) } },
inOnChange = { v -> inEdit { it.copy(params = v) } })
/* Request Var tab: inherited variables (case-sensitive {{name}}) + the request's
own. A request's own variable overrides the same name inherited from a pack /
the session when the request is sent. */
/** Request Var tab: inherited variables (case-sensitive {{name}}) + the request's
own. A request's own variable overrides the same name inherited from a pack /
the session when the request is sent. */
@Composable
internal fun VarTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
InheritedEditableTab(inInherited, inReq.variables, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request variables",
internal fun VarTab(
inReq: ApiRequest,
inInherited: List<InheritedKv>,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) =
InheritedEditableTab(
inInherited,
inReq.variables,
inCaseInsensitive = false,
inReadOnly = inReadOnly,
inOwnTitle = "Request variables",
inOnOverride = { vKv -> inEdit { it.copy(variables = it.variables + vKv) } },
inOnChange = { v -> inEdit { it.copy(variables = v) } })
@@ -79,14 +105,18 @@ internal fun VarTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnl
// MARK: Inheritance UI (source tags / override markers / inherited lists)
// ==================
/* A small pill naming the kind of scope an inherited value comes from ("Session"
/ "Pack"); hover shows the full path (e.g. "Methods / Nested"). The path tooltip
is skipped when it would just repeat the label (the session). */
/** A small pill naming the kind of scope an inherited value comes from ("Session"
/ "Pack"); hover shows the full path (e.g. "Methods / Nested"). The path tooltip
is skipped when it would just repeat the label (the session). */
@Composable
internal fun SourceTag(inLabel: String, inPath: String) {
val c = LocalAppColors.current
val vPill = @Composable {
Box(modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(c.accent.copy(alpha = 0.16f), RoundedCornerShape(4.dp)).padding(horizontal = 5.dp, vertical = 1.dp)) {
Box(
modifier = Modifier.clip(RoundedCornerShape(4.dp))
.background(c.accent.copy(alpha = 0.16f), RoundedCornerShape(4.dp))
.padding(horizontal = 5.dp, vertical = 1.dp)
) {
Text(inLabel, color = c.accent, fontSize = 9.sp)
}
}
@@ -101,8 +131,8 @@ internal fun SourceTag(inLabel: String, inPath: String) {
}
}
/* The tiny marker shown on an own value that shadows an inherited one — hover for
"Overrides <key> from <path>". */
/** The tiny marker shown on an own value that shadows an inherited one — hover for
"Overrides <key> from <path>". */
@Composable
internal fun OverrideMark(inKey: String, inPath: String) {
val c = LocalAppColors.current
@@ -120,9 +150,9 @@ internal fun OverrideMark(inKey: String, inPath: String) {
}
}
/* Read-only list of inherited key/values, each tagged with its source. When not
already overridden in this scope (own key absent) and inOnOverride != null, a
row offers a one-click Override that copies it into this scope's own list. */
/** Read-only list of inherited key/values, each tagged with its source. When not
already overridden in this scope (own key absent) and inOnOverride != null, a
row offers a one-click Override that copies it into this scope's own list. */
@Composable
internal fun InheritedKvSection(
inTitle: String,
@@ -139,15 +169,26 @@ internal fun InheritedKvSection(
vShown.forEach { vIt ->
val vKey = if (inCaseInsensitive) vIt.kv.key.lowercase() else vIt.kv.key
val vOverridden = vKey in inOwnKeys
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SourceTag(vIt.source, vIt.path)
Text(vIt.kv.key, color = c.dim, fontSize = 12.sp, modifier = Modifier.weight(0.42f))
Text(vIt.kv.value, color = c.dim.copy(alpha = if (vOverridden) 0.4f else 1f), fontSize = 12.sp, modifier = Modifier.weight(0.58f))
Text(
vIt.kv.value,
color = c.dim.copy(alpha = if (vOverridden) 0.4f else 1f),
fontSize = 12.sp,
modifier = Modifier.weight(0.58f)
)
when {
vOverridden -> Text("overridden", color = c.dim.copy(alpha = 0.6f), fontSize = 10.sp)
inOnOverride != null -> Box(
modifier = Modifier.clip(RoundedCornerShape(6.dp)).border(1.dp, c.border, RoundedCornerShape(6.dp))
.clickable { inOnOverride(KeyVal(vIt.kv.key, vIt.kv.value)) }.padding(horizontal = 8.dp, vertical = 3.dp),
modifier = Modifier.clip(RoundedCornerShape(6.dp))
.border(1.dp, c.border, RoundedCornerShape(6.dp))
.clickable { inOnOverride(KeyVal(vIt.kv.key, vIt.kv.value)) }
.padding(horizontal = 8.dp, vertical = 3.dp),
) { Text("Override", color = c.accent, fontSize = 11.sp) }
}
}
@@ -159,11 +200,15 @@ internal fun InheritedKvSection(
// MARK: Key/value editor
// ==================
/* inOverrideInfo maps an own row to the source label it shadows (or null) so the
editor can flag overriding rows with an OverrideMark. It sits before inOnChange
so existing trailing-lambda calls (KeyValEditor(rows) { … }) still bind to onChange. */
/** inOverrideInfo maps an own row to the source label it shadows (or null) so the
editor can flag overriding rows with an OverrideMark. It sits before inOnChange
so existing trailing-lambda calls (KeyValEditor(rows) { … }) still bind to onChange. */
@Composable
internal fun KeyValEditor(inRows: List<KeyVal>, inOverrideInfo: (KeyVal) -> String? = { null }, inOnChange: (List<KeyVal>) -> Unit) {
internal fun KeyValEditor(
inRows: List<KeyVal>,
inOverrideInfo: (KeyVal) -> String? = { null },
inOnChange: (List<KeyVal>) -> Unit
) {
val c = LocalAppColors.current
val vAnyOverride = inRows.any { inOverrideInfo(it) != null }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -188,15 +233,33 @@ internal fun KeyValEditor(inRows: List<KeyVal>, inOverrideInfo: (KeyVal) -> Stri
Checkbox(
checked = vKv.enabled,
onCheckedChange = null,
colors = CheckboxDefaults.colors(checkedColor = c.accent, uncheckedColor = c.dim, checkmarkColor = c.onAccent),
colors = CheckboxDefaults.colors(
checkedColor = c.accent,
uncheckedColor = c.dim,
checkmarkColor = c.onAccent
),
)
}
if (vAnyOverride) Box(modifier = Modifier.width(18.dp), contentAlignment = Alignment.Center) {
inOverrideInfo(vKv)?.let { vSrc -> OverrideMark(vKv.key, vSrc) }
}
ThinField(vKv.key, { v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(key = v) else vR }) }, inModifier = Modifier.weight(1f), inPlaceholder = "key")
ThinField(vKv.value, { v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(value = v) else vR }) }, inModifier = Modifier.weight(1.4f), inPlaceholder = "value")
IconBtn(MaterialSymbols.Close, "Remove", inSize = 16.dp) { inOnChange(inRows.filterIndexed { vJ, _ -> vJ != vI }) }
ThinField(
vKv.key,
{ v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(key = v) else vR }) },
inModifier = Modifier.weight(1f),
inPlaceholder = "key"
)
ThinField(
vKv.value,
{ v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(value = v) else vR }) },
inModifier = Modifier.weight(1.4f),
inPlaceholder = "value"
)
IconBtn(
MaterialSymbols.Close,
"Remove",
inSize = 16.dp
) { inOnChange(inRows.filterIndexed { vJ, _ -> vJ != vI }) }
}
}
OutlinedAction(MaterialSymbols.Add, "Add row") { inOnChange(inRows + KeyVal()) }
+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
+180 -102
View File
@@ -6,11 +6,11 @@ import kotlinx.serialization.Serializable
// MARK: Pack model (serialized to / from .json)
// ==================
/* A "pack" is a savable collection of requests plus its environment variables
— the export/import unit. Variables are referenced as {{name}} in any URL,
query value, header or body and substituted just before the request is sent
(see resolveVars in Tools.kt). Packs may be empty; the rich starter set lives
in defaultSession(). */
/** A "pack" is a savable collection of requests plus its environment variables
— the export/import unit. Variables are referenced as {{name}} in any URL,
query value, header or body and substituted just before the request is sent
(see resolveVars in Tools.kt). Packs may be empty; the rich starter set lives
in defaultSession(). */
@Serializable
data class Pack(
val name: String = "My Pack",
@@ -26,9 +26,9 @@ 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
to libcurl's CURLOPT_SSLCERT / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. */
/** A client-certificate (mutual TLS) configuration. Lives on a request today and
— with the pack tree — on the session / packs too (inherited downward). Maps
to libcurl's CURLOPT_SSLCERT / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. */
@Serializable
data class CertConfig(
val certPath: String = "",
@@ -38,13 +38,13 @@ 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
ladder (session → pack → sub-pack → request, for variables / query params /
headers / client cert, innermost wins), loose root requests, a nested sub-pack,
per-request overrides, a client-cert pack, and a linked-copy pack. Each /get or
/anything endpoint echoes the args + headers it received, so the merged,
inherited result is visible in the response. Loaded as an unsaved session. */
/** The starter SESSION loaded on first launch (and on demand from the session
menu) — a guided tour of every feature against httpbin.org: the inheritance
ladder (session → pack → sub-pack → request, for variables / query params /
headers / client cert, innermost wins), loose root requests, a nested sub-pack,
per-request overrides, a client-cert pack, and a linked-copy pack. Each /get or
/anything endpoint echoes the args + headers it received, so the merged,
inherited result is visible in the response. Loaded as an unsaved session. */
fun defaultSession(): Session = Session(
activePack = 0,
// ── Session level — the base of every inheritance ladder ──
@@ -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(
ApiRequest(name = "Ping (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
ApiRequest(name = "Headers echo (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/headers"),
)),
root = Pack(
isRoot = true, name = "", requests = listOf(
ApiRequest(name = "Ping (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
ApiRequest(name = "Headers echo (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/headers"),
)
),
packs = listOf(
// ── Methods — pack header/param/var inherited by its requests, plus a
// nested sub-pack and per-request overrides of each kind. ──
SavedPack(pack = Pack(name = "Methods", id = "pk-methods", color = 1,
headers = listOf(KeyVal("Accept", "application/json")), // pack header, inherited by its requests
params = listOf(KeyVal("source", "methods")), // pack query param, inherited (echoed in /get args)
variables = listOf(KeyVal("apiVer", "v2")), // overrides the session's apiVer for this pack
requests = listOf(
ApiRequest(name = "GET — echoes inherited", method = ReqMethod.GET, url = "{{baseUrl}}/get",
params = listOf(KeyVal("q", "compose"))), // own q + inherited trace=session + source=methods
ApiRequest(name = "GET — overrides query param", method = ReqMethod.GET, url = "{{baseUrl}}/get",
params = listOf(KeyVal("trace", "request"))), // request's trace wins over the session's
ApiRequest(name = "POST — overrides header", method = ReqMethod.POST, url = "{{baseUrl}}/post",
headers = listOf(KeyVal("Accept", "text/plain")), // request's Accept wins over the pack's
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{\n \"user\": \"{{user}}\",\n \"api\": \"{{apiVer}}\"\n}"),
ApiRequest(name = "POST — overrides {{user}} var", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
variables = listOf(KeyVal("user", "request-user")), // request var beats session/pack — see body echo
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"who\": \"{{user}}\" }"),
ApiRequest(name = "PUT — replace", method = ReqMethod.PUT, url = "{{baseUrl}}/put",
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"id\": 1, \"name\": \"updated\" }"),
ApiRequest(name = "DELETE", method = ReqMethod.DELETE, url = "{{baseUrl}}/delete"),
ApiRequest(name = "Anything echo", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"hello\": \"world\" }"),
),
// Nested sub-pack — inherits Session → Methods → here (apiVer becomes v3,
// plus its own X-Nested header; trace / source / Accept still flow down).
subPacks = listOf(
Pack(name = "Nested", color = 4,
variables = listOf(KeyVal("apiVer", "v3")),
headers = listOf(KeyVal("X-Nested", "true")),
requests = listOf(
ApiRequest(name = "GET — deep inheritance", method = ReqMethod.GET, url = "{{baseUrl}}/anything/{{apiVer}}"),
)),
))),
SavedPack(
pack = Pack(
name = "Methods", id = "pk-methods", color = 1,
headers = listOf(KeyVal("Accept", "application/json")), // pack header, inherited by its requests
params = listOf(
KeyVal(
"source",
"methods"
)
), // pack query param, inherited (echoed in /get args)
variables = listOf(
KeyVal(
"apiVer",
"v2"
)
), // overrides the session's apiVer for this pack
requests = listOf(
ApiRequest(
name = "GET — echoes inherited", method = ReqMethod.GET, url = "{{baseUrl}}/get",
params = listOf(KeyVal("q", "compose"))
), // own q + inherited trace=session + source=methods
ApiRequest(
name = "GET — overrides query param", method = ReqMethod.GET, url = "{{baseUrl}}/get",
params = listOf(KeyVal("trace", "request"))
), // request's trace wins over the session's
ApiRequest(
name = "POST — overrides header",
method = ReqMethod.POST,
url = "{{baseUrl}}/post",
headers = listOf(KeyVal("Accept", "text/plain")), // request's Accept wins over the pack's
bodyType = BodyType.TEXT,
bodyFormat = BodyFormat.JSON,
body = "{\n \"user\": \"{{user}}\",\n \"api\": \"{{apiVer}}\"\n}"
),
ApiRequest(
name = "POST — overrides {{user}} var", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
variables = listOf(
KeyVal(
"user",
"request-user"
)
), // request var beats session/pack — see body echo
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"who\": \"{{user}}\" }"
),
ApiRequest(
name = "PUT — replace",
method = ReqMethod.PUT,
url = "{{baseUrl}}/put",
bodyType = BodyType.TEXT,
bodyFormat = BodyFormat.JSON,
body = "{ \"id\": 1, \"name\": \"updated\" }"
),
ApiRequest(name = "DELETE", method = ReqMethod.DELETE, url = "{{baseUrl}}/delete"),
ApiRequest(
name = "Anything echo", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"hello\": \"world\" }"
),
),
// Nested sub-pack — inherits Session → Methods → here (apiVer becomes v3,
// plus its own X-Nested header; trace / source / Accept still flow down).
subPacks = listOf(
Pack(
name = "Nested", color = 4,
variables = listOf(KeyVal("apiVer", "v3")),
headers = listOf(KeyVal("X-Nested", "true")),
requests = listOf(
ApiRequest(
name = "GET — deep inheritance",
method = ReqMethod.GET,
url = "{{baseUrl}}/anything/{{apiVer}}"
),
)
),
)
)
),
// ── Auth & status — uses the inherited {{token}} / {{user}} / {{password}} ──
SavedPack(pack = Pack(name = "Auth & status", color = 2, requests = listOf(
ApiRequest(name = "Bearer auth", method = ReqMethod.GET, url = "{{baseUrl}}/bearer",
headers = listOf(KeyVal("Authorization", "Bearer {{token}}"))),
ApiRequest(name = "Basic auth", method = ReqMethod.GET, url = "{{baseUrl}}/basic-auth/{{user}}/{{password}}"),
ApiRequest(name = "Status 200", method = ReqMethod.GET, url = "{{baseUrl}}/status/200"),
ApiRequest(name = "Status 500", method = ReqMethod.GET, url = "{{baseUrl}}/status/500"),
ApiRequest(name = "Redirect x3", method = ReqMethod.GET, url = "{{baseUrl}}/redirect/3"),
ApiRequest(name = "Delay 3s (try Cancel)", method = ReqMethod.GET, url = "{{baseUrl}}/delay/3"),
))),
SavedPack(
pack = Pack(
name = "Auth & status", color = 2, requests = listOf(
ApiRequest(
name = "Bearer auth", method = ReqMethod.GET, url = "{{baseUrl}}/bearer",
headers = listOf(KeyVal("Authorization", "Bearer {{token}}"))
),
ApiRequest(
name = "Basic auth",
method = ReqMethod.GET,
url = "{{baseUrl}}/basic-auth/{{user}}/{{password}}"
),
ApiRequest(name = "Status 200", method = ReqMethod.GET, url = "{{baseUrl}}/status/200"),
ApiRequest(name = "Status 500", method = ReqMethod.GET, url = "{{baseUrl}}/status/500"),
ApiRequest(name = "Redirect x3", method = ReqMethod.GET, url = "{{baseUrl}}/redirect/3"),
ApiRequest(name = "Delay 3s (try Cancel)", method = ReqMethod.GET, url = "{{baseUrl}}/delay/3"),
)
)
),
// ── Formats — response viewer (JSON / XML / HTML / image) ──
SavedPack(pack = Pack(name = "Formats", color = 3, requests = listOf(
ApiRequest(name = "JSON", method = ReqMethod.GET, url = "{{baseUrl}}/json"),
ApiRequest(name = "XML", method = ReqMethod.GET, url = "{{baseUrl}}/xml"),
ApiRequest(name = "HTML", method = ReqMethod.GET, url = "{{baseUrl}}/html"),
ApiRequest(name = "UTF-8 text", method = ReqMethod.GET, url = "{{baseUrl}}/encoding/utf8"),
ApiRequest(name = "PNG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/png"),
ApiRequest(name = "SVG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/svg"),
))),
SavedPack(
pack = Pack(
name = "Formats", color = 3, requests = listOf(
ApiRequest(name = "JSON", method = ReqMethod.GET, url = "{{baseUrl}}/json"),
ApiRequest(name = "XML", method = ReqMethod.GET, url = "{{baseUrl}}/xml"),
ApiRequest(name = "HTML", method = ReqMethod.GET, url = "{{baseUrl}}/html"),
ApiRequest(name = "UTF-8 text", method = ReqMethod.GET, url = "{{baseUrl}}/encoding/utf8"),
ApiRequest(name = "PNG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/png"),
ApiRequest(name = "SVG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/svg"),
)
)
),
// ── Secure (mTLS) — a PACK-LEVEL client cert, inherited by its requests.
// Open a request's Cert tab to see the inherited cert (source pill +
// Override). Set real cert paths to actually send; the example paths
// won't load. The second request overrides with its own cert. ──
SavedPack(pack = Pack(name = "Secure (mTLS)", color = 5,
cert = CertConfig(certPath = "/path/to/client.p12", certFormat = CertFormat.PKCS12, certPassword = "changeit"),
requests = listOf(
ApiRequest(name = "Inherits the pack cert", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
ApiRequest(name = "Overrides with own cert", method = ReqMethod.GET, url = "{{baseUrl}}/get",
certPath = "/path/to/other-client.pem", certFormat = CertFormat.PEM, keyPath = "/path/to/other-key.pem"),
))),
SavedPack(
pack = Pack(
name = "Secure (mTLS)", color = 5,
cert = CertConfig(
certPath = "/path/to/client.p12",
certFormat = CertFormat.PKCS12,
certPassword = "changeit"
),
requests = listOf(
ApiRequest(name = "Inherits the pack cert", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
ApiRequest(
name = "Overrides with own cert",
method = ReqMethod.GET,
url = "{{baseUrl}}/get",
certPath = "/path/to/other-client.pem",
certFormat = CertFormat.PEM,
keyPath = "/path/to/other-key.pem"
),
)
)
),
// ── Linked copy of "Methods": a read-only mirror of its requests, but with
// its own baseUrl var → the same calls run against the httpbingo sibling.
// Showcases linked packs + per-pack variable override. ──
SavedPack(pack = Pack(name = "Methods · httpbingo", color = 6, requests = emptyList(),
variables = listOf(KeyVal("baseUrl", "https://httpbingo.org")), linkedTo = "pk-methods")),
SavedPack(
pack = Pack(
name = "Methods · httpbingo", color = 6, requests = emptyList(),
variables = listOf(KeyVal("baseUrl", "https://httpbingo.org")), linkedTo = "pk-methods"
)
),
),
)
@@ -159,27 +243,29 @@ data class ApiRequest(
val certPassword: String = "",
)
/* Whether this request carries a client certificate (and so is sent via the
libcurl mTLS path rather than the default engine). */
/** 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
/ 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. */
/** Certificate / private-key encodings, mapped to libcurl's CURLOPT_SSLCERTTYPE
/ CURLOPT_SSLKEYTYPE string values and to `curl --cert-type` / `--key-type`.
Runtime support depends on the TLS backend: OpenSSL (macOS/Linux) handles all
three; Schannel (Windows) effectively supports P12. */
@Serializable
enum class CertFormat(val curlName: String, val label: String) {
PEM("PEM", "PEM"),
@@ -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,9 +292,9 @@ val BodyType.label: String
BodyType.FILE -> "File"
}
/* The Content-Type the request body implies (null when there's no body). For a
TEXT body it comes from the chosen format (Text→text/plain, JSON→application/
json, …); FORM / FILE are fixed. Sent unless the request already sets one. */
/** The Content-Type the request body implies (null when there's no body). For a
TEXT body it comes from the chosen format (Text→text/plain, JSON→application/
json, …); FORM / FILE are fixed. Sent unless the request already sets one. */
fun ApiRequest.bodyContentType(): String? = when (bodyType) {
BodyType.NONE -> null
BodyType.TEXT -> bodyFormat.contentType
@@ -228,7 +307,6 @@ fun ApiRequest.bodyContentType(): String? = when (bodyType) {
// ==================
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
+14 -14
View File
@@ -1,8 +1,8 @@
package apidemo
import apidemo.compat.systemFileSystem
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okio.FileSystem
import okio.Path.Companion.toPath
// ==================
@@ -16,8 +16,8 @@ private val fJson = Json {
encodeDefaults = true
}
/* Write the pack as pretty JSON to inPath. Returns null on success, else the
error message. */
/** Write the pack as pretty JSON to inPath. Returns null on success, else the
error message. */
fun exportPack(inPack: Pack, inPath: String): String? = try {
systemFileSystem.write(inPath.trim().toPath()) {
writeUtf8(fJson.encodeToString(inPack))
@@ -27,7 +27,7 @@ fun exportPack(inPack: Pack, inPath: String): String? = try {
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))
@@ -35,7 +35,7 @@ fun importPack(inPath: String): Result<Pack> = try {
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))
@@ -45,7 +45,7 @@ fun exportSession(inSession: Session, inPath: String): String? = try {
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))
@@ -53,8 +53,8 @@ fun importSession(inPath: String): Result<Session> = try {
Result.failure(e)
}
/* Rename inPath to inNewName within the same directory (adding a .json
extension when the new name has none), returning the new path on success. */
/** Rename inPath to inNewName within the same directory (adding a .json
extension when the new name has none), returning the new path on success. */
fun renameFile(inPath: String, inNewName: String): Result<String> = try {
val vSrc = inPath.trim().toPath()
val vLeaf = inNewName.trim().let { if (it.contains('.')) it else "$it.json" }
@@ -65,8 +65,8 @@ fun renameFile(inPath: String, inNewName: String): Result<String> = try {
Result.failure(e)
}
/* Write arbitrary text (a response body / headers dump) to inPath. Returns null
on success, else the error message. */
/** Write arbitrary text (a response body / headers dump) to inPath. Returns null
on success, else the error message. */
fun writeTextFile(inPath: String, inText: String): String? = try {
systemFileSystem.write(inPath.trim().toPath()) { writeUtf8(inText) }
null
@@ -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
isn't valid JSON (so plain-text / HTML responses still show). */
/** Re-indent a JSON response for display; returns the input unchanged if it
isn't valid JSON (so plain-text / HTML responses still show). */
fun prettyJsonOrRaw(inText: String): String = try {
fJson.encodeToString(JsonElement.serializer(), fJson.parseToJsonElement(inText))
} catch (e: Throwable) {
} catch (_: Throwable) {
inText
}
+25 -22
View File
@@ -1,22 +1,25 @@
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,
every open pack (with its file path + dirty flag so unsaved work survives a
quit) and which pack was active. `launched` flips to true after the very
first run so the httpbin starter pack is only auto-loaded once. */
/** Everything the app restores on relaunch: the theme, the global override env,
every open pack (with its file path + dirty flag so unsaved work survives a
quit) and which pack was active. `launched` flips to true after the very
first run so the httpbin starter pack is only auto-loaded once. */
@Serializable
data class AppState(
val launched: Boolean = false,
val dark: Boolean = true,
val theme: String = "purple", // selected voltic palette id (VolticPalette.id)
val globalEnv: List<KeyVal> = emptyList(), // session-level variables (base of the inheritance ladder)
val globalHeaders: List<KeyVal> = emptyList(), // session-level headers (inherited by every request)
val globalParams: List<KeyVal> = emptyList(), // session-level query params (inherited by every request)
@@ -34,8 +37,8 @@ data class AppState(
val activeReq: Int = -1,
)
/* A pack as stored in the app state: the pack content plus where it was last
saved (null = never saved to a file) and whether it has unsaved edits. */
/** A pack as stored in the app state: the pack content plus where it was last
saved (null = never saved to a file) and whether it has unsaved edits. */
@Serializable
data class SavedPack(
val path: String? = null,
@@ -43,9 +46,9 @@ data class SavedPack(
val pack: Pack = Pack(),
)
/* A whole session, the export/import unit for the working set: every open pack
(self-contained — full content embedded) plus the shared global env and which
pack was active. Only one session is open at a time. */
/** A whole session, the export/import unit for the working set: every open pack
(self-contained — full content embedded) plus the shared global env and which
pack was active. Only one session is open at a time. */
@Serializable
data class Session(
val packs: List<SavedPack> = emptyList(),
@@ -72,40 +75,40 @@ private const val kOrg = "ComposeDesktopNative"
private const val kApp = "ApiManager"
private const val kStateFile = "state.json"
/* Absolute path to the state file in the per-user app-data dir (created on
demand by SDL_GetPrefPath). Null if no writable dir is available. */
/** Absolute path to the state file in the per-user app-data dir (created on
demand by SDL_GetPrefPath). Null if no writable dir is available. */
private fun stateFilePath(): String? {
val vDir = appDataDir(kOrg, kApp) ?: return null // ends with a separator
return vDir + kStateFile
}
/* Read the persisted app state, or a fresh (first-launch) AppState if there's
nothing saved yet or it can't be read. */
/** Read the persisted app state, or a fresh (first-launch) AppState if there's
nothing saved yet or it can't be read. */
fun loadAppState(): AppState {
val vPath = stateFilePath() ?: return AppState()
return try {
val vText = systemFileSystem.read(vPath.toPath()) { readUtf8() }
fStateJson.decodeFromString<AppState>(vText)
} catch (e: Throwable) {
} catch (_: Throwable) {
AppState()
}
}
/* Write the app state. Best-effort — failures are swallowed (it's a cache of
the session, not the user's exported .json packs). */
/** Write the app state. Best-effort — failures are swallowed (it's a cache of
the session, not the user's exported .json packs). */
fun saveAppState(inState: AppState) {
val vPath = stateFilePath() ?: return
try {
systemFileSystem.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
} catch (e: Throwable) {
} catch (_: Throwable) {
// ignore — nothing the user can act on, and packs can still be exported
}
}
/* Open the per-user app-data folder where the state file lives in the OS file
manager. Revealing the state file opens its containing folder, which SDL has
already created even when nothing's been saved yet. Fire-and-forget — the
reveal launches off the main thread (see revealInFileManager). */
/** Open the per-user app-data folder where the state file lives in the OS file
manager. Revealing the state file opens its containing folder, which SDL has
already created even when nothing's been saved yet. Fire-and-forget — the
reveal launches off the main thread (see revealInFileManager). */
fun openSettingsFolder() {
val vPath = stateFilePath() ?: return
revealInFileManager(vPath)
+55 -15
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,8 +86,8 @@ internal fun OptionsMenu(
// MARK: Method picker (dropdown)
// ==================
/* Panel 2 — one unified bar: a method dropdown (coloured label + unfold arrows),
a borderless URL field that melts into the bar, and Send (or Cancel). */
/** Panel 2 — one unified bar: a method dropdown (coloured label + unfold arrows),
a borderless URL field that melts into the bar, and Send (or Cancel). */
@Composable
internal fun UrlBar(
inReq: ApiRequest,
@@ -99,16 +129,18 @@ internal fun UrlBar(
// Borderless URL field — no box, so it reads as part of the bar.
Box(
modifier = Modifier.weight(1f).alpha(if (inReadOnly) 0.55f else 1f).onKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) { inOnSend(); true } else false
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) {
inOnSend(); true
} else false
},
) {
if (inReq.url.isEmpty()) Text("https://example.com/path", color = c.dim, fontSize = 14.sp)
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
),
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
),
) {
BasicTextField(
value = inReq.url,
@@ -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,8 +176,10 @@ 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
)
}
}
}
@@ -151,13 +188,16 @@ internal fun UrlBar(
// 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,6 +15,8 @@ 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
@@ -24,8 +24,8 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// MARK: Panel 3 — request builder (Query / Headers / Body)
// ==================
/* Tabs to edit query params, headers and the body, a Preview toggle top-right,
and a body-type dropdown pinned at the bottom. */
/** Tabs to edit query params, headers and the body, a Preview toggle top-right,
and a body-type dropdown pinned at the bottom. */
@Composable
internal fun RequestBuilder(
inReq: ApiRequest,
@@ -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,10 +53,10 @@ 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
)
}
@@ -145,8 +145,8 @@ internal fun RequestBuilder(
}
}
/* The body editing area for the current body type (driven by the bottom menu). A
Text body is highlighted per its chosen format (bodyFormat). */
/** The body editing area for the current body type (driven by the bottom menu). A
Text body is highlighted per its chosen format (bodyFormat). */
@Composable
internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
when (inReq.bodyType) {
@@ -157,21 +157,22 @@ internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest)
inOnChange = { v -> inEdit { it.copy(body = v) } },
inFormat = inReq.bodyFormat,
)
BodyType.FORM -> KeyValEditor(inReq.form) { v -> inEdit { it.copy(form = v) } }
BodyType.FILE -> FileBody(inReq) { v -> inEdit(v) }
}
}
/* Magic-wand button that pretty-prints the current body in place (JSON /
XML). Lives at the right end of the body type/format bar. */
/** Magic-wand button that pretty-prints the current body in place (JSON /
XML). Lives at the right end of the body type/format bar. */
@Composable
internal fun FormatButton(inOnClick: () -> Unit) {
HoverIconBtn(MaterialSymbols.AutoFixHigh, "Format (pretty-print)", inOnClick)
}
/* Icon button with the toolbar's standard hover treatment — accent-tinted
background + accent icon on hover (matching the TLS chain button). inActive
keeps it lit, for toggles such as the preview eye. */
/** Icon button with the toolbar's standard hover treatment — accent-tinted
background + accent icon on hover (matching the TLS chain button). inActive
keeps it lit, for toggles such as the preview eye. */
@Composable
internal fun HoverIconBtn(
inIcon: Int,
@@ -186,8 +187,8 @@ internal fun HoverIconBtn(
val vHover by vHoverSrc.collectIsHoveredAsState()
val vBg = when {
inActive -> c.accent.copy(alpha = 0.20f)
vHover -> c.accent.copy(alpha = 0.18f)
else -> Color.Transparent
vHover -> c.accent.copy(alpha = 0.18f)
else -> Color.Transparent
}
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
@@ -202,15 +203,17 @@ internal fun HoverIconBtn(
.clickable(onClick = inOnClick)
.padding(6.dp),
) {
MaterialSymbolsOutlined(inIcon, contentDescription = inTooltip,
tint = if (inActive || vHover) c.accent else c.dim, size = inSize)
MaterialSymbolsOutlined(
inIcon, contentDescription = inTooltip,
tint = if (inActive || vHover) c.accent else c.dim, size = inSize
)
}
}
}
/* Tab-size picker (2 / 4 / 8), shown left of the format button. Sets the global
editor tab width via TextLayoutConfig — how wide a typed '\t' renders AND how
deep the formatter indents. Snapshot-backed, so the change is live. */
/** Tab-size picker (2 / 4 / 8), shown left of the format button. Sets the global
editor tab width via TextLayoutConfig — how wide a typed '\t' renders AND how
deep the formatter indents. Snapshot-backed, so the change is live. */
@Composable
internal fun TabSizeSelector() {
val c = LocalAppColors.current
@@ -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 +
passphrase. PEM/DER are used directly by OpenSSL on macOS/Linux; on Windows
they're imported into the certificate store for the request then removed
(see CurlMtls). PKCS#12 bundles its own private key. */
/** Client-certificate (mTLS) editor — certificate + optional separate key +
passphrase. PEM/DER are used directly by OpenSSL on macOS/Linux; on Windows
they're imported into the certificate store for the request then removed
(see CurlMtls). PKCS#12 bundles its own private key. */
@Composable
internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client certificate (mTLS)", inOverrideSource: String? = null, inOnChange: (CertConfig) -> Unit) {
internal fun CertConfigEditor(
inCert: CertConfig,
inHeading: String = "Client certificate (mTLS)",
inOverrideSource: String? = null,
inOnChange: (CertConfig) -> Unit
) {
val c = LocalAppColors.current
val vHasCert = inCert.certPath.isNotBlank()
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -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
Override action (copy it into this scope) when this scope hasn't set its own. */
/** A read-only card for an inherited client cert: its source pill + path, plus an
Override action (copy it into this scope) when this scope hasn't set its own. */
@Composable
internal fun InheritedCertCard(inInherited: InheritedCert, inOwnSet: Boolean, inReadOnly: Boolean, inOnOverride: () -> Unit) {
internal fun InheritedCertCard(
inInherited: InheritedCert,
inOwnSet: Boolean,
inReadOnly: Boolean,
inOnOverride: () -> Unit
) {
val c = LocalAppColors.current
Column(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).border(1.dp, c.border, RoundedCornerShape(8.dp)).padding(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
.border(1.dp, c.border, RoundedCornerShape(8.dp)).padding(10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -341,23 +377,35 @@ internal fun InheritedCertCard(inInherited: InheritedCert, inOwnSet: Boolean, in
}
}
/* The request's Cert tab: the inherited cert (from pack / session) shown read-only
with its source + an Override action when the request has none, then the
request's own cert editor (its own cert overrides the inherited one). */
/** The request's Cert tab: the inherited cert (from pack / session) shown read-only
with its source + an Override action when the request has none, then the
request's own cert editor (its own cert overrides the inherited one). */
@Composable
internal fun RequestCertTab(inReq: ApiRequest, inInheritedCert: InheritedCert?, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) {
internal fun RequestCertTab(
inReq: ApiRequest,
inInheritedCert: InheritedCert?,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (inInheritedCert != null) InheritedCertCard(inInheritedCert, inOwnSet = inReq.hasClientCert, inReadOnly = inReadOnly) {
if (inInheritedCert != null) InheritedCertCard(
inInheritedCert,
inOwnSet = inReq.hasClientCert,
inReadOnly = inReadOnly
) {
inEdit { it.withCert(inInheritedCert.cert) }
}
CertConfigEditor(inReq.certConfig(), inOverrideSource = if (inReq.hasClientCert) inInheritedCert?.path else null) { vCc -> inEdit { it.withCert(vCc) } }
CertConfigEditor(
inReq.certConfig(),
inOverrideSource = if (inReq.hasClientCert) inInheritedCert?.path else null
) { vCc -> inEdit { it.withCert(vCc) } }
}
}
/* Scope settings editor (Variables / Query / Headers / Cert sub-tabs) — shared by
the session settings tab and each pack's settings tab. Each sub-tab shows what
the scope inherits from above (source-tagged, with Override) over its own
editable list, mirroring the request panels. */
/** Scope settings editor (Variables / Query / Headers / Cert sub-tabs) — shared by
the session settings tab and each pack's settings tab. Each sub-tab shows what
the scope inherits from above (source-tagged, with Override) over its own
editable list, mirroring the request panels. */
@Composable
internal fun ScopeSettings(
inTab: Int,
@@ -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
+183 -128
View File
@@ -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,12 +31,12 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// MARK: Request tab strip (open requests; drag a tab to reorder)
// ==================
/* Strip of open requests above the editor. Each tab is clickable to select
and drag-reorderable. While dragging, the grabbed tab lifts (zIndex),
fades, and follows the cursor via a graphicsLayer translation — its
neighbours don't shuffle; the reorder commits on drop, to the slot whose
neighbours' centres the cursor passed. key(tab) keeps each tab's
LayoutNode stable so the drag session survives the reorder. */
/** Strip of open requests above the editor. Each tab is clickable to select
and drag-reorderable. While dragging, the grabbed tab lifts (zIndex),
fades, and follows the cursor via a graphicsLayer translation — its
neighbours don't shuffle; the reorder commits on drop, to the slot whose
neighbours' centres the cursor passed. key(tab) keeps each tab's
LayoutNode stable so the drag session survives the reorder. */
@Composable
internal fun RequestTabStrip(
inTabs: List<StripTab>,
@@ -71,141 +72,175 @@ internal fun RequestTabStrip(
modifier = Modifier.fillMaxWidth().background(c.panel),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f)
.horizontalScroll(vScroll).padding(horizontal = 6.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
inTabs.forEach { vTab ->
val vKey = vTab.tabKey
if (vKey == vBarBeforeKey) DropBar()
key(vKey) {
val vSel = vKey == inActiveKey
val vDragged = vKey == vDragKey
Row(
modifier = Modifier.weight(1f)
.horizontalScroll(vScroll).padding(horizontal = 6.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
inTabs.forEach { vTab ->
val vKey = vTab.tabKey
if (vKey == vBarBeforeKey) DropBar()
key(vKey) {
val vSel = vKey == inActiveKey
val vDragged = vKey == vDragKey
var vMod = Modifier
.onGloballyPositioned { vLeft[vKey] = it.x }
.onSizeChanged { vWidth[vKey] = it.width }
// NOTE: `alpha` and `translationX` MUST be on the same graphicsLayer.
// `Modifier.alpha` expands to `graphicsLayer(alpha, clip = true)` — clip is
// applied in the ALPHA layer's local coord space (before any child layer's
// translation), so `.alpha().graphicsLayer(translationX = X)` clips the drag
// ghost inside its original bounds and the ghost disappears as it slides. One
// layer with both properties = clip stays false, alpha modulates via layerPaint.
if (vDragged) vMod = vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = vDragDx, translationY = 0f)
vMod = vMod
.clip(RoundedCornerShape(7.dp))
.background(if (vSel) c.accent.copy(alpha = 0.20f) else c.field, RoundedCornerShape(7.dp))
.border(1.dp, if (vSel || vDragged) c.accent else c.border, RoundedCornerShape(7.dp))
.pointerInput(vKey) {
// Accumulate the per-frame delta rather than reading change.position.
// The tab wears a graphicsLayer(translationX = vDragDx) while dragging,
// which transforms the modifier's local pointer frame — so position.x
// would compensate for the translation each frame and the tab would
// trail the mouse by roughly half its offset. dragAmount is the
// root-frame delta and doesn't feed back through the layer transform.
detectDragGestures(
onDragStart = { offset ->
vDragKey = vKey
vPressRelX = offset.x.toInt()
vDragDx = 0f
vDragTarget = inTabs.indexOfFirst { it.tabKey == vKey }
},
onDrag = { _, dragAmount ->
val vd = vDragKey ?: return@detectDragGestures
vDragDx += dragAmount.x
val vRelX = vPressRelX + vDragDx.toInt()
val vCursorX = (vLeft[vd] ?: 0) + vRelX
var vCount = 0
inTabs.forEach { vT ->
if (vT.tabKey != vd) {
val vCenter = (vLeft[vT.tabKey] ?: 0) + (vWidth[vT.tabKey] ?: 0) / 2
if (vCursorX > vCenter) vCount++
var vMod = Modifier
.onGloballyPositioned { vLeft[vKey] = it.x }
.onSizeChanged { vWidth[vKey] = it.width }
// NOTE: `alpha` and `translationX` MUST be on the same graphicsLayer.
// `Modifier.alpha` expands to `graphicsLayer(alpha, clip = true)` — clip is
// applied in the ALPHA layer's local coord space (before any child layer's
// translation), so `.alpha().graphicsLayer(translationX = X)` clips the drag
// ghost inside its original bounds and the ghost disappears as it slides. One
// layer with both properties = clip stays false, alpha modulates via layerPaint.
if (vDragged) vMod =
vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = vDragDx, translationY = 0f)
vMod = vMod
.clip(RoundedCornerShape(7.dp))
.background(if (vSel) c.accent.copy(alpha = 0.20f) else c.field, RoundedCornerShape(7.dp))
.border(1.dp, if (vSel || vDragged) c.accent else c.border, RoundedCornerShape(7.dp))
.pointerInput(vKey) {
// Accumulate the per-frame delta rather than reading change.position.
// The tab wears a graphicsLayer(translationX = vDragDx) while dragging,
// which transforms the modifier's local pointer frame — so position.x
// would compensate for the translation each frame and the tab would
// trail the mouse by roughly half its offset. dragAmount is the
// root-frame delta and doesn't feed back through the layer transform.
detectDragGestures(
onDragStart = { offset ->
vDragKey = vKey
vPressRelX = offset.x.toInt()
vDragDx = 0f
vDragTarget = inTabs.indexOfFirst { it.tabKey == vKey }
},
onDrag = { _, dragAmount ->
val vd = vDragKey ?: return@detectDragGestures
vDragDx += dragAmount.x
val vRelX = vPressRelX + vDragDx.toInt()
val vCursorX = (vLeft[vd] ?: 0) + vRelX
var vCount = 0
inTabs.forEach { vT ->
if (vT.tabKey != vd) {
val vCenter = (vLeft[vT.tabKey] ?: 0) + (vWidth[vT.tabKey] ?: 0) / 2
if (vCursorX > vCenter) vCount++
}
}
}
vDragTarget = vCount
},
onDragEnd = {
val vd = vDragKey
if (vd != null) {
val vFrom = inTabs.indexOfFirst { it.tabKey == vd }
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(vFrom, vDragTarget)
}
vDragKey = null; vDragDx = 0f; vDragTarget = -1
},
onDragCancel = { vDragKey = null; vDragDx = 0f; vDragTarget = -1 },
)
}
// Middle-click closes the tab; right-click opens the tab menu. Compose
// has no first-class match for either — inline pointerInput checks the
// pressed PointerButton at each Press.
.pointerInput(vTab) {
awaitPointerEventScope {
while (true) {
val vEv = awaitPointerEvent()
if (vEv.type != PointerEventType.Press) continue
when (vEv.button) {
PointerButton.Tertiary -> {
vEv.changes.firstOrNull()?.consume()
inOnClose(vTab)
vDragTarget = vCount
},
onDragEnd = {
val vd = vDragKey
if (vd != null) {
val vFrom = inTabs.indexOfFirst { it.tabKey == vd }
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(
vFrom,
vDragTarget
)
}
PointerButton.Secondary -> {
vEv.changes.firstOrNull()?.consume()
vMenuTab = vTab; vMenu = true
vDragKey = null; vDragDx = 0f; vDragTarget = -1
},
onDragCancel = { vDragKey = null; vDragDx = 0f; vDragTarget = -1 },
)
}
// Middle-click closes the tab; right-click opens the tab menu. Compose
// has no first-class match for either — inline pointerInput checks the
// pressed PointerButton at each Press.
.pointerInput(vTab) {
awaitPointerEventScope {
while (true) {
val vEv = awaitPointerEvent()
if (vEv.type != PointerEventType.Press) continue
when (vEv.button) {
PointerButton.Tertiary -> {
vEv.changes.firstOrNull()?.consume()
inOnClose(vTab)
}
PointerButton.Secondary -> {
vEv.changes.firstOrNull()?.consume()
vMenuTab = vTab; vMenu = true
}
}
}
}
}
}
.clickable { inOnSelect(vTab) }
.padding(start = 9.dp, top = 6.dp, bottom = 6.dp, end = 3.dp)
.clickable { inOnSelect(vTab) }
.padding(start = 9.dp, top = 6.dp, bottom = 6.dp, end = 3.dp)
Row(
modifier = vMod,
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val vRs = vTab.req
if (vRs == null) {
// Session / pack settings tab.
MaterialSymbolsOutlined(MaterialSymbols.Tune, tint = if (vSel) c.accent else c.dim, size = 13.dp)
Text(if (vTab.isSession) "Session" else (vTab.pack?.name ?: ""), color = if (vSel) c.text else c.dim, fontSize = 13.sp)
} else {
Box(modifier = Modifier.size(7.dp).background(methodColor(vRs.req.method), RoundedCornerShape(4.dp)))
Text(vRs.req.name, color = if (vSel) c.text else c.dim, fontSize = 13.sp)
if (vRs.loading) CircularProgressIndicator(modifier = Modifier.size(12.dp), color = c.accent, strokeWidth = 1.5.dp)
Row(
modifier = vMod,
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val vRs = vTab.req
if (vRs == null) {
// Session / pack settings tab.
MaterialSymbolsOutlined(
MaterialSymbols.Tune,
tint = if (vSel) c.accent else c.dim,
size = 13.dp
)
Text(
if (vTab.isSession) "Session" else (vTab.pack?.name ?: ""),
color = if (vSel) c.text else c.dim,
fontSize = 13.sp
)
} else {
Box(
modifier = Modifier.size(7.dp)
.background(methodColor(vRs.req.method), RoundedCornerShape(4.dp))
)
Text(vRs.req.name, color = if (vSel) c.text else c.dim, fontSize = 13.sp)
if (vRs.loading) CircularProgressIndicator(
modifier = Modifier.size(12.dp),
color = c.accent,
strokeWidth = 1.5.dp
)
}
IconBtn(MaterialSymbols.Close, "Close", inSize = 13.dp) { inOnClose(vTab) }
}
IconBtn(MaterialSymbols.Close, "Close", inSize = 13.dp) { inOnClose(vTab) }
}
}
if (vBarAtEnd) DropBar()
// Shared right-click menu, opened at the cursor over the clicked tab.
val vMTab = vMenuTab
if (vMenu && vMTab != null) {
DropdownMenu(
expanded = true,
onDismissRequest = { vMenu = false },
modifier = Modifier.widthIn(min = 188.dp),
) {
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Close, "Close tab") },
onClick = { vMenu = false; inOnClose(vMTab) })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Clear, "Close other tabs") },
onClick = { vMenu = false; inOnCloseOthers(vMTab) })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Clear, "Close all tabs") },
onClick = { vMenu = false; inOnCloseAll() })
}
}
}
if (vBarAtEnd) DropBar()
// Shared right-click menu, opened at the cursor over the clicked tab.
val vMTab = vMenuTab
if (vMenu && vMTab != null) {
DropdownMenu(
expanded = true,
onDismissRequest = { vMenu = false },
modifier = Modifier.widthIn(min = 188.dp),
) {
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Close, "Close tab") }, onClick = { vMenu = false; inOnClose(vMTab) })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Clear, "Close other tabs") }, onClick = { vMenu = false; inOnCloseOthers(vMTab) })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Clear, "Close all tabs") }, onClick = { vMenu = false; inOnCloseAll() })
}
}
}
// Overflow → chevron opens a scrollable list of every open tab.
if (vScroll.maxValue > 0) {
Box(modifier = Modifier.padding(end = 4.dp)) {
IconBtn(MaterialSymbols.ExpandMore, "All tabs") { vListOpen = true }
DropdownMenu(expanded = vListOpen, onDismissRequest = { vListOpen = false }, modifier = Modifier.widthIn(min = 240.dp)) {
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
between tabs so the Row centres it vertically and reserves a little space. */
/** 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))
)
}
+167 -76
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,8 +40,8 @@ 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).
The pack's visual identity in the sidebar's foldable pack header. */
/** A small rounded square in the pack's palette colour (neutral when unset).
The pack's visual identity in the sidebar's foldable pack header. */
@Composable
internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
val c = LocalAppColors.current
@@ -47,8 +49,8 @@ internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
Box(Modifier.size(inSize).background(vCol, RoundedCornerShape(3.dp)))
}
/* A 5×4 grid of swatches; tapping one sets the pack's colour (1-based index).
The current colour gets a ring. Sized to fit inside the (fixed-width) ⋮ menu. */
/** A 5×4 grid of swatches; tapping one sets the pack's colour (1-based index).
The current colour gets a ring. Sized to fit inside the (fixed-width) ⋮ menu. */
@Composable
internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
val c = LocalAppColors.current
@@ -77,15 +79,19 @@ internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
// MARK: Add-pack menu (header '+')
// ==================
/* The header '+' — a small menu to add a pack to the open session, either blank
or imported from a .json file. Importing a pack always lands it in the session. */
/** The header '+' — a small menu to add a pack to the open session, either blank
or imported from a .json file. Importing a pack always lands it in the session. */
@Composable
internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnImport: () -> Unit) {
val c = LocalAppColors.current
var vOpen by remember { mutableStateOf(false) }
Box {
IconBtn(MaterialSymbols.Add, "Add", inSize = 18.dp) { vOpen = true }
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }, modifier = Modifier.widthIn(min = 200.dp)) {
DropdownMenu(
expanded = vOpen,
onDismissRequest = { vOpen = false },
modifier = Modifier.widthIn(min = 200.dp)
) {
DropdownMenuItem(text = {
MenuRow(
MaterialSymbols.Send,
@@ -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,13 +116,13 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
// MARK: Session menu (save / open / new / recent)
// ==================
/* The last path segment of inPath (the file name), tolerant of either slash. */
/** The last path segment of inPath (the file name), tolerant of either slash. */
internal fun fileLeaf(inPath: String): String =
inPath.trimEnd('/', '\\').substringAfterLast('/').substringAfterLast('\\')
/* Shortens a long path to inMax chars keeping the start and end (so the root
and file stay visible), joined by an ellipsis. The menu has no text-overflow
primitive, so we trim the string ourselves. */
/** Shortens a long path to inMax chars keeping the start and end (so the root
and file stay visible), joined by an ellipsis. The menu has no text-overflow
primitive, so we trim the string ourselves. */
internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
if (inText.length <= inMax) return inText
val vKeep = inMax - 1
@@ -122,10 +130,10 @@ internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
return inText.take(vHead) + "…" + inText.takeLast(vKeep - vHead)
}
/* Header dropdown for the one open session. Shows its file name with the full
path underneath when it has a file (so you can see it's saved — file-backed
sessions auto-save), or "Untitled session" with a dot when it has no file yet.
Menu: Save / Save as… / Rename / Reveal / Open… / New + a recent list. */
/** Header dropdown for the one open session. Shows its file name with the full
path underneath when it has a file (so you can see it's saved — file-backed
sessions auto-save), or "Untitled session" with a dot when it has no file yet.
Menu: Save / Save as… / Rename / Reveal / Open… / New + a recent list. */
@Composable
internal fun SessionMenu(
inPath: String?,
@@ -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,
@@ -245,7 +266,9 @@ internal fun SessionMenu(
// 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,8 +303,8 @@ internal fun SessionMenu(
// MARK: Pack tree (recursive sidebar rendering)
// ==================
/* The per-pack operations a PackSection needs, bundled so PackTree can bind them
recursively for sub-packs while PackSection itself stays callback-driven. */
/** The per-pack operations a PackSection needs, bundled so PackTree can bind them
recursively for sub-packs while PackSection itself stays callback-driven. */
internal class PackOps(
val onEnv: (PackState) -> Unit,
val onToggle: (PackState) -> Unit,
@@ -307,11 +330,18 @@ internal class PackOps(
val packDropEnd: () -> Unit,
)
/* Renders a pack then its sub-packs recursively, each indented by its depth.
inSiblings is the list this pack belongs to (top-level vPacks, or a parent's
subPacks) — used to position the pack-reorder drop bars by index. */
/** Renders a pack then its sub-packs recursively, each indented by its depth.
inSiblings is the list this pack belongs to (top-level vPacks, or a parent's
subPacks) — used to position the pack-reorder drop bars by index. */
@Composable
internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: Int, inOps: PackOps, inActive: PackState?, inEnvActive: Boolean) {
internal fun PackTree(
inPack: PackState,
inSiblings: List<PackState>,
inDepth: Int,
inOps: PackOps,
inActive: PackState?,
inEnvActive: Boolean
) {
val vDrag = inOps.drag
val vMyIndex = inSiblings.indexOf(inPack)
val vMoving = vDrag.draggingPack && vDrag.engaged && vDrag.dropInto == null
@@ -319,7 +349,7 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
val vPad = Modifier.padding(start = (inDepth * 14).dp)
val vBarBefore = vMoving && vDrag.dropParent === inPack.parent && vDrag.dropSibIndex == vMyIndex
val vBarAfter = vMoving && vDrag.dropParent === inPack.parent &&
vMyIndex == inSiblings.size - 1 && vDrag.dropSibIndex == inSiblings.size
vMyIndex == inSiblings.size - 1 && vDrag.dropSibIndex == inSiblings.size
key(inPack) {
if (vBarBefore) Box(modifier = vPad) { RowDropBar() }
Box(modifier = vPad) {
@@ -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,11 +398,11 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
// MARK: Pack section (foldable pack in the sidebar)
// ==================
/* One open pack rendered as a foldable section: a header (fold chevron, colour
box, name, dirty dot, request count, ⋮ menu) over the pack's request list
when expanded. Clicking the header body selects the pack (so the Env tab and
main panel follow it); the chevron only folds. The request list drags to
reorder, scoped to this pack. Right-click the header for the pack menu. */
/** One open pack rendered as a foldable section: a header (fold chevron, colour
box, name, dirty dot, request count, ⋮ menu) over the pack's request list
when expanded. Clicking the header body selects the pack (so the Env tab and
main panel follow it); the chevron only folds. The request list drags to
reorder, scoped to this pack. Right-click the header for the pack menu. */
@Composable
internal fun PackSection(
inPack: PackState,
@@ -404,13 +443,14 @@ internal fun PackSection(
// to reorder / reparent; highlighted when it's the "drop inside" target.
val vHeaderDragged = inDrag.dragPack === inPack && vMoving
val vIntoHi = vMoving && ((inDrag.draggingPack && inDrag.dropInto === inPack) ||
(inDrag.draggingReq && inDrag.dropPack === inPack && !inPack.expanded))
(inDrag.draggingReq && inDrag.dropPack === inPack && !inPack.expanded))
var vHeadMod = Modifier.fillMaxWidth()
.onGloballyPositioned { inDrag.headTop[inPack] = it.y }
.onSizeChanged { inDrag.headH[inPack] = it.height }
// See RequestTabStrip note: alpha + translation on the SAME graphicsLayer so
// clip=false and the drag ghost isn't clipped to its original bounds.
if (vHeaderDragged) vHeadMod = vHeadMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
if (vHeaderDragged) vHeadMod =
vHeadMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
vHeadMod = vHeadMod.clip(RoundedCornerShape(6.dp))
.background(
when {
@@ -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),
) {
@@ -476,8 +522,16 @@ internal fun PackSection(
// + (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,16 +539,34 @@ 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() })
}
}
}
@@ -522,7 +594,12 @@ 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) {
@@ -536,28 +613,30 @@ internal fun PackSection(
.onSizeChanged { inDrag.rowH[vRs] = it.height }
// translate is draw-only (doesn't shift absoluteY), so the
// cursor offset below stays correct while dragging.
if (vDragged) vMod = vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
if (vDragged) vMod =
vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
if (!inPack.isLinked) vMod = vMod.pointerInput(vRs) {
// Same graphicsLayer feedback concern as the pack drag —
// accumulate dragAmount instead of reading change.position.
detectDragGestures(
onDragStart = { offset ->
inDrag.clear()
inDrag.dragReq = vRs; inDrag.dragReqOwner = inPack
inDrag.pressRel = offset.y.toInt(); inDrag.dy = 0f
inDrag.dropPack = inPack; inDrag.dropIndex = vReqs.indexOf(vRs)
},
onDrag = { _, dragAmount ->
val vd = inDrag.dragReq ?: return@detectDragGestures
inDrag.dy += dragAmount.y
val vRelY = inDrag.pressRel + inDrag.dy.toInt()
if (!inDrag.engaged && kotlin.math.abs(inDrag.dy) >= kDragSlop) inDrag.engaged = true
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
},
onDragEnd = { inOnReqDropEnd() },
onDragCancel = { inOnReqDropEnd() },
)
}
// Same graphicsLayer feedback concern as the pack drag —
// accumulate dragAmount instead of reading change.position.
detectDragGestures(
onDragStart = { offset ->
inDrag.clear()
inDrag.dragReq = vRs; inDrag.dragReqOwner = inPack
inDrag.pressRel = offset.y.toInt(); inDrag.dy = 0f
inDrag.dropPack = inPack; inDrag.dropIndex = vReqs.indexOf(vRs)
},
onDrag = { _, dragAmount ->
val vd = inDrag.dragReq ?: return@detectDragGestures
inDrag.dy += dragAmount.y
val vRelY = inDrag.pressRel + inDrag.dy.toInt()
if (!inDrag.engaged && kotlin.math.abs(inDrag.dy) >= kDragSlop) inDrag.engaged =
true
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
},
onDragEnd = { inOnReqDropEnd() },
onDragCancel = { inOnReqDropEnd() },
)
}
Box(modifier = vMod) {
RequestRow(
inRs = vRs,
@@ -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() })
}
}
}
+328 -297
View File
@@ -10,357 +10,388 @@ import kotlinx.serialization.Serializable
// MARK: BodyFormat — viewer/highlighter selection
// ==================
/* Format of a body payload as shown in the viewer, and (for a TEXT request body)
the type the user picks on the right of the Body tab — it drives both syntax
highlighting and the sent Content-Type. RAW disables highlighting and falls back
to the selectable BasicTextField; other values pick a tokeniser. */
/** 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"
BodyFormat.JSON -> "JSON"
BodyFormat.XML -> "XML"
BodyFormat.YAML -> "YAML"
BodyFormat.HTML -> "HTML"
}
get() = when (this) {
BodyFormat.RAW -> "RAW"
BodyFormat.JSON -> "JSON"
BodyFormat.XML -> "XML"
BodyFormat.YAML -> "YAML"
BodyFormat.HTML -> "HTML"
}
/* The Content-Type a TEXT body of this format is sent with. */
/** The Content-Type a TEXT body of this format is sent with. */
val BodyFormat.contentType: String
get() = when (this) {
BodyFormat.RAW -> "text/plain"
BodyFormat.JSON -> "application/json"
BodyFormat.XML -> "application/xml"
BodyFormat.YAML -> "application/yaml"
BodyFormat.HTML -> "text/html"
}
get() = when (this) {
BodyFormat.RAW -> "text/plain"
BodyFormat.JSON -> "application/json"
BodyFormat.XML -> "application/xml"
BodyFormat.YAML -> "application/yaml"
BodyFormat.HTML -> "text/html"
}
/* Pick a format from a Content-Type header. JSON / XML / YAML / HTML
match their canonical types and common variants; anything else
(image types, text/plain, …) falls back to RAW. */
/** Pick a format from a Content-Type header. JSON / XML / YAML / HTML
match their canonical types and common variants; anything else
(image types, text/plain, …) falls back to RAW. */
fun autoFormatFor(inContentType: String?): BodyFormat {
val vCt = inContentType?.substringBefore(';')?.trim()?.lowercase() ?: return BodyFormat.RAW
return when {
vCt.endsWith("+json") || vCt == "application/json" -> BodyFormat.JSON
vCt.endsWith("+xml") || vCt == "application/xml" || vCt == "text/xml" -> BodyFormat.XML
vCt == "application/yaml" || vCt == "text/yaml" || vCt == "application/x-yaml" -> BodyFormat.YAML
vCt == "text/html" -> BodyFormat.HTML
else -> BodyFormat.RAW
}
val vCt = inContentType?.substringBefore(';')?.trim()?.lowercase() ?: return BodyFormat.RAW
return when {
vCt.endsWith("+json") || vCt == "application/json" -> BodyFormat.JSON
vCt.endsWith("+xml") || vCt == "application/xml" || vCt == "text/xml" -> BodyFormat.XML
vCt == "application/yaml" || vCt == "text/yaml" || vCt == "application/x-yaml" -> BodyFormat.YAML
vCt == "text/html" -> BodyFormat.HTML
else -> BodyFormat.RAW
}
}
// ==================
// MARK: Highlight palette
// ==================
/* Per-token colours. Two preset themes: a dark-on-dark and a dark-on-
light variant matching VS Code's defaults. Caller picks based on
surrounding theme; auto-pick available via SyntaxPalette.forDark(). */
/** Per-token colours. Two preset themes: a dark-on-dark and a dark-on-
light variant matching VS Code's defaults. Caller picks based on
surrounding theme; auto-pick available via SyntaxPalette.forDark(). */
data class SyntaxPalette(
val key: Color,
val string: Color,
val number: Color,
val keyword: Color,
val tag: Color,
val comment: Color,
val punct: Color,
val key: Color,
val string: Color,
val number: Color,
val keyword: Color,
val tag: Color,
val comment: Color,
val punct: Color,
) {
companion object {
/* Light-on-dark — VS Code Dark+. */
val Dark = SyntaxPalette(
key = Color(0xFF9CDCFE),
string = Color(0xFFCE9178),
number = Color(0xFFB5CEA8),
keyword = Color(0xFFC586C0),
tag = Color(0xFF569CD6),
comment = Color(0xFF6A9955),
punct = Color(0xFFCCCCCC),
)
/* Dark-on-light — VS Code Light+. Higher contrast on white. */
val Light = SyntaxPalette(
key = Color(0xFF0451A5),
string = Color(0xFFA31515),
number = Color(0xFF098658),
keyword = Color(0xFF0000FF),
tag = Color(0xFF800000),
comment = Color(0xFF008000),
punct = Color(0xFF333333),
)
companion object {
/** Light-on-dark — VS Code Dark+. */
val Dark = SyntaxPalette(
key = Color(0xFF9CDCFE),
string = Color(0xFFCE9178),
number = Color(0xFFB5CEA8),
keyword = Color(0xFFC586C0),
tag = Color(0xFF569CD6),
comment = Color(0xFF6A9955),
punct = Color(0xFFCCCCCC),
)
/* Pick a palette by the host theme's background brightness. */
fun forDark(inIsDark: Boolean): SyntaxPalette = if (inIsDark) Dark else Light
}
/** Dark-on-light — VS Code Light+. Higher contrast on white. */
val Light = SyntaxPalette(
key = Color(0xFF0451A5),
string = Color(0xFFA31515),
number = Color(0xFF098658),
keyword = Color(0xFF0000FF),
tag = Color(0xFF800000),
comment = Color(0xFF008000),
punct = Color(0xFF333333),
)
/** Pick a palette by the host theme's background brightness. */
fun forDark(inIsDark: Boolean): SyntaxPalette = if (inIsDark) Dark else Light
}
}
// ==================
// MARK: Entry point
// ==================
/* Render the given text with per-token colour spans for the requested
format. RAW returns an unstyled AnnotatedString. */
/** Render the given text with per-token colour spans for the requested
format. RAW returns an unstyled AnnotatedString. */
fun highlight(
inText: String,
inFormat: BodyFormat,
inPalette: SyntaxPalette = SyntaxPalette.Dark,
inText: String,
inFormat: BodyFormat,
inPalette: SyntaxPalette = SyntaxPalette.Dark,
): AnnotatedString = when (inFormat) {
BodyFormat.RAW -> AnnotatedString(inText)
BodyFormat.JSON -> highlightJson(inText, inPalette)
BodyFormat.XML, BodyFormat.HTML -> highlightXml(inText, inPalette)
BodyFormat.YAML -> highlightYaml(inText, inPalette)
BodyFormat.RAW -> AnnotatedString(inText)
BodyFormat.JSON -> highlightJson(inText, inPalette)
BodyFormat.XML, BodyFormat.HTML -> highlightXml(inText, inPalette)
BodyFormat.YAML -> highlightYaml(inText, inPalette)
}
// ==================
// MARK: JSON tokeniser — single-pass, error-tolerant
// ==================
/* Tokeniser for JSON. Walks the string once and emits style spans for
strings, numbers, booleans, null, punctuation. Distinguishes "keys"
(strings followed by ':') from string values by peeking after each
string. Error-tolerant — malformed JSON still highlights as best
effort rather than throwing. */
/** Tokeniser for JSON. Walks the string once and emits style spans for
strings, numbers, booleans, null, punctuation. Distinguishes "keys"
(strings followed by ':') from string values by peeking after each
string. Error-tolerant — malformed JSON still highlights as best
effort rather than throwing. */
private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
val vN = inText.length
var vI = 0
while (vI < vN) {
val vC = inText[vI]
when {
vC == '"' -> {
// Find closing quote, honouring backslash-escaped chars so an
// escaped \" doesn't terminate the string early.
val vStart = vI
var vJ = vI + 1
while (vJ < vN) {
if (inText[vJ] == '\\' && vJ + 1 < vN) { vJ += 2; continue }
if (inText[vJ] == '"') { vJ++; break }
vJ++
}
// Peek for ':' (skipping whitespace) → it's a key, not a value.
var vK = vJ
while (vK < vN && inText[vK].isWhitespace()) vK++
val vIsKey = vK < vN && inText[vK] == ':'
val vColor = if (vIsKey) inP.key else inP.string
pushStyle(SpanStyle(color = vColor))
append(inText.substring(vStart, vJ))
pop()
vI = vJ
}
vC.isDigit() || (vC == '-' && vI + 1 < vN && inText[vI + 1].isDigit()) -> {
val vStart = vI
if (vC == '-') vI++
while (vI < vN && (inText[vI].isDigit() || inText[vI] in ".eE+-")) vI++
pushStyle(SpanStyle(color = inP.number))
append(inText.substring(vStart, vI))
pop()
}
vC.isLetter() -> {
val vStart = vI
while (vI < vN && inText[vI].isLetter()) vI++
val vWord = inText.substring(vStart, vI)
if (vWord == "true" || vWord == "false" || vWord == "null") {
pushStyle(SpanStyle(color = inP.keyword))
append(vWord)
pop()
} else {
append(vWord)
}
}
vC in "{}[],:" -> {
pushStyle(SpanStyle(color = inP.punct))
append(vC)
pop()
vI++
}
else -> { append(vC); vI++ }
}
}
val vN = inText.length
var vI = 0
while (vI < vN) {
val vC = inText[vI]
when {
vC == '"' -> {
// Find closing quote, honouring backslash-escaped chars so an
// escaped \" doesn't terminate the string early.
val vStart = vI
var vJ = vI + 1
while (vJ < vN) {
if (inText[vJ] == '\\' && vJ + 1 < vN) {
vJ += 2; continue
}
if (inText[vJ] == '"') {
vJ++; break
}
vJ++
}
// Peek for ':' (skipping whitespace) → it's a key, not a value.
var vK = vJ
while (vK < vN && inText[vK].isWhitespace()) vK++
val vIsKey = vK < vN && inText[vK] == ':'
val vColor = if (vIsKey) inP.key else inP.string
pushStyle(SpanStyle(color = vColor))
append(inText.substring(vStart, vJ))
pop()
vI = vJ
}
vC.isDigit() || (vC == '-' && vI + 1 < vN && inText[vI + 1].isDigit()) -> {
val vStart = vI
if (vC == '-') vI++
while (vI < vN && (inText[vI].isDigit() || inText[vI] in ".eE+-")) vI++
pushStyle(SpanStyle(color = inP.number))
append(inText.substring(vStart, vI))
pop()
}
vC.isLetter() -> {
val vStart = vI
while (vI < vN && inText[vI].isLetter()) vI++
val vWord = inText.substring(vStart, vI)
if (vWord == "true" || vWord == "false" || vWord == "null") {
pushStyle(SpanStyle(color = inP.keyword))
append(vWord)
pop()
} else {
append(vWord)
}
}
vC in "{}[],:" -> {
pushStyle(SpanStyle(color = inP.punct))
append(vC)
pop()
vI++
}
else -> {
append(vC); vI++
}
}
}
}
// ==================
// MARK: XML / HTML tokeniser
// ==================
/* Tokeniser for XML / HTML. Distinguishes tag names, attribute names,
attribute values (string), and comments. Inside tags, attr=value pairs
are recognised; outside tags text is left in the default colour. */
/** Tokeniser for XML / HTML. Distinguishes tag names, attribute names,
attribute values (string), and comments. Inside tags, attr=value pairs
are recognised; outside tags text is left in the default colour. */
private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
val vN = inText.length
var vI = 0
while (vI < vN) {
val vC = inText[vI]
when {
// Comment block: <!-- ... -->
vC == '<' && inText.startsWith("<!--", vI) -> {
val vEnd = inText.indexOf("-->", vI + 4).let { if (it < 0) vN else it + 3 }
pushStyle(SpanStyle(color = inP.comment))
append(inText.substring(vI, vEnd))
pop()
vI = vEnd
}
vC == '<' -> {
// Tag open: <[/]name attr=val attr2="val2">
val vGt = inText.indexOf('>', vI).let { if (it < 0) vN else it + 1 }
pushStyle(SpanStyle(color = inP.punct)); append('<'); pop()
var vJ = vI + 1
if (vJ < vN && inText[vJ] == '/') { pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++ }
// Tag name.
val vTagStart = vJ
while (vJ < vN && !inText[vJ].isWhitespace() && inText[vJ] != '>' && inText[vJ] != '/') vJ++
if (vJ > vTagStart) {
pushStyle(SpanStyle(color = inP.tag))
append(inText.substring(vTagStart, vJ))
pop()
}
// Attributes — name="value" pairs.
while (vJ < vGt - 1) {
val vC2 = inText[vJ]
when {
vC2.isWhitespace() -> { append(vC2); vJ++ }
vC2 == '/' -> { pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++ }
vC2 == '=' -> { pushStyle(SpanStyle(color = inP.punct)); append('='); pop(); vJ++ }
vC2 == '"' || vC2 == '\'' -> {
val vEnd = inText.indexOf(vC2, vJ + 1).let { if (it < 0) vGt - 1 else it + 1 }
pushStyle(SpanStyle(color = inP.string))
append(inText.substring(vJ, vEnd))
pop()
vJ = vEnd
}
else -> {
val vAttrStart = vJ
while (vJ < vGt - 1 && !inText[vJ].isWhitespace() && inText[vJ] != '=' && inText[vJ] != '>' && inText[vJ] != '/') vJ++
pushStyle(SpanStyle(color = inP.key))
append(inText.substring(vAttrStart, vJ))
pop()
}
}
}
if (vGt > vI && inText.getOrNull(vGt - 1) == '>') {
pushStyle(SpanStyle(color = inP.punct)); append('>'); pop()
}
vI = vGt
}
else -> { append(vC); vI++ }
}
}
val vN = inText.length
var vI = 0
while (vI < vN) {
when (val vC = inText[vI]) {
'<' if inText.startsWith("<!--", vI) -> {
val vEnd = inText.indexOf("-->", vI + 4).let { if (it < 0) vN else it + 3 }
pushStyle(SpanStyle(color = inP.comment))
append(inText.substring(vI, vEnd))
pop()
vI = vEnd
}
'<' -> {
// Tag open: <[/]name attr=val attr2="val2">
val vGt = inText.indexOf('>', vI).let { if (it < 0) vN else it + 1 }
pushStyle(SpanStyle(color = inP.punct)); append('<'); pop()
var vJ = vI + 1
if (vJ < vN && inText[vJ] == '/') {
pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++
}
// Tag name.
val vTagStart = vJ
while (vJ < vN && !inText[vJ].isWhitespace() && inText[vJ] != '>' && inText[vJ] != '/') vJ++
if (vJ > vTagStart) {
pushStyle(SpanStyle(color = inP.tag))
append(inText.substring(vTagStart, vJ))
pop()
}
// Attributes — name="value" pairs.
while (vJ < vGt - 1) {
val vC2 = inText[vJ]
when {
vC2.isWhitespace() -> {
append(vC2); vJ++
}
vC2 == '/' -> {
pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++
}
vC2 == '=' -> {
pushStyle(SpanStyle(color = inP.punct)); append('='); pop(); vJ++
}
vC2 == '"' || vC2 == '\'' -> {
val vEnd = inText.indexOf(vC2, vJ + 1).let { if (it < 0) vGt - 1 else it + 1 }
pushStyle(SpanStyle(color = inP.string))
append(inText.substring(vJ, vEnd))
pop()
vJ = vEnd
}
else -> {
val vAttrStart = vJ
while (vJ < vGt - 1 && !inText[vJ].isWhitespace() && inText[vJ] != '=' && inText[vJ] != '>' && inText[vJ] != '/') vJ++
pushStyle(SpanStyle(color = inP.key))
append(inText.substring(vAttrStart, vJ))
pop()
}
}
}
if (vGt > vI && inText.getOrNull(vGt - 1) == '>') {
pushStyle(SpanStyle(color = inP.punct)); append('>'); pop()
}
vI = vGt
}
else -> {
append(vC); vI++
}
}
}
}
// ==================
// MARK: YAML tokeniser — line-based
// ==================
/* Tokeniser for YAML. Walks line by line because YAML's grammar is
indent-sensitive and most of the interesting colour decisions
(key vs value, list item vs scalar) depend on position within a
line. Comments (#), keys (text before ':'), quoted strings, numbers
and booleans are coloured; everything else stays default. */
/** Tokeniser for YAML. Walks line by line because YAML's grammar is
indent-sensitive and most of the interesting colour decisions
(key vs value, list item vs scalar) depend on position within a
line. Comments (#), keys (text before ':'), quoted strings, numbers
and booleans are coloured; everything else stays default. */
private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
val vLines = inText.split('\n')
for ((vIdx, vLineRaw) in vLines.withIndex()) {
val vLine = vLineRaw
// Comment: rest of the line after a # (not inside a quoted string).
val vCommentAt = findUnquotedHash(vLine)
val vCodePart = if (vCommentAt >= 0) vLine.substring(0, vCommentAt) else vLine
val vCommentPart = if (vCommentAt >= 0) vLine.substring(vCommentAt) else ""
val vLines = inText.split('\n')
for ((vIdx, vLineRaw) in vLines.withIndex()) {
val vLine = vLineRaw
// Comment: rest of the line after a # (not inside a quoted string).
val vCommentAt = findUnquotedHash(vLine)
val vCodePart = if (vCommentAt >= 0) vLine.substring(0, vCommentAt) else vLine
val vCommentPart = if (vCommentAt >= 0) vLine.substring(vCommentAt) else ""
// Find a key: indent + (optional "- ") + key + ':' + (space|eol).
val vColon = indexOfKeyColon(vCodePart)
if (vColon > 0) {
val vPrefix = vCodePart.substring(0, vColon)
val vRest = vCodePart.substring(vColon + 1)
// Lead whitespace + optional list marker, then key.
var vK = 0
while (vK < vPrefix.length && vPrefix[vK].isWhitespace()) { append(vPrefix[vK]); vK++ }
if (vK + 1 < vPrefix.length && vPrefix[vK] == '-' && vPrefix[vK + 1].isWhitespace()) {
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
append(vPrefix[vK + 1])
vK += 2
}
pushStyle(SpanStyle(color = inP.key))
append(vPrefix.substring(vK))
pop()
pushStyle(SpanStyle(color = inP.punct)); append(':'); pop()
appendYamlValue(vRest, inP)
} else {
appendYamlValue(vCodePart, inP)
}
if (vCommentPart.isNotEmpty()) {
pushStyle(SpanStyle(color = inP.comment))
append(vCommentPart)
pop()
}
if (vIdx < vLines.size - 1) append('\n')
}
// Find a key: indent + (optional "- ") + key + ':' + (space|eol).
val vColon = indexOfKeyColon(vCodePart)
if (vColon > 0) {
val vPrefix = vCodePart.substring(0, vColon)
val vRest = vCodePart.substring(vColon + 1)
// Lead whitespace + optional list marker, then key.
var vK = 0
while (vK < vPrefix.length && vPrefix[vK].isWhitespace()) {
append(vPrefix[vK]); vK++
}
if (vK + 1 < vPrefix.length && vPrefix[vK] == '-' && vPrefix[vK + 1].isWhitespace()) {
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
append(vPrefix[vK + 1])
vK += 2
}
pushStyle(SpanStyle(color = inP.key))
append(vPrefix.substring(vK))
pop()
pushStyle(SpanStyle(color = inP.punct)); append(':'); pop()
appendYamlValue(vRest, inP)
} else {
appendYamlValue(vCodePart, inP)
}
if (vCommentPart.isNotEmpty()) {
pushStyle(SpanStyle(color = inP.comment))
append(vCommentPart)
pop()
}
if (vIdx < vLines.size - 1) append('\n')
}
}
/* Render the value portion of a YAML line — applies string / number /
keyword / list-marker colours where applicable. */
/** Render the value portion of a YAML line — applies string / number /
keyword / list-marker colours where applicable. */
private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPalette) {
val vTrimmed = inSeg.trimStart()
val vPad = inSeg.substring(0, inSeg.length - vTrimmed.length)
append(vPad)
when {
vTrimmed.isEmpty() -> { /* nothing */ }
vTrimmed.startsWith('-') && (vTrimmed.length == 1 || vTrimmed[1].isWhitespace()) -> {
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
appendYamlValue(vTrimmed.substring(1), inP)
}
vTrimmed.startsWith('"') || vTrimmed.startsWith('\'') -> {
val vQ = vTrimmed[0]
val vEnd = vTrimmed.indexOf(vQ, 1).let { if (it < 0) vTrimmed.length else it + 1 }
pushStyle(SpanStyle(color = inP.string))
append(vTrimmed.substring(0, vEnd))
pop()
append(vTrimmed.substring(vEnd))
}
vTrimmed == "true" || vTrimmed == "false" || vTrimmed == "null" || vTrimmed == "~" -> {
pushStyle(SpanStyle(color = inP.keyword)); append(vTrimmed); pop()
}
vTrimmed.first().let { it.isDigit() || it == '-' && vTrimmed.length > 1 && vTrimmed[1].isDigit() } -> {
var vJ = 0
if (vTrimmed[0] == '-') vJ++
while (vJ < vTrimmed.length && (vTrimmed[vJ].isDigit() || vTrimmed[vJ] in ".eE+-")) vJ++
pushStyle(SpanStyle(color = inP.number))
append(vTrimmed.substring(0, vJ))
pop()
append(vTrimmed.substring(vJ))
}
else -> append(vTrimmed)
}
val vTrimmed = inSeg.trimStart()
val vPad = inSeg.substring(0, inSeg.length - vTrimmed.length)
append(vPad)
when {
vTrimmed.isEmpty() -> { /* nothing */
}
vTrimmed.startsWith('-') && (vTrimmed.length == 1 || vTrimmed[1].isWhitespace()) -> {
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
appendYamlValue(vTrimmed.substring(1), inP)
}
vTrimmed.startsWith('"') || vTrimmed.startsWith('\'') -> {
val vQ = vTrimmed[0]
val vEnd = vTrimmed.indexOf(vQ, 1).let { if (it < 0) vTrimmed.length else it + 1 }
pushStyle(SpanStyle(color = inP.string))
append(vTrimmed.substring(0, vEnd))
pop()
append(vTrimmed.substring(vEnd))
}
vTrimmed == "true" || vTrimmed == "false" || vTrimmed == "null" || vTrimmed == "~" -> {
pushStyle(SpanStyle(color = inP.keyword)); append(vTrimmed); pop()
}
vTrimmed.first().let { it.isDigit() || it == '-' && vTrimmed.length > 1 && vTrimmed[1].isDigit() } -> {
var vJ = 0
if (vTrimmed[0] == '-') vJ++
while (vJ < vTrimmed.length && (vTrimmed[vJ].isDigit() || vTrimmed[vJ] in ".eE+-")) vJ++
pushStyle(SpanStyle(color = inP.number))
append(vTrimmed.substring(0, vJ))
pop()
append(vTrimmed.substring(vJ))
}
else -> append(vTrimmed)
}
}
/* Find the index of the first '#' that isn't inside a quoted string,
or -1 if there is none. Used to split a YAML line into code + comment. */
/** Find the index of the first '#' that isn't inside a quoted string,
or -1 if there is none. Used to split a YAML line into code + comment. */
private fun findUnquotedHash(inLine: String): Int {
var vInSingle = false
var vInDouble = false
for (vI in inLine.indices) {
val vC = inLine[vI]
when {
vC == '"' && !vInSingle -> vInDouble = !vInDouble
vC == '\'' && !vInDouble -> vInSingle = !vInSingle
vC == '#' && !vInSingle && !vInDouble -> return vI
}
}
return -1
var vInSingle = false
var vInDouble = false
for (vI in inLine.indices) {
when (inLine[vI]) {
'"' if !vInSingle -> vInDouble = !vInDouble
'\'' if !vInDouble -> vInSingle = !vInSingle
'#' if !vInSingle && !vInDouble -> return vI
}
}
return -1
}
/* Locate the colon that separates a YAML key from its value at the
current indent level — the FIRST unquoted ':' that's followed by
either whitespace or end-of-line. Returns -1 if the line has no key. */
/** Locate the colon that separates a YAML key from its value at the
current indent level — the FIRST unquoted ':' that's followed by
either whitespace or end-of-line. Returns -1 if the line has no key. */
private fun indexOfKeyColon(inLine: String): Int {
var vInSingle = false
var vInDouble = false
for (vI in inLine.indices) {
val vC = inLine[vI]
when {
vC == '"' && !vInSingle -> vInDouble = !vInDouble
vC == '\'' && !vInDouble -> vInSingle = !vInSingle
vC == ':' && !vInSingle && !vInDouble -> {
val vNext = if (vI + 1 < inLine.length) inLine[vI + 1] else ' '
if (vNext.isWhitespace() || vI + 1 == inLine.length) return vI
}
}
}
return -1
var vInSingle = false
var vInDouble = false
for (vI in inLine.indices) {
when (inLine[vI]) {
'"' if !vInSingle -> vInDouble = !vInDouble
'\'' if !vInDouble -> vInSingle = !vInSingle
':' if !vInSingle && !vInDouble -> {
val vNext = if (vI + 1 < inLine.length) inLine[vI + 1] else ' '
if (vNext.isWhitespace() || vI + 1 == inLine.length) return vI
}
}
}
return -1
}
+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) }
+63 -29
View File
@@ -15,39 +15,54 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.Popup
import apidemo.compat.clipEntryOfText
import apidemo.compat.x
import apidemo.compat.y
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
// ==================
// MARK: TLS certificate-chain dialog + cert cards
// ==================
/* Dialog showing the server's TLS certificate chain (one card per cert, plus a
Copy PEM action). Fetched on demand by the lock button in the URL bar. */
/** Dialog showing the server's TLS certificate chain (one card per cert, plus a
Copy PEM action). Fetched on demand by the lock button in the URL bar. */
@Composable
internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -> Unit) {
val c = LocalAppColors.current
Dialog(onDismissRequest = inOnDismiss) {
Surface(color = c.panel, shape = RoundedCornerShape(10.dp), modifier = Modifier.width(540.dp)) {
Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
MaterialSymbolsOutlined(MaterialSymbols.Lock, tint = c.accent, size = 20.dp)
Text("TLS certificate chain", color = c.text, fontSize = 16.sp)
}
val vCerts = inChain?.certs.orEmpty()
when {
inChain == null -> Text("Probing…", color = c.dim, fontSize = 13.sp)
vCerts.isEmpty() -> Text(inChain.error ?: "No chain reported.", color = kWarnColor, fontSize = 13.sp)
vCerts.isEmpty() -> Text(
inChain.error ?: "No chain reported.",
color = VolticTheme.extended.warning,
fontSize = 13.sp
)
else -> {
Text("${vCerts.size} certificate(s) presented by ${hostOf(inUrl)}", color = c.dim, fontSize = 12.sp)
Text(
"${vCerts.size} certificate(s) presented by ${hostOf(inUrl)}",
color = c.dim,
fontSize = 12.sp
)
Column(
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
@@ -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,10 +84,10 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
}
}
/* One certificate's summary card (subject / issuer / validity). Server-presented
certs get a solid border; derived ones (issuer pulled from the OS store, or a
name-only placeholder) get an accent-coloured border + a label. A self-signed
cert shows "Self-signed" in green instead of repeating its issuer. */
/** One certificate's summary card (subject / issuer / validity). Server-presented
certs get a solid border; derived ones (issuer pulled from the OS store, or a
name-only placeholder) get an accent-coloured border + a label. A self-signed
cert shows "Self-signed" in green instead of repeating its issuer. */
@Composable
internal fun CertCard(inIndex: Int, inCert: ChainCert) {
val c = LocalAppColors.current
@@ -86,14 +104,22 @@ 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) }
@@ -115,10 +141,10 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
internal val kSelfSignedColor = Color(0xFF3FB950L)
/* Copy button — rounded, hover overlay, real click that copies inText and shows
a green check. Icon-only (per cert) flashes a small floating "Copied" bubble
for 2s; the labelled variant (Copy chain) flips its label to "Copied" instead.
Uses a non-catching Popup so it never dismisses the dialog or eats clicks. */
/** Copy button — rounded, hover overlay, real click that copies inText and shows
a green check. Icon-only (per cert) flashes a small floating "Copied" bubble
for 2s; the labelled variant (Copy chain) flips its label to "Copied" instead.
Uses a non-catching Popup so it never dismisses the dialog or eats clicks. */
@Composable
internal fun CopyButton(inText: String, inLabel: String? = null) {
val c = LocalAppColors.current
@@ -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,23 +209,23 @@ internal fun CertLine(inLabel: String, inValue: String, inValueColor: Color? = n
}
}
/* Look up a CURLINFO_CERTINFO field by name (case-insensitive). */
/** Look up a CURLINFO_CERTINFO field by name (case-insensitive). */
internal fun certField(inFields: List<Pair<String, String>>, inName: String): String? =
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
/* The CN= value out of a distinguished-name string. Handles every
format the platform backends emit:
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
"/C=US/O=Let's Encrypt/CN=R3" (OpenSSL X509_NAME_oneline)
"CN = R3, O = Let's Encrypt, C = US" (OpenSSL X509_NAME_print_ex
default, with spaces — what
libcurl on macOS emits)
Null when there's no CN. */
/** The CN= value out of a distinguished-name string. Handles every
format the platform backends emit:
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
"/C=US/O=Let's Encrypt/CN=R3" (OpenSSL X509_NAME_oneline)
"CN = R3, O = Let's Encrypt, C = US" (OpenSSL X509_NAME_print_ex
default, with spaces — what
libcurl on macOS emits)
Null when there's no CN. */
internal fun cnOf(inDn: String): String? {
val vMatch = Regex("""(?i)\bCN\s*=\s*([^,/]+)""").find(inDn) ?: return null
return vMatch.groupValues[1].trim().ifBlank { null }
}
/* The host portion of a URL (for the chain dialog header). */
/** The host portion of a URL (for the chain dialog header). */
internal fun hostOf(inUrl: String): String =
inUrl.substringAfter("://", inUrl).substringBefore("/").substringBefore("?").ifBlank { inUrl }
+10 -10
View File
@@ -8,22 +8,22 @@ package apidemo
// parity app has no bundled libcurl, so its actuals report the feature as
// native-only instead.
/* One certificate in the chain: its CURLINFO_CERTINFO-style fields (Subject /
Issuer / the PEM under "Cert" / dates …) and whether the server actually
presented it. Derived certs — an issuer resolved from the OS store, or a
name-only placeholder — have fromServer = false and are drawn dotted. */
/** One certificate in the chain: its CURLINFO_CERTINFO-style fields (Subject /
Issuer / the PEM under "Cert" / dates …) and whether the server actually
presented it. Derived certs — an issuer resolved from the OS store, or a
name-only placeholder — have fromServer = false and are drawn dotted. */
class ChainCert(val fields: List<Pair<String, String>>, val fromServer: Boolean)
/* The server's TLS certificate chain. error is set instead when it couldn't be
fetched; fields vary by TLS backend. */
/** 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
startup; no-op where the OS store isn't used. */
/** 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()
+44 -25
View File
@@ -4,13 +4,13 @@ package apidemo
// MARK: Variable substitution ({{name}})
// ==================
/* Matches a {{ name }} token. Names may contain letters, digits, _, . and -.
Surrounding whitespace inside the braces is tolerated. */
/** Matches a {{ name }} token. Names may contain letters, digits, _, . and -.
Surrounding whitespace inside the braces is tolerated. */
private val kVarRegex = Regex("""\{\{\s*([A-Za-z0-9_.\-]+)\s*\}\}""")
/* Replace every {{name}} token in inText with the matching enabled variable's
value. Unknown / disabled names are left untouched so the user can spot the
ones that won't resolve. */
/** Replace every {{name}} token in inText with the matching enabled variable's
value. Unknown / disabled names are left untouched so the user can spot the
ones that won't resolve. */
fun substituteVars(inText: String, inVars: List<KeyVal>): String {
if (inText.isEmpty()) return inText
val vMap = inVars
@@ -20,26 +20,41 @@ fun substituteVars(inText: String, inVars: List<KeyVal>): String {
return kVarRegex.replace(inText) { vMatch -> vMap[vMatch.groupValues[1]] ?: vMatch.value }
}
/* Apply variable substitution across every field that actually gets sent — URL,
query params, headers and body — returning a fully-resolved request. The
original (template) request is left untouched. */
/** Apply variable substitution across every field that actually gets sent — URL,
query params, headers and body — returning a fully-resolved request. The
original (template) request is left untouched. */
fun resolveVars(inReq: ApiRequest, inVars: List<KeyVal>): ApiRequest {
val vActive = inVars.any { it.enabled && it.key.isNotBlank() }
if (!vActive) return inReq
return inReq.copy(
url = substituteVars(inReq.url, inVars),
params = inReq.params.map { it.copy(key = substituteVars(it.key, inVars), value = substituteVars(it.value, inVars)) },
headers = inReq.headers.map { it.copy(key = substituteVars(it.key, inVars), value = substituteVars(it.value, inVars)) },
params = inReq.params.map {
it.copy(
key = substituteVars(it.key, inVars),
value = substituteVars(it.value, inVars)
)
},
headers = inReq.headers.map {
it.copy(
key = substituteVars(it.key, inVars),
value = substituteVars(it.value, inVars)
)
},
body = substituteVars(inReq.body, inVars),
form = inReq.form.map { it.copy(key = substituteVars(it.key, inVars), value = substituteVars(it.value, inVars)) },
form = inReq.form.map {
it.copy(
key = substituteVars(it.key, inVars),
value = substituteVars(it.value, inVars)
)
},
certPath = substituteVars(inReq.certPath, inVars),
keyPath = substituteVars(inReq.keyPath, inVars),
certPassword = substituteVars(inReq.certPassword, inVars),
)
}
/* The distinct {{names}} referenced anywhere in the request that have no enabled
variable to fill them — surfaced as a warning in the editor. */
/** The distinct {{names}} referenced anywhere in the request that have no enabled
variable to fill them — surfaced as a warning in the editor. */
fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
val vDefined = inVars
.filter { it.enabled && it.key.isNotBlank() }
@@ -49,7 +64,7 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
append(inReq.url).append('\n')
inReq.params.forEach { append(it.key).append('\n').append(it.value).append('\n') }
inReq.headers.forEach { append(it.key).append('\n').append(it.value).append('\n') }
if (inReq.method.allowsBody) {
if (true) {
when (inReq.bodyType) {
BodyType.FORM -> inReq.form.forEach { append(it.key).append('\n').append(it.value).append('\n') }
else -> append(inReq.body)
@@ -67,10 +82,10 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
// MARK: Copy as cURL
// ==================
/* Render inReq as a runnable, multi-line curl command (pass an already-resolved
request so the output has real values, not {{tokens}}). Enabled query params
are folded into the URL; a Content-Type header is added for the body type
unless the request already sets one. */
/** Render inReq as a runnable, multi-line curl command (pass an already-resolved
request so the output has real values, not {{tokens}}). Enabled query params
are folded into the URL; a Content-Type header is added for the body type
unless the request already sets one. */
fun toCurl(inReq: ApiRequest): String {
val vSb = StringBuilder()
vSb.append("curl -X ").append(inReq.method.name)
@@ -91,27 +106,31 @@ fun toCurl(inReq: ApiRequest): String {
if (inReq.certPassword.isNotEmpty()) vSb.append(" \\\n --pass ").append(shellQuote(inReq.certPassword))
}
if (inReq.method.allowsBody && inReq.bodyType != BodyType.NONE) {
if (true && inReq.bodyType != BodyType.NONE) {
val vContentType = inReq.bodyContentType()
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
if (vContentType != null && !vHasCt) {
vSb.append(" \\\n -H ").append(shellQuote("Content-Type: $vContentType"))
}
when (inReq.bodyType) {
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ").append(shellQuote(formEncode(inReq.form)))
BodyType.FILE -> if (inReq.body.isNotBlank()) vSb.append(" \\\n --data-binary ").append(shellQuote("@${inReq.body}"))
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ")
.append(shellQuote(formEncode(inReq.form)))
BodyType.FILE -> if (inReq.body.isNotBlank()) vSb.append(" \\\n --data-binary ")
.append(shellQuote("@${inReq.body}"))
else -> if (inReq.body.isNotEmpty()) vSb.append(" \\\n --data ").append(shellQuote(inReq.body))
}
}
return vSb.toString()
}
/* URL-encode enabled form fields into an application/x-www-form-urlencoded body. */
/** URL-encode enabled form fields into an application/x-www-form-urlencoded body. */
fun formEncode(inForm: List<KeyVal>): String =
inForm.filter { it.enabled && it.key.isNotBlank() }
.joinToString("&") { "${urlEncode(it.key)}=${urlEncode(it.value)}" }
/* The request URL with its enabled query params appended (percent-encoded). */
/** The request URL with its enabled query params appended (percent-encoded). */
internal fun urlWithParams(inReq: ApiRequest): String {
val vTrimmed = inReq.url.trim()
val vEnabled = inReq.params.filter { it.enabled && it.key.isNotBlank() }
@@ -121,10 +140,10 @@ internal fun urlWithParams(inReq: ApiRequest): String {
return vTrimmed + vSep + vQuery
}
/* Single-quote a string for a POSIX shell, escaping any embedded quotes. */
/** Single-quote a string for a POSIX shell, escaping any embedded quotes. */
private fun shellQuote(inS: String): String = "'" + inS.replace("'", "'\\''") + "'"
/* Minimal RFC-3986 percent-encoding for query keys / values. */
/** Minimal RFC-3986 percent-encoding for query keys / values. */
private fun urlEncode(inS: String): String = buildString {
for (vByte in inS.encodeToByteArray()) {
val vCode = vByte.toInt() and 0xFF
+40 -34
View File
@@ -30,8 +30,8 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// MARK: Small UI atoms (shared across panels / dialogs / sidebar)
// ==================
/* Method as a compact rounded badge (soft colour fill + coloured label), fixed
width so request names line up. */
/** Method as a compact rounded badge (soft colour fill + coloured label), fixed
width so request names line up. */
@Composable
internal fun MethodTag(inMethod: ReqMethod) {
val vCol = methodColor(inMethod)
@@ -45,16 +45,16 @@ internal fun MethodTag(inMethod: ReqMethod) {
}
}
/* Thin horizontal accent line showing where a dragged request row will drop
(the open-tab strip uses the vertical DropBar). */
/** Thin horizontal accent line showing where a dragged request row will drop
(the open-tab strip uses the vertical DropBar). */
@Composable
internal fun RowDropBar() {
val c = LocalAppColors.current
Box(modifier = Modifier.fillMaxWidth().height(2.dp).background(c.accent, RoundedCornerShape(1.dp)))
}
/* Tab indices listed in inDots get a small accent dot after their label — used
to flag a tab that holds content (e.g. a non-empty request body). */
/** Tab indices listed in inDots get a small accent dot after their label — used
to flag a tab that holds content (e.g. a non-empty request body). */
@Composable
internal fun TabBar(inTabs: List<String>, inSelected: Int, inDots: Set<Int> = emptySet(), inOnSelect: (Int) -> Unit) {
val c = LocalAppColors.current
@@ -90,10 +90,17 @@ internal fun TogglePill(inLabel: String, inSelected: Boolean, inOnClick: () -> U
) { Text(inLabel, color = if (inSelected) c.onAccent else c.dim, fontSize = 12.sp) }
}
/* Compact single-line (or fixed-height multi-line) input — a BasicTextField in
a slim bordered box, much shorter than the 56 dp Material OutlinedTextField. */
/** Compact single-line (or fixed-height multi-line) input — a BasicTextField in
a slim bordered box, much shorter than the 56 dp Material OutlinedTextField. */
@Composable
internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier: Modifier = Modifier, inPlaceholder: String = "", inSingleLine: Boolean = true, inOnEnter: (() -> Unit)? = null) {
internal fun ThinField(
inValue: String,
inOnChange: (String) -> Unit,
inModifier: Modifier = Modifier,
inPlaceholder: String = "",
inSingleLine: Boolean = true,
inOnEnter: (() -> Unit)? = null
) {
val c = LocalAppColors.current
// Box is an ancestor of the field, so it sees Enter the single-line field
// leaves unconsumed (it propagates up the focus → root chain).
@@ -102,7 +109,9 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
.border(1.dp, c.border, RoundedCornerShape(6.dp))
.padding(horizontal = 10.dp, vertical = 9.dp)
if (inOnEnter != null) vBoxMod = vBoxMod.onKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) { inOnEnter(); true } else false
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) {
inOnEnter(); true
} else false
}
Box(
modifier = vBoxMod,
@@ -111,10 +120,10 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
if (inValue.isEmpty() && inPlaceholder.isNotEmpty()) Text(inPlaceholder, color = c.dim, fontSize = 13.sp)
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
),
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
),
) {
BasicTextField(
value = inValue,
@@ -128,10 +137,17 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
}
}
/* Small icon-only button (burger / pack / options / save / close). inPadding
tightens the tap area for slimmer rows. */
/** Small icon-only button (burger / pack / options / save / close). inPadding
tightens the tap area for slimmer rows. */
@Composable
internal fun IconBtn(inIcon: Int, inDesc: String, inModifier: Modifier = Modifier, inSize: Dp = 18.dp, inPadding: Dp = 6.dp, inOnClick: () -> Unit) {
internal fun IconBtn(
inIcon: Int,
inDesc: String,
inModifier: Modifier = Modifier,
inSize: Dp = 18.dp,
inPadding: Dp = 6.dp,
inOnClick: () -> Unit
) {
val c = LocalAppColors.current
val vHoverSrc = remember { MutableInteractionSource() }
val vHover by vHoverSrc.collectIsHoveredAsState()
@@ -152,18 +168,18 @@ internal fun IconBtn(inIcon: Int, inDesc: String, inModifier: Modifier = Modifie
}
}
/* Outlined icon+label action (New request / Add row / pack actions). */
/** Outlined icon+label action (New request / Add row / pack actions). */
@Composable
internal fun OutlinedAction(inIcon: Int, inLabel: String, inOnClick: () -> Unit) {
val c = LocalAppColors.current
OutlinedButton(onClick = inOnClick) { BtnContent(inIcon, inLabel, c.accent) }
}
/* Filled red icon+label button for destructive / stop actions (delete, cancel, quit).
Uses the m3 Button so its size + ripple + interaction match every other
Button in the app — previously handrolled Box + background + clickable + padding
gave it a different height / horizontal padding, and the Discard button sat
next to a Button ("Save first…") looking noticeably smaller in dialogs. */
/** Filled red icon+label button for destructive / stop actions (delete, cancel, quit).
Uses the m3 Button so its size + ripple + interaction match every other
Button in the app — previously handrolled Box + background + clickable + padding
gave it a different height / horizontal padding, and the Discard button sat
next to a Button ("Save first…") looking noticeably smaller in dialogs. */
@Composable
internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
val vRed = methodColor(ReqMethod.DELETE)
@@ -173,17 +189,7 @@ internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
) { BtnContent(inIcon, inLabel, Color.White) }
}
/* Bordered icon+label chip (Copy / Save as / Cancel). */
@Composable
internal fun IconLabelChip(inIcon: Int, inLabel: String, inOnClick: () -> Unit) {
val c = LocalAppColors.current
Box(
modifier = Modifier.clip(RoundedCornerShape(6.dp)).border(1.dp, c.border, RoundedCornerShape(6.dp))
.clickable(onClick = inOnClick).padding(horizontal = 8.dp, vertical = 5.dp),
) { BtnContent(inIcon, inLabel, c.dim, 14.dp) }
}
/* Icon + label row used inside buttons. */
/** Icon + label row used inside buttons. */
@Composable
internal fun BtnContent(inIcon: Int, inLabel: String, inColor: Color, inSize: Dp = 16.dp) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
+218 -213
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,10 +47,10 @@ import kotlinx.coroutines.launch
// MARK: Response
// ==================
/* 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. */
/** 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. */
@Composable
internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
val c = LocalAppColors.current
@@ -85,7 +87,7 @@ 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 has actually been sent (sentReq != null).
@@ -102,8 +104,8 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
// pre-send / loading state.
val (vRespAccent, vRespColor) = when {
vResp?.error != null -> "FAILED" to statusColor(0)
vResp != null -> vResp.status.toString() to statusColor(vResp.status)
else -> null to c.dim
vResp != null -> vResp.status.toString() to statusColor(vResp.status)
else -> null to c.dim
}
ViewerTab(
inLabel = "Response",
@@ -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))
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(
@@ -202,7 +207,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
// 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))
}
@@ -255,10 +260,10 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
// MARK: HttpFlowView — flat httpie-style request/response layout
// ==================
/* 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. */
/** 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. */
@Composable
internal fun HttpFlowView(
inStatusLine: AnnotatedString,
@@ -303,7 +308,7 @@ internal fun HttpFlowView(
if (inBody == null) emptyList()
else {
val vAnn = if (inBodyFormat == BodyFormat.RAW) AnnotatedString(inBody)
else highlight(inBody, inBodyFormat, SyntaxPalette.forDark(vDark))
else highlight(inBody, inBodyFormat, SyntaxPalette.forDark(vDark))
buildBodyChunks(inBody, vAnn, kLinesPerChunk)
}
}
@@ -323,7 +328,12 @@ internal fun HttpFlowView(
// 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.
@@ -334,8 +344,8 @@ internal fun HttpFlowView(
// 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.
val vChunkItemOffset = 1 +
(if (!inHeadersCollapsed && inHeaders.isNotEmpty()) 1 else 0) +
(if (inBody != null || inIsImage) 1 else 0)
(if (!inHeadersCollapsed && inHeaders.isNotEmpty()) 1 else 0) +
(if (inBody != null || inIsImage) 1 else 0)
// After a keyboard action that moves the caret, if the caret's chunk is not
// in the visible items list scroll it in. Instant (no anim) so rapid
@@ -384,7 +394,7 @@ internal fun HttpFlowView(
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 {
@@ -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
}
},
@@ -629,23 +652,23 @@ 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
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.
/** 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
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. */
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. */
private data class BodySel(val anchor: Int, val caret: Int) {
val start get() = minOf(anchor, caret)
val end get() = maxOf(anchor, caret)
val end get() = maxOf(anchor, caret)
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,17 +676,17 @@ 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
its '\n', or the length of [inText] if this is the last line. */
/** 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)
val vNext = inText.indexOf('\n', vClamped)
return if (vNext < 0) inText.length else vNext
}
/* 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. */
/** 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 {
val vLineStart = lineStartAt(inText, inOffset)
val vCol = inOffset - vLineStart
@@ -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,10 +707,10 @@ 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"
(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. */
/** 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. */
private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
val vN = inText.length
if (vN == 0) return 0..0
@@ -695,8 +718,8 @@ private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
fun isWord(c: Char) = c.isLetterOrDigit() || c == '_'
// A click just after the last char of a word should still select that word.
val vProbe = if (vClamped < vN && isWord(inText[vClamped])) vClamped
else if (vClamped > 0 && isWord(inText[vClamped - 1])) vClamped - 1
else return vClamped..vClamped
else if (vClamped > 0 && isWord(inText[vClamped - 1])) vClamped - 1
else return vClamped..vClamped
var vStart = vProbe
while (vStart > 0 && isWord(inText[vStart - 1])) vStart--
var vEnd = vProbe
@@ -704,9 +727,9 @@ 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
count is O(body / kLinesPerChunk) which stays modest even for huge bodies. */
/** 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
for (i in inChunks.indices) {
@@ -721,22 +744,22 @@ 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
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
the shared h-scroll and never joins the selection.
/** 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
the shared h-scroll and never joins the selection.
Gutter alignment under soft-wrap: the gutter starts as plain 1:1 numbers, then the
body's own onTextLayout tells us how many VISUAL rows each source line wrapped into,
and we pad the gutter with that many blank rows so number N still sits on line N's
first row. It reuses the body's layout (no extra measurement) and only recomputes on
(re)layout, not per frame. No-wrap mode is 1 row per line, so the plain gutter is exact.
Gutter alignment under soft-wrap: the gutter starts as plain 1:1 numbers, then the
body's own onTextLayout tells us how many VISUAL rows each source line wrapped into,
and we pad the gutter with that many blank rows so number N still sits on line N's
first row. It reuses the body's layout (no extra measurement) and only recomputes on
(re)layout, not per frame. No-wrap mode is 1 row per line, so the plain gutter is exact.
The block's body text carries the current selection as a background SpanStyle
intersected with [chunk.startCharOffset, +chunk.body.length). onTextLayout also
reports the layout back up to the viewer so hit-testing can convert pointer
positions into character offsets across the whole body. */
The block's body text carries the current selection as a background SpanStyle
intersected with [chunk.startCharOffset, +chunk.body.length). onTextLayout also
reports the layout back up to the viewer so hit-testing can convert pointer
positions into character offsets across the whole body. */
@Composable
private fun BodyChunkRow(
inChunk: BodyChunk,
@@ -822,8 +845,9 @@ 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)
.coerceIn(vStart, 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,11 +871,11 @@ private fun BodyChunkRow(
}
}
/* 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),
and the highlighted block text. */
/** 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),
and the highlighted block text. */
private class BodyChunk(
val firstLine: Int,
val lineCount: Int,
@@ -860,9 +884,9 @@ private class BodyChunk(
val body: AnnotatedString,
)
/* 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. */
/** 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. */
private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerChunk: Int): List<BodyChunk> {
val vStarts = lineStartOffsets(inText)
val vN = vStarts.size
@@ -882,10 +906,10 @@ 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),
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
every one of N lines on each width change (the other half of the freeze). */
/** 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
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)
vStarts.add(0)
@@ -893,9 +917,9 @@ private fun lineStartOffsets(inText: String): IntArray {
return vStarts.toIntArray()
}
/* 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. */
/** 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
internal fun HeaderTable(inHeaders: List<Pair<String, String>>, modifier: Modifier = Modifier) {
val c = LocalAppColors.current
@@ -919,8 +943,8 @@ internal fun HeaderTable(inHeaders: List<Pair<String, String>>, modifier: Modifi
}
}
/* Body view with line numbers in the gutter — mimics the code panel
in httpie. Numbers are dim; body text uses the regular colour. */
/** 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(
inText: String,
@@ -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()) {
@@ -1023,16 +1048,16 @@ internal fun BodyView(
}
}
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),
),
LocalTextSelectionColors provides
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
),
) {
BasicTextField(
value = inText,
onValueChange = inOnChange,
textStyle = androidx.compose.ui.text.TextStyle(
textStyle = TextStyle(
color = c.text,
fontSize = 12.sp,
fontFamily = monoFontFamily,
@@ -1071,11 +1096,11 @@ internal fun BodyView(
}
}
/* "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
stay dimmed. */
/** "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
stay dimmed. */
@Composable
internal fun ViewerTab(
inLabel: String,
@@ -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))
@@ -1112,10 +1141,10 @@ internal fun ViewerTab(
// MARK: Helpers — header parsing / formatting
// ==================
/* "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-
spaced to match the request-line formatting. */
/** "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-
spaced to match the request-line formatting. */
internal fun formatStatusLine(inResp: ApiResponse?, inColors: AppColors): AnnotatedString {
if (inResp == null) return buildAnnotatedString {
pushStyle(SpanStyle(color = inColors.dim))
@@ -1142,34 +1171,35 @@ internal fun formatStatusLine(inResp: ApiResponse?, inColors: AppColors): Annota
}
}
/* "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 {
pushStyle(SpanStyle(color = methodColor(inReq.method), fontWeight = FontWeight.Bold))
append(inReq.method.name)
pop()
append(" ")
pushStyle(SpanStyle(color = inColors.text))
// urlWithParams appends the Query-tab key/values percent-encoded;
// urlPathOnly then strips scheme+host so the request line reads
// "GET /search?q=foo&n=10 HTTP/1.1" like httpie / curl trace.
append(urlPathOnly(urlWithParams(inReq)).ifEmpty { "/" })
pop()
append(" ")
pushStyle(SpanStyle(color = inColors.dim))
append(inHttpVersion)
pop()
}
/** "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 {
pushStyle(SpanStyle(color = methodColor(inReq.method), fontWeight = FontWeight.Bold))
append(inReq.method.name)
pop()
append(" ")
pushStyle(SpanStyle(color = inColors.text))
// urlWithParams appends the Query-tab key/values percent-encoded;
// urlPathOnly then strips scheme+host so the request line reads
// "GET /search?q=foo&n=10 HTTP/1.1" like httpie / curl trace.
append(urlPathOnly(urlWithParams(inReq)).ifEmpty { "/" })
pop()
append(" ")
pushStyle(SpanStyle(color = inColors.dim))
append(inHttpVersion)
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
separate Host: header line, not in the path. Falls back to the input
string when no "://" is present (relative URL). */
/** 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 {
val vIdx = inUrl.indexOf("://")
if (vIdx < 0) return inUrl
@@ -1178,8 +1208,8 @@ 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
header for display when the engine didn't surface one. */
/** 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("://")
if (vIdx < 0) return null
@@ -1189,44 +1219,44 @@ internal fun urlHost(inUrl: String): String? {
return vAuthority.ifEmpty { null }
}
/* 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. */
/** 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
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. */
/** 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. */
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
raw UTF-8 byte count; FORM serialises and counts; FILE skips
(loading the file just for the count would be wasteful — the engine
sets the field anyway). */
/** 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
sets the field anyway). */
internal fun computedBodyLength(inReq: ApiRequest): Int? = when (inReq.bodyType) {
BodyType.TEXT -> inReq.body.encodeToByteArray().size
BodyType.FORM -> formEncode(inReq.form).encodeToByteArray().size
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"
@@ -1240,10 +1270,10 @@ internal fun formatTimingSize(inResp: ApiResponse): String {
// MARK: ViewerOverflowMenu — 3-dot menu in the bottom-right of the viewer
// ==================
/* 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
memory-backed image resource for the current request. */
/** 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
memory-backed image resource for the current request. */
@Composable
internal fun ViewerOverflowMenu(
inIsRequestTab: Boolean,
@@ -1333,13 +1363,15 @@ 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"
(background gets a light foreground). Standard Rec. 709 weights. */
/** 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
return vY < 0.5f
@@ -1349,9 +1381,9 @@ internal fun isDarkBg(inColor: Color): Boolean {
// MARK: BodyFormatSelector — small dropdown for RAW / JSON / XML / YAML / HTML
// ==================
/* 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. */
/** 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
internal fun BodyFormatSelector(
inSelected: BodyFormat,
@@ -1373,23 +1405,28 @@ 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
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
certificate by default, so a non-error response is implicit proof
that the OS validated the chain. */
/** 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
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 {
if (!inUrl.startsWith("https://", ignoreCase = true)) return false
if (inResp == null) return false
@@ -1397,8 +1434,8 @@ 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
table renderer can lay them out as a key/value grid. */
/** 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()
return inText.split('\n').mapNotNull { vLine ->
@@ -1407,16 +1444,16 @@ internal fun parseHeaderLines(inText: String): List<Pair<String, String>> {
}
}
/* "content-type" → "Content-Type"; preserves single-word keys; leaves
already-correct ones unchanged. */
/** "content-type" → "Content-Type"; preserves single-word keys; leaves
already-correct ones unchanged. */
internal fun titleCaseHeader(inKey: String): String =
inKey.split('-').joinToString("-") { vWord ->
if (vWord.isEmpty()) vWord
else vWord[0].uppercaseChar() + vWord.drop(1).lowercase()
}
/* Centered icon + label placeholder for the viewer (Not sent / Not received /
No Body). Fills its parent unless a sized modifier is supplied. */
/** 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()) {
val c = LocalAppColors.current
@@ -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
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). */
/** 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),
),
)
+24 -24
View File
@@ -1,10 +1,10 @@
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
// ==================
@@ -15,63 +15,63 @@ import okio.FileSystem
// 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
inFontPx (already density-scaled). Used by the body gutter so its line
numbers stay aligned with the wrapped body text. */
/** Rows the given line occupies when soft-wrapped to inMaxWidthPx at
inFontPx (already density-scaled). Used by the body gutter so its line
numbers stay aligned with the wrapped body text. */
expect fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int
/* Editor tab width in spaces — how wide a typed '\t' renders. On the native
stack this drives the project text pipeline (TextLayoutConfig); the jvm
parity app only stores the preference. */
/** Editor tab width in spaces — how wide a typed '\t' renders. On the native
stack this drives the project text pipeline (TextLayoutConfig); the jvm
parity app only stores the preference. */
expect var editorTabWidth: Int
// ============
// Infrastructure
/* okio's FileSystem.SYSTEM is declared per-platform, not in its common
metadata — surface it through a seam. */
/** okio's FileSystem.SYSTEM is declared per-platform, not in its common
metadata — surface it through a seam. */
internal expect val systemFileSystem: FileSystem
/* The app's Ktor client: Curl on native (bundled libcurl — same TLS stack as
the mTLS path), CIO on the jvm parity target. */
/** The app's Ktor client: Curl on native (bundled libcurl — same TLS stack as
the mTLS path), CIO on the jvm parity target. */
expect fun createApiHttpClient(): HttpClient
/* Window-shell integration: an intercepting close handler (return true to
allow the close) and an app-wide key-shortcut handler; both cleared when
the composition leaves. */
/** Window-shell integration: an intercepting close handler (return true to
allow the close) and an app-wide key-shortcut handler; both cleared when
the composition leaves. */
@Composable
expect fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean)
@@ -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
@@ -2,41 +2,33 @@
// 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,24 +38,24 @@ import kotlin.math.roundToInt
// MARK: Scrollbar (Compose Desktop-style)
// ==================
/* A faithful subset of Compose Desktop's scrollbar (androidx.compose.foundation).
You overlay a VerticalScrollbar / HorizontalScrollbar on a scrollable and feed
it a ScrollbarAdapter built from the same ScrollState. This project's Box has
no BoxScope (so no Modifier.align) — pin the bar with the Box's
contentAlignment; the fillMaxSize content fills the rest:
/** A faithful subset of Compose Desktop's scrollbar (androidx.compose.foundation).
You overlay a VerticalScrollbar / HorizontalScrollbar on a scrollable and feed
it a ScrollbarAdapter built from the same ScrollState. This project's Box has
no BoxScope (so no Modifier.align) — pin the bar with the Box's
contentAlignment; the fillMaxSize content fills the rest:
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
val state = rememberScrollState()
Column(Modifier.fillMaxSize().verticalScroll(state)) { ... }
VerticalScrollbar(
adapter = rememberScrollbarAdapter(state),
modifier = Modifier.fillMaxHeight(),
)
}
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
val state = rememberScrollState()
Column(Modifier.fillMaxSize().verticalScroll(state)) { ... }
VerticalScrollbar(
adapter = rememberScrollbarAdapter(state),
modifier = Modifier.fillMaxHeight(),
)
}
Always visible (no auto-hide); the thumb tweens unhoverColor -> hoverColor on
hover over hoverDurationMillis, matching upstream. Drag the thumb to scroll;
click the track to page. */
Always visible (no auto-hide); the thumb tweens unhoverColor -> hoverColor on
hover over hoverDurationMillis, matching upstream. Drag the thumb to scroll;
click the track to page. */
@Immutable
class ScrollbarStyle(
@@ -75,8 +67,8 @@ class ScrollbarStyle(
val hoverColor: Color,
)
/* Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
provide a white-tinted style via LocalScrollbarStyle or the `style` param. */
/** Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
provide a white-tinted style via LocalScrollbarStyle or the `style` param. */
fun defaultScrollbarStyle(): ScrollbarStyle = ScrollbarStyle(
minimalHeight = 16.dp,
thickness = 8.dp,
@@ -92,9 +84,9 @@ val LocalScrollbarStyle = compositionLocalOf { defaultScrollbarStyle() }
// MARK: ScrollbarAdapter
// ==================
/* Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
contentSize / viewportSize are along the scroll axis; the thumb is sized by
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
/** Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
contentSize / viewportSize are along the scroll axis; the thumb is sized by
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
interface ScrollbarAdapter {
val scrollOffset: Double
val contentSize: Double
@@ -106,12 +98,12 @@ 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
size of its VISIBLE items, so exact pixel geometry of off-screen content is
unknowable — we estimate it from the average visible item size × total item
count (the same approach as Compose Desktop). Accurate enough for a thumb
whose length/position track the scroll position; the estimate self-corrects
as items of different sizes scroll through the viewport. */
/** Adapter for a virtualized LazyColumn / LazyRow. A lazy list only knows the
size of its VISIBLE items, so exact pixel geometry of off-screen content is
unknowable — we estimate it from the average visible item size × total item
count (the same approach as Compose Desktop). Accurate enough for a thumb
whose length/position track the scroll position; the estimate self-corrects
as items of different sizes scroll through the viewport. */
@Composable
fun rememberScrollbarAdapter(scrollState: LazyListState): ScrollbarAdapter =
remember(scrollState) { LazyListScrollbarAdapter(scrollState) }
@@ -171,21 +163,12 @@ fun VerticalScrollbar(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = true)
@Composable
fun HorizontalScrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean = false,
style: ScrollbarStyle = LocalScrollbarStyle.current,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = false)
/* Shared impl. The root Box is the track (fixed thickness on the cross axis,
filling the main axis from the caller's modifier); a child Box is the thumb,
positioned with an offset. Drag + track-click are handled on the track in
track-relative coordinates (a stable origin), so the grab point stays under
the pointer with no feedback from the thumb moving. interactionSource is
accepted for API parity; hover here is driven by Modifier.hoverable. */
/** Shared impl. The root Box is the track (fixed thickness on the cross axis,
filling the main axis from the caller's modifier); a child Box is the thumb,
positioned with an offset. Drag + track-click are handled on the track in
track-relative coordinates (a stable origin), so the grab point stays under
the pointer with no feedback from the thumb moving. interactionSource is
accepted for API parity; hover here is driven by Modifier.hoverable. */
@Composable
private fun Scrollbar(
adapter: ScrollbarAdapter,
@@ -238,9 +221,9 @@ private fun Scrollbar(
val vThumbLenDp = with(vDensity) { vThumbLen.toDp() }
val vThumbMod = (
if (isVertical) Modifier.offset { IntOffset(0, vThumbPos) }.width(style.thickness).height(vThumbLenDp)
else Modifier.offset { IntOffset(vThumbPos, 0) }.height(style.thickness).width(vThumbLenDp)
)
if (isVertical) Modifier.offset { IntOffset(0, vThumbPos) }.width(style.thickness).height(vThumbLenDp)
else Modifier.offset { IntOffset(vThumbPos, 0) }.height(style.thickness).width(vThumbLenDp)
)
.clip(style.shape)
.background(vColor)
@@ -274,7 +257,7 @@ private fun Scrollbar(
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
val vTravelPx = vTrackPx - vLen
val vRaw = if (vTravelPx <= 0) 0
else (vTravelPx * (adapter.scrollOffset / vMaxScrollPx)).roundToInt()
else (vTravelPx * (adapter.scrollOffset / vMaxScrollPx)).roundToInt()
val vPos = (if (reverseLayout) vTravelPx - vRaw else vRaw).coerceIn(0, vTravelPx)
// Map a track-relative thumb-top to a scroll offset. The
@@ -325,9 +308,9 @@ private fun Scrollbar(
}
}
/* Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
comes from the caller pre-converted via LocalDensity (Option-B: layout
coords are physical px, so px-to-dp goes through the current density). */
/** Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
comes from the caller pre-converted via LocalDensity (Option-B: layout
coords are physical px, so px-to-dp goes through the current density). */
private fun Modifier.matchTrackSize(inVertical: Boolean, inTrackDp: Dp, inThickness: Dp): Modifier =
if (inVertical) this.width(inThickness).height(inTrackDp)
else this.height(inThickness).width(inTrackDp)
+60 -133
View File
@@ -2,31 +2,20 @@
// 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
@@ -44,129 +33,67 @@ import androidx.compose.ui.unit.dp
* back at the modifier boundary. */
@Composable
fun HorizontalSplitPane(
modifier: Modifier = Modifier,
initialFirstSize: Dp = 200.dp,
minFirstSize: Dp = 50.dp,
minSecondSize: Dp = 50.dp,
dividerColor: Color = Color(0x33FFFFFFL),
dividerHoverColor: Color = Color(0x66FFFFFFL),
first: @Composable () -> Unit,
second: @Composable () -> Unit,
modifier: Modifier = Modifier,
initialFirstSize: Dp = 200.dp,
minFirstSize: Dp = 50.dp,
minSecondSize: Dp = 50.dp,
dividerColor: Color = Color(0x33FFFFFFL),
dividerHoverColor: Color = Color(0x66FFFFFFL),
first: @Composable () -> Unit,
second: @Composable () -> Unit,
) {
val vDensity = LocalDensity.current
val vDividerPx = with(vDensity) { SplitPaneDefaults.DividerThickness.toPx().toInt() }
val vMinFirstPx = with(vDensity) { minFirstSize.toPx().toInt() }
val vMinSecondPx = with(vDensity) { minSecondSize.toPx().toInt() }
val vInitialFirstPx = with(vDensity) { initialFirstSize.toPx().toInt() }
val vDensity = LocalDensity.current
val vDividerPx = with(vDensity) { SplitPaneDefaults.DividerThickness.toPx().toInt() }
val vMinFirstPx = with(vDensity) { minFirstSize.toPx().toInt() }
val vMinSecondPx = with(vDensity) { minSecondSize.toPx().toInt() }
val vInitialFirstPx = with(vDensity) { initialFirstSize.toPx().toInt() }
var vTotalWidthPx by remember { mutableStateOf(0) }
var vFirstWidthPx by remember { mutableStateOf(vInitialFirstPx) }
val vDividerSource = remember { MutableInteractionSource() }
val vDividerHover by vDividerSource.collectIsHoveredAsState()
var vDragging by remember { mutableStateOf(false) }
var vTotalWidthPx by remember { mutableStateOf(0) }
var vFirstWidthPx by remember { mutableStateOf(vInitialFirstPx) }
val vDividerSource = remember { MutableInteractionSource() }
val vDividerHover by vDividerSource.collectIsHoveredAsState()
var vDragging by remember { mutableStateOf(false) }
Row(
modifier = modifier
.fillMaxSize()
.onSizeChanged { vTotalWidthPx = it.width },
) {
val vClampedFirstPx = if (vTotalWidthPx > 0)
vFirstWidthPx.coerceIn(vMinFirstPx, (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx))
else vFirstWidthPx
val vSecondWidthPx = (vTotalWidthPx - vClampedFirstPx - vDividerPx).coerceAtLeast(0)
Row(
modifier = modifier
.fillMaxSize()
.onSizeChanged { vTotalWidthPx = it.width },
) {
val vClampedFirstPx = if (vTotalWidthPx > 0)
vFirstWidthPx.coerceIn(vMinFirstPx, (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx))
else vFirstWidthPx
val vSecondWidthPx = (vTotalWidthPx - vClampedFirstPx - vDividerPx).coerceAtLeast(0)
Box(modifier = Modifier.width(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxHeight()) { first() }
Box(modifier = Modifier.width(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxHeight()) { first() }
// Solid divider that fills its own slot — what you see is exactly what
// you can grab. Hover or an in-progress drag just changes the colour (no
// size change → no layout shift, and no flicker when the pointer briefly
// leaves the slot mid-drag).
val vActive = vDividerHover || vDragging
Box(
modifier = Modifier
.width(SplitPaneDefaults.DividerThickness)
.fillMaxHeight()
.background(if (vActive) dividerHoverColor else dividerColor)
.hoverable(vDividerSource)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { vDragging = true },
onDragEnd = { vDragging = false },
onDragCancel = { vDragging = false },
onDrag = { _, delta ->
val vMax = (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx)
vFirstWidthPx = (vFirstWidthPx + delta.x.toInt()).coerceIn(vMinFirstPx, vMax)
},
)
},
)
// Solid divider that fills its own slot — what you see is exactly what
// you can grab. Hover or an in-progress drag just changes the colour (no
// size change → no layout shift, and no flicker when the pointer briefly
// leaves the slot mid-drag).
val vActive = vDividerHover || vDragging
Box(
modifier = Modifier
.width(SplitPaneDefaults.DividerThickness)
.fillMaxHeight()
.background(if (vActive) dividerHoverColor else dividerColor)
.hoverable(vDividerSource)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { vDragging = true },
onDragEnd = { vDragging = false },
onDragCancel = { vDragging = false },
onDrag = { _, delta ->
val vMax = (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx)
vFirstWidthPx = (vFirstWidthPx + delta.x.toInt()).coerceIn(vMinFirstPx, vMax)
},
)
},
)
Box(modifier = Modifier.width(with(vDensity) { vSecondWidthPx.toDp() }).fillMaxHeight()) { second() }
}
}
@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() }
}
Box(modifier = Modifier.width(with(vDensity) { vSecondWidthPx.toDp() }).fillMaxHeight()) { second() }
}
}
object SplitPaneDefaults {
val DividerThickness: Dp = 4.dp // divider width = hit/drag area (solid; what you see is what you grab)
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 }
+10
View File
@@ -1,20 +1,30 @@
package apidemo
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.res.useResource
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 org.jetbrains.compose.resources.decodeToImageBitmap
// 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 {
// Voltic window icon (mirrors the native window icon). Staged onto the
// classpath at icon/ by jvmProcessResources.
val vIcon = remember {
BitmapPainter(useResource("icon/voltic-icon-256.png") { it.readAllBytes().decodeToImageBitmap() })
}
Window(
// The shared App installs a persist-then-close hook (InstallWindowHooks).
onCloseRequest = { if (jvmOnCloseRequest?.invoke() != false) exitApplication() },
onPreviewKeyEvent = { jvmOnKeyShortcut?.invoke(it) ?: false },
title = "API Manager — JVM (upstream Compose)",
icon = vIcon,
state = rememberWindowState(width = 1240.dp, height = 820.dp),
) {
App()
+8 -9
View File
@@ -8,19 +8,18 @@ package apidemo
// 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,
sizeBytes = 0,
headers = emptyList(),
body = "",
error = kNativeOnly,
status = 0,
statusText = "",
timeMs = 0,
sizeBytes = 0,
headers = emptyList(),
body = "",
error = kNativeOnly,
)
actual fun sweepTempClientCerts() {}
@@ -0,0 +1,164 @@
@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 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 fTabWidth by mutableStateOf(4)
actual var editorTabWidth: Int
get() = fTabWidth
set(value) {
fTabWidth = value
}
+12 -12
View File
@@ -3,20 +3,20 @@ 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.
/* Point libcurl straight at the certificate / key files. */
/** Point libcurl straight at the certificate / key files. */
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
PreparedCert(
sslCert = inReq.certPath,
sslCertType = inReq.certFormat.curlName,
sslKey = inReq.keyPath.ifBlank { null },
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
keyPassword = inReq.certPassword.ifEmpty { null },
cleanup = {},
)
PreparedCert(
sslCert = inReq.certPath,
sslCertType = inReq.certFormat.curlName,
sslKey = inReq.keyPath.ifBlank { null },
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
keyPassword = inReq.certPassword.ifEmpty { null },
cleanup = {},
)
/* No temporary certificate store off Windows. */
/** No temporary certificate store off Windows. */
actual fun sweepTempClientCerts() {}
/* No OS-store issuer resolution here — just continue with the name-only issuer. */
/** No OS-store issuer resolution here — just continue with the name-only issuer. */
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> =
serverChainWithIssuerName(inServerCerts)
serverChainWithIssuerName(inServerCerts)
@@ -3,7 +3,6 @@ package apidemo
import kotlinx.cinterop.*
import platform.windows.*
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
// ==================
// MARK: Windows client-certificate store import (Schannel mTLS)
@@ -32,464 +31,529 @@ private const val kCryptUserKeyset = 0x00001000u // CRYPT_USER_KEYSET (no
private val kEncoding: UInt = X509_ASN_ENCODING.toUInt() or PKCS_7_ASN_ENCODING.toUInt()
/* Import the request's certificate into CurrentUser\MY and return a libcurl
store reference plus a cleanup that removes it again. */
/** Import the request's certificate into CurrentUser\MY and return a libcurl
store reference plus a cleanup that removes it again. */
@OptIn(ExperimentalForeignApi::class)
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert
{
val vCertBytes = readFileBytes(inReq.certPath)
val (vThumb, vContainer) = when (inReq.certFormat)
{
CertFormat.PKCS12 -> importPkcs12(vCertBytes, inReq.certPassword)
else -> importPemDerRsa(vCertBytes, inReq)
}
return PreparedCert(
sslCert = "$kStorePath\\$vThumb",
sslCertType = null, // store reference — not a file type
sslKey = null,
sslKeyType = null,
keyPassword = null,
cleanup = { deleteTempCert(vThumb, vContainer) },
)
}
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert {
val vCertBytes = readFileBytes(inReq.certPath)
val (vThumb, vContainer) = when (inReq.certFormat) {
CertFormat.PKCS12 -> importPkcs12(vCertBytes, inReq.certPassword)
else -> importPemDerRsa(vCertBytes, inReq)
}
return PreparedCert(
sslCert = "$kStorePath\\$vThumb",
sslCertType = null, // store reference — not a file type
sslKey = null,
sslKeyType = null,
keyPassword = null,
cleanup = { deleteTempCert(vThumb, vContainer) },
)
}
// ============
// PEM / DER (RSA) → store
// ============
/* Build a cert context from PEM/DER, import its RSA private key into a named
container, link the two, tag and add to CurrentUser\MY. Returns
(thumbprintHex, containerName). */
/** Build a cert context from PEM/DER, import its RSA private key into a named
container, link the two, tag and add to CurrentUser\MY. Returns
(thumbprintHex, containerName). */
@OptIn(ExperimentalForeignApi::class)
private fun importPemDerRsa(inCertBytes: ByteArray, inReq: ApiRequest): Pair<String, String?>
{
val vCertDer = derFromMaybePem(inCertBytes, "CERTIFICATE")
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
val vKeyDer = privateKeyDer(vKeyBytes)
?: throw RuntimeException("No private key found — PEM/DER on Windows needs the RSA key (a key file, or a key block in the certificate file).")
val vBlob = try { rsaKeyDerToBlob(vKeyDer) }
catch (e: Throwable) { throw RuntimeException("Unsupported private key (RSA PKCS#1/PKCS#8 is supported; EC isn't yet). Convert to PKCS#12: openssl pkcs12 -export -inkey key.pem -in cert.pem -out client.p12") }
private fun importPemDerRsa(inCertBytes: ByteArray, inReq: ApiRequest): Pair<String, String?> {
val vCertDer = derFromMaybePem(inCertBytes, "CERTIFICATE")
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
val vKeyDer = privateKeyDer(vKeyBytes)
?: throw RuntimeException("No private key found — PEM/DER on Windows needs the RSA key (a key file, or a key block in the certificate file).")
val vBlob = try {
rsaKeyDerToBlob(vKeyDer)
} catch (e: Throwable) {
throw RuntimeException("Unsupported private key (RSA PKCS#1/PKCS#8 is supported; EC isn't yet). Convert to PKCS#12: openssl pkcs12 -export -inkey key.pem -in cert.pem -out client.p12")
}
val vCert = vCertDer.usePinned { vPin ->
CertCreateCertificateContext(kEncoding, vPin.addressOf(0).reinterpret(), vCertDer.size.convert())
} ?: winError("CertCreateCertificateContext")
val vCert = vCertDer.usePinned { vPin ->
CertCreateCertificateContext(kEncoding, vPin.addressOf(0).reinterpret(), vCertDer.size.convert())
} ?: winError("CertCreateCertificateContext")
try
{
val vThumb = thumbprintHex(vCert)
val vContainer = kTagPrefix + vThumb
importRsaIntoContainer(vContainer, vBlob)
try
{
linkKeyToCert(vCert, vContainer)
tagFriendlyName(vCert, kTagPrefix + vThumb)
addToStore(vCert)
return vThumb to vContainer
}
catch (e: Throwable) { deleteContainer(vContainer); throw e }
}
finally { CertFreeCertificateContext(vCert) }
}
/* Create a fresh named key container and import the RSA blob into it. */
@OptIn(ExperimentalForeignApi::class)
private fun importRsaIntoContainer(inContainer: String, inBlob: ByteArray) = memScoped {
deleteContainer(inContainer) // drop a stale one if it somehow exists
val vProv = alloc<ULongVar>()
if (CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_NEWKEYSET.toUInt()) == 0)
winError("CryptAcquireContext(NEWKEYSET)")
try
{
val vKey = alloc<ULongVar>()
val vOk = inBlob.usePinned { vPin ->
CryptImportKey(vProv.value, vPin.addressOf(0).reinterpret(), inBlob.size.convert(), 0uL, CRYPT_EXPORTABLE.toUInt(), vKey.ptr)
}
if (vOk == 0) winError("CryptImportKey")
CryptDestroyKey(vKey.value)
}
finally { CryptReleaseContext(vProv.value, 0u) }
try {
val vThumb = thumbprintHex(vCert)
val vContainer = kTagPrefix + vThumb
importRsaIntoContainer(vContainer, vBlob)
try {
linkKeyToCert(vCert, vContainer)
tagFriendlyName(vCert, kTagPrefix + vThumb)
addToStore(vCert)
return vThumb to vContainer
} catch (e: Throwable) {
deleteContainer(vContainer); throw e
}
} finally {
CertFreeCertificateContext(vCert)
}
}
/* Attach the named container to the cert so Schannel can find its key. */
/** Create a fresh named key container and import the RSA blob into it. */
@OptIn(ExperimentalForeignApi::class)
private fun importRsaIntoContainer(inContainer: String, inBlob: ByteArray) = memScoped {
deleteContainer(inContainer) // drop a stale one if it somehow exists
val vProv = alloc<ULongVar>()
if (CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_NEWKEYSET.toUInt()) == 0)
winError("CryptAcquireContext(NEWKEYSET)")
try {
val vKey = alloc<ULongVar>()
val vOk = inBlob.usePinned { vPin ->
CryptImportKey(
vProv.value,
vPin.addressOf(0).reinterpret(),
inBlob.size.convert(),
0uL,
CRYPT_EXPORTABLE.toUInt(),
vKey.ptr
)
}
if (vOk == 0) winError("CryptImportKey")
CryptDestroyKey(vKey.value)
} finally {
CryptReleaseContext(vProv.value, 0u)
}
}
/** Attach the named container to the cert so Schannel can find its key. */
@OptIn(ExperimentalForeignApi::class)
private fun linkKeyToCert(inCert: CPointer<CERT_CONTEXT>, inContainer: String) = memScoped {
val vInfo = alloc<CRYPT_KEY_PROV_INFO>()
vInfo.pwszContainerName = inContainer.wcstr.ptr
vInfo.pwszProvName = kProvName.wcstr.ptr
vInfo.dwProvType = PROV_RSA_FULL.toUInt()
vInfo.dwFlags = 0u
vInfo.cProvParam = 0u
vInfo.rgProvParam = null
vInfo.dwKeySpec = AT_KEYEXCHANGE.toUInt()
if (CertSetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), 0u, vInfo.ptr) == 0)
winError("CertSetCertificateContextProperty(KEY_PROV_INFO)")
val vInfo = alloc<CRYPT_KEY_PROV_INFO>()
vInfo.pwszContainerName = inContainer.wcstr.ptr
vInfo.pwszProvName = kProvName.wcstr.ptr
vInfo.dwProvType = PROV_RSA_FULL.toUInt()
vInfo.dwFlags = 0u
vInfo.cProvParam = 0u
vInfo.rgProvParam = null
vInfo.dwKeySpec = AT_KEYEXCHANGE.toUInt()
if (CertSetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), 0u, vInfo.ptr) == 0)
winError("CertSetCertificateContextProperty(KEY_PROV_INFO)")
}
// ============
// RSA key ASN.1 → CryptoAPI PRIVATEKEYBLOB
// ============
/* Minimal DER reader for the RSA key structures. */
private class Der(val b: ByteArray)
{
var p = 0
fun tag(): Int = b[p++].toInt() and 0xFF
fun len(): Int
{
val vFirst = b[p++].toInt() and 0xFF
if (vFirst and 0x80 == 0) return vFirst
var vLen = 0
repeat(vFirst and 0x7F) { vLen = (vLen shl 8) or (b[p++].toInt() and 0xFF) }
return vLen
}
fun sequence() { require(tag() == 0x30) { "SEQUENCE expected" }; len() }
fun peek(): Int = b[p].toInt() and 0xFF
fun skip() { tag(); val vL = len(); p += vL }
fun octetString(): ByteArray { require(tag() == 0x04) { "OCTET STRING expected" }; val vL = len(); return b.copyOfRange(p, p + vL).also { p += vL } }
// An INTEGER's magnitude (big-endian, leading zero stripped).
fun integer(): ByteArray
{
require(tag() == 0x02) { "INTEGER expected" }
val vL = len(); val vV = b.copyOfRange(p, p + vL); p += vL
var vS = 0; while (vS < vV.size - 1 && vV[vS].toInt() == 0) vS++
return vV.copyOfRange(vS, vV.size)
}
}
/** Minimal DER reader for the RSA key structures. */
private class Der(val b: ByteArray) {
var p = 0
fun tag(): Int = b[p++].toInt() and 0xFF
fun len(): Int {
val vFirst = b[p++].toInt() and 0xFF
if (vFirst and 0x80 == 0) return vFirst
var vLen = 0
repeat(vFirst and 0x7F) { vLen = (vLen shl 8) or (b[p++].toInt() and 0xFF) }
return vLen
}
/* Turn a PKCS#1 or PKCS#8 RSA private-key DER into a CryptoAPI PRIVATEKEYBLOB.
Throws if it isn't an RSA key we can lay out. */
private fun rsaKeyDerToBlob(inDer: ByteArray): ByteArray
{
val vR = Der(inDer)
vR.sequence(); vR.integer() // outer version
return if (vR.peek() == 0x30)
{
// PKCS#8 PrivateKeyInfo: skip AlgorithmIdentifier, parse the inner key.
vR.skip()
parsePkcs1(vR.octetString())
}
else
// PKCS#1: n, e, d, p, q, dp, dq, iqmp follow the version we just read.
buildBlob(vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer())
}
fun sequence() {
require(tag() == 0x30) { "SEQUENCE expected" }; len()
}
private fun parsePkcs1(inDer: ByteArray): ByteArray
{
val vR = Der(inDer)
vR.sequence(); vR.integer() // version
return buildBlob(vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer())
}
fun peek(): Int = b[p].toInt() and 0xFF
fun skip() {
tag()
val vL = len(); p += vL
}
/* Lay out the MS PRIVATEKEYBLOB (all little-endian) from the RSA components. */
fun octetString(): ByteArray {
require(tag() == 0x04) { "OCTET STRING expected" }
val vL = len(); return b.copyOfRange(p, p + vL).also { p += vL }
}
// An INTEGER's magnitude (big-endian, leading zero stripped).
fun integer(): ByteArray {
require(tag() == 0x02) { "INTEGER expected" }
val vL = len()
val vV = b.copyOfRange(p, p + vL); p += vL
var vS = 0; while (vS < vV.size - 1 && vV[vS].toInt() == 0) vS++
return vV.copyOfRange(vS, vV.size)
}
}
/** Turn a PKCS#1 or PKCS#8 RSA private-key DER into a CryptoAPI PRIVATEKEYBLOB.
Throws if it isn't an RSA key we can lay out. */
private fun rsaKeyDerToBlob(inDer: ByteArray): ByteArray {
val vR = Der(inDer)
vR.sequence(); vR.integer() // outer version
return if (vR.peek() == 0x30) {
// PKCS#8 PrivateKeyInfo: skip AlgorithmIdentifier, parse the inner key.
vR.skip()
parsePkcs1(vR.octetString())
} else
// PKCS#1: n, e, d, p, q, dp, dq, iqmp follow the version we just read.
buildBlob(
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer()
)
}
private fun parsePkcs1(inDer: ByteArray): ByteArray {
val vR = Der(inDer)
vR.sequence(); vR.integer() // version
return buildBlob(
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer()
)
}
/** Lay out the MS PRIVATEKEYBLOB (all little-endian) from the RSA components. */
private fun buildBlob(
inN: ByteArray, inE: ByteArray, inD: ByteArray,
inP: ByteArray, inQ: ByteArray, inDp: ByteArray, inDq: ByteArray, inIq: ByteArray,
): ByteArray
{
val vMod = inN.size // modulus length in bytes
val vHalf = vMod / 2
val vOut = ArrayList<Byte>(20 + vMod * 2 + vHalf * 5)
vOut.add(PRIVATEKEYBLOB.toByte()); vOut.add(CUR_BLOB_VERSION.toByte()); vOut.add(0); vOut.add(0) // BLOBHEADER
vOut.addAll(le32(CALG_RSA_KEYX.toUInt()).toList())
vOut.addAll(le32(0x32415352u).toList()) // "RSA2"
vOut.addAll(le32((vMod * 8).toUInt()).toList()) // bitlen
vOut.addAll(le32(beToUInt(inE)).toList()) // pubexp
vOut.addAll(leField(inN, vMod).toList())
vOut.addAll(leField(inP, vHalf).toList())
vOut.addAll(leField(inQ, vHalf).toList())
vOut.addAll(leField(inDp, vHalf).toList())
vOut.addAll(leField(inDq, vHalf).toList())
vOut.addAll(leField(inIq, vHalf).toList())
vOut.addAll(leField(inD, vMod).toList())
return vOut.toByteArray()
}
inN: ByteArray, inE: ByteArray, inD: ByteArray,
inP: ByteArray, inQ: ByteArray, inDp: ByteArray, inDq: ByteArray, inIq: ByteArray,
): ByteArray {
val vMod = inN.size // modulus length in bytes
val vHalf = vMod / 2
val vOut = ArrayList<Byte>(20 + vMod * 2 + vHalf * 5)
vOut.add(PRIVATEKEYBLOB.toByte()); vOut.add(CUR_BLOB_VERSION.toByte()); vOut.add(0); vOut.add(0) // BLOBHEADER
vOut.addAll(le32(CALG_RSA_KEYX.toUInt()).toList())
vOut.addAll(le32(0x32415352u).toList()) // "RSA2"
vOut.addAll(le32((vMod * 8).toUInt()).toList()) // bitlen
vOut.addAll(le32(beToUInt(inE)).toList()) // pubexp
vOut.addAll(leField(inN, vMod).toList())
vOut.addAll(leField(inP, vHalf).toList())
vOut.addAll(leField(inQ, vHalf).toList())
vOut.addAll(leField(inDp, vHalf).toList())
vOut.addAll(leField(inDq, vHalf).toList())
vOut.addAll(leField(inIq, vHalf).toList())
vOut.addAll(leField(inD, vMod).toList())
return vOut.toByteArray()
}
/* A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
private fun leField(inBe: ByteArray, inSize: Int): ByteArray
{
val vOut = ByteArray(inSize)
for (vI in inBe.indices) if (vI < inSize) vOut[vI] = inBe[inBe.size - 1 - vI]
return vOut
}
/** A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
private fun leField(inBe: ByteArray, inSize: Int): ByteArray {
val vOut = ByteArray(inSize)
for (vI in inBe.indices) if (vI < inSize) vOut[vI] = inBe[inBe.size - 1 - vI]
return vOut
}
private fun le32(inV: UInt): ByteArray =
byteArrayOf((inV and 0xFFu).toByte(), ((inV shr 8) and 0xFFu).toByte(), ((inV shr 16) and 0xFFu).toByte(), ((inV shr 24) and 0xFFu).toByte())
byteArrayOf(
(inV and 0xFFu).toByte(),
((inV shr 8) and 0xFFu).toByte(),
((inV shr 16) and 0xFFu).toByte(),
((inV shr 24) and 0xFFu).toByte()
)
private fun beToUInt(inBe: ByteArray): UInt
{
var vV = 0u
for (vB in inBe) vV = (vV shl 8) or (vB.toInt() and 0xFF).toUInt()
return vV
}
private fun beToUInt(inBe: ByteArray): UInt {
var vV = 0u
for (vB in inBe) vV = (vV shl 8) or (vB.toInt() and 0xFF).toUInt()
return vV
}
// ============
// PKCS#12 → store
// ============
/* Import a .p12/.pfx into a memory store, then copy its cert (with the key
container PFXImportCertStore created) into CurrentUser\MY. */
/** Import a .p12/.pfx into a memory store, then copy its cert (with the key
container PFXImportCertStore created) into CurrentUser\MY. */
@OptIn(ExperimentalForeignApi::class)
private fun importPkcs12(inBytes: ByteArray, inPassword: String): Pair<String, String?> = memScoped {
inBytes.usePinned { vPin ->
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = inBytes.size.convert()
vBlob.pbData = vPin.addressOf(0).reinterpret()
val vMem = PFXImportCertStore(vBlob.ptr, inPassword, kCryptUserKeyset or CRYPT_EXPORTABLE.toUInt())
?: winError("PFXImportCertStore (wrong password, or not a PKCS#12 file?)")
try
{
val vSrc = CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
val vThumb = thumbprintHex(vSrc)
val vContainer = containerNameOf(vSrc)
tagFriendlyName(vSrc, kTagPrefix + vThumb)
addToStore(vSrc)
vThumb to vContainer
}
finally { CertCloseStore(vMem, 0u) }
}
inBytes.usePinned { vPin ->
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = inBytes.size.convert()
vBlob.pbData = vPin.addressOf(0).reinterpret()
val vMem = PFXImportCertStore(vBlob.ptr, inPassword, kCryptUserKeyset or CRYPT_EXPORTABLE.toUInt())
?: winError("PFXImportCertStore (wrong password, or not a PKCS#12 file?)")
try {
val vSrc =
CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
val vThumb = thumbprintHex(vSrc)
val vContainer = containerNameOf(vSrc)
tagFriendlyName(vSrc, kTagPrefix + vThumb)
addToStore(vSrc)
vThumb to vContainer
} finally {
CertCloseStore(vMem, 0u)
}
}
}
/* The key-container name PFXImportCertStore assigned to a cert (from its
CERT_KEY_PROV_INFO), so cleanup can delete it. */
/** The key-container name PFXImportCertStore assigned to a cert (from its
CERT_KEY_PROV_INFO), so cleanup can delete it. */
@OptIn(ExperimentalForeignApi::class)
private fun containerNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
val vLen = alloc<UIntVar>()
if (CertGetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), null, vLen.ptr) == 0) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CertGetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
vBuf.reinterpret<CRYPT_KEY_PROV_INFO>().pointed.pwszContainerName?.toKStringFromUtf16()
val vLen = alloc<UIntVar>()
if (CertGetCertificateContextProperty(
inCert,
CERT_KEY_PROV_INFO_PROP_ID.toUInt(),
null,
vLen.ptr
) == 0
) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CertGetCertificateContextProperty(
inCert,
CERT_KEY_PROV_INFO_PROP_ID.toUInt(),
vBuf,
vLen.ptr
) == 0
) return@memScoped null
vBuf.reinterpret<CRYPT_KEY_PROV_INFO>().pointed.pwszContainerName?.toKStringFromUtf16()
}
// ============
// Shared store helpers
// ============
/* Tag a cert with our friendly-name prefix so the startup sweep recognises it. */
/** Tag a cert with our friendly-name prefix so the startup sweep recognises it. */
@OptIn(ExperimentalForeignApi::class)
private fun tagFriendlyName(inCert: CPointer<CERT_CONTEXT>, inName: String) = memScoped {
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = ((inName.length + 1) * 2).convert()
vBlob.pbData = inName.wcstr.ptr.reinterpret()
CertSetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), 0u, vBlob.ptr)
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = ((inName.length + 1) * 2).convert()
vBlob.pbData = inName.wcstr.ptr.reinterpret()
CertSetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), 0u, vBlob.ptr)
}
/* Add a cert context to CurrentUser\MY (replacing any same-thumbprint entry). */
/** Add a cert context to CurrentUser\MY (replacing any same-thumbprint entry). */
@OptIn(ExperimentalForeignApi::class)
private fun addToStore(inCert: CPointer<CERT_CONTEXT>)
{
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
try
{
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
winError("CertAddCertificateContextToStore")
}
finally { CertCloseStore(vStore, 0u) }
}
private fun addToStore(inCert: CPointer<CERT_CONTEXT>) {
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
try {
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
winError("CertAddCertificateContextToStore")
} finally {
CertCloseStore(vStore, 0u)
}
}
/* Open CurrentUser\MY (the current user's Personal store). */
/** Open CurrentUser\MY (the current user's Personal store). */
@OptIn(ExperimentalForeignApi::class)
private fun openMyStore() = CertOpenSystemStoreW(0.convert(), kStoreName)
/* The SHA-1 thumbprint of a cert as uppercase hex. */
/** The SHA-1 thumbprint of a cert as uppercase hex. */
@OptIn(ExperimentalForeignApi::class)
private fun thumbprintHex(inCert: CPointer<CERT_CONTEXT>): String = memScoped {
val vLen = alloc<UIntVar>().apply { value = 20u }
val vBuf = allocArray<UByteVar>(20)
if (CertGetCertificateContextProperty(inCert, CERT_SHA1_HASH_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0)
winError("CertGetCertificateContextProperty(SHA1)")
buildString { for (vI in 0 until vLen.value.toInt()) append(vBuf[vI].toInt().toString(16).uppercase().padStart(2, '0')) }
val vLen = alloc<UIntVar>().apply { value = 20u }
val vBuf = allocArray<UByteVar>(20)
if (CertGetCertificateContextProperty(inCert, CERT_SHA1_HASH_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0)
winError("CertGetCertificateContextProperty(SHA1)")
buildString {
for (vI in 0 until vLen.value.toInt()) append(
vBuf[vI].toInt().toString(16).uppercase().padStart(2, '0')
)
}
}
/* Remove a temporary cert (by thumbprint) from CurrentUser\MY and delete its
key container. Called per-request in finally; ignores what's already gone. */
/** Remove a temporary cert (by thumbprint) from CurrentUser\MY and delete its
key container. Called per-request in finally; ignores what's already gone. */
@OptIn(ExperimentalForeignApi::class)
private fun deleteTempCert(inThumbHex: String, inContainer: String?)
{
val vStore = openMyStore()
if (vStore != null) memScoped {
val vHash = hexToBytes(inThumbHex)
val vBuf = allocArray<UByteVar>(vHash.size)
for (vI in vHash.indices) vBuf[vI] = vHash[vI].toUByte()
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = vHash.size.convert()
vBlob.pbData = vBuf
val vFound = CertFindCertificateInStore(vStore, kEncoding, 0u, CERT_FIND_SHA1_HASH.toUInt(), vBlob.ptr, null)
if (vFound != null) CertDeleteCertificateFromStore(vFound) // also frees the context
CertCloseStore(vStore, 0u)
}
if (inContainer != null) deleteContainer(inContainer)
}
private fun deleteTempCert(inThumbHex: String, inContainer: String?) {
val vStore = openMyStore()
if (vStore != null) memScoped {
val vHash = hexToBytes(inThumbHex)
val vBuf = allocArray<UByteVar>(vHash.size)
for (vI in vHash.indices) vBuf[vI] = vHash[vI].toUByte()
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
vBlob.cbData = vHash.size.convert()
vBlob.pbData = vBuf
val vFound = CertFindCertificateInStore(vStore, kEncoding, 0u, CERT_FIND_SHA1_HASH.toUInt(), vBlob.ptr, null)
if (vFound != null) CertDeleteCertificateFromStore(vFound) // also frees the context
CertCloseStore(vStore, 0u)
}
if (inContainer != null) deleteContainer(inContainer)
}
/* Delete a named key container (best-effort). */
/** Delete a named key container (best-effort). */
@OptIn(ExperimentalForeignApi::class)
private fun deleteContainer(inContainer: String) = memScoped {
val vProv = alloc<ULongVar>()
CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_DELETEKEYSET.toUInt())
val vProv = alloc<ULongVar>()
CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_DELETEKEYSET.toUInt())
}
/* Remove every temp cert we ever added (prefix-tagged) plus its container — for
clearing crash leftovers at startup. The user's own certs are never matched. */
/** Remove every temp cert we ever added (prefix-tagged) plus its container — for
clearing crash leftovers at startup. The user's own certs are never matched. */
@OptIn(ExperimentalForeignApi::class)
actual fun sweepTempClientCerts()
{
val vStore = openMyStore() ?: return
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
var vCert = CertEnumCertificatesInStore(vStore, null)
while (vCert != null)
{
val vFriendly = friendlyNameOf(vCert)
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
vCert = CertEnumCertificatesInStore(vStore, vCert)
}
CertCloseStore(vStore, 0u)
vDoomed.forEach { deleteTempCert(it.first, it.second) }
}
actual fun sweepTempClientCerts() {
val vStore = openMyStore() ?: return
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
var vCert = CertEnumCertificatesInStore(vStore, null)
while (vCert != null) {
val vFriendly = friendlyNameOf(vCert)
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
vCert = CertEnumCertificatesInStore(vStore, vCert)
}
CertCloseStore(vStore, 0u)
vDoomed.forEach { deleteTempCert(it.first, it.second) }
}
/* A cert's friendly name, or null. */
/** A cert's friendly name, or null. */
@OptIn(ExperimentalForeignApi::class)
private fun friendlyNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
val vLen = alloc<UIntVar>()
if (CertGetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), null, vLen.ptr) == 0) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CertGetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
vBuf.reinterpret<UShortVar>().toKStringFromUtf16()
val vLen = alloc<UIntVar>()
if (CertGetCertificateContextProperty(
inCert,
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
null,
vLen.ptr
) == 0
) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CertGetCertificateContextProperty(
inCert,
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
vBuf,
vLen.ptr
) == 0
) return@memScoped null
vBuf.reinterpret<UShortVar>().toKStringFromUtf16()
}
// ============
// Chain extension via the OS certificate store
// ============
/* Continue the server's chain by asking the OS to build it from the leaf — this
pulls intermediates/roots from the Windows store with full info. Falls back to
a name-only issuer if the OS can't (or the leaf can't be parsed). */
/** Continue the server's chain by asking the OS to build it from the leaf — this
pulls intermediates/roots from the Windows store with full info. Falls back to
a name-only issuer if the OS can't (or the leaf can't be parsed). */
@OptIn(ExperimentalForeignApi::class)
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert>
{
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
?: return serverChainWithIssuerName(inServerCerts)
val vOs = runCatching { osChain(vLeafPem, inServerCerts.size) }.getOrNull()
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
}
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> {
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
?: return serverChainWithIssuerName(inServerCerts)
val vOs = runCatching { osChain(vLeafPem, inServerCerts.size) }.getOrNull()
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
}
/* Build the full chain from the leaf via CertGetCertificateChain (OS store). */
/** Build the full chain from the leaf via CertGetCertificateChain (OS store). */
@OptIn(ExperimentalForeignApi::class)
private fun osChain(inLeafPem: String, inServerCount: Int): List<ChainCert> = memScoped {
val vDer = derFromMaybePem(inLeafPem.encodeToByteArray(), "CERTIFICATE")
val vLeaf = vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
?: return@memScoped emptyList()
try
{
val vPara = alloc<CERT_CHAIN_PARA>()
vPara.cbSize = sizeOf<CERT_CHAIN_PARA>().convert()
val vChainPtr = alloc<CPointerVar<CERT_CHAIN_CONTEXT>>()
if (CertGetCertificateChain(null, vLeaf, null, null, vPara.ptr, 0u, null, vChainPtr.ptr) == 0)
return@memScoped emptyList()
val vChain = vChainPtr.value ?: return@memScoped emptyList()
try
{
val vSimple = vChain.pointed.rgpChain?.get(0)?.pointed ?: return@memScoped emptyList()
val vElems = vSimple.rgpElement ?: return@memScoped emptyList()
val vResult = ArrayList<ChainCert>()
for (vI in 0 until vSimple.cElement.toInt())
{
val vCtx = vElems[vI]?.pointed?.pCertContext ?: continue
vResult.add(ChainCert(certFieldsFromContext(vCtx), vI < inServerCount))
}
// If the chain didn't reach a self-signed root, end with the name only.
val vLast = vResult.lastOrNull()
if (vLast != null)
{
val vSub = certFieldOf(vLast.fields, "Subject")
val vIss = certFieldOf(vLast.fields, "Issuer")
if (!vIss.isNullOrBlank() && vIss != vSub) vResult.add(ChainCert(listOf("Subject" to vIss), false))
}
vResult
}
finally { CertFreeCertificateChain(vChain) }
}
finally { CertFreeCertificateContext(vLeaf) }
val vDer = derFromMaybePem(inLeafPem.encodeToByteArray(), "CERTIFICATE")
val vLeaf =
vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
?: return@memScoped emptyList()
try {
val vPara = alloc<CERT_CHAIN_PARA>()
vPara.cbSize = sizeOf<CERT_CHAIN_PARA>().convert()
val vChainPtr = alloc<CPointerVar<CERT_CHAIN_CONTEXT>>()
if (CertGetCertificateChain(null, vLeaf, null, null, vPara.ptr, 0u, null, vChainPtr.ptr) == 0)
return@memScoped emptyList()
val vChain = vChainPtr.value ?: return@memScoped emptyList()
try {
val vSimple = vChain.pointed.rgpChain?.get(0)?.pointed ?: return@memScoped emptyList()
val vElems = vSimple.rgpElement ?: return@memScoped emptyList()
val vResult = ArrayList<ChainCert>()
for (vI in 0 until vSimple.cElement.toInt()) {
val vCtx = vElems[vI]?.pointed?.pCertContext ?: continue
vResult.add(ChainCert(certFieldsFromContext(vCtx), vI < inServerCount))
}
// If the chain didn't reach a self-signed root, end with the name only.
val vLast = vResult.lastOrNull()
if (vLast != null) {
val vSub = certFieldOf(vLast.fields, "Subject")
val vIss = certFieldOf(vLast.fields, "Issuer")
if (!vIss.isNullOrBlank() && vIss != vSub) vResult.add(ChainCert(listOf("Subject" to vIss), false))
}
vResult
} finally {
CertFreeCertificateChain(vChain)
}
} finally {
CertFreeCertificateContext(vLeaf)
}
}
/* Subject / Issuer (RDN strings) + the PEM, read from a cert context. */
/** Subject / Issuer (RDN strings) + the PEM, read from a cert context. */
@OptIn(ExperimentalForeignApi::class)
private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<String, String>>
{
val vFields = ArrayList<Pair<String, String>>()
certNameString(inCtx, 0u)?.let { vFields.add("Subject" to it) }
certNameString(inCtx, CERT_NAME_ISSUER_FLAG.toUInt())?.let { vFields.add("Issuer" to it) }
certPem(inCtx)?.let { vFields.add("Cert" to it) }
return vFields
}
private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<String, String>> {
val vFields = ArrayList<Pair<String, String>>()
certNameString(inCtx, 0u)?.let { vFields.add("Subject" to it) }
certNameString(inCtx, CERT_NAME_ISSUER_FLAG.toUInt())?.let { vFields.add("Issuer" to it) }
certPem(inCtx)?.let { vFields.add("Cert" to it) }
return vFields
}
/* A cert's Subject (inFlags=0) or Issuer (CERT_NAME_ISSUER_FLAG) as an X.500 string. */
/** A cert's Subject (inFlags=0) or Issuer (CERT_NAME_ISSUER_FLAG) as an X.500 string. */
@OptIn(ExperimentalForeignApi::class)
private fun certNameString(inCtx: CPointer<CERT_CONTEXT>, inFlags: UInt): String? = memScoped {
val vType = alloc<UIntVar>().apply { value = CERT_X500_NAME_STR.toUInt() }
val vLen = CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, null, 0u)
if (vLen <= 1u) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.toInt())
CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, vBuf, vLen)
vBuf.toKString()
val vType = alloc<UIntVar>().apply { value = CERT_X500_NAME_STR.toUInt() }
val vLen = CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, null, 0u)
if (vLen <= 1u) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.toInt())
CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, vBuf, vLen)
vBuf.toKString()
}
/* The PEM (base64-with-header) of a cert context. */
/** The PEM (base64-with-header) of a cert context. */
@OptIn(ExperimentalForeignApi::class)
private fun certPem(inCtx: CPointer<CERT_CONTEXT>): String? = memScoped {
val vData = inCtx.pointed.pbCertEncoded ?: return@memScoped null
val vSize = inCtx.pointed.cbCertEncoded
val vLen = alloc<UIntVar>()
if (CryptBinaryToStringA(vData, vSize, CRYPT_STRING_BASE64HEADER.toUInt(), null, vLen.ptr) == 0) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CryptBinaryToStringA(vData, vSize, CRYPT_STRING_BASE64HEADER.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
vBuf.toKString()
val vData = inCtx.pointed.pbCertEncoded ?: return@memScoped null
val vSize = inCtx.pointed.cbCertEncoded
val vLen = alloc<UIntVar>()
if (CryptBinaryToStringA(
vData,
vSize,
CRYPT_STRING_BASE64HEADER.toUInt(),
null,
vLen.ptr
) == 0
) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CryptBinaryToStringA(
vData,
vSize,
CRYPT_STRING_BASE64HEADER.toUInt(),
vBuf,
vLen.ptr
) == 0
) return@memScoped null
vBuf.toKString()
}
// ============
// Small helpers
// ============
/* Decode a PEM block (by label) to DER, or pass bytes through if already DER. */
@OptIn(ExperimentalEncodingApi::class)
private fun derFromMaybePem(inBytes: ByteArray, inLabel: String): ByteArray
{
val vText = inBytes.decodeToString()
if (!vText.contains("-----BEGIN")) return inBytes
val vBegin = vText.indexOf("-----BEGIN $inLabel-----")
val vEnd = vText.indexOf("-----END $inLabel-----")
val vBlock = if (vBegin >= 0 && vEnd >= 0) vText.substring(vBegin, vEnd) else vText
return Base64.Default.decode(pemBody(vBlock))
}
/** Decode a PEM block (by label) to DER, or pass bytes through if already DER. */
private fun derFromMaybePem(inBytes: ByteArray, inLabel: String): ByteArray {
val vText = inBytes.decodeToString()
if (!vText.contains("-----BEGIN")) return inBytes
val vBegin = vText.indexOf("-----BEGIN $inLabel-----")
val vEnd = vText.indexOf("-----END $inLabel-----")
val vBlock = if (vBegin >= 0 && vEnd >= 0) vText.substring(vBegin, vEnd) else vText
return Base64.decode(pemBody(vBlock))
}
/* Extract + DER-decode the first PRIVATE KEY block from PEM text, or pass DER
bytes through. Null if no key. */
@OptIn(ExperimentalEncodingApi::class)
private fun privateKeyDer(inBytes: ByteArray): ByteArray?
{
val vText = inBytes.decodeToString()
if (!vText.contains("-----BEGIN")) return inBytes // already DER
val vMatch = Regex("-----BEGIN [A-Z 0-9]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z 0-9]*PRIVATE KEY-----").find(vText)
?: return null
return Base64.Default.decode(pemBody(vMatch.value))
}
/** Extract + DER-decode the first PRIVATE KEY block from PEM text, or pass DER
bytes through. Null if no key. */
private fun privateKeyDer(inBytes: ByteArray): ByteArray? {
val vText = inBytes.decodeToString()
if (!vText.contains("-----BEGIN")) return inBytes // already DER
val vMatch = Regex("-----BEGIN [A-Z 0-9]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z 0-9]*PRIVATE KEY-----").find(vText)
?: return null
return Base64.decode(pemBody(vMatch.value))
}
/* The base64 body of a PEM block (drop the BEGIN/END lines and whitespace). */
/** The base64 body of a PEM block (drop the BEGIN/END lines and whitespace). */
private fun pemBody(inPem: String): String =
inPem.lineSequence().filterNot { it.startsWith("-----") }.joinToString("") { it.trim() }
inPem.lineSequence().filterNot { it.startsWith("-----") }.joinToString("") { it.trim() }
private fun hexToBytes(inHex: String): ByteArray =
ByteArray(inHex.length / 2) { inHex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
ByteArray(inHex.length / 2) { inHex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
/* Read a null-terminated UTF-16 (wide) string. */
/** Read a null-terminated UTF-16 (wide) string. */
@OptIn(ExperimentalForeignApi::class)
private fun CPointer<UShortVar>.toKStringFromUtf16(): String
{
val vSb = StringBuilder()
var vI = 0
while (true) { val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++ }
return vSb.toString()
}
private fun CPointer<UShortVar>.toKStringFromUtf16(): String {
val vSb = StringBuilder()
var vI = 0
while (true) {
val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++
}
return vSb.toString()
}
@OptIn(ExperimentalForeignApi::class)
private fun winError(inWhat: String): Nothing =
throw RuntimeException("$inWhat failed (Windows error 0x${GetLastError().toString(16)})")
throw RuntimeException("$inWhat failed (Windows error 0x${GetLastError().toString(16)})")
+312 -273
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
@@ -21,22 +21,22 @@ import kotlin.time.TimeSource
// from HttpRunner.run(); cancellation is best-effort (an in-flight perform can't
// be interrupted — the result is simply discarded if the caller cancelled).
/* A client certificate readied for libcurl: the CURLOPT_SSLCERT value plus the
other SSL options to set, and a cleanup to run once the request is done.
macOS/Linux (OpenSSL) just point curl at the files. Windows (Schannel) imports
the cert + key into CurrentUser\MY, references it by SHA-1 thumbprint, and the
cleanup removes it again so the store never accumulates entries. */
/** A client certificate readied for libcurl: the CURLOPT_SSLCERT value plus the
other SSL options to set, and a cleanup to run once the request is done.
macOS/Linux (OpenSSL) just point curl at the files. Windows (Schannel) imports
the cert + key into CurrentUser\MY, references it by SHA-1 thumbprint, and the
cleanup removes it again so the store never accumulates entries. */
class PreparedCert(
val sslCert: String, // CURLOPT_SSLCERT (file path, or "CurrentUser\\MY\\<thumbprint>")
val sslCertType: String?, // CURLOPT_SSLCERTTYPE (null for a store reference)
val sslKey: String?, // CURLOPT_SSLKEY (null when the cert carries its key)
val sslKeyType: String?, // CURLOPT_SSLKEYTYPE
val keyPassword: String?, // CURLOPT_KEYPASSWD
val cleanup: () -> Unit,
val sslCert: String, // CURLOPT_SSLCERT (file path, or "CurrentUser\\MY\\<thumbprint>")
val sslCertType: String?, // CURLOPT_SSLCERTTYPE (null for a store reference)
val sslKey: String?, // CURLOPT_SSLKEY (null when the cert carries its key)
val sslKeyType: String?, // CURLOPT_SSLKEYTYPE
val keyPassword: String?, // CURLOPT_KEYPASSWD
val cleanup: () -> Unit,
)
/* Ready the request's client certificate for libcurl (platform-specific).
Throws with a user-facing message if the cert/key can't be loaded. */
/** Ready the request's client certificate for libcurl (platform-specific).
Throws with a user-facing message if the cert/key can't be loaded. */
expect fun prepareClientCert(inReq: ApiRequest): PreparedCert
// ChainCert / TlsChain and the sweepTempClientCerts / inspectTlsChain /
@@ -44,287 +44,326 @@ expect fun prepareClientCert(inReq: ApiRequest): PreparedCert
// 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
issuer is resolved from the OS certificate store with full info; otherwise the
chain ends with a name-only placeholder. Every derived (non-server) cert is
flagged fromServer = false. Platform-specific (OS store on Windows). */
/** Continue a server-presented chain with its issuer(s). Where possible the
issuer is resolved from the OS certificate store with full info; otherwise the
chain ends with a name-only placeholder. Every derived (non-server) cert is
flagged fromServer = false. Platform-specific (OS store on Windows). */
expect fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert>
/* A CURLINFO_CERTINFO field by name (case-insensitive). */
/** A CURLINFO_CERTINFO field by name (case-insensitive). */
internal fun certFieldOf(inFields: List<Pair<String, String>>, inName: String): String? =
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
/* Server certs as ChainCerts; if the last one isn't self-signed, append a
dotted, name-only issuer so the chain visibly continues. The cross-platform
fallback when the OS store can't (or isn't able to) resolve the real issuer. */
internal fun serverChainWithIssuerName(inServer: List<List<Pair<String, String>>>): List<ChainCert>
{
val vOut = inServer.map { ChainCert(it, true) }.toMutableList()
val vLast = inServer.lastOrNull()
if (vLast != null)
{
val vSubject = certFieldOf(vLast, "Subject")
val vIssuer = certFieldOf(vLast, "Issuer")
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
vOut.add(ChainCert(listOf("Subject" to vIssuer), false))
}
return vOut
}
/* Per-transfer accumulators handed to the C callbacks through a StableRef. */
private class CurlSink
{
val body = Buffer() // response body bytes (already decompressed by curl)
val headerRaw = StringBuilder() // raw response header lines, all responses incl. redirects
}
/* Body write callback — appends each chunk to the sink's buffer. */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlBody(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t
{
val vSink = inUserdata.asStableRef<CurlSink>().get()
val vLen = (inSize * inCount).toLong()
if (vLen > 0) vSink.body.write(inBuffer.readBytes(vLen.toInt()))
return vLen.convert()
}
/* Header callback — one parsed header line per call; accumulated for parsing. */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlHeader(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t
{
val vSink = inUserdata.asStableRef<CurlSink>().get()
val vLen = (inSize * inCount).toLong()
if (vLen > 0) vSink.headerRaw.append(inBuffer.readBytes(vLen.toInt()).decodeToString())
return vLen.convert()
}
/* Sink callback that discards everything (used by the TLS-chain probe so the
response body/headers don't spill to stdout). */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlDiscard(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t =
(inSize * inCount)
/* Open a TLS connection to the request's URL (handshake only — no body) and
return the server's certificate chain via CURLINFO_CERTINFO. Reuses the
request's client certificate if it has one (so mTLS endpoints work too).
inReq is already var-resolved. */
@OptIn(ExperimentalForeignApi::class)
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain
{
val vCert = try { if (inReq.hasClientCert) prepareClientCert(inReq) else null }
catch (e: Throwable) { return TlsChain(emptyList(), e.message ?: "Client certificate error") }
val vCurl = curl_easy_init() ?: run { vCert?.cleanup(); return TlsChain(emptyList(), "Could not initialize libcurl") }
try
{
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L) // just the handshake + headers
curl_easy_setopt(vCurl, CURLOPT_CERTINFO, 1L)
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 0L) // inspect the host as typed
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlDiscard))
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlDiscard))
vCert?.let { vC ->
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vC.sslCert)
vC.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
vC.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
vC.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
vC.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
}
val vCode = curl_easy_perform(vCurl)
if (vCode != CURLE_OK)
return TlsChain(emptyList(), curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode")
val vRaw = readCertInfo(vCurl)
if (vRaw.isEmpty()) return TlsChain(emptyList(), "No certificate chain reported (not an HTTPS URL, or the TLS backend didn't expose it).")
return TlsChain(extendChain(vRaw), null)
}
finally
{
curl_easy_cleanup(vCurl)
vCert?.cleanup()
}
}
/* Read CURLINFO_CERTINFO off a performed handle into per-cert field lists. */
@OptIn(ExperimentalForeignApi::class)
private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>>> = memScoped {
val vPtr = alloc<COpaquePointerVar>()
if (curl_easy_getinfo(inCurl, CURLINFO_CERTINFO, vPtr.ptr) != CURLE_OK) return@memScoped emptyList()
val vInfo = vPtr.value?.reinterpret<curl_certinfo>()?.pointed ?: return@memScoped emptyList()
val vArr = vInfo.certinfo ?: return@memScoped emptyList()
val vResult = ArrayList<List<Pair<String, String>>>()
for (vI in 0 until vInfo.num_of_certs)
{
val vFields = ArrayList<Pair<String, String>>()
var vNode = vArr[vI]
while (vNode != null)
{
val vData = vNode.pointed.data?.toKString()
if (vData != null)
{
val vColon = vData.indexOf(':')
if (vColon > 0) vFields.add(vData.substring(0, vColon) to vData.substring(vColon + 1))
else vFields.add("" to vData)
}
vNode = vNode.pointed.next
}
vResult.add(vFields)
}
vResult
/** Server certs as ChainCerts; if the last one isn't self-signed, append a
dotted, name-only issuer so the chain visibly continues. The cross-platform
fallback when the OS store can't (or isn't able to) resolve the real issuer. */
internal fun serverChainWithIssuerName(inServer: List<List<Pair<String, String>>>): List<ChainCert> {
val vOut = inServer.map { ChainCert(it, true) }.toMutableList()
val vLast = inServer.lastOrNull()
if (vLast != null) {
val vSubject = certFieldOf(vLast, "Subject")
val vIssuer = certFieldOf(vLast, "Issuer")
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
vOut.add(ChainCert(listOf("Subject" to vIssuer), false))
}
return vOut
}
/* Send a client-certificate request through libcurl and adapt the result into
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
/** Per-transfer accumulators handed to the C callbacks through a StableRef. */
private class CurlSink {
val body = Buffer() // response body bytes (already decompressed by curl)
val headerRaw = StringBuilder() // raw response header lines, all responses incl. redirects
}
/** Body write callback — appends each chunk to the sink's buffer. */
@OptIn(ExperimentalForeignApi::class)
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
{
val vMark = TimeSource.Monotonic.markNow()
val vCert = try { prepareClientCert(inReq) }
catch (e: Throwable) { return errorResponse(e.message ?: "Client certificate error", vMark.elapsedNow().inWholeMilliseconds) }
val vCurl = curl_easy_init()
?: run { vCert.cleanup(); return errorResponse("Could not initialize libcurl", vMark.elapsedNow().inWholeMilliseconds) }
private fun onCurlBody(
inBuffer: CPointer<ByteVar>,
inSize: size_t,
inCount: size_t,
inUserdata: COpaquePointer
): size_t {
val vSink = inUserdata.asStableRef<CurlSink>().get()
val vLen = (inSize * inCount).toLong()
if (vLen > 0) vSink.body.write(inBuffer.readBytes(vLen.toInt()))
return vLen.convert()
}
val vSink = CurlSink()
val vSinkRef = StableRef.create(vSink)
var vHeaders: CPointer<curl_slist>? = null
/** Header callback — one parsed header line per call; accumulated for parsing. */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlHeader(
inBuffer: CPointer<ByteVar>,
inSize: size_t,
inCount: size_t,
inUserdata: COpaquePointer
): size_t {
val vSink = inUserdata.asStableRef<CurlSink>().get()
val vLen = (inSize * inCount).toLong()
if (vLen > 0) vSink.headerRaw.append(inBuffer.readBytes(vLen.toInt()).decodeToString())
return vLen.convert()
}
try
{
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 1L)
curl_easy_setopt(vCurl, CURLOPT_ACCEPT_ENCODING, "") // advertise + auto-decompress
/** Sink callback that discards everything (used by the TLS-chain probe so the
response body/headers don't spill to stdout). */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlDiscard(
inBuffer: CPointer<ByteVar>,
inSize: size_t,
inCount: size_t,
inUserdata: COpaquePointer
): size_t =
(inSize * inCount)
// ============
// Method + body
val vBody = if (inReq.method == ReqMethod.HEAD) null else requestBodyBytes(inReq)
if (inReq.method == ReqMethod.HEAD)
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
else
curl_easy_setopt(vCurl, CURLOPT_CUSTOMREQUEST, inReq.method.name)
if (vBody != null && vBody.bytes.isNotEmpty())
{
curl_easy_setopt(vCurl, CURLOPT_POSTFIELDSIZE_LARGE, vBody.bytes.size.toLong())
vBody.bytes.usePinned { curl_easy_setopt(vCurl, CURLOPT_COPYPOSTFIELDS, it.addressOf(0)) }
}
/** Open a TLS connection to the request's URL (handshake only — no body) and
return the server's certificate chain via CURLINFO_CERTINFO. Reuses the
request's client certificate if it has one (so mTLS endpoints work too).
inReq is already var-resolved. */
@OptIn(ExperimentalForeignApi::class)
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain {
val vCert = try {
if (inReq.hasClientCert) prepareClientCert(inReq) else null
} catch (e: Throwable) {
return TlsChain(emptyList(), e.message ?: "Client certificate error")
}
val vCurl =
curl_easy_init() ?: run { vCert?.cleanup(); return TlsChain(emptyList(), "Could not initialize libcurl") }
try {
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L) // just the handshake + headers
curl_easy_setopt(vCurl, CURLOPT_CERTINFO, 1L)
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 0L) // inspect the host as typed
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlDiscard))
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlDiscard))
vCert?.let { vC ->
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vC.sslCert)
vC.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
vC.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
vC.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
vC.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
}
val vCode = curl_easy_perform(vCurl)
if (vCode != CURLE_OK)
return TlsChain(emptyList(), curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode")
val vRaw = readCertInfo(vCurl)
if (vRaw.isEmpty()) return TlsChain(
emptyList(),
"No certificate chain reported (not an HTTPS URL, or the TLS backend didn't expose it)."
)
return TlsChain(extendChain(vRaw), null)
} finally {
curl_easy_cleanup(vCurl)
vCert?.cleanup()
}
}
// ============
// Headers (+ Content-Type for the body type if the user didn't set one)
inReq.headers
.filter { it.enabled && it.key.isNotBlank() }
.forEach { vHeaders = curl_slist_append(vHeaders, "${it.key}: ${it.value}") }
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
if (vBody?.contentType != null && !vHasCt)
vHeaders = curl_slist_append(vHeaders, "Content-Type: ${vBody.contentType}")
vHeaders = curl_slist_append(vHeaders, "Expect:") // disable 100-continue, as Ktor's engine does
vHeaders?.let { curl_easy_setopt(vCurl, CURLOPT_HTTPHEADER, it) }
/** Read CURLINFO_CERTINFO off a performed handle into per-cert field lists. */
@OptIn(ExperimentalForeignApi::class)
private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>>> = memScoped {
val vPtr = alloc<COpaquePointerVar>()
if (curl_easy_getinfo(inCurl, CURLINFO_CERTINFO, vPtr.ptr) != CURLE_OK) return@memScoped emptyList()
val vInfo = vPtr.value?.reinterpret<curl_certinfo>()?.pointed ?: return@memScoped emptyList()
val vArr = vInfo.certinfo ?: return@memScoped emptyList()
val vResult = ArrayList<List<Pair<String, String>>>()
for (vI in 0 until vInfo.num_of_certs) {
val vFields = ArrayList<Pair<String, String>>()
var vNode = vArr[vI]
while (vNode != null) {
val vData = vNode.pointed.data?.toKString()
if (vData != null) {
val vColon = vData.indexOf(':')
if (vColon > 0) vFields.add(vData.substring(0, vColon) to vData.substring(vColon + 1))
else vFields.add("" to vData)
}
vNode = vNode.pointed.next
}
vResult.add(vFields)
}
vResult
}
// ============
// Response capture callbacks
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlBody))
curl_easy_setopt(vCurl, CURLOPT_WRITEDATA, vSinkRef.asCPointer())
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlHeader))
curl_easy_setopt(vCurl, CURLOPT_HEADERDATA, vSinkRef.asCPointer())
/** Send a client-certificate request through libcurl and adapt the result into
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
@OptIn(ExperimentalForeignApi::class)
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse {
val vMark = TimeSource.Monotonic.markNow()
val vCert = try {
prepareClientCert(inReq)
} catch (e: Throwable) {
return errorResponse(e.message ?: "Client certificate error", vMark.elapsedNow().inWholeMilliseconds)
}
val vCurl = curl_easy_init()
?: run {
vCert.cleanup(); return errorResponse(
"Could not initialize libcurl",
vMark.elapsedNow().inWholeMilliseconds
)
}
// ============
// Client certificate (prepared per-platform: cert/key files on the
// OpenSSL backends, a CurrentUser\MY thumbprint on Windows/Schannel).
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vCert.sslCert)
vCert.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
vCert.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
vCert.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
vCert.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
val vSink = CurlSink()
val vSinkRef = StableRef.create(vSink)
var vHeaders: CPointer<curl_slist>? = null
// ============
// Perform
val vCode = curl_easy_perform(vCurl)
val vMs = vMark.elapsedNow().inWholeMilliseconds
if (vCode != CURLE_OK)
return errorResponse(curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode", vMs)
try {
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 1L)
curl_easy_setopt(vCurl, CURLOPT_ACCEPT_ENCODING, "") // advertise + auto-decompress
val vBytes = vSink.body.readByteArray()
val vStatus = memScoped { val p = alloc<LongVar>(); curl_easy_getinfo(vCurl, CURLINFO_RESPONSE_CODE, p.ptr); p.value.toInt() }
val vCt = memScoped { val p = alloc<CPointerVar<ByteVar>>(); curl_easy_getinfo(vCurl, CURLINFO_CONTENT_TYPE, p.ptr); p.value?.toKString() }
val vVer = memScoped { val p = alloc<LongVar>(); curl_easy_getinfo(vCurl, CURLINFO_HTTP_VERSION, p.ptr); httpVersionName(p.value) }
val vIsImage = vCt?.startsWith("image/", ignoreCase = true) == true
val vBinary = !vIsImage && isBinaryBody(vCt, vBytes)
// ============
// Method + body
val vBody = if (inReq.method == ReqMethod.HEAD) null else requestBodyBytes(inReq)
if (inReq.method == ReqMethod.HEAD)
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
else
curl_easy_setopt(vCurl, CURLOPT_CUSTOMREQUEST, inReq.method.name)
if (vBody != null && vBody.bytes.isNotEmpty()) {
curl_easy_setopt(vCurl, CURLOPT_POSTFIELDSIZE_LARGE, vBody.bytes.size.toLong())
vBody.bytes.usePinned { curl_easy_setopt(vCurl, CURLOPT_COPYPOSTFIELDS, it.addressOf(0)) }
}
// Headers we explicitly put on the wire (curl also adds Host / Accept /
// User-Agent / Content-Length, which it doesn't expose without a debug hook).
val vSent = buildList {
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { add(it.key to it.value) }
if (vBody?.contentType != null && !vHasCt) add("Content-Type" to vBody.contentType)
}.sortedBy { it.first.lowercase() }
// ============
// Headers (+ Content-Type for the body type if the user didn't set one)
inReq.headers
.filter { it.enabled && it.key.isNotBlank() }
.forEach { vHeaders = curl_slist_append(vHeaders, "${it.key}: ${it.value}") }
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
if (vBody?.contentType != null && !vHasCt)
vHeaders = curl_slist_append(vHeaders, "Content-Type: ${vBody.contentType}")
vHeaders = curl_slist_append(vHeaders, "Expect:") // disable 100-continue, as Ktor's engine does
vHeaders?.let { curl_easy_setopt(vCurl, CURLOPT_HTTPHEADER, it) }
return ApiResponse(
ok = true,
status = vStatus,
statusText = runCatching { HttpStatusCode.fromValue(vStatus).description }.getOrDefault(""),
timeMs = vMs,
sizeBytes = vBytes.size.toLong(),
headers = parseRawHeaders(vSink.headerRaw.toString()),
body = when {
vIsImage -> ""
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes — not shown; use Save as…)"
else -> vBytes.decodeToString()
},
bytes = vBytes,
contentType = vCt,
requestHeaders = vSent,
httpVersion = vVer,
)
}
finally
{
vHeaders?.let { curl_slist_free_all(it) }
curl_easy_cleanup(vCurl)
vSinkRef.dispose()
vCert.cleanup()
}
}
// ============
// Response capture callbacks
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlBody))
curl_easy_setopt(vCurl, CURLOPT_WRITEDATA, vSinkRef.asCPointer())
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlHeader))
curl_easy_setopt(vCurl, CURLOPT_HEADERDATA, vSinkRef.asCPointer())
/* A failed-request ApiResponse with a message (mirrors HttpRunner's catch). */
// ============
// Client certificate (prepared per-platform: cert/key files on the
// OpenSSL backends, a CurrentUser\MY thumbprint on Windows/Schannel).
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vCert.sslCert)
vCert.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
vCert.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
vCert.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
vCert.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
// ============
// Perform
val vCode = curl_easy_perform(vCurl)
val vMs = vMark.elapsedNow().inWholeMilliseconds
if (vCode != CURLE_OK)
return errorResponse(curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode", vMs)
val vBytes = vSink.body.readByteArray()
val vStatus = memScoped {
val p = alloc<LongVar>(); curl_easy_getinfo(
vCurl,
CURLINFO_RESPONSE_CODE,
p.ptr
); p.value.toInt()
}
val vCt = memScoped {
val p = alloc<CPointerVar<ByteVar>>(); curl_easy_getinfo(
vCurl,
CURLINFO_CONTENT_TYPE,
p.ptr
); p.value?.toKString()
}
val vVer = memScoped {
val p = alloc<LongVar>(); curl_easy_getinfo(
vCurl,
CURLINFO_HTTP_VERSION,
p.ptr
); httpVersionName(p.value)
}
val vIsImage = vCt?.startsWith("image/", ignoreCase = true) == true
val vBinary = !vIsImage && isBinaryBody(vCt, vBytes)
// Headers we explicitly put on the wire (curl also adds Host / Accept /
// User-Agent / Content-Length, which it doesn't expose without a debug hook).
val vSent = buildList {
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { add(it.key to it.value) }
if (vBody?.contentType != null && !vHasCt) add("Content-Type" to vBody.contentType)
}.sortedBy { it.first.lowercase() }
return ApiResponse(
status = vStatus,
statusText = runCatching { HttpStatusCode.fromValue(vStatus).description }.getOrDefault(""),
timeMs = vMs,
sizeBytes = vBytes.size.toLong(),
headers = parseRawHeaders(vSink.headerRaw.toString()),
body = when {
vIsImage -> ""
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes — not shown; use Save as…)"
else -> vBytes.decodeToString()
},
bytes = vBytes,
contentType = vCt,
requestHeaders = vSent,
httpVersion = vVer,
)
} finally {
vHeaders?.let { curl_slist_free_all(it) }
curl_easy_cleanup(vCurl)
vSinkRef.dispose()
vCert.cleanup()
}
}
/** A failed-request ApiResponse with a message (mirrors HttpRunner's catch). */
private fun errorResponse(inMessage: String, inMs: Long): ApiResponse =
ApiResponse(ok = false, status = 0, statusText = "—", timeMs = inMs, sizeBytes = 0, headers = emptyList(), body = "", error = inMessage)
ApiResponse(
status = 0,
statusText = "—",
timeMs = inMs,
sizeBytes = 0,
headers = emptyList(),
body = "",
error = inMessage
)
/* The encoded body for the request's body type, with the Content-Type curl
should send (null when there's no body). Mirrors HttpRunner.run(). */
/** The encoded body for the request's body type, with the Content-Type curl
should send (null when there's no body). Mirrors HttpRunner.run(). */
private class CurlBody(val contentType: String?, val bytes: ByteArray)
private fun requestBodyBytes(inReq: ApiRequest): CurlBody?
{
if (inReq.bodyType == BodyType.NONE) return null
return when (inReq.bodyType)
{
BodyType.TEXT -> CurlBody(inReq.bodyContentType(), inReq.body.encodeToByteArray())
BodyType.FORM -> CurlBody(inReq.bodyContentType(), formEncode(inReq.form).encodeToByteArray())
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(inReq.bodyContentType(), readFileBytes(inReq.body))
BodyType.NONE -> null
}
}
private fun requestBodyBytes(inReq: ApiRequest): CurlBody? {
if (inReq.bodyType == BodyType.NONE) return null
return when (inReq.bodyType) {
BodyType.TEXT -> CurlBody(inReq.bodyContentType(), inReq.body.encodeToByteArray())
BodyType.FORM -> CurlBody(inReq.bodyContentType(), formEncode(inReq.form).encodeToByteArray())
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(
inReq.bodyContentType(),
readFileBytes(inReq.body)
)
/* Parse libcurl's accumulated raw header text into ordered key/value pairs,
keeping only the final response's block (a new "HTTP/" status line — emitted
per redirect hop — resets what we've gathered). */
private fun parseRawHeaders(inRaw: String): List<Pair<String, String>>
{
var vCurrent = mutableListOf<Pair<String, String>>()
for (vRawLine in inRaw.split("\r\n", "\n"))
{
val vLine = vRawLine.trimEnd()
if (vLine.isEmpty()) continue
if (vLine.startsWith("HTTP/", ignoreCase = true)) { vCurrent = mutableListOf(); continue }
val vColon = vLine.indexOf(':')
if (vColon <= 0) continue
vCurrent.add(vLine.substring(0, vColon).trim() to vLine.substring(vColon + 1).trim())
}
return vCurrent.sortedBy { it.first.lowercase() }
}
BodyType.NONE -> null
}
}
/* libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
/** Parse libcurl's accumulated raw header text into ordered key/value pairs,
keeping only the final response's block (a new "HTTP/" status line — emitted
per redirect hop — resets what we've gathered). */
private fun parseRawHeaders(inRaw: String): List<Pair<String, String>> {
var vCurrent = mutableListOf<Pair<String, String>>()
for (vRawLine in inRaw.split("\r\n", "\n")) {
val vLine = vRawLine.trimEnd()
if (vLine.isEmpty()) continue
if (vLine.startsWith("HTTP/", ignoreCase = true)) {
vCurrent = mutableListOf(); continue
}
val vColon = vLine.indexOf(':')
if (vColon <= 0) continue
vCurrent.add(vLine.substring(0, vColon).trim() to vLine.substring(vColon + 1).trim())
}
return vCurrent.sortedBy { it.first.lowercase() }
}
/** libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
@OptIn(ExperimentalForeignApi::class)
private fun httpVersionName(inCode: Long): String = when (inCode)
{
CURL_HTTP_VERSION_1_0.toLong() -> "HTTP/1.0"
CURL_HTTP_VERSION_1_1.toLong() -> "HTTP/1.1"
CURL_HTTP_VERSION_2_0.toLong() -> "HTTP/2"
CURL_HTTP_VERSION_3.toLong() -> "HTTP/3"
else -> "HTTP/1.1"
}
private fun httpVersionName(inCode: Long): String = when (inCode) {
CURL_HTTP_VERSION_1_0.toLong() -> "HTTP/1.0"
CURL_HTTP_VERSION_1_1.toLong() -> "HTTP/1.1"
CURL_HTTP_VERSION_2_0.toLong() -> "HTTP/2"
CURL_HTTP_VERSION_3.toLong() -> "HTTP/3"
else -> "HTTP/1.1"
}
+10 -1
View File
@@ -1,11 +1,20 @@
package apidemo
import com.compose.sdl.AppWindowIcon
import com.compose.sdl.nativeComposeWindow
// ==================
// MARK: Entry point (native — SDL window shell)
// ==================
// 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")
)
fun main() {
nativeComposeWindow(title = "API Manager", width = 1240, height = 820) { App() }
nativeComposeWindow(title = "API Manager", width = 1240, height = 820, icon = kAppIcon) { App() }
}
@@ -1,34 +1,43 @@
package apidemo
package apidemo.compat
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.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.currentTextMeasurer
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? =
com.compose.sdl.appDataDir(inOrg, inApp)
appDataDir(inOrg, inApp)
actual fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)?) =
com.compose.sdl.revealInFileManager(inPath, inOnResult)
revealInFileManager(inPath, inOnResult)
actual fun fileManagerName(): String = com.compose.sdl.fileManagerName()
actual fun fileManagerName(): String = fileManagerName()
actual fun showSaveFileDialog(inDefaultName: String?, inOnResult: (String?) -> Unit) =
com.compose.sdl.showSaveFileDialog(inDefaultName, inOnResult)
showSaveFileDialog(inDefaultName, inOnResult)
actual fun showOpenFileDialog(inOnResult: (String?) -> Unit) =
com.compose.sdl.showOpenFileDialog(inOnResult)
showOpenFileDialog(inOnResult)
actual fun clipEntryOfText(inText: String): ClipEntry = ClipEntry.withPlainText(inText)
@@ -38,26 +47,28 @@ 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)
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
currentTextMeasurer.wrap(inText, inFontPx, inMaxWidthPx, inFamilyName).lines.size
actual var editorTabWidth: Int
get() = TextLayoutConfig.tabWidth
set(value) { TextLayoutConfig.tabWidth = value }
get() = TextLayoutConfig.tabWidth
set(value) {
TextLayoutConfig.tabWidth = value
}
internal actual val systemFileSystem: okio.FileSystem = okio.FileSystem.SYSTEM
internal actual val systemFileSystem: FileSystem = FileSystem.SYSTEM
actual fun createApiHttpClient(): io.ktor.client.HttpClient =
io.ktor.client.HttpClient(io.ktor.client.engine.curl.Curl)
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) }
}
val vWindow = LocalComposeNativeWindow.current
DisposableEffect(inOnCloseRequest, inOnKeyShortcut) {
vWindow.setOnCloseRequest(inOnCloseRequest)
vWindow.setOnKeyShortcut(inOnKeyShortcut)
onDispose { vWindow.setOnCloseRequest(null); vWindow.setOnKeyShortcut(null) }
}
}
+37 -23
View File
@@ -1,32 +1,20 @@
// 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"))
ignoredProjects.addAll(listOf("demo", "apidemo"))
}
// 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")
@@ -38,6 +26,7 @@ allprojects {
// ==================
// 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
// for the shared kotlinMultiplatform metadata. The CI publish workflow runs on
@@ -66,6 +55,20 @@ val kPublishedLibs = setOf(
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 {
@@ -79,7 +82,16 @@ subprojects {
}
}
}
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
}
plugins.apply("maven-publish")
afterEvaluate {
extensions.configure<PublishingExtension> {
@@ -117,7 +129,6 @@ subprojects {
// 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 {
@@ -140,17 +151,20 @@ allprojects {
// 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"))
// 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"))
}
}
}
}
// 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,308 @@
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
/**
* hb-subset each bundled Material Symbols font to the glyphs the sources reference
* (scripts/subset-material-symbols.py + hb-subset; full font when hb-subset is
* absent). Needs [bundleMaterialSymbols]; still gated by -PsubsetIcons.
*/
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(":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(":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) }
}
/**
* hb-subset inStyle's downloaded TTF to the codepoints in usage-codepoint.txt, into this
* app's build/icons/. Bundles the full font instead when hb-subset is not on PATH.
*/
private fun Project.registerSubsetTask(
inStyle: String,
inIconsBuildDir: Provider<Directory>,
inFindUsage: TaskProvider<*>,
): 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 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()
// ProcessBuilder: project.exec isn't usable inside doLast on Gradle 9.
// hb-subset is optional : without it, bundle the full font.
val vProc = try {
ProcessBuilder(
"hb-subset",
vInputFile.absolutePath,
"-o", vOut.absolutePath,
"--unicodes=$vUnicodes",
).redirectErrorStream(true).start()
} catch (_: java.io.IOException) {
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")
}
}
}
+7 -49
View File
@@ -3,8 +3,8 @@
// 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,
// SDL3_image decoding, SDL locale/theme environment. Apps' JVM targets keep
// using the official Maven artifact — this module is native-only.
// 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)
@@ -17,7 +17,6 @@ repositories {
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
val useSdl3Everywhere = (findProperty("renderer") as? String) == "sdl3"
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
kotlin {
@@ -41,52 +40,11 @@ kotlin {
}
nativeMain {
kotlin.srcDir("src/vendor/native/kotlin")
}
// ============
// Renderer roots — same conditional wiring as :ui: only the source
// sets that will actually be attached are created.
val sdlRendererMain = create("sdlRendererMain") {
dependsOn(nativeMain.get())
kotlin.srcDir("src/vendor/sdlRenderer/kotlin")
}
if (vHostSupportsMingw) {
val sdlRendererMingwMain = create("sdlRendererMingwMain") { dependsOn(sdlRendererMain) }
get("mingwX64Main").dependsOn(sdlRendererMingwMain)
}
val macosArm64Main = get("macosArm64Main")
val linuxX64Main = get("linuxX64Main")
val linuxArm64Main = get("linuxArm64Main")
if (useSdl3Everywhere) {
val sdlRendererMacosMain = create("sdlRendererMacosMain") { dependsOn(sdlRendererMain) }
val sdlRendererLinuxMain = create("sdlRendererLinuxMain") { dependsOn(sdlRendererMain) }
macosArm64Main.dependsOn(sdlRendererMacosMain)
linuxX64Main.dependsOn(sdlRendererLinuxMain)
linuxArm64Main.dependsOn(sdlRendererLinuxMain)
} else {
// The Font/Image actuals for this module are pure project code
// (:ui's IconFont / NamedFont / decodeEncodedImageBitmap in nativeMain)
// so they work identically under either renderer. Only one renderer
// source set is attached to a given target, so pointing skikoRenderer
// at sdlRendererMain's srcDir doesn't cause duplicate actuals.
// Upstream's skikoMain vendored files here referenced Compose-Desktop
// Skia extensions (SystemFont / toComposeImageBitmap / nativeCanvas)
// that this port's :ui doesn't expose — sharing the SDL actuals sidesteps
// that gap entirely.
val skikoRendererMain = create("skikoRendererMain") {
dependsOn(nativeMain.get())
kotlin.srcDir("src/sdlRendererMain/kotlin")
dependencies {
implementation(libs.skiko)
}
}
val skikoRendererMacosMain = create("skikoRendererMacosMain") { dependsOn(skikoRendererMain) }
val skikoRendererLinuxMain = create("skikoRendererLinuxMain") { dependsOn(skikoRendererMain) }
macosArm64Main.dependsOn(skikoRendererMacosMain)
linuxX64Main.dependsOn(skikoRendererLinuxMain)
linuxArm64Main.dependsOn(skikoRendererLinuxMain)
// The Font / Image resource actuals — pure project code that delegates
// to :ui's IconFont / NamedFont / decodeEncodedImageBitmap (the Skia
// decoder). Renderer-agnostic and skiko-free, so a single native set
// covers every target. (Dir name is historical — .sdl.kt.)
kotlin.srcDir("src/sdlRendererMain/kotlin")
}
}
@@ -5,9 +5,10 @@
# 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/sdlRendererMain — the Font/Image resource actuals: renderer-agnostic
# project code (image decode via :ui's Skia decoder, Font
# via the project font registry). Dir name is historical;
# the build attaches it to nativeMain.
# Re-sync: python scripts/compose-fork/sync.py components/resources/library
SET_REPO=https://github.com/JetBrains/compose-multiplatform@<COMPOSE_REF>
@@ -54,10 +55,9 @@ skikoMain/kotlin/org/jetbrains/compose/resources/ResourceReader.skiko.kt -> src/
# 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/sdlRendererMain/kotlin/,
# attached to nativeMain) 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
@@ -22,7 +22,7 @@ import sdl3.SDL_free
// MARK: ResourceEnvironment — SDL actual
// ==================
/* Non-composable system environment for qualifier resolution (values-fr,
/** Non-composable system environment for qualifier resolution (values-fr,
drawable-dark, …): locale from SDL_GetPreferredLocales, theme from
SDL_GetSystemTheme. Density is reported as 1f — under this port's Option-B
density flow layout runs in physical pixels and drawables are bundled at a
@@ -11,7 +11,7 @@ import com.compose.sdl.loadComposeResourceBytes
// MARK: ResourceReader — data.kres actual
// ==================
/* The platform reader for this port: every app ships its composeResources
/** 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
@@ -16,6 +16,8 @@ package org.jetbrains.compose.resources.vector.xmldom
CDATA skipping, and the five predefined entities plus numeric character
references. No DTD expansion, no processing-instruction handling beyond
skipping — malformed input throws MalformedXMLException like upstream. */
// VENDOR-BASE(COMPOSE_REF): components/resources/library/src/nativeMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt @ v1.12.0-beta03+dev4483
// (fresh REIMPL, not a copy-edit — the base ref marks the upstream API it tracks)
internal fun parse(xml: String): Element {
val vParser = XmlDomParser(xml)
@@ -18,7 +18,7 @@ import com.compose.sdl.text.NamedFont
// MARK: Font actuals — SDL renderer
// ==================
/* The SDL text stack resolves fonts by FAMILY NAME through the project font
/** The SDL 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
@@ -15,7 +15,7 @@ import com.compose.sdl.graphics.decodeEncodedImageBitmap
// MARK: Image actuals — SDL renderer
// ==================
/* Decoding goes through the :ui hook the SDL backend registers at init
/** Decoding goes through the :ui hook the SDL backend registers at init
(SDL3_image, IMG auto-detect). 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. */
@@ -23,7 +23,7 @@ internal actual fun ByteArray.toImageBitmap(resourceDensity: Int, targetDensity:
decodeEncodedImageBitmap(this)
?: error("Image decode failed — is the render backend initialised before painterResource ran?")
/* SVG on SDL: SDL3_image rasterises SVG at its intrinsic size (IMG_LoadSVG),
/** SVG on SDL: SDL3_image rasterises SVG at its intrinsic size (IMG_LoadSVG),
so the "element" is just the raw bytes and the painter is the rasterised
bitmap. No DOM, no vector scaling — prefer XML vector drawables for
resolution-independent art on this renderer. */
@@ -18,6 +18,7 @@
// MANUAL VENDOR of upstream compose/foundation/foundation/src/commonMain/kotlin/
// androidx/compose/foundation/text/selection/SelectionLayout.kt.
// VENDOR-BASE: compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionLayout.kt @ v1.12.0-beta03+dev4483
//
// Change vs upstream: MultiSelectionLayout.crossStatus and slotToIndex clamp their
// infoList index to the valid range. Upstream assumes that if the drag position never
@@ -8,7 +8,7 @@ import androidx.compose.runtime.remember
// MARK: InteractionSource convenience
// ==================
/* Convenience around the official MutableInteractionSource() factory. Official
/** Convenience around the official MutableInteractionSource() factory. Official
Compose uses `remember { MutableInteractionSource() }` directly; this helper
is a project shorthand. Lives in :foundation because it references
androidx.compose.foundation.interaction.MutableInteractionSource. */
@@ -42,7 +42,7 @@ 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
@@ -71,7 +71,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,
@@ -88,7 +88,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 {
@@ -115,7 +115,7 @@ private class ScrollStateScrollbarAdapter(private val state: ScrollState) : Scro
}
}
/* 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
count (the same approach as Compose Desktop). Accurate enough for a thumb
@@ -189,7 +189,7 @@ fun HorizontalScrollbar(
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
@@ -334,7 +334,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,38 +1,31 @@
package com.compose.sdl.text
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.TextUnit
// ==================
// MARK: IconText — project-only text composable for icon fonts
// MARK: IconText — icon-font text on the standard text path
// ==================
/*
/**
A minimal project text composable that renders a codepoint through a named
icon font with optional variable-font axis settings (Material Symbols).
icon font with optional variable-font axis settings (Material Symbols FILL /
wght / GRAD / opsz).
Sits outside `androidx.compose.foundation.text.BasicText` so BasicText can
remain a byte-identical match of upstream. Icons need:
- `fontFamily: String` — the registered icon font family name (project's
`FontFamily.Named`, not upstream `FontFamily.Resolver`).
- `fontVariationSettings` — per-usage variable-font axis values (FILL,
wght, GRAD, opsz for Material Symbols).
Now just a [BasicText]: the icon font family and its variable axes are threaded
through [namedFontFamily] (`axes = …`), which the skiko text engine reads via
`FontFamily.projectFontVariations()` and applies to the typeface. So icons
measure + draw through the same skiko `skparagraph` path as ordinary text — no
separate renderer/measurer seam.
Upstream `TextStyle` has neither: fontFamily routes through the
`FontFamily.Resolver`, and axis values are set per-Font at construction
time. Rather than fight that abstraction for one composable, icons use
this project path — the TextDrawElement modifier feeds directly into
SdlParagraph / Sdl3Canvas.drawNativeText.
Material `Icon(codepoint = ..., fontFamily = ...)` uses this. Everything
else (Text, BasicText, TextField) goes through the upstream-shaped path.
Material `Icon(codepoint = …, fontFamily = …)` uses this; Text / BasicText /
TextField go through the upstream-shaped path already.
*/
@Composable
fun IconText(
@@ -44,28 +37,16 @@ fun IconText(
textAlign: TextAlign = TextAlign.Start,
fontVariationSettings: List<FontVariation.Setting>? = null,
) {
// Layout runs in physical pixels (LocalDensity = DPR), so the icon font
// size must also convert `sp → px`. Matches SdlParagraph's `fontSize.value
// * density`. Without this the icons render at half size on Retina.
val vDensity = LocalDensity.current.density
val vFontPx = (fontSize.value * vDensity).toInt().coerceAtLeast(1)
Box(modifier = modifier) {
androidx.compose.ui.layout.Layout(
modifier = TextDrawElement(
text = text,
spans = null,
color = if (color == Color.Unspecified) Color.Black else color,
fontSizePx = vFontPx,
textAlign = textAlign,
softWrap = false,
fontFamily = fontFamily,
fontVariations = fontVariationSettings,
),
) { _, constraints ->
val vSize = currentTextMeasurer.measure(text, vFontPx, Int.MAX_VALUE, fontFamily, fontVariationSettings)
val w = vSize.width.coerceIn(constraints.minWidth, constraints.maxWidth)
val h = vSize.height.coerceIn(constraints.minHeight, constraints.maxHeight)
layout(w, h) {}
}
}
BasicText(
text = text,
modifier = modifier,
style = TextStyle(
color = if (color == Color.Unspecified) Color.Black else color,
fontSize = fontSize,
fontFamily = namedFontFamily(fontFamily, axes = fontVariationSettings),
textAlign = textAlign,
),
softWrap = false,
maxLines = 1,
)
}
@@ -13,7 +13,7 @@ import androidx.compose.ui.unit.IntOffset
// MARK: SelectionRect
// ==================
/* One selection-highlight rectangle (in layout pixels), painted behind the glyphs.
/** One selection-highlight rectangle (in layout pixels), painted behind the glyphs.
Internal render glue shared by BasicText (selection-aware) and BasicTextField.
Sizes come from Paragraph.getBoundingBox / getPathForRange — already pixels
(see § HiDPI in CLAUDE.md), so use the pixel-based offset + a raw Layout
@@ -6,7 +6,7 @@ import androidx.compose.ui.node.DelegatableNode
// MARK: Clickable — native actuals
// ==================
/* Desktop/SDL values (mirror upstream desktop): no tap-indication delay, and the
/** Desktop/SDL values (mirror upstream desktop): no tap-indication delay, and the
compose root is never inside a platform scrollable container. */
internal actual val TapIndicationDelay: Long = 0L
@@ -7,7 +7,7 @@ import androidx.compose.runtime.CompositionLocalAccessorScope
// MARK: Overscroll actuals — desktop no-op
// ==================
/*
/**
Actuals for vendored Overscroll.kt's two expect declarations. Desktop/native
has no built-in overscroll animation (Android/iOS use platform-native bounce);
both actuals return null, matching upstream's desktopMain actual.
@@ -22,7 +22,7 @@ import androidx.compose.ui.draw.CacheDrawScope
import androidx.compose.ui.draw.DrawResult
import androidx.compose.ui.graphics.drawscope.DrawScope
/* Native actuals for the vendored `DragAndDropSource.kt` internal `expect`s.
/** Native actuals for the vendored `DragAndDropSource.kt` internal `expect`s.
*
* Upstream's skiko actuals use Skiko-JVM extensions (`asComposeCanvas`,
* `skiaCanvas`, `PictureRecorder`) plus `PointerMatcher.mouse(...)` from the
@@ -9,7 +9,7 @@ import androidx.compose.runtime.Composable
// MARK: ContextMenu — native actuals
// ==================
/*
/**
Mirrors upstream macosMain / desktopMain / iosMain — all three overloads
delegate to CommonContextMenuArea (vendored). This unblocks vendored
BasicTextField / SelectionContainer / CoreTextField call sites.
@@ -19,7 +19,7 @@ import androidx.compose.ui.text.input.TextFieldValue
// MARK: CoreTextField / TextFieldKeyInput / TextFieldPointerModifier — native actuals
// ==================
/*
/**
Byte-identical mirror of upstream macosMain — all three CoreTextField
expects delegate to default helpers baked into CoreTextField.kt /
TextFieldPointerModifier.common.kt. `isTypedEvent` uses the desktop-shape
@@ -8,7 +8,7 @@ import kotlin.native.Platform
// MARK: platformDefaultKeyMapping — native actual
// ==================
/*
/**
Actual for vendored KeyMapping.kt's expect val. Upstream ships this per
platform (macos: createMacOsDefaultKeyMapping, ios: create.../etc). We use
Kotlin/Native's `Platform.osFamily` to pick at startup:
@@ -4,7 +4,7 @@ package androidx.compose.foundation.text
// MARK: StringHelpers native actuals
// ==================
/*
/**
Actuals for vendored StringHelpers.kt. Upstream's nonJvm actual calls
StringBuilder.appendCodePoint() which is a JVM-only extension; on
Kotlin/Native, StringBuilder holds UTF-16 code units, so we encode
@@ -49,7 +49,7 @@ internal actual fun String.findCodePointOrEmojiStartBefore(index: Int, ifNotFoun
return i
}
/*
/**
Byte-identical extract of `internal fun CharSequence.offsetByCodePoints`
from upstream `foundation.text.StringHelpers.skiko.kt`. The skiko file
pulls org.jetbrains.skia.BreakIterator, so we can't vendor it whole; this
@@ -10,7 +10,7 @@ import androidx.compose.ui.text.input.VisualTransformation
// MARK: TextFieldScroll — native actuals
// ==================
/*
/**
Byte-identical mirror of upstream desktopMain — no overscroll on desktop,
textFieldScroll delegates to `defaultTextFieldScroll` (a private helper in
the vendored `TextFieldScroll.kt`).
@@ -10,7 +10,7 @@ import androidx.compose.ui.text.TextRange
// MARK: TextFieldCoreModifier — native actuals
// ==================
/*
/**
Byte-identical mirror of upstream macosMain — both delegate to the default
selection-highlight and cursor draw helpers baked into the vendored
TextFieldCoreModifier.kt.
@@ -4,7 +4,7 @@ package androidx.compose.foundation.text.input.internal
// MARK: createTextFieldKeyEventHandler — native actual
// ==================
/*
/**
Mirrors upstream macosMain / desktopMain / iosMain / webMain — all
delegate to `createSkikoTextFieldKeyEventHandler` from the already-vendored
`TextFieldKeyEventHandler.skiko.kt`.
@@ -7,7 +7,7 @@ import androidx.compose.foundation.text.input.internal.TransformedTextFieldState
// MARK: TextFieldMagnifierNode — native actual (no-op)
// ==================
/*
/**
Mirrors upstream macosMain / desktopMain — no magnifier on desktop.
*/
internal actual fun textFieldMagnifierNode(
@@ -12,7 +12,7 @@ import kotlinx.coroutines.CoroutineScope
// MARK: TextFieldSelectionState — native actuals
// ==================
/*
/**
Byte-close mirror of upstream macosMain — desktop tap / selection gestures
delegate to the default helpers baked into TextFieldSelectionState.
addBasicTextFieldTextContextMenuComponents = no-op (TODO CMP-7819).
@@ -8,7 +8,7 @@ import androidx.compose.ui.layout.LayoutCoordinates
// MARK: makeSelectionModifier — native actual
// ==================
/*
/**
Mirrors upstream desktopMain / macosMain / iosMain actuals — all just
delegate to `makeDefaultSelectionModifier`. That helper lives inside
`SelectionController.kt` (vendored) and installs a SelectionModifierNode
@@ -26,28 +26,28 @@ import androidx.compose.ui.unit.DpSize
SDL renderer's identical for all three, so one set of actuals covers all.
*/
/* Cmd on macOS, Ctrl elsewhere. We don't know which platform at compile
/** Cmd on macOS, Ctrl elsewhere. We don't know which platform at compile
time — accept both, matches Ctrl+C on Windows/Linux and Cmd+C on macOS.
Also honours the dedicated Copy key. */
internal actual fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean =
keyEvent.key == Key.Copy ||
(keyEvent.key == Key.C && (keyEvent.isMetaPressed || keyEvent.isCtrlPressed))
/* No selection magnifier on desktop — mobile-only affordance. */
/** No selection magnifier on desktop — mobile-only affordance. */
internal actual fun Modifier.selectionMagnifier(manager: SelectionManager): Modifier = this
/* Desktop text-context-menu doesn't add SelectionContainer-specific
/** Desktop text-context-menu doesn't add SelectionContainer-specific
items yet. TODO(CMP-7819): wire the paste-into-selection / clear
/ select-all extras through here when the toolbar lands. */
internal actual fun Modifier.addSelectionContainerTextContextMenuComponents(
selectionManager: SelectionManager
): Modifier = this
/* Default long-press adjustment on desktop = Word (matches upstream macOS). */
/** Default long-press adjustment on desktop = Word (matches upstream macOS). */
internal actual val FirstLongPressSelectionAdjustment: SelectionAdjustment
get() = SelectionAdjustment.Word
/* Mobile-style draggable selection handle. Desktop never shows one —
/** Mobile-style draggable selection handle. Desktop never shows one —
mouse drag directly extends the selection, no handle bubble.
TODO: If touch input lands (SDL_EVENT_FINGER_*), route to a real
handle Composable driven by SelectionHandleInfo. */
@@ -7,7 +7,7 @@ import kotlinx.coroutines.CoroutineScope
// MARK: TextFieldSelectionManager — native actuals
// ==================
/*
/**
Byte-identical mirror of upstream macosMain — magnification and toolbar
context-menu components are desktop no-ops. `isSelectionHandleInVisibleBound`
delegates to the default-inside-visible-rect check (defined in the vendored
@@ -14,7 +14,7 @@ import androidx.compose.ui.unit.sp
// MARK: IconFontIcon — codepoint-based Icon rendered via a text glyph
// ==================
/* Renders a single Unicode codepoint from a named IconFont (Material Symbols /
/** Renders a single Unicode codepoint from a named IconFont (Material Symbols /
any glyph font registered via IconFont.registerIcon) at the given size.
Lived in `androidx.compose.material.Icon` until :material was retired. Moved
@@ -49,7 +49,7 @@ fun IconFontIcon(
object IconDefaults {
val DefaultIconSize: Dp = 24.dp
/* STATIC fallback tint for direct IconFontIcon use — :foundation cannot
/** STATIC fallback tint for direct IconFontIcon use — :foundation cannot
read material3's LocalContentColor (module layering, same as upstream).
The themed default lives one layer up: MaterialSymbols<Style> defaults
its tint to material3's LocalContentColor.current, mirroring upstream
@@ -58,7 +58,7 @@ object IconDefaults {
val LocalContentColor: Color = Color.White
}
/* Converts a Unicode codepoint to a String, handling supplementary-plane
/** Converts a Unicode codepoint to a String, handling supplementary-plane
codepoints (≥0x10000) via UTF-16 surrogate pairs. */
internal fun codepointToString(inCodepoint: Int): String {
if (inCodepoint < 0x10000) return Char(inCodepoint).toString()
@@ -7,7 +7,7 @@ import androidx.compose.runtime.ReadOnlyComposable
// MARK: CalendarLocale — native project actuals
// ==================
/*
/**
Upstream's darwinMain / desktopMain provide these actuals from platform locale
APIs (NSLocale on darwin, java.util.Locale on JVM). Our K/N native target has
neither, so we ship a locale-agnostic stub — a single implicit "current" locale
@@ -11,7 +11,7 @@ import kotlinx.datetime.toLocalDateTime
// MARK: PlatformDateFormat — native project actual
// ==================
/*
/**
Upstream ships this via NSDateFormatter (darwinMain) or java.text.DateFormat
(desktopMain). Neither is available for K/N on Linux/Windows, so we ship a
minimal English-only stub that unblocks DatePicker + TimePicker compilation.
@@ -0,0 +1,26 @@
package com.compose.sdl
// ==================
// MARK: AppWindowIcon
// ==================
/**
* The window / taskbar icon for a [Window] / [nativeComposeWindow], resolved
* against the OS theme: the [dark] set is used when the system is in dark mode
* (and it is non-empty), otherwise [light].
*
* Each list holds data.kres resource paths of PRE-DECODED `.rgba` icon blobs
* (produced by the bridge plugin's `icon { }` packaging, or by
* `scripts/make-app-icon.py rgba` — i.e. an 8-byte
* `[width u32-le][height u32-le]` header + straight-alpha RGBA pixels). List
* one path per size you bundle; the largest becomes the base and the rest are
* attached as alternate resolutions SDL chooses from (title bar vs taskbar vs
* Alt-Tab), so the icon stays crisp at every size.
*
* On Windows the runtime icon set here complements the icon EMBEDDED in the
* `.exe` (Explorer / pinned taskbar) — see the app / bridge-plugin Gradle setup.
*/
class AppWindowIcon(
val light: List<String>,
val dark: List<String> = light,
)
@@ -25,16 +25,21 @@ import androidx.compose.ui.input.pointer.PointerEventType
import androidx.lifecycle.enableSavedStateHandles
import com.compose.sdl.node.ComposeRootHost
import com.compose.sdl.res.currentImageLoader
import com.compose.sdl.text.currentTextMeasurer
import com.compose.sdl.window.LocalPopupHost
import com.compose.sdl.window.PopupLayer
import com.compose.sdl.window.createPopupHostState
import kotlinx.coroutines.*
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.cinterop.toKString
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.alloc
import kotlinx.cinterop.ptr
import sdl3.SDL_Delay
import sdl3.SDL_GetPerformanceCounter
import sdl3.SDL_GetPerformanceFrequency
import sdl3.SDL_GetTicks
import sdl3.SDL_GetTicksNS
import sdl3.SDL_Quit
import sdl3.SDL_SetWindowTitle
import sdl3.SDL_WaitEventTimeout
@@ -43,7 +48,7 @@ import sdl3.SDL_WaitEventTimeout
// MARK: Application entry — nativeComposeApp + Window()
// ==================
/*
/**
Multi-window entry point, shaped like Compose Desktop's `application {}`:
nativeComposeApp {
@@ -67,12 +72,12 @@ import sdl3.SDL_WaitEventTimeout
*/
interface ApplicationScope {
/* Requests the main loop to exit after the current iteration (all windows
/** Requests the main loop to exit after the current iteration (all windows
are torn down on the way out). */
fun exitApplication()
}
/* Declares one native window for as long as this composable stays in the app
/** Declares one native window for as long as this composable stays in the app
composition. `onCloseRequest` fires when the user asks the window to close
(OS close button, or `window.close()` from content) — remove the state that
composes this Window to actually close it, or call exitApplication(). */
@@ -83,6 +88,7 @@ fun ApplicationScope.Window(
width: Int = 800,
height: Int = 600,
gpu: GpuMode = GpuMode.Auto,
icon: AppWindowIcon? = null,
onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
content: @Composable ComposeWindowScope.() -> Unit,
) {
@@ -96,6 +102,7 @@ fun ApplicationScope.Window(
inWidth = width,
inHeight = height,
inGpu = gpu,
inIcon = icon,
inOnFrame = onFrame,
// The window composition reads the holder each recomposition, so
// updated content lambdas propagate without recreating the window.
@@ -110,7 +117,7 @@ fun ApplicationScope.Window(
}
}
/* Boots SDL + the Compose runtime, runs `content` as the application
/** Boots SDL + the Compose runtime, runs `content` as the application
composition, and drives every declared Window until exitApplication() or
the last window closes. */
fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
@@ -141,7 +148,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
appComposition.setContent { appScope.content() }
// Compose the initial Window() declarations before entering the loop.
appClock.sendFrame()
appClock.sendFrame(frameClockNanos())
yield()
// ============
@@ -149,6 +156,10 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
var vGcLastTicks = SDL_GetTicks()
var vRenderedSinceGc = false
while (!runtime.exitRequested) {
FrameProfiler.mark()
// One virtual 16.6ms tick per loop iteration (no-op unless useVirtualFrameTime) —
// all windows' clocks share the same timestamp this iteration.
advanceVirtualFrame()
Snapshot.sendApplyNotifications()
// ============
@@ -166,21 +177,25 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
is AppEvent.WindowClose -> runtime.windowFor(vEvent.windowId)?.requestClose()
is AppEvent.WindowResized -> runtime.windowFor(vEvent.windowId)?.onResizedEvent()
is AppEvent.RedrawNeeded -> runtime.windowFor(vEvent.windowId)?.let { it.needsFrame = true }
is AppEvent.SystemThemeChanged -> for (vW in runtime.windows) vW.onSystemThemeChanged()
is AppEvent.WindowActivation -> runtime.windowFor(vEvent.windowId)?.onActivationEvent(vEvent)
is AppEvent.Pointer -> runtime.windowFor(vEvent.windowId)?.onPointerEvent(vEvent)
is AppEvent.PointerExit -> runtime.windowFor(vEvent.windowId)?.onPointerExit()
is AppEvent.MouseWheel -> runtime.windowFor(vEvent.windowId)?.onWheelEvent(vEvent)
is AppEvent.Key -> runtime.windowFor(vEvent.windowId)?.onKeyEvent(vEvent)
is AppEvent.TextInput -> runtime.windowFor(vEvent.windowId)?.onTextInputEvent(vEvent)
is AppEvent.TextEditing -> runtime.windowFor(vEvent.windowId)?.onTextEditingEvent(vEvent)
is AppEvent.Drop -> runtime.windowFor(vEvent.windowId)?.onDropEvent(vEvent)
}
}
mainDispatcher.drainPending()
FrameProfiler.phase("events")
// ============
// App composition pump — Window()s may appear / disappear here.
Snapshot.sendApplyNotifications()
appClock.sendFrame()
appClock.sendFrame(frameClockNanos())
yield()
runtime.reapDestroyed()
@@ -195,6 +210,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
// Exit when the last window is gone (after at least one existed).
if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true
if (runtime.exitRequested) break
FrameProfiler.phase("app")
// ============
// Per-window pump + render.
@@ -203,18 +219,29 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
val vAppPending = appRecomposer.hasPendingWork
for (vW in runtime.windows.toList()) {
vW.installGlobals()
vW.host.sendAnimationFrame(SDL_GetTicks().toLong() * 1_000_000L)
vW.host.sendAnimationFrame(animationClockNanos())
mainDispatcher.drainPending()
Snapshot.sendApplyNotifications()
vW.frameClock.sendFrame()
vW.frameClock.sendFrame(frameClockNanos())
yield()
FrameProfiler.phase("pump")
if (vW.shouldRender()) {
vW.renderFrame()
vAnyRendered = true
if (!vW.backend.vsyncEnabled) vAllVsync = false
}
FrameProfiler.phase("render")
}
runtime.reapDestroyed()
FrameProfiler.frameDone(vAnyRendered)
// ============
// Drain deferred native-resource disposals on the MAIN thread —
// textures/surfaces whose owner closed them or whose Cleaner fired
// on a GC worker enqueue here (SDL calls aren't thread-safe). This
// is the ownership path that makes the GC nudge below a mere
// backstop (ROADMAP.md item 1).
com.compose.sdl.graphics.NativeReleaseQueue.drain()
// ============
// Pace / idle-skip.
@@ -250,6 +277,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
snapshotHandle.dispose()
for (vW in runtime.windows.toList()) runtime.scheduleDestroy(vW)
runtime.reapDestroyed()
com.compose.sdl.graphics.NativeReleaseQueue.drain()
appComposition.dispose()
appRecomposer.cancel()
appRecomposeJob.cancelAndJoin()
@@ -262,18 +290,143 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
SDL_Quit()
}
/* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
/** CDN_PROFILE=1 — per-phase timings, printed every ~2s of rendered frames.
A named-phase SINGLETON so both the main loop (events / app / pump / render)
AND renderFrame's sub-steps (render.layout / render.draw / render.present)
report into one line. `mark()` resets the stopwatch; `phase(name)` charges
the elapsed since the last mark/phase to that name. Measure first, optimize
second — see ROADMAP.md. */
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
internal object FrameProfiler {
// null until first checked; then true/false for the run's lifetime.
private var fEnabled: Boolean? = null
// Output file — resolved once from CDN_PROFILE. Writing to a file (not
// stdout) lets GUI-subsystem apps (the demo links --subsystem,windows, so
// it has no console) be profiled too. CDN_PROFILE=1 → "cdn_profile.log" in
// the cwd; CDN_PROFILE=<path> → that path.
private var fPath: String = "cdn_profile.log"
val enabled: Boolean
get() = fEnabled ?: run {
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv
(vEnv != null).also { fEnabled = it }
}
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
// Insertion-ordered so the printed line follows the call order.
private val fSum = LinkedHashMap<String, Double>()
private val fMax = LinkedHashMap<String, Double>()
private var fFrames = 0
private var fLastPrintMs = SDL_GetTicks()
private var fMark = 0uL
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
fun phase(inName: String) {
if (!enabled) return
val vNow = SDL_GetPerformanceCounter()
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
fSum[inName] = (fSum[inName] ?: 0.0) + vMs
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
fMark = vNow
}
fun frameDone(inRendered: Boolean) {
if (!enabled) return
if (inRendered) fFrames++
val vNowMs = SDL_GetTicks()
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
val vParts = fSum.keys.map { vName ->
val vAvg = (fSum[vName] ?: 0.0) / fFrames
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
}
// Per-frame draw-work averages (see DrawStats): what's inside `draw`.
val vStats = com.compose.sdl.graphics.DrawStats
val vDraw = "geo=${vStats.geometrySubmits / fFrames} verts=${vStats.vertices / fFrames} " +
"masks=${vStats.maskRealizations / fFrames} text=${vStats.textDraws / fFrames} img=${vStats.imageBlits / fFrames}"
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " | per-frame " + vDraw + "\n"
val vFile = platform.posix.fopen(fPath, "a")
if (vFile != null) {
platform.posix.fputs(vLine, vFile)
platform.posix.fclose(vFile)
}
vStats.reset()
fSum.clear(); fMax.clear()
fFrames = 0
fLastPrintMs = vNowMs
}
}
}
/** CDN_FORCERENDER=1 forces every frame to render so sustained steady-state
timings can be measured on otherwise-idle screens (see TOOLING.md, frame
profiler). */
private val kForceRender: Boolean = platform.posix.getenv("CDN_FORCERENDER") != null
// ==================
// MARK: Probe / screenshot support (render-to-quiescence)
// ==================
/** Set BEFORE nativeComposeApp/nativeComposeWindow: every window's composition then runs
under an InfiniteAnimationPolicy that CANCELS infinite animations, so
rememberInfiniteTransition & co. freeze at their initial value — the same mechanism
upstream's test rules use. Screenshot/parity runs enable it so looping screens can
reach quiescence and capture deterministically. */
var disableInfiniteAnimations: Boolean = false
/** The cancelling policy: a coroutine that ends in CancellationException counts as
cancelled (not failed), so only the animation coroutine stops — nothing propagates. */
private object CancelInfiniteAnimationsPolicy : androidx.compose.ui.platform.InfiniteAnimationPolicy {
override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R =
throw CancellationException("infinite animations are disabled (disableInfiniteAnimations)")
}
/** Set BEFORE nativeComposeApp/nativeComposeWindow: the composition + animation frame
clocks advance a VIRTUAL 16.6ms per main-loop iteration instead of reading SDL's
real-time ticks — the native mirror of the JVM parity leg's render(nanos) stepping.
Animations then progress by exact per-frame deltas, so anything time-raced (e.g. a
bring-into-view scroll interrupted mid-flight) resolves identically on every run and
screenshots become deterministic. Input timestamps and FPS stay on real time. */
var useVirtualFrameTime: Boolean = false
private var virtualFrameNanos = 0L
private fun advanceVirtualFrame() {
if (useVirtualFrameTime) virtualFrameNanos += 16_666_667L
}
// Timestamp for the composition frame clocks (recomposer + withFrameNanos animations).
private fun frameClockNanos(): Long =
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicksNS().toLong()
// Timestamp for the owner's node-animation clock — real path keeps the pre-existing
// ms-resolution SDL_GetTicks base so non-screenshot behaviour is bit-for-bit unchanged.
private fun animationClockNanos(): Long =
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicks().toLong() * 1_000_000L
// The window currently inside renderFrame — the loop is single-threaded, so a plain
// var is enough for onFrame probes to address "the window I'm being called for".
private var renderingWindow: WindowInstance? = null
/** From inside an onFrame callback: true while the just-rendered window still has pending
work (recomposition, layout/draw invalidation, or an animation awaiting the next frame).
Screenshot probes capture once this stays false for a few consecutive frames instead of
at a fixed frame count. Outside onFrame it answers false. */
fun windowHasInvalidations(): Boolean = renderingWindow?.hasInvalidations() ?: false
/** Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
their native memory (see the main loop's native-memory nudge). */
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
/* Single-window compatibility wrapper — the pre-multi-window entry point.
/** Single-window compatibility wrapper — the pre-multi-window entry point.
Closing the window exits the app, exactly as before. */
fun nativeComposeWindow(
title: String = "ComposeNativeSDL3",
width: Int = 800,
height: Int = 600,
gpu: GpuMode = GpuMode.Auto,
icon: AppWindowIcon? = null,
onFrame: ((backend: RenderBackend, frameIndex: Int) -> Boolean)? = null,
content: @Composable ComposeWindowScope.() -> Unit,
) {
@@ -284,6 +437,7 @@ fun nativeComposeWindow(
width = width,
height = height,
gpu = gpu,
icon = icon,
onFrame = onFrame,
content = content,
)
@@ -298,7 +452,7 @@ internal class ApplicationScopeImpl(val runtime: AppRuntime) : ApplicationScope
override fun exitApplication() { runtime.exitRequested = true }
}
/* Registry + lifecycle for the live windows. Windows are created by the app
/** Registry + lifecycle for the live windows. Windows are created by the app
composition (Window()'s remember) and destroyed by the LOOP — DisposableEffect
onDispose only schedules, because teardown disposes a composition and that
must not run re-entrantly inside another composition's apply pass. */
@@ -322,10 +476,11 @@ internal class AppRuntime {
inWidth: Int,
inHeight: Int,
inGpu: GpuMode,
inIcon: AppWindowIcon?,
inOnFrame: ((RenderBackend, Int) -> Boolean)?,
inContent: () -> (@Composable ComposeWindowScope.() -> Unit),
): WindowInstance {
val vWindow = WindowInstance(inTitle, inWidth, inHeight, inGpu, inOnFrame, inContent)
val vWindow = WindowInstance(inTitle, inWidth, inHeight, inGpu, inIcon, inOnFrame, inContent)
// A Window() was declared either way — the "exit when the last window
// is gone" rule must also fire when every declared window failed to
// initialise (otherwise the loop would spin forever with none).
@@ -370,11 +525,19 @@ internal class WindowInstance(
inWidth: Int,
inHeight: Int,
inGpu: GpuMode,
inIcon: AppWindowIcon?,
private val onFrame: ((RenderBackend, Int) -> Boolean)?,
private val contentHolder: () -> (@Composable ComposeWindowScope.() -> Unit),
) {
private val gpuMode = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
val backend = SDL3Backend(initialTitle, inWidth, inHeight, gpuMode = gpuMode)
// Resolved renderer mode. `var` so init() can fall back GPU → CPU raster when
// a Skia GPU context can't be created (RDP / headless / missing GL driver).
private var gpuMode = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
private val initialWidth = inWidth
private val initialHeight = inHeight
private val icon = inIcon
// Assigned by init() from the first backend that comes up (see makeBackend()).
lateinit var backend: SDL3Backend
private set
private var renderBackend: RenderBackend? = null
lateinit var host: ComposeRootHost
private set
@@ -402,8 +565,9 @@ internal class WindowInstance(
private var hasMousePos = false
private var frameIndex = 0
private var fpsFrames = 0
private var fpsLastMs = SDL_GetTicks()
private var fpsEma = 0.0
private var fpsLastFrameMs = SDL_GetTicks()
private var fpsLastTitleMs = 0uL
// Escape → back (upstream desktop's BackNavigationEventInput): unconsumed
// Escape completes a back navigation on THIS window's dispatcher.
@@ -431,6 +595,7 @@ internal class WindowInstance(
fun onActivationEvent(inEvent: AppEvent.WindowActivation) {
inEvent.focused?.let { windowFocused = it }
inEvent.visible?.let { windowVisible = it }
if (::host.isInitialized) host.setWindowFocused(windowFocused)
architectureOwner.setLifecycleState(
when {
!windowVisible -> androidx.lifecycle.Lifecycle.State.CREATED
@@ -443,20 +608,52 @@ internal class WindowInstance(
needsFrame = true
}
fun init(inScope: CoroutineScope): Boolean {
if (!backend.init()) return false
backend.updateWindowSize()
// Creates + initialises an SDL3Backend for [mode] and its RenderBackend. On
// success commits `backend` and returns the renderer; else tears everything
// down and returns null so the caller can retry with a different mode.
private fun makeBackend(mode: GpuMode): RenderBackend? {
val vBackend = SDL3Backend(
initialTitle, initialWidth, initialHeight, gpuMode = mode,
iconLightResourcePaths = icon?.light ?: emptyList(),
iconDarkResourcePaths = icon?.dark ?: emptyList(),
)
if (!vBackend.init()) {
vBackend.destroy(inQuitSdl = false)
return null
}
vBackend.updateWindowSize()
val vRender = createRenderBackend(vBackend, mode)
if (vRender == null || !vRender.ensureSize(vBackend.pixelWidth, vBackend.pixelHeight)) {
vRender?.destroy()
vBackend.destroy(inQuitSdl = false)
return null
}
backend = vBackend
return vRender
}
val vRender = createRenderBackend(backend, gpuMode)
if (vRender == null || !vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)) {
println("Failed to init render backend for $gpuMode")
backend.destroy(inQuitSdl = false)
fun init(inScope: CoroutineScope): Boolean {
// Try the resolved gpuMode; if it's a Skia GPU mode whose context/bridge
// can't come up (RDP / headless / missing GL driver), fall back once to
// CPU raster (Software) so the window still opens instead of failing.
var vRender = makeBackend(gpuMode)
if (vRender == null && gpuMode is GpuMode.Skia) {
println("GPU renderer ($gpuMode) unavailable — falling back to CPU raster (Software)")
gpuMode = GpuMode.Software
vRender = makeBackend(gpuMode)
}
if (vRender == null) {
println("Failed to init render backend")
return false
}
renderBackend = vRender
host = ComposeRootHost(inDensity = backend.pixelDensity)
host.attach()
// A layer whose content changed (OwnedLayer.invalidate) schedules a frame
// even when nothing else (recompose / relayout) is pending — retained layers
// need this so a draw-only state change still repaints.
host.setInvalidationCallback { needsFrame = true }
facade = ComposeNativeWindow(backend, gpuMode, initialTitle)
navigationEventOwner.navigationEventDispatcher.addInput(backNavigationInput)
@@ -465,7 +662,11 @@ internal class WindowInstance(
// setContent below).
installGlobals()
val vRecomposer = Recomposer(inScope.coroutineContext + frameClock)
// Effect context for this window's composition — the screenshot flag injects the
// infinite-animation-cancelling policy (see disableInfiniteAnimations above).
var vEffectContext = inScope.coroutineContext + frameClock
if (disableInfiniteAnimations) vEffectContext += CancelInfiniteAnimationsPolicy
val vRecomposer = Recomposer(vEffectContext)
recomposer = vRecomposer
recomposeJob = inScope.launch(frameClock) { vRecomposer.runRecomposeAndApplyChanges() }
val vComposition = Composition(host.applier, vRecomposer)
@@ -536,12 +737,16 @@ internal class WindowInstance(
}
},
) {
Box(modifier = Modifier.fillMaxSize()) {
// Read through the holder so Window() recompositions with a
// new content lambda propagate into this composition.
val vContent = contentHolder()
with(vWindowScope) { vContent() }
PopupLayer(vPopupHost)
// Seed the :ui-internal LocalPointerIconService so Modifier.pointerHoverIcon
// drives the SDL cursor (the local + service type can't cross the module boundary).
host.ProvidePointerIconService {
Box(modifier = Modifier.fillMaxSize()) {
// Read through the holder so Window() recompositions with a
// new content lambda propagate into this composition.
val vContent = contentHolder()
with(vWindowScope) { vContent() }
PopupLayer(vPopupHost)
}
}
}
}
@@ -560,15 +765,17 @@ internal class WindowInstance(
return true
}
/* Points the render-bridge globals (text measurer / image loader /
/** Points the render-bridge globals (text measurer / image loader /
viewport) at this window's renderer. Must be called before composing,
measuring, or drawing this window's tree. */
fun installGlobals() {
val vRender = renderBackend ?: return
currentTextMeasurer = vRender.textMeasurer
currentImageLoader = vRender.imageLoader
com.compose.sdl.text.currentViewportWidth = backend.pixelWidth
com.compose.sdl.text.currentViewportHeight = backend.pixelHeight
// Register bundled generic fonts (FontFamily.Monospace → NotoSansMono) once
// data.kres is loadable; idempotent, no-op if the font isn't bundled.
com.compose.sdl.text.registerGenericFonts()
}
// ============
@@ -601,6 +808,17 @@ internal class WindowInstance(
hasMousePos = true
}
/** Pointer left the window (SDL_EVENT_WINDOW_MOUSE_LEAVE). Stop the per-frame
synthetic hover re-dispatch and fire one Exit at the last position so a
hovered widget clears its highlight instead of sticking forever. */
fun onPointerExit() {
needsFrame = true
if (hasMousePos) {
host.onPointerRaw(lastMouseX, lastMouseY, 3, 0, SDL_GetTicks().toLong())
}
hasMousePos = false
}
fun onWheelEvent(inEvent: AppEvent.MouseWheel) {
needsFrame = true
installGlobals()
@@ -620,12 +838,43 @@ internal class WindowInstance(
fun onTextInputEvent(inEvent: AppEvent.TextInput) {
needsFrame = true
installGlobals()
// SDL's committed text is the only layout-correct source of characters —
// SDL key events carry UNSHIFTED keycodes (no uppercase, no numpad digits,
// no dead keys). Synthesise typed KeyEvents so the vendored text stacks
// (CoreTextField's isTypedEvent path and the state-based field's key
// handler) commit it.
dispatchTypedText(host, inEvent.text)
// A focused text field runs an IME session (ComposeOwner.textInputSession):
// commit through it so the text REPLACES any active composition. Falls back
// to synthesising typed KeyEvents when no field is focused — SDL key events
// carry UNSHIFTED keycodes (no uppercase/numpad/dead keys), so committed
// text is the only layout-correct character source.
if (!com.compose.sdl.text.input.ImeBridge.commit(inEvent.text)) {
dispatchTypedText(host, inEvent.text)
}
}
fun onTextEditingEvent(inEvent: AppEvent.TextEditing) {
needsFrame = true
installGlobals()
// IME preedit: show the composing (underlined) region in the focused field.
// No-op if no field is focused. Also nudge the OS candidate window to the
// field's on-screen rect.
com.compose.sdl.text.input.ImeBridge.compose(inEvent.text)
updateImeArea()
}
// Nudge the OS IME candidate window to the focused field's on-screen rect.
// Best-effort: no-op without an active session or a laid-out field.
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, kotlinx.cinterop.ExperimentalForeignApi::class)
private fun updateImeArea() {
val vRequest = com.compose.sdl.text.input.ImeBridge.request ?: return
val vRect = vRequest.focusedRectInRoot() ?: return
val vWindow = backend.window ?: return
val vDpr = backend.pixelDensity
kotlinx.cinterop.memScoped {
// SDL wants the area in logical window points; layout runs in physical px.
val vSdlRect = alloc<sdl3.SDL_Rect>()
vSdlRect.x = (vRect.left / vDpr).toInt()
vSdlRect.y = (vRect.top / vDpr).toInt()
vSdlRect.w = (vRect.width / vDpr).toInt().coerceAtLeast(1)
vSdlRect.h = (vRect.height / vDpr).toInt().coerceAtLeast(1)
sdl3.SDL_SetTextInputArea(vWindow.reinterpret(), vSdlRect.ptr, 0)
}
}
fun onDropEvent(inEvent: AppEvent.Drop) {
@@ -650,7 +899,13 @@ internal class WindowInstance(
facade.onResized()
}
/* OS close button / app-level Quit: honour the veto handler, then hand the
/** OS light/dark theme changed — re-pick the theme-appropriate window icon
(no-op if this window has no icon configured or the choice is unchanged). */
fun onSystemThemeChanged() {
backend.applyThemeIcon()
}
/** OS close button / app-level Quit: honour the veto handler, then hand the
decision to the Window() declaration. */
fun requestClose() {
if (closeDispatched) return
@@ -664,7 +919,15 @@ internal class WindowInstance(
// Frame pump
fun shouldRender(): Boolean =
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null || kForceRender
/** True while this window still has pending work: a state/layout/draw invalidation
(needsFrame), recomposer work, or a composition/node animation awaiting the next
frame. Sampled by windowHasInvalidations() from onFrame probes — the quiescence
signal for render-to-settle screenshot capture. */
fun hasInvalidations(): Boolean =
needsFrame || recomposer?.hasPendingWork == true ||
frameClock.hasAwaiters || host.hasAnimationAwaiters()
fun renderFrame() {
val vRender = renderBackend ?: return
@@ -674,7 +937,16 @@ internal class WindowInstance(
backend.updateWindowSize()
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
// Deliver any state written by this iteration's frame-clock continuations
// (withFrameNanos animations — notably smooth wheel scrolling) BEFORE we lay
// out. Those writes happen in the per-window pump's sendFrame()/yield(), which
// is AFTER the loop's last sendApplyNotifications() — so without this the
// relayout they trigger wasn't registered until the next frame, and the frame
// drew last frame's positions. That one-frame trail read as "the clip is a
// frame late" when scrolling (content briefly drawn past the viewport edge).
Snapshot.sendApplyNotifications()
host.measureAndLayout()
FrameProfiler.phase(" layout")
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
if (hasMousePos) {
@@ -682,30 +954,48 @@ internal class WindowInstance(
}
vRender.beginFrame(1f)
vRender.drawRoot(host)
if (onFrame != null && !onFrame.invoke(vRender, frameIndex)) {
vRender.drawRoot { canvas -> host.drawRoot(canvas) }
if (onFrame != null) {
renderingWindow = this
val vContinue = onFrame.invoke(vRender, frameIndex)
renderingWindow = null
// Probe consumers end the app when their scenario completes.
facade.close()
if (!vContinue) facade.close()
}
FrameProfiler.phase(" draw")
vRender.endFrame()
FrameProfiler.phase(" present")
frameIndex++
// FPS — refreshed ~once a second, per window.
fpsFrames++
// FPS — instantaneous inter-frame rate, EMA-smoothed, title refreshed
// ~4x/sec. This shows within ~2 rendered frames of ANY activity rather
// than waiting for a full second of unbroken rendering to accumulate
// (the old fixed-window counter never got its first update: bursty
// interaction kept idling before 1s elapsed, and the idle-skip reset the
// window each time — so FPS only appeared during a long enough page
// transition). A dt outside 1..100ms is the first frame or a resume from
// idle (the gap isn't a real frame interval), so it's not sampled — the
// title just holds the last active rate while idle.
val vNowMs = SDL_GetTicks()
val vElapsed = (vNowMs - fpsLastMs).toInt()
if (vElapsed >= 1000) {
val vFps = fpsFrames * 1000 / vElapsed
facade.updateFps(vFps)
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
fpsFrames = 0
fpsLastMs = vNowMs
val vDt = (vNowMs - fpsLastFrameMs).toInt()
fpsLastFrameMs = vNowMs
if (vDt in 1..100) {
val vInst = 1000.0 / vDt
fpsEma = if (fpsEma <= 0.0) vInst else fpsEma * 0.9 + vInst * 0.1
if ((vNowMs - fpsLastTitleMs).toInt() >= 250) {
val vFps = (fpsEma + 0.5).toInt()
facade.updateFps(vFps)
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
fpsLastTitleMs = vNowMs
}
}
}
// Idle-skip calls this: drop the frame timer so the resume-from-idle frame's
// dt (the whole idle gap) isn't sampled as a real interval. The EMA is left
// intact so the title keeps showing the last active rate while idle.
fun resetFpsWindow() {
fpsFrames = 0
fpsLastMs = SDL_GetTicks()
fpsLastFrameMs = SDL_GetTicks()
}
fun destroy() {
@@ -728,7 +1018,7 @@ internal class WindowInstance(
// MARK: WindowArchitectureOwner
// ==================
/* Per-window architecture-components owner, modeled on upstream desktop's
/** Per-window architecture-components owner, modeled on upstream desktop's
DefaultArchitectureComponentsOwner (compose/ui skikoMain
PlatformOwnerProvider.skiko.kt): one object implements LifecycleOwner +
ViewModelStoreOwner + SavedStateRegistryOwner (+ the SavedState-aware
@@ -776,7 +1066,7 @@ private class WindowArchitectureOwner :
lifecycle.currentState = androidx.lifecycle.Lifecycle.State.CREATED
}
/* Focus/visibility-driven state (see WindowInstance.onActivationEvent).
/** Focus/visibility-driven state (see WindowInstance.onActivationEvent).
Ignored once destroyed — a stray SDL event during teardown must not
resurrect the registry. */
fun setLifecycleState(inState: androidx.lifecycle.Lifecycle.State) {
@@ -797,7 +1087,7 @@ private class WindowArchitectureOwner :
// MARK: Input helpers
// ==================
/* Escape → back: port of upstream desktop's BackNavigationEventInput. An
/** Escape → back: port of upstream desktop's BackNavigationEventInput. An
unconsumed Escape KeyDown completes a back navigation on the dispatcher
this input is registered with (BackHandler / PredictiveBackHandler
consumers: m3 SearchBar collapse, dialog dismissal, …). */
@@ -813,7 +1103,7 @@ private class BackNavigationInput : androidx.navigationevent.NavigationEventInpu
}
}
/* Re-dispatches committed text (SDL TEXT_INPUT) as one synthetic typed
/** Re-dispatches committed text (SDL TEXT_INPUT) as one synthetic typed
KeyDown per Unicode codepoint (surrogate pairs folded). Key.Unknown +
codePoint + no modifiers matches both vendored text stacks' isTypedEvent
criteria; the SDL key mapper leaves codePoint = 0 on real key events, so

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