Compare commits

...
36 Commits
Author SHA1 Message Date
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
239 changed files with 8574 additions and 4958 deletions
+5 -4
View File
@@ -215,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
+56 -22
View File
@@ -89,7 +89,8 @@ utils/
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
@@ -132,16 +133,42 @@ 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/;
@@ -393,18 +420,25 @@ 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
+133 -76
View File
@@ -15,33 +15,65 @@ Severity is a rough guide, not a mandate:
Context for prioritizing: the project goal is **G1, cheap upstream-tracking**
(see [RENDERER.md](RENDERER.md)). The **JVM Compose Desktop target is the
documented Windows fidelity/feature tier**, so SDL-leg visual gaps (section J)
are lower priority than the cross-cutting platform gaps (input, locale, a11y,
cursor) that affect every backend including Skia on macOS/Linux.
documented Windows fidelity/feature tier**, so SDL-leg visual gaps (section H)
are lower priority than the cross-cutting platform gaps that affect every
backend including Skia on macOS/Linux.
Renderer-internal cleanups (the deferred D2 shadow-lighting split, D3 matrix
dedupe) live in [RENDERER.md](RENDERER.md#6-remaining-and-future-work), not
here.
## 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** (SDL leg) — `ByteArray.decodeToImageBitmap()` /
`createImageBitmap(bytes)` decodes via SDL3_image; was
`UnsupportedOperationException`. Verified `demo --imagebytestest` on both legs.
- **`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`.
SDL renderer fidelity (all verified against the Skia leg via `demo --<name>test`):
- **DashPathEffect** (`--dashtest`), **gradient TileMode** repeat/mirror/decal
(`--tilemodetest`), **drawPoints/drawRawPoints** Points/Lines/Polygon
(`--pointstest`), **rotated images** via textured `SDL_RenderGeometry`
(`--rotimgtest`).
- **BlendMode** (`774271eb`, `--blendtest`) — Plus/Modulate/Multiply/Src via SDL
hardware blend (`withBlend`); other separable modes fall back to SrcOver.
- **Stroke joins + Square cap** (`3518521c`, `--jointest`) — miter/bevel/round
(`emitJoin`) + round/square end caps (`emitCap`), pixel-identical to Skia.
- **ColorFilter on shapes** (`169a51f0`, `--filtertest`) — tint / colorMatrix
(grayscale/saturation) / lighting per-vertex; pixel-exact vs Skia.
- **Stroked elliptical ovals** (`efaf20d9`, `--ovaltest`) — true ellipse band, not
a circular ring on the averaged radius.
---
## Release-blocking shortlist for stable 1.12
The cross-cutting items to weigh first (all detailed below):
1. **Locale is hardcoded to `en-US`** (section E). One fix unlocks the 40+
vendored Material 3 translations. Cheapest high-value win in this file.
2. **No IME / text composition** (section B). Only committed Latin text works;
CJK, dead keys, and accented input do not.
3. **No cursor changes** (section D). The OS cursor never becomes an I-beam over
text or a hand over clickables.
4. **No copy/paste context menu** (section C). Keyboard copy works; there is no
right-click or selection toolbar.
5. **Accessibility is entirely absent** (section A). Decide whether stable
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.
6. **Standard `Font(...)` / resource-font loading is a no-op** (section F). Only
pre-registered named fonts render. A blocker only if apps use the standard
font APIs.
3. Fonts (section D): compose-resources `Font(Res.font.x)` works on both legs, 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.
---
@@ -56,67 +88,51 @@ a11y). Screen readers get nothing.
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:297` · `accessibilityManager` implements only `calculateRecommendedTimeoutMillis` (passthrough); no announce/focus/event surface. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../semantics/SemanticsRegion.native.kt:14` · `StubSemanticsRegion.intersect()`/`difference()` hardcode `false`; only `set`/`bounds` work. Region math for semantics culling is inert. **Nice-to-have.**
## B. Text input / IME
## B. Text toolbar and context menus (copy / paste / select-all)
Committed text, arrow/backspace/enter editing, and mouse-drag selection work.
Composition (preedit) does not.
- `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.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:407` · `textInputSession` awaits cancellation forever (`awaitCancellation()` at :414); no real `PlatformTextInputSession`. The modern `PlatformTextInputModifierNode` pipeline (BasicTextField2) has no IME start/stop, composition, or cursor-rect reporting. **Blocker.**
- `compose/ui/ui/src/nativeMain/.../SDL3EventMapper.kt:126` · maps `SDL_EVENT_TEXT_INPUT` (committed) only; `SDL_EVENT_TEXT_EDITING` (preedit/composition) is never mapped, so there is no underlined composing region for dead keys / CJK. **Blocker for CJK and dead-key input.**
- `compose/ui/ui/src/nativeMain/.../SDL3Backend.kt:111` · `SDL_StartTextInput` is called once at window init and never scoped to the focused field; `SDL_SetTextInputArea` is never called, so the IME candidate window renders at the wrong location. **Nice-to-have.**
- `compose/ui/ui/src/commonMain/.../text/input/NoOpPlatformTextInputService.kt:24,32` · legacy `PlatformTextInputService` `startInput`/`stopInput`/`updateState` + software-keyboard toggles are all NOP. Superseded by the modern path above. **Nice-to-have / cosmetic (desktop).**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:328` · `softwareKeyboardController.show/hide` NOP. **Cosmetic (desktop).**
## C. Locale / i18n tail (needs locale-aware DATA, no ICU on K/N)
## C. Text toolbar and context menus (copy / paste / select-all)
`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/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:311` · `textToolbar` is a stub: `showMenu`/`hide` NOP, `status` always `Hidden`. No selection or right-click copy/paste popup anywhere. Keyboard copy still works. **Blocker for text UX.**
- `compose/foundation/.../text/selection/SelectionActuals.native.kt:42` · `addSelectionContainerTextContextMenuComponents` NOP (upstream TODO CMP-7819). No copy/select-all menu for `SelectionContainer`. **Nice-to-have.**
- `compose/foundation/.../text/selection/TextFieldSelectionManager.native.kt:24` and `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:36` · `addBasicTextFieldTextContextMenuComponents` NOP for both the legacy and state-based `BasicTextField`. **Nice-to-have.**
## D. Pointer / cursor icons
The OS cursor never changes. No SDL cursor API is called anywhere in the port.
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:333` · `pointerIconService.getIcon()` always returns `Default`; `setIcon()` body is empty. This is the hook `Modifier.pointerHoverIcon` drives, so hover cursor changes are swallowed. Should store the requested icon and call `SDL_SetCursor`. **Blocker for UX.**
- `compose/ui/ui/src/nativeMain/.../input/pointer/PointerIcon.native.kt:18` · `pointerIconDefault|Crosshair|Text|Hand` are inert marker objects carrying only a name; nothing maps them to `SDL_SYSTEM_CURSOR_*`. Needs `SDL_CreateSystemCursor` + `SDL_SetCursor` (and cursor caching in the `:window` loop). **Blocker (paired with above).**
## E. Locale and internationalization
System-locale plumbing already exists in exactly one place:
`components/resources/.../ResourceEnvironment.native.kt:38` reads
`SDL_GetPreferredLocales()` for resource-qualifier resolution. Every other i18n
consumer ignores it and hardcodes English. Wiring `Locale.current` to that same
call is the flagship cheap win.
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt:57` · `DefaultLocale` is a fixed `Locale("en-US")`, so `Locale.current` can never change. This one value keys Material 3 string translation selection, stranding the 40+ vendored `material3/internal/l10n/*.kt` tables (they are present and reachable by tag, but the key is always en-US). Fix by querying `SDL_GetPreferredLocales()` (already bound in the `sdl3` cinterop). **Blocker; fixing it transitively localizes all of Material 3.**
- `compose/ui/ui/src/nativeMain/.../text/intl/PlatformLocale.native.kt:18` · `createPlatformLocaleDelegate().current` hardcodes `LocaleList(listOf(Locale("en-US")))`; should reflect the full ordered preferred-locale list SDL returns (the resources module currently reads only `[0]`). **Blocker.**
- `compose/material3/.../CalendarLocale.native.kt:20` · locale-agnostic stub: `toString()` fixed `"en"`, single shared instance, `equals` treats all instances as equal. A separate type from `ui.text.intl.Locale`, so it needs its own wiring so `CalendarModel`/`PlatformDateFormat` can vary by locale. **Blocker (DatePicker/TimePicker).**
- `compose/material3/.../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.**
- `:32` `formatWithPattern`/`formatWithSkeleton` ignore the pattern/skeleton and always emit ISO `yyyy-MM-dd`. **Blocker.**
- `: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:17` · `Locale.isRtl()` not implemented; the pipeline is LTR-only (`SdlParagraph.native.kt:333` `getBidiRunDirection` always returns `Ltr`; owners/DrawScopes default `LayoutDirection.Ltr`). No RTL shaping. **Nice-to-have.**
- `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.**
**Beyond a real `getLocale`:** SDL reports language + country only (no script subtag, no calendar/clock/number preferences), and Kotlin/Native bundles no ICU. Localized month/weekday name tables, date/number/currency patterns, first-day-of-week, locale-aware casing, and RTL all still require a bundled CLDR subset or a third-party K/N i18n library. The SDL wiring is the cheap 80%; these are the expensive tail.
## D. Font resolution
## F. 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)
on both renderer legs (`FontResources.sdl.kt`, shared by the skiko leg via a srcDir
alias), so the standard CMP way to bundle a font renders correctly.
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:32` · `SdlPlatformFontLoader.loadBlocking`/`awaitLoad` are NOP (return `Unit`); font bytes for `ResourceFont` / `Font(bytes)` / `LoadedFontFamily` are never loaded. Only pre-registered `NamedFont` names reach the renderer. **Blocker if the app uses standard `Font(...)` / resource-font APIs; otherwise nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:38` · `PlatformFontFamilyTypefaceAdapter.resolve` returns `TypefaceResult.Immutable(Unit)`; any code reading the resolved typeface gets `Unit` (the renderer bypasses this via name lookup, so usually harmless). **Nice-to-have.**
- `compose/ui/ui/src/commonMain/.../text/NamedFont.kt:62` · `FontFamily.projectFontName` resolves only a `FontListFontFamily` whose first font is a `NamedFont`; returns `null` for `Serif`/`SansSerif`/`Monospace`/`Cursive`/`Default`, `LoadedFontFamily`, and resource-backed families, so all of those collapse to the single default font (no serif/mono/cursive distinction). **Nice-to-have.**
- `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.**
## G. Text rendering details
## 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.**
## H. Drag and drop
## F. Drag and drop
Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
@@ -124,10 +140,8 @@ Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:119` · incoming transfer carries only file paths + a text blob; no image/URL/custom-MIME payloads. **Cosmetic.**
- `compose/foundation/.../draganddrop/DragAndDropSource.native.kt:36` · no drag-shadow / drag-image feedback for internal DnD (upstream skiko renders one). **Cosmetic.**
## I. Window and platform info
## G. Window and platform info
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:354` · `windowInfo.containerSize`/`containerDpSize` return `IntSize.Zero`/`DpSize.Zero`; `LocalWindowInfo.containerSize` readers (popup sizing, window-size-aware layout) get 0. **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:353` · `windowInfo.isWindowFocused` hardcoded `true`; not fed by SDL focus events (which the window already tracks). Focus-reactive UI will not react. **Nice-to-have.**
- Multi-monitor: no display enumeration or per-monitor placement anywhere (`SDL_GetDisplays`/`SDL_GetDisplayBounds`/`SDL_GetDisplayForWindow` unused). **Nice-to-have.**
- `compose/ui/ui/src/nativeMain/.../platform/PrefetchLocals.native.kt:18` · `LocalPlatformPrefetchScheduler` default is a NOP scheduler; lazy lists skip ahead-of-time item composition (correctness fine, possible scroll-in jank). **Nice-to-have (perf).**
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:324` · `autofill`/`autofillManager` are null; `requestAutofill` NOP. **Nice-to-have.**
@@ -135,26 +149,69 @@ Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:287` · deprecated `clipboardManager` `setText`/`getText` NOP, and `clipboard.getClipEntry` null. Harmless: the real `LocalClipboard` (text + PNG image via SDL3) works. **Cosmetic.**
- `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:44` · `ClipboardPasteState.hasClip` aliased to `hasText`, so image-only clipboard is not detected for the paste affordance. **Cosmetic.**
## J. SDL renderer graphics (Windows / `-Prenderer=sdl3` only)
## H. SDL renderer graphics (Windows / `-Prenderer=sdl3` only)
These affect only the SDL leg. On macOS/Linux the Skia leg implements them
correctly, and JVM is the documented Windows fidelity tier, so these are lower
priority under G1. Listed because SDL is the shipped Windows renderer.
priority under G1. Listed because SDL is the shipped Windows renderer. (Several
SDL fidelity gaps are now closed — see **Completed** above.)
- `compose/ui/ui/src/sdlRendererMain/.../graphics/PathEffect.sdl.kt:15` · `dash`/`corner`/`chain`/`stamped` path effects are all NOP; dashed/dotted strokes render solid. **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../graphics/RenderEffect.sdl.kt:16` · `RenderEffect.isSupported()` is `false`; `Modifier.blur()` and `graphicsLayer{renderEffect=...}` do nothing. `compose/ui/ui/src/sdlRendererMain/.../graphics/shadow/Blur.native.kt:33` · `Paint.setBlurFilter` NOP. **Nice-to-have (Windows visual parity).**
- Gradients render real ramps, but `TileMode` is dropped: `t` is clamped, so `Repeated`/`Mirror`/`Decal` all behave as `Clamp` (`Sdl3DrawScope.kt:1070` samplers; `CanvasPaintActuals.native.kt:66`). **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../graphics/CanvasPaintActuals.native.kt:106` · `ActualImageShader`/`ActualCompositeShader` are stubs; image/composite `ShaderBrush` degrades to solid fill (white/black). **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../graphics/ColorFilter.sdl.kt:13` · tint/color-matrix/lighting color filters are empty stubs; only `BlendModeColorFilter` tint on image blits is honored (`Sdl3Canvas.kt:1251`). ColorMatrix (grayscale/saturation) and all filters on shapes are inert. **Nice-to-have.**
- BlendMode: only `SrcOver` (plus a `Clear` special case) composites; `Multiply`/`Screen`/`Plus`/etc. are ignored (`Sdl3DrawScope.kt:206`, `Sdl3Canvas.kt:926`). Note `BlendMode.isSupported()` misleadingly returns `true`. **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:436` · `saveLayer` has no offscreen buffer; layer alpha is multiplied into each primitive, so overlapping content double-composites (wrong group opacity) and the layer paint's colorFilter/blendMode/renderEffect are dropped. **Nice-to-have (blocker for correct group-alpha over overlapping content).**
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:509` · `clipPath` degrades an arbitrary path to its bounding box; rotated/sheared `clipRect` collapses to an AABB. Non-rect clips leak. (Rounded/difference clips are real, with feathered AA.) **Nice-to-have.**
- Stroke joins (`StrokeJoin`) are never applied, so polyline corners notch; `StrokeCap.Square` falls back to butt (`Sdl3DrawScope.kt:380,342`). **Nice-to-have / cosmetic.**
- `PathFillType` (NonZero vs EvenOdd) is mostly ignored beyond a 2-contour border case; interior holes and self-intersections are not cut out, and concave fills self-overlap (`Sdl3DrawScope.kt:366,717`). **Nice-to-have.**
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:1276` · `drawPoints`/`drawRawPoints`/`drawVertices` are NOP; point-mode drawing and custom vertex meshes render nothing. **Nice-to-have.**
- Images under a rotated layer are not rotated (`SDL_RenderTexture` is axis-aligned, `Sdl3Canvas.kt:1264`); stroked non-square ovals stroke as a circular ring (`Sdl3DrawScope.kt:527`); `SdlImageBitmap.readPixels` is a no-op (`Sdl3Offscreen.kt:117`). **Cosmetic / nice-to-have.**
- Drop shadow is approximated (9-slice / stacked rings, not a gaussian blur; ambient vs spot largely collapsed) at `Sdl3Canvas.kt:785`; AA is a ~1px geometry fringe rather than analytic coverage. **Cosmetic.**
The biggest remaining value here is the **offscreen-layer work** (blur + real
`saveLayer`); the two are the same infrastructure and warrant a dedicated pass
with the full parity + verify-mac gate, not a probe.
## K. GraphicsLayer / image
- **Blur / RenderEffect** — `compose/ui/ui/src/sdlRendererMain/.../graphics/RenderEffect.sdl.kt:16` `RenderEffect.isSupported()` is `false`; `Modifier.blur()` and `graphicsLayer{renderEffect=...}` do nothing; `.../graphics/shadow/Blur.native.kt:33` `Paint.setBlurFilter` NOP. Needs an offscreen render target + a blur pass (box-blur infra already exists in `Sdl3ShadowCache`). **Nice-to-have (Windows visual parity).**
- **`saveLayer` has no offscreen buffer** — `Sdl3Canvas.kt:436`: layer alpha is multiplied into each primitive, so overlapping content double-composites (wrong group opacity) and the layer paint's colorFilter/blendMode/renderEffect are dropped. Same offscreen infra as blur. **Nice-to-have (blocker for correct group-alpha over overlapping content).**
- **`clipPath` degrades to a bounding box** — `Sdl3Canvas.kt:509`: an arbitrary path clip collapses to its AABB; a rotated/sheared `clipRect` also collapses to an AABB, so non-rect clips leak. (Rounded/difference clips are real, with feathered AA.) **Nice-to-have.**
- **`ActualImageShader` / `ActualCompositeShader` are stubs** — `CanvasPaintActuals.native.kt:106`: an image/composite `ShaderBrush` degrades to a solid white/black fill (the tessellator emits per-vertex colours, not UVs). **Nice-to-have.**
- **`PathFillType` (NonZero vs EvenOdd)** — mostly ignored beyond the 2-contour border-ring case; interior holes and self-intersections are not cut out, and concave fills self-overlap (fan triangulation) (`Sdl3DrawScope.kt:366,717`). Needs a scanline/tessellation fill. **Nice-to-have.**
- **`drawVertices`** (custom vertex meshes) is still a NOP in the DrawScope. **Nice-to-have.**
- **ColorFilter/blur on IMAGE blits** — image blits honor only `BlendModeColorFilter` tint (`Sdl3Canvas.kt:1281`); a `ColorMatrix`/lighting filter or blur on a bitmap needs per-pixel work. Capture-mode (`graphicsLayer`) block-replay also drops the shape colorFilter. **Nice-to-have.**
- **`SdlImageBitmap.readPixels` is a no-op** (`Sdl3Offscreen.kt:117`) — can't read back an offscreen-rendered bitmap. **Cosmetic / nice-to-have.**
- **Drop shadow is approximated** (`Sdl3Canvas.kt:785`) — 9-slice / stacked rings, not a true gaussian; ambient vs spot largely collapsed; AA is a ~1px geometry fringe, not analytic coverage. **Cosmetic.**
- `DashPathEffect` `corner`/`chain`/`stamped` variants remain NOP (only the interval dash is implemented). **Cosmetic.**
- `compose/ui/ui/src/sdlRendererMain/.../graphics/CanvasPaintActuals.native.kt:151` · `createImageBitmap(bytes)` throws `UnsupportedOperationException`. (`GraphicsLayer.toImageBitmap` and `snapshotBgra` do work on both renderers.) **Nice-to-have.**
## I. Skia renderer (macOS/Linux default) anomalies
The Skia leg is normally the fidelity reference, so its bugs are rarely tracked
here. This one surfaced while adding SDL BlendMode parity (`demo --blendtest`):
- `BlendMode.Multiply` renders wrong on the Skia leg. 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,
and the SDL leg produces exactly that. `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 (Skia leg).**
## J. Needs further review (2026-07 bug audit)
An audit (3 parallel passes over the renderer, recent SDL features, and the
text/input/event actuals) surfaced these. The clear, contained bugs were fixed
in the same pass (see below); the items here are left open because they are
either invasive, risky, or a cosmetic-AA judgement call.
Fixed in the audit (for reference, not open work): the `roundRectCore`
inverted-rect crash; `drawArc(useCenter=false)` now fills the segment (was always
a pie sector); `BlendMode.Src` no longer punches the AA fringe out via
`SDL_BLENDMODE_NONE`; the cover-fill fast-path no longer drops the paint's
`colorFilter`; `saveLayer` in capture mode keeps the save/restore stack balanced;
stroked oval / round-rect inner radius is clamped ≥ 0; the per-vertex
`ColorMatrix` copy is hoisted out of the sample loop; `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).**
Open — SDL renderer (cosmetic AA / draw-time):
- **Stroke joins are not antialiased and reach ~0.5px past the band** (`Sdl3DrawScope.kt` `emitJoin`). The segment band feathers to `halfW − kAaHalf` solid + fringe, but joins fill solid to full `halfW` with a hard edge, so corners read as slightly darker/crisper nubs — visible on thin or translucent strokes. Needs feathered join geometry. **Review (cosmetic).**
- **Round join over-draws the stroke body** (`Sdl3DrawScope.kt` `emitJoin`, `StrokeJoin.Round`): a full disc overlaps the adjacent segments, so a semi-transparent stroke double-blends darker at each corner. Fill only the convex wedge. **Review (cosmetic).**
- **Text / icon draws ignore the draw-time `colorFilter`** — `activeColorFilter` is consulted only by the tessellation sampler; glyph/icon runs blit through the text renderer with a pre-resolved colour, so a `BlendModeColorFilter` tint applied at draw time to a `Text`/icon node isn't honored (acceptable if callers pre-fold the tint into the glyph colour). **Review.**
- **Rounded-clip containment ignores stroke half-width** (`Sdl3Canvas.kt` `admitDraw`/`tryDrawRectUnderPendingClips`): a stroked shape whose fill-box is inside a pending rounded clip but whose stroke pokes past it skips mask realization, so the stroke's outer half is bounded only by the AABB, not the rounded outline. Marginal (only bites at the exact clip boundary). **Review.**
+59 -281
View File
@@ -1,111 +1,59 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType
// :apidemo — a Postman-style REST API manager built on the compose-desktop-native
// stack. Uses Ktor with the Curl engine on every desktop target (bundled
// libcurl: Schannel on Windows, OpenSSL on macOS/Linux),
// kotlinx.serialization for the savable "packs", and okio for file I/O.
import com.bitsycore.compose.sdl.build.registerComposeFontBundling
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.plugin.compose)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.kotlin.plugin.serialization)
id("com.bitsycore.compose-desktop-native.bridge")
}
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
val vHostSupportsMingw: Boolean by rootProject.extra
val isHostMingwCompatible = rootProject.extra["vHostSupportsMingw"] as Boolean
// JVM parity target's Compose version — the dev build matching the pinned
// COMPOSE_CORE_REF (see scripts/compose-fork/compose.properties); mirrors
// :demo's forcing (Gradle orders "+dev" BELOW the plain version, so the
// plugin's beta01 would win conflict resolution without it).
val vComposeJvmVersion = libs.versions.compose.get()
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
val vComposeJvmForced = mapOf(
"org.jetbrains.compose.runtime" to vComposeJvmVersion,
"org.jetbrains.compose.ui" to vComposeJvmVersion,
"org.jetbrains.compose.foundation" to vComposeJvmVersion,
"org.jetbrains.compose.animation" to vComposeJvmVersion,
"org.jetbrains.compose.material" to vComposeJvmVersion,
"org.jetbrains.compose.material3" to vComposeM3JvmVersion,
)
configurations.configureEach {
if (name.startsWith("jvm")) {
resolutionStrategy.eachDependency {
vComposeJvmForced[requested.group]?.let { useVersion(it) }
}
}
}
// ==================
// MARK: Targets
// ==================
kotlin {
jvm()
linuxArm64()
linuxX64()
macosArm64()
if (vHostSupportsMingw) mingwX64()
applyDefaultHierarchyTemplate()
targets.withType<KotlinNativeTarget>().all {
val isMingw = name == "mingwX64"
val isLinux = name.startsWith("linux")
val isApple = name.startsWith("macos") || name.startsWith("ios")
binaries.executable {
if (buildType == NativeBuildType.RELEASE) {
// --gc-sections is a GNU ld/lld flag; Apple's ld64 rejects it
// (it dead-strips at -O anyway). Only pass it on GNU-ld targets.
if (!isApple) linkerOpts("-Wl,--gc-sections")
compilerOptions {
freeCompilerArgs.add("-opt")
}
}
entryPoint = "apidemo.main"
// SDL3 + SDL3_ttf + SDL3_image + FreeType + image codecs come from
// :ui's cinterop klibs (staticLibraries directive in each .def); the
// system libs they need are in sdl3.def's linkerOpts.<target>. Only
// shell-level flags and app-specific extras (crypt32 for mTLS) live
// here. Ktor's Curl engine bundles its own libcurl + TLS stack.
if (isMingw) linkerOpts(
// crypt32: client-cert (mTLS) import into the Windows cert store
// (CertOpenStore / PFXImportCertStore / CertAddCertificateContextToStore…).
buildList {
add(linuxArm64())
add(linuxX64())
add(macosArm64())
if (isHostMingwCompatible) add(mingwX64())
}.forEach {
it.binaries {
executable {
when {
target.name == "mingwX64" -> linkerOpts(
// crypt32: client-cert (mTLS) import into the Windows cert store.
"-lcrypt32",
// Code-shrink: drop unused sections and strip symbols.
"-Wl,--gc-sections", "-Wl,-s",
// GUI subsystem so launching the .exe pops no console window.
// Keep the standard C `main` entry — ld would otherwise default
// a GUI-subsystem PE to WinMainCRTStartup (needs WinMain) and
// fail to link, since Kotlin/Native emits `main`.
// GUI subsystem (no console window), keeping the C `main` entry.
"-Wl,--subsystem,windows", "-Wl,-e,mainCRTStartup",
)
// Skia (default renderer) references the system graphics stack.
// K/N's LLD sysroot on linuxX64 doesn't include the host's multi-arch
// dir, so add the -L for both x86_64 and aarch64 (harmless if either
// is absent — LLD skips missing dirs).
if (isLinux) linkerOpts(
target.name.startsWith("linux") -> linkerOpts(
"-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib/aarch64-linux-gnu",
"-lfontconfig", "-lGL", "-lX11",
)
}
}
}
}
applyDefaultHierarchyTemplate()
sourceSets {
commonMain {
dependencies {
// Common API on both stacks — usable from shared code.
implementation(project(":material-symbols"))
val vComposeJvmVersion = libs.versions.compose.get()
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
// Official Maven coords for everything the shared UI touches,
// so commonMain metadata (and the IDE's common analysis)
// resolve. Metadata + jvm resolve them from Maven; the NATIVE
// configurations substitute each for its port module (root
// build's FULL-COMMONIZATION BRIDGE). Every artifact must be
// declared DIRECTLY: transitives of a substituted module are
// invisible to the granular-metadata visibility check. The
// runtime is the official klib everywhere, never substituted
// (jvm configs force the +dev build above).
// Official Maven coords for everything the shared UI touches; native
// configurations substitute them for the port modules automatically
// (root bridge in-repo, the bridge plugin for consumers).
implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
implementation("org.jetbrains.compose.ui:ui:$vComposeJvmVersion")
implementation("org.jetbrains.compose.ui:ui-graphics:$vComposeJvmVersion")
@@ -119,6 +67,9 @@ kotlin {
implementation("org.jetbrains.compose.animation:animation-core:$vComposeJvmVersion")
implementation("org.jetbrains.compose.material3:material3:$vComposeM3JvmVersion")
// Common Material Symbols Utility
implementation(project(":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"),
compose.desktop.native {
entryPoint = "apidemo.main"
icon {
light.from(
layout.projectDirectory.file("icons/voltic-icon-32.png"),
layout.projectDirectory.file("icons/voltic-icon-128.png"),
)
fun detectUsedStyles(): List<String> {
val vSrcRoot = layout.projectDirectory.dir("src").asFile
val vUsed = mutableSetOf<String>()
if (!vSrcRoot.exists()) return emptyList()
vSrcRoot.walk().filter { it.isFile && it.extension == "kt" }.forEach { vFile ->
val vText = vFile.readText()
for ((vStyle, vRegex) in kAllStyles) {
if (vStyle !in vUsed && vRegex.containsMatchIn(vText)) vUsed.add(vStyle)
}
}
return vUsed.toList()
}
val vUsedStyles: List<String> = detectUsedStyles()
// ==================
// MARK: Material Symbols subsetting (-PsubsetIcons=true)
// ==================
// scripts/subset-material-symbols.py scans this module's Kotlin sources for
// MaterialSymbols.<Name> references and writes build/icons/usage-codepoint.txt.
// When -PsubsetIcons=true is passed, each icon-font module's downloaded TTF
// is hb-subset'd against that file and the Zip below bundles the trimmed
// font instead of the full one — typically 80-95% smaller.
//
// Needs: python3 + hb-subset on PATH (brew install harfbuzz /
// apt install harfbuzz-utils).
val subsetIcons = (findProperty("subsetIcons") as? String)?.toBoolean() ?: false
val iconsBuildDir = layout.buildDirectory.dir("icons")
val findMaterialSymbolsUsage = tasks.register<Exec>("findMaterialSymbolsUsage") {
description = "Scan src/ for MaterialSymbols.<Name> usages → usage-codepoint.txt."
val vScript = rootProject.layout.projectDirectory.file("scripts/subset-material-symbols.py").asFile
val vConstants = rootProject.project(":material-symbols").layout.projectDirectory
.file("src/commonMain/kotlin/com/compose/sdl/icons/MaterialSymbols.kt").asFile
val vUsageFile = iconsBuildDir.get().file("usage-codepoint.txt").asFile
inputs.files(fileTree("src") { include("**/*.kt") })
inputs.file(vConstants)
outputs.file(vUsageFile)
commandLine(
"python3", vScript.absolutePath,
"--src", layout.projectDirectory.dir("src").asFile.absolutePath,
"--constants", vConstants.absolutePath,
"--out", vUsageFile.absolutePath,
dark.from(
layout.projectDirectory.file("icons/voltic-icon-dark-32.png"),
layout.projectDirectory.file("icons/voltic-icon-dark-128.png"),
)
exeIcon.from(
listOf(16, 32, 48, 64, 128, 256).map {
layout.projectDirectory.file("icons/voltic-icon-$it.png")
}
/* Register `subsetMaterialSymbols<Style>` for a style — hb-subsets the style's
downloaded TTF (owned by :material-symbols) to only the codepoints in
usage-codepoint.txt and writes the output under this app's build/icons/ dir. */
fun registerSubsetTask(inStyle: String): TaskProvider<*> {
val vSymbolsProject = rootProject.project(":material-symbols")
return tasks.register("subsetMaterialSymbols$inStyle") {
description = "hb-subset the $inStyle Material Symbols font to icons actually used."
@Suppress("UNCHECKED_CAST")
val vInputProvider = vSymbolsProject.extra["iconFontFile$inStyle"] as org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$inStyle"] as TaskProvider<*>
val vOut = iconsBuildDir.get().file("MaterialSymbols$inStyle.subset.ttf").asFile
val vUsage = iconsBuildDir.get().file("usage-codepoint.txt").asFile
inputs.file(vInputProvider)
inputs.file(vUsage)
outputs.file(vOut)
dependsOn(findMaterialSymbolsUsage)
dependsOn(vDownloadTask)
doLast {
val vCodepoints = vUsage.readLines()
.filter { it.isNotBlank() && !it.startsWith("#") && it.contains("=") }
.map { it.substringAfter("=").trim().removePrefix("0x").removePrefix("0X") }
if (vCodepoints.isEmpty()) throw GradleException(
"usage-codepoint.txt has no entries — refusing to subset to an empty font.")
val vUnicodes = vCodepoints.joinToString(",") { "U+$it" }
vOut.parentFile.mkdirs()
val vInputFile = vInputProvider.get().asFile
val vBefore = vInputFile.length()
// ProcessBuilder rather than project.exec — the latter isn't
// available inside doLast on Gradle 9 (only ExecOperations via
// an injected service is, which would mean a buildSrc plugin).
// hb-subset (HarfBuzz) is optional: if it isn't on PATH — common on
// Windows — bundle the full font instead of failing the build.
val vProc = try {
ProcessBuilder(
"hb-subset",
vInputFile.absolutePath,
"-o", vOut.absolutePath,
"--unicodes=$vUnicodes",
).redirectErrorStream(true).start()
} catch (e: java.io.IOException) {
vInputFile.copyTo(vOut, overwrite = true)
logger.warn("[subset $inStyle] hb-subset not found on PATH — bundling the full font " +
"(${vBefore / 1024}KB). Install harfbuzz to shrink it (brew install harfbuzz / " +
"pacman -S mingw-w64-x86_64-harfbuzz / apt install harfbuzz-utils).")
return@doLast
}
val vOutput = vProc.inputStream.bufferedReader().readText()
val vCode = vProc.waitFor()
if (vCode != 0) throw GradleException("hb-subset failed (exit $vCode):\n$vOutput")
val vAfter = vOut.length()
val vPct = if (vBefore == 0L) 0 else ((100 - 100 * vAfter / vBefore)).coerceAtLeast(0)
logger.lifecycle("[subset $inStyle] ${vBefore / 1024}KB → ${vAfter / 1024}KB (-$vPct%) · ${vCodepoints.size} glyphs kept")
}
}
}
// Pre-create subset tasks per USED style when enabled; the Zip below uses
// them as the font source. Built outside the variant×target loop so each
// style is subset once and reused across all bundle variants.
val subsetTasksByStyle: Map<String, TaskProvider<*>> =
if (subsetIcons) vUsedStyles.associateWith { registerSubsetTask(it) } else emptyMap()
for (variant in variants) {
for (target in nativeTargets) {
val variantCap = variant.replaceFirstChar { it.uppercase() }
val targetCap = target.replaceFirstChar { it.uppercase() }
val outDir = layout.buildDirectory.dir("bin/$target/${variant}Executable")
val copyTask = tasks.register<Zip>("copy${variantCap}ComposeResources${targetCap}") {
archiveFileName.set("data.kres")
destinationDirectory.set(outDir)
entryCompression = if ((findProperty("compressResources") as? String)?.toBoolean() == true) org.gradle.api.tasks.bundling.ZipEntryCompression.DEFLATED else org.gradle.api.tasks.bundling.ZipEntryCompression.STORED
from(notoSansFile) { into("font") }
from(notoMonoFile) { into("font") }
dependsOn(":ui:downloadNotoFonts")
val vSymbolsProject = rootProject.project(":material-symbols")
for (vStyle in vUsedStyles) {
@Suppress("UNCHECKED_CAST")
val vFontFile = vSymbolsProject.extra["iconFontFile$vStyle"] as org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$vStyle"] as TaskProvider<*>
val vSubsetTask = subsetTasksByStyle[vStyle]
if (vSubsetTask != null) {
// Trimmed font: depend on the subset task and bundle its output
// with the original filename so the runtime IconFont registration
// doesn't need to know the bundle was subset.
val vOriginalName = vFontFile.get().asFile.name
from(vSubsetTask.get().outputs.files) {
into("font")
rename { vOriginalName }
}
dependsOn(vSubsetTask)
} else {
from(vFontFile) { into("font") }
}
dependsOn(vDownloadTask)
}
}
listOf("link${variantCap}Executable${targetCap}", "run${variantCap}Executable${targetCap}").forEach { taskName ->
tasks.matching { it.name == taskName }.configureEach { dependsOn(copyTask) }
}
)
embedWindowsIcon = providers.gradleProperty("embedWindowsIcon").map { it.toBoolean() }
}
}
// ==================
// MARK: Stage fonts onto the JVM classpath
// MARK: Fonts (shared pipeline — buildSrc ComposeFontBundling.kt)
// ==================
// The JVM analog of the data.kres font/ entries: :material-symbols' JVM actual
// loads each style's font from the classpath at font/<Style>.ttf, and the
// jvm Fonts actual loads the mono body font at font/NotoSansMono.ttf.
registerComposeFontBundling {
bundleNotoSans = true
// The mono body font is loaded through the app's own seam (Fonts.kt), not via
// FontFamily.Monospace call sites — auto-detection can't see it, bundle explicitly.
bundleNotoSansMono = true
bundleMaterialSymbols = true
enableIconSubsetting = true
}
// Parity-window icon (cosmetic : the harness compares the canvas, not chrome).
tasks.named<ProcessResources>("jvmProcessResources") {
from(notoMonoFile) { into("font") }
dependsOn(":ui:downloadNotoFonts")
val vSymbolsProject = rootProject.project(":material-symbols")
for (vStyle in vUsedStyles) {
@Suppress("UNCHECKED_CAST")
val vFontFile = vSymbolsProject.extra["iconFontFile$vStyle"] as org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$vStyle"] as TaskProvider<*>
from(vFontFile) { into("font") }
dependsOn(vDownloadTask)
}
from(layout.projectDirectory.file("icons/voltic-icon-256.png")) { into("icon") }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+13 -12
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,7 +11,7 @@ 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,
@@ -21,10 +22,10 @@ actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
cleanup = {},
)
/* No temporary certificate store off Windows. */
/** No temporary certificate store off Windows. */
actual fun sweepTempClientCerts() {}
/* Continue the server's chain with intermediates / roots pulled from the
/** 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
@@ -41,7 +42,7 @@ actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<Ch
// Chain walk via Security.framework
// ============
/* Build the chain by handing the leaf to SecTrust + evaluating against
/** 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
@@ -124,7 +125,7 @@ private fun osChainApple(
return vResolved
}
/* SecCertificate from DER bytes. Returns null if the bytes aren't a
/** SecCertificate from DER bytes. Returns null if the bytes aren't a
valid X.509 cert. */
@OptIn(ExperimentalForeignApi::class)
private fun createSecCertificate(inDer: ByteArray): SecCertificateRef? {
@@ -136,7 +137,7 @@ private fun createSecCertificate(inDer: ByteArray): SecCertificateRef? {
return vCert
}
/* CFArray of SecCertificateRef — the input to SecTrustCreateWithCertificates. */
/** CFArray of SecCertificateRef — the input to SecTrustCreateWithCertificates. */
@OptIn(ExperimentalForeignApi::class)
private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
@@ -146,7 +147,7 @@ private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
return vArr
}
/* Apple's "subject summary" — basically the CN, but falls back to the
/** Apple's "subject summary" — basically the CN, but falls back to the
organisation when no CN is present (matches what Keychain Access
displays in its cert list). */
@OptIn(ExperimentalForeignApi::class)
@@ -156,7 +157,7 @@ private fun secSubjectSummary(inCert: SecCertificateRef): String? {
return vNs?.toString()
}
/* PEM string for a SecCertificate — base64 of the DER bytes wrapped
/** PEM string for a SecCertificate — base64 of the DER bytes wrapped
with -----BEGIN CERTIFICATE-----/-----END----- headers. */
@OptIn(ExperimentalForeignApi::class, ExperimentalEncodingApi::class)
private fun secCertPem(inCert: SecCertificateRef): String? {
@@ -166,17 +167,17 @@ private fun secCertPem(inCert: SecCertificateRef): String? {
val vPtr = CFDataGetBytePtr(vData)
if (vPtr != null) for (vI in 0 until vLen) vBytes[vI] = vPtr[vI].toByte()
CFRelease(vData)
val vB64 = Base64.Default.encode(vBytes)
val vB64 = Base64.encode(vBytes)
val vWrapped = vB64.chunked(64).joinToString("\n")
return "-----BEGIN CERTIFICATE-----\n$vWrapped\n-----END CERTIFICATE-----"
}
/* Decode a PEM block to DER bytes, or pass DER bytes through. */
/** Decode a PEM block to DER bytes, or pass DER bytes through. */
@OptIn(ExperimentalEncodingApi::class)
private fun derFromPem(inPem: String): ByteArray? {
if (!inPem.contains("-----BEGIN")) return inPem.encodeToByteArray()
val vBody = inPem.lineSequence()
.filterNot { it.startsWith("-----") }
.joinToString("") { it.trim() }
return runCatching { Base64.Default.decode(vBody) }.getOrNull()
return runCatching { Base64.decode(vBody) }.getOrNull()
}
File diff suppressed because it is too large Load Diff
+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"
+44 -19
View File
@@ -1,19 +1,21 @@
package apidemo
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import kotlinx.coroutines.Job
// ==================
// MARK: Working state (session / pack / request / tabs / drag)
// ==================
internal class HistoryEntry(val method: ReqMethod, val url: String, val status: Int, val timeMs: Long, val request: ApiRequest)
internal class HistoryEntry(
val method: ReqMethod,
val url: String,
val status: Int,
val timeMs: Long,
val request: ApiRequest
)
/* One request plus its live session state — response, in-flight job (for
/** One request plus its live session state — response, in-flight job (for
cancel) and which sub-tabs show. Stable identity lets the sidebar and the
open-tab strip reference the same request without index juggling. */
internal class ReqState(inInitial: ApiRequest) {
@@ -26,6 +28,7 @@ internal class ReqState(inInitial: ApiRequest) {
var preview by mutableStateOf(false) // panel 4 showing the resolved, not-yet-sent request
var sentReq by mutableStateOf<ApiRequest?>(null) // resolved request actually sent (Request tab)
var imageKey: String? = null // memory-resource key when the response is an image
// null = auto-detect from Content-Type each frame; non-null = user-pinned override.
var respFormatOverride by mutableStateOf<BodyFormat?>(null)
var bodyWrap by mutableStateOf(true) // body view: true = soft-wrap, false = no wrap + horizontal scroll
@@ -35,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
/** One open pack: its file path (null = never saved), a dirty flag (edits since
last file save), and its own requests, variables and open-tab strip. Switching
packs swaps the whole working set. */
internal class PackState(inPack: Pack, inPath: String?, inDirty: Boolean, inOpenTabs: List<Int> = emptyList(), inActive: Int = -1) {
internal class PackState(
inPack: Pack,
inPath: String?,
inDirty: Boolean,
inOpenTabs: List<Int> = emptyList(),
inActive: Int = -1
) {
var name by mutableStateOf(inPack.name)
var path by mutableStateOf(inPath)
var dirty by mutableStateOf(inDirty)
var color by mutableStateOf(inPack.color)
// A linked copy mirrors its source's requests read-only; non-linked packs own
// their requests. `requests` resolves to whichever applies.
var linkedSource by mutableStateOf<PackState?>(null)
@@ -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,33 +88,42 @@ 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
/** A fresh random id for a pack (used to wire linked-copy packs to their source
across save / load). */
internal fun newPackId(): String = "pk-" + kotlin.random.Random.nextLong().toULong().toString(16)
/* One entry in the unified tab strip: a request tab, a pack's settings tab when
/** One entry in the unified tab strip: a request tab, a pack's settings tab when
req == null, or the session settings tab when isSession (pack == null). The
underlying ReqState / PackState (or the session key) is the stable identity. */
internal const val kSessionTabKey = "session-settings"
internal class StripTab(val pack: PackState?, val req: ReqState?, val isSession: Boolean = false)
/* Identity of a strip tab. A linked pack shares the source's ReqState objects,
/** Identity of a strip tab. A linked pack shares the source's ReqState objects,
so the key must include the pack — otherwise the source's and linked's tabs
for the same request collide (double selection, duplicate key()). Data class →
structural equality, so compare keys with == (not ===). */
internal data class TabKey(val pack: PackState?, val req: ReqState?)
internal val StripTab.tabKey: Any get() = if (isSession) kSessionTabKey else TabKey(pack, req)
// ==================
// MARK: Cross-pack drag (sidebar tree)
// ==================
/* Shared state for dragging within and across packs in the sidebar tree. One
/** Shared state for dragging within and across packs in the sidebar tree. One
instance is hoisted to App so any PackSection can be the drag source or the
drop target. Two drag kinds: a request row (dragReq, owned by dragReqOwner) or
a whole pack header (dragPack). Every rendered row / header registers its
@@ -113,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)
+43 -50
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,7 +11,7 @@ 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
@@ -25,15 +24,12 @@ class CertDetails(
// ============
// DER reader: each next() returns [tag, contentStart, contentEnd] and advances.
private class DerReader(val b: ByteArray, var pos: Int, val end: Int)
{
private class DerReader(val b: ByteArray, var pos: Int, val end: Int) {
fun hasMore() = pos < end
fun next(): IntArray
{
fun next(): IntArray {
val vTag = b[pos++].toInt() and 0xFF
var vLen = b[pos++].toInt() and 0xFF
if (vLen and 0x80 != 0)
{
if (vLen and 0x80 != 0) {
val vN = vLen and 0x7F
vLen = 0
repeat(vN) { vLen = (vLen shl 8) or (b[pos++].toInt() and 0xFF) }
@@ -44,13 +40,11 @@ private class DerReader(val b: ByteArray, var pos: Int, val end: Int)
}
}
/* Parse the PEM into the fields the chain dialog shows. Returns null on any
/** Parse the PEM into the fields the chain dialog shows. Returns null on any
structural surprise (the UI just falls back to whatever the backend gave). */
fun parseCertDetails(inPem: String): CertDetails?
{
fun parseCertDetails(inPem: String): CertDetails? {
val vDer = pemToDer(inPem) ?: return null
return try
{
return try {
val vCert = DerReader(vDer, 0, vDer.size).next() // Certificate SEQUENCE
val vCertInner = DerReader(vDer, vCert[1], vCert[2])
val vTbs = vCertInner.next() // tbsCertificate
@@ -77,44 +71,43 @@ fun parseCertDetails(inPem: String): CertDetails?
// SPKI = SEQUENCE { algorithm AlgorithmIdentifier SEQUENCE { OID … }, key BIT STRING }
val vKeyAlg = firstOidName(vDer, DerReader(vDer, vSpki[1], vSpki[2]).next())
val vSans = ArrayList<String>()
while (vT.hasMore())
{
while (vT.hasMore()) {
val vX = vT.next()
if (vX[0] == 0xA3) parseSans(vDer, vX, vSans) // extensions [3]
}
CertDetails(vVersion, vSerial, vSigAlg, vKeyAlg, vNotBefore, vNotAfter, vSans)
} catch (_: Throwable) {
null
}
catch (e: Throwable) { null }
}
// ============
// Helpers
@OptIn(ExperimentalEncodingApi::class)
private fun pemToDer(inPem: String): ByteArray?
{
private fun pemToDer(inPem: String): ByteArray? {
val vBody = inPem.lineSequence().filterNot { it.contains("-----") }.joinToString("") { it.trim() }
if (vBody.isBlank()) return null
return try { Base64.decode(vBody) } catch (e: Throwable) { null }
return try {
Base64.decode(vBody)
} catch (_: Throwable) {
null
}
}
/* The friendly name (or dotted OID) of the first child OID of a SEQUENCE — used
/** The friendly name (or dotted OID) of the first child OID of a SEQUENCE — used
for signatureAlgorithm and subjectPublicKeyInfo's AlgorithmIdentifier. */
private fun firstOidName(inB: ByteArray, inSeq: IntArray): String?
{
private fun firstOidName(inB: ByteArray, inSeq: IntArray): String? {
val vR = DerReader(inB, inSeq[1], inSeq[2])
val vOid = vR.next()
if (vOid[0] != 0x06) return null
return oidName(oidToString(inB, vOid[1], vOid[2]))
}
/* Pull dNSName entries out of the SubjectAltName extension under [3]. */
private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<String>)
{
/** Pull dNSName entries out of the SubjectAltName extension under [3]. */
private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<String>) {
val vSeq = DerReader(inB, inExt3[1], inExt3[2]).next() // SEQUENCE OF Extension
val vEs = DerReader(inB, vSeq[1], vSeq[2])
while (vEs.hasMore())
{
while (vEs.hasMore()) {
val vExtn = vEs.next()
val vEr = DerReader(inB, vExtn[1], vExtn[2])
val vOid = vEr.next() // extnID
@@ -129,8 +122,7 @@ private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<Strin
{
val vGnSeq = DerReader(inB, vVal[1], vVal[2]).next()
val vGn = DerReader(inB, vGnSeq[1], vGnSeq[2])
while (vGn.hasMore())
{
while (vGn.hasMore()) {
val vName = vGn.next()
if (vName[0] == 0x82) inOut.add(inB.copyOfRange(vName[1], vName[2]).decodeToString()) // [2] dNSName
}
@@ -138,54 +130,55 @@ private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<Strin
}
}
/* ASN.1 UTCTime (YYMMDD…) / GeneralizedTime (YYYYMMDD…) → readable UTC string. */
private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String?
{
/** ASN.1 UTCTime (YYMMDD…) / GeneralizedTime (YYYYMMDD…) → readable UTC string. */
private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String? {
val vS = inB.copyOfRange(inTlv[1], inTlv[2]).decodeToString()
return try
{
return try {
val vYear: String
val vRest: String
if (inTlv[0] == 0x18) // GeneralizedTime
{ vYear = vS.substring(0, 4); vRest = vS.substring(4) }
else // UTCTime — RFC 5280 pivot at 50
{
vYear = vS.substring(0, 4); vRest = vS.substring(4)
} else // UTCTime — RFC 5280 pivot at 50
{
val vYy = vS.substring(0, 2).toInt()
vYear = (if (vYy >= 50) "19" else "20") + vS.substring(0, 2)
vRest = vS.substring(2)
}
val vMo = vRest.substring(0, 2); val vDa = vRest.substring(2, 4)
val vHh = vRest.substring(4, 6); val vMi = vRest.substring(6, 8)
val vMo = vRest.substring(0, 2)
val vDa = vRest.substring(2, 4)
val vHh = vRest.substring(4, 6)
val vMi = vRest.substring(6, 8)
val vSs = if (vRest.length >= 10 && vRest[8].isDigit()) vRest.substring(8, 10) else "00"
"$vYear-$vMo-$vDa $vHh:$vMi:$vSs UTC"
} catch (_: Throwable) {
vS
}
catch (e: Throwable) { vS }
}
/* Colon-separated hex of a byte range (serial number rendering). */
/** Colon-separated hex of a byte range (serial number rendering). */
private fun bytesToHex(inB: ByteArray, inFrom: Int, inTo: Int): String =
(inFrom until inTo).joinToString(":") { (inB[it].toInt() and 0xFF).toString(16).padStart(2, '0') }
/* Decode an OID's content bytes to dotted-decimal. */
private fun oidToString(inB: ByteArray, inFrom: Int, inTo: Int): String
{
/** Decode an OID's content bytes to dotted-decimal. */
private fun oidToString(inB: ByteArray, inFrom: Int, inTo: Int): String {
if (inTo <= inFrom) return ""
val vSb = StringBuilder()
val vFirst = inB[inFrom].toInt() and 0xFF
vSb.append(vFirst / 40).append('.').append(vFirst % 40)
var vAcc = 0L
for (vI in (inFrom + 1) until inTo)
{
for (vI in (inFrom + 1) until inTo) {
val vByte = inB[vI].toInt() and 0xFF
vAcc = (vAcc shl 7) or (vByte and 0x7F).toLong()
if (vByte and 0x80 == 0) { vSb.append('.').append(vAcc); vAcc = 0 }
if (vByte and 0x80 == 0) {
vSb.append('.').append(vAcc); vAcc = 0
}
}
return vSb.toString()
}
/* Friendly name for the common signature / public-key OIDs, else the OID. */
private fun oidName(inOid: String): String = when (inOid)
{
/** Friendly name for the common signature / public-key OIDs, else the OID. */
private fun oidName(inOid: String): String = when (inOid) {
"1.2.840.113549.1.1.1" -> "RSA"
"1.2.840.113549.1.1.5" -> "SHA1withRSA"
"1.2.840.113549.1.1.11" -> "SHA256withRSA"
+2 -2
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
/** The raw family name string — used by wrappedRowCount, which measures via the
platform text pipeline (accepts a name string). Null when not bundled. */
expect val monoFontFamilyName: String?
/* The material3 Text / BasicTextField-shaped FontFamily for the mono family.
/** The material3 Text / BasicTextField-shaped FontFamily for the mono family.
Null when not bundled. */
expect val monoFontFamily: FontFamily?
+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
}
+8 -10
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,7 +16,7 @@ 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
/** 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. */
@@ -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,7 +116,7 @@ class HttpRunner {
fun close() = fClient.close()
}
/* Heuristic for a non-text body: textual content types (text, json, xml, html,
/** Heuristic for a non-text body: textual content types (text, json, xml, html,
javascript, csv, form) are shown as text; everything else, or bytes with
embedded NULs, is treated as binary. */
internal fun isBinaryBody(inContentType: String?, inBytes: ByteArray): Boolean {
@@ -131,17 +129,17 @@ internal fun isBinaryBody(inContentType: String?, inBytes: ByteArray): Boolean {
return inBytes.take(1024).any { it.toInt() == 0 } // no type: sniff for NUL bytes
}
/* True when the bytes are gzip — either the response says so or they carry the
/** True when the bytes are gzip — either the response says so or they carry the
gzip magic number (1f 8b). */
private fun isGzip(inEncoding: String?, inBytes: ByteArray): Boolean =
inEncoding?.contains("gzip", ignoreCase = true) == true ||
(inBytes.size >= 2 && inBytes[0] == 0x1f.toByte() && inBytes[1] == 0x8b.toByte())
/* Read a file's raw bytes (used for a FILE request body). */
/** Read a file's raw bytes (used for a FILE request body). */
internal fun readFileBytes(inPath: String): ByteArray =
systemFileSystem.read(inPath.trim().toPath()) { readByteArray() }
/* Inflate a gzip stream to its original bytes via okio's GzipSource. */
/** Inflate a gzip stream to its original bytes via okio's GzipSource. */
private fun gunzip(inBytes: ByteArray): ByteArray {
val vGz = GzipSource(Buffer().write(inBytes)).buffer()
val vResult = vGz.readByteArray()
+91 -28
View File
@@ -1,5 +1,3 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package apidemo
import androidx.compose.foundation.background
@@ -21,7 +19,7 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// MARK: Headers tab (inherited read-only + own editable)
// ==================
/* A request tab pairing an inherited (read-only, source-tagged) list with the
/** A request tab pairing an inherited (read-only, source-tagged) list with the
request's own editable list: Override copies an inherited entry down, and own
rows that shadow an inherited one get an OverrideMark. Used by Query / Var /
Headers — the only differences are the key case-sensitivity and the labels. */
@@ -40,8 +38,10 @@ internal fun InheritedEditableTab(
fun norm(inKey: String) = if (inCaseInsensitive) inKey.lowercase() else inKey
val vOwnKeys = inOwn.filter { it.key.isNotBlank() }.map { norm(it.key) }.toSet()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
InheritedKvSection(inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
inOnOverride = if (inReadOnly) null else inOnOverride)
InheritedKvSection(
inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
inOnOverride = if (inReadOnly) null else inOnOverride
)
if (inInherited.any { it.kv.key.isNotBlank() }) {
HorizontalDivider(color = c.border)
Text(inOwnTitle, color = c.dim, fontSize = 11.sp)
@@ -52,26 +52,52 @@ internal fun InheritedEditableTab(
}
}
/* Request Headers tab: inherited headers (case-insensitive) + the request's own. */
/** Request Headers tab: inherited headers (case-insensitive) + the request's own. */
@Composable
internal fun HeadersTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
InheritedEditableTab(inInherited, inReq.headers, inCaseInsensitive = true, inReadOnly = inReadOnly, inOwnTitle = "Request headers",
internal fun HeadersTab(
inReq: ApiRequest,
inInherited: List<InheritedKv>,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) =
InheritedEditableTab(
inInherited, inReq.headers, inCaseInsensitive = true, inReadOnly = inReadOnly, inOwnTitle = "Request headers",
inOnOverride = { vKv -> inEdit { it.copy(headers = it.headers + vKv) } },
inOnChange = { v -> inEdit { it.copy(headers = v) } })
/* Request Query tab: inherited query params (case-sensitive) + the request's own. */
/** Request Query tab: inherited query params (case-sensitive) + the request's own. */
@Composable
internal fun QueryTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
InheritedEditableTab(inInherited, inReq.params, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request query params",
internal fun QueryTab(
inReq: ApiRequest,
inInherited: List<InheritedKv>,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) =
InheritedEditableTab(
inInherited,
inReq.params,
inCaseInsensitive = false,
inReadOnly = inReadOnly,
inOwnTitle = "Request query params",
inOnOverride = { vKv -> inEdit { it.copy(params = it.params + vKv) } },
inOnChange = { v -> inEdit { it.copy(params = v) } })
/* Request Var tab: inherited variables (case-sensitive {{name}}) + the request's
/** Request Var tab: inherited variables (case-sensitive {{name}}) + the request's
own. A request's own variable overrides the same name inherited from a pack /
the session when the request is sent. */
@Composable
internal fun VarTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
InheritedEditableTab(inInherited, inReq.variables, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request variables",
internal fun VarTab(
inReq: ApiRequest,
inInherited: List<InheritedKv>,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) =
InheritedEditableTab(
inInherited,
inReq.variables,
inCaseInsensitive = false,
inReadOnly = inReadOnly,
inOwnTitle = "Request variables",
inOnOverride = { vKv -> inEdit { it.copy(variables = it.variables + vKv) } },
inOnChange = { v -> inEdit { it.copy(variables = v) } })
@@ -79,14 +105,18 @@ internal fun VarTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnl
// MARK: Inheritance UI (source tags / override markers / inherited lists)
// ==================
/* A small pill naming the kind of scope an inherited value comes from ("Session"
/** A small pill naming the kind of scope an inherited value comes from ("Session"
/ "Pack"); hover shows the full path (e.g. "Methods / Nested"). The path tooltip
is skipped when it would just repeat the label (the session). */
@Composable
internal fun SourceTag(inLabel: String, inPath: String) {
val c = LocalAppColors.current
val vPill = @Composable {
Box(modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(c.accent.copy(alpha = 0.16f), RoundedCornerShape(4.dp)).padding(horizontal = 5.dp, vertical = 1.dp)) {
Box(
modifier = Modifier.clip(RoundedCornerShape(4.dp))
.background(c.accent.copy(alpha = 0.16f), RoundedCornerShape(4.dp))
.padding(horizontal = 5.dp, vertical = 1.dp)
) {
Text(inLabel, color = c.accent, fontSize = 9.sp)
}
}
@@ -101,7 +131,7 @@ internal fun SourceTag(inLabel: String, inPath: String) {
}
}
/* The tiny marker shown on an own value that shadows an inherited one — hover for
/** The tiny marker shown on an own value that shadows an inherited one — hover for
"Overrides <key> from <path>". */
@Composable
internal fun OverrideMark(inKey: String, inPath: String) {
@@ -120,7 +150,7 @@ internal fun OverrideMark(inKey: String, inPath: String) {
}
}
/* Read-only list of inherited key/values, each tagged with its source. When not
/** Read-only list of inherited key/values, each tagged with its source. When not
already overridden in this scope (own key absent) and inOnOverride != null, a
row offers a one-click Override that copies it into this scope's own list. */
@Composable
@@ -139,15 +169,26 @@ internal fun InheritedKvSection(
vShown.forEach { vIt ->
val vKey = if (inCaseInsensitive) vIt.kv.key.lowercase() else vIt.kv.key
val vOverridden = vKey in inOwnKeys
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SourceTag(vIt.source, vIt.path)
Text(vIt.kv.key, color = c.dim, fontSize = 12.sp, modifier = Modifier.weight(0.42f))
Text(vIt.kv.value, color = c.dim.copy(alpha = if (vOverridden) 0.4f else 1f), fontSize = 12.sp, modifier = Modifier.weight(0.58f))
Text(
vIt.kv.value,
color = c.dim.copy(alpha = if (vOverridden) 0.4f else 1f),
fontSize = 12.sp,
modifier = Modifier.weight(0.58f)
)
when {
vOverridden -> Text("overridden", color = c.dim.copy(alpha = 0.6f), fontSize = 10.sp)
inOnOverride != null -> Box(
modifier = Modifier.clip(RoundedCornerShape(6.dp)).border(1.dp, c.border, RoundedCornerShape(6.dp))
.clickable { inOnOverride(KeyVal(vIt.kv.key, vIt.kv.value)) }.padding(horizontal = 8.dp, vertical = 3.dp),
modifier = Modifier.clip(RoundedCornerShape(6.dp))
.border(1.dp, c.border, RoundedCornerShape(6.dp))
.clickable { inOnOverride(KeyVal(vIt.kv.key, vIt.kv.value)) }
.padding(horizontal = 8.dp, vertical = 3.dp),
) { Text("Override", color = c.accent, fontSize = 11.sp) }
}
}
@@ -159,11 +200,15 @@ internal fun InheritedKvSection(
// MARK: Key/value editor
// ==================
/* inOverrideInfo maps an own row to the source label it shadows (or null) so the
/** inOverrideInfo maps an own row to the source label it shadows (or null) so the
editor can flag overriding rows with an OverrideMark. It sits before inOnChange
so existing trailing-lambda calls (KeyValEditor(rows) { … }) still bind to onChange. */
@Composable
internal fun KeyValEditor(inRows: List<KeyVal>, inOverrideInfo: (KeyVal) -> String? = { null }, inOnChange: (List<KeyVal>) -> Unit) {
internal fun KeyValEditor(
inRows: List<KeyVal>,
inOverrideInfo: (KeyVal) -> String? = { null },
inOnChange: (List<KeyVal>) -> Unit
) {
val c = LocalAppColors.current
val vAnyOverride = inRows.any { inOverrideInfo(it) != null }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -188,15 +233,33 @@ internal fun KeyValEditor(inRows: List<KeyVal>, inOverrideInfo: (KeyVal) -> Stri
Checkbox(
checked = vKv.enabled,
onCheckedChange = null,
colors = CheckboxDefaults.colors(checkedColor = c.accent, uncheckedColor = c.dim, checkmarkColor = c.onAccent),
colors = CheckboxDefaults.colors(
checkedColor = c.accent,
uncheckedColor = c.dim,
checkmarkColor = c.onAccent
),
)
}
if (vAnyOverride) Box(modifier = Modifier.width(18.dp), contentAlignment = Alignment.Center) {
inOverrideInfo(vKv)?.let { vSrc -> OverrideMark(vKv.key, vSrc) }
}
ThinField(vKv.key, { v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(key = v) else vR }) }, inModifier = Modifier.weight(1f), inPlaceholder = "key")
ThinField(vKv.value, { v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(value = v) else vR }) }, inModifier = Modifier.weight(1.4f), inPlaceholder = "value")
IconBtn(MaterialSymbols.Close, "Remove", inSize = 16.dp) { inOnChange(inRows.filterIndexed { vJ, _ -> vJ != vI }) }
ThinField(
vKv.key,
{ v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(key = v) else vR }) },
inModifier = Modifier.weight(1f),
inPlaceholder = "key"
)
ThinField(
vKv.value,
{ v -> inOnChange(inRows.mapIndexed { vJ, vR -> if (vJ == vI) vR.copy(value = v) else vR }) },
inModifier = Modifier.weight(1.4f),
inPlaceholder = "value"
)
IconBtn(
MaterialSymbols.Close,
"Remove",
inSize = 16.dp
) { inOnChange(inRows.filterIndexed { vJ, _ -> vJ != vI }) }
}
}
OutlinedAction(MaterialSymbols.Add, "Add row") { inOnChange(inRows + KeyVal()) }
+1 -1
View File
@@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
/* Icon + label row used inside nearly every DropdownMenuItem. */
/** Icon + label row used inside nearly every DropdownMenuItem. */
@Composable
internal fun MenuRow(inIcon: Int, inLabel: String, inColor: Color? = null) {
val c = LocalAppColors.current
+137 -59
View File
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
// MARK: Pack model (serialized to / from .json)
// ==================
/* A "pack" is a savable collection of requests plus its environment variables
/** 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
@@ -26,7 +26,7 @@ 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
/** 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
@@ -38,7 +38,7 @@ data class CertConfig(
val certPassword: String = "",
)
/* The starter SESSION loaded on first launch (and on demand from the 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,
@@ -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(
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,
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
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",
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\" }"),
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\" }"),
ApiRequest(
name = "Anything echo", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"hello\": \"world\" }"
),
),
// Nested sub-pack — inherits Session → Methods → here (apiVer becomes v3,
// plus its own X-Nested header; trace / source / Accept still flow down).
subPacks = listOf(
Pack(name = "Nested", color = 4,
Pack(
name = "Nested", color = 4,
variables = listOf(KeyVal("apiVer", "v3")),
headers = listOf(KeyVal("X-Nested", "true")),
requests = listOf(
ApiRequest(name = "GET — deep inheritance", method = ReqMethod.GET, url = "{{baseUrl}}/anything/{{apiVer}}"),
)),
))),
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}}"),
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(
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"),
SavedPack(
pack = Pack(
name = "Secure (mTLS)", color = 5,
cert = CertConfig(
certPath = "/path/to/client.p12",
certFormat = CertFormat.PKCS12,
certPassword = "changeit"
),
requests = listOf(
ApiRequest(name = "Inherits the pack cert", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
ApiRequest(name = "Overrides with own cert", method = ReqMethod.GET, url = "{{baseUrl}}/get",
certPath = "/path/to/other-client.pem", certFormat = CertFormat.PEM, keyPath = "/path/to/other-key.pem"),
))),
ApiRequest(
name = "Overrides with own cert",
method = ReqMethod.GET,
url = "{{baseUrl}}/get",
certPath = "/path/to/other-client.pem",
certFormat = CertFormat.PEM,
keyPath = "/path/to/other-key.pem"
),
)
)
),
// ── Linked copy of "Methods": a read-only mirror of its requests, but with
// its own baseUrl var → the same calls run against the httpbingo sibling.
// Showcases linked packs + per-pack variable override. ──
SavedPack(pack = Pack(name = "Methods · httpbingo", color = 6, requests = emptyList(),
variables = listOf(KeyVal("baseUrl", "https://httpbingo.org")), linkedTo = "pk-methods")),
SavedPack(
pack = Pack(
name = "Methods · httpbingo", color = 6, requests = emptyList(),
variables = listOf(KeyVal("baseUrl", "https://httpbingo.org")), linkedTo = "pk-methods"
)
),
),
)
@@ -159,24 +243,26 @@ data class ApiRequest(
val certPassword: String = "",
)
/* Whether this request carries a client certificate (and so is sent via the
/** Whether this request carries a client certificate (and so is sent via the
libcurl mTLS path rather than the default engine). */
val ApiRequest.hasClientCert: Boolean
get() = certPath.isNotBlank()
/* This request's cert fields as a CertConfig (certPath may be blank = none). */
/** This request's cert fields as a CertConfig (certPath may be blank = none). */
fun ApiRequest.certConfig(): CertConfig = CertConfig(certPath, certFormat, keyPath, keyFormat, certPassword)
/* This request with its cert fields replaced by inCert's. */
/** This request with its cert fields replaced by inCert's. */
fun ApiRequest.withCert(inCert: CertConfig): ApiRequest =
copy(certPath = inCert.certPath, certFormat = inCert.certFormat,
keyPath = inCert.keyPath, keyFormat = inCert.keyFormat, certPassword = inCert.certPassword)
copy(
certPath = inCert.certPath, certFormat = inCert.certFormat,
keyPath = inCert.keyPath, keyFormat = inCert.keyFormat, certPassword = inCert.certPassword
)
/* True when this config actually selects a certificate. */
/** True when this config actually selects a certificate. */
val CertConfig.isSet: Boolean
get() = certPath.isNotBlank()
/* Certificate / private-key encodings, mapped to libcurl's CURLOPT_SSLCERTTYPE
/** Certificate / private-key encodings, mapped to libcurl's CURLOPT_SSLCERTTYPE
/ CURLOPT_SSLKEYTYPE string values and to `curl --cert-type` / `--key-type`.
Runtime support depends on the TLS backend: OpenSSL (macOS/Linux) handles all
three; Schannel (Windows) effectively supports P12. */
@@ -187,24 +273,17 @@ enum class CertFormat(val curlName: String, val label: String) {
PKCS12("P12", "PKCS#12"),
}
/* A toggleable key/value row, used for both query params and headers. */
/** A toggleable key/value row, used for both query params and headers. */
@Serializable
data class KeyVal(val key: String = "", val value: String = "", val enabled: Boolean = true)
@Serializable
enum class ReqMethod { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS }
/* HTTP doesn't forbid a body on GET / HEAD / OPTIONS — RFC 7230 §3.3
leaves it as "SHOULD NOT" / server discretion. Many APIs use a body
on GET (Elasticsearch search, GraphQL queries) so we don't gate it
here; the UI just sets bodyType=NONE by default for those methods. */
val ReqMethod.allowsBody: Boolean
get() = true
@Serializable
enum class BodyType { NONE, TEXT, FORM, FILE }
/* Title-case label for the body-type picker. */
/** Title-case label for the body-type picker. */
val BodyType.label: String
get() = when (this) {
BodyType.NONE -> "None"
@@ -213,7 +292,7 @@ val BodyType.label: String
BodyType.FILE -> "File"
}
/* The Content-Type the request body implies (null when there's no body). For a
/** The Content-Type the request body implies (null when there's no body). For a
TEXT body it comes from the chosen format (Text→text/plain, JSON→application/
json, …); FORM / FILE are fixed. Sent unless the request already sets one. */
fun ApiRequest.bodyContentType(): String? = when (bodyType) {
@@ -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
+10 -10
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,7 +16,7 @@ private val fJson = Json {
encodeDefaults = true
}
/* Write the pack as pretty JSON to inPath. Returns null on success, else the
/** Write the pack as pretty JSON to inPath. Returns null on success, else the
error message. */
fun exportPack(inPack: Pack, inPath: String): String? = try {
systemFileSystem.write(inPath.trim().toPath()) {
@@ -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,7 +53,7 @@ fun importSession(inPath: String): Result<Session> = try {
Result.failure(e)
}
/* Rename inPath to inNewName within the same directory (adding a .json
/** Rename inPath to inNewName within the same directory (adding a .json
extension when the new name has none), returning the new path on success. */
fun renameFile(inPath: String, inNewName: String): Result<String> = try {
val vSrc = inPath.trim().toPath()
@@ -65,7 +65,7 @@ fun renameFile(inPath: String, inNewName: String): Result<String> = try {
Result.failure(e)
}
/* Write arbitrary text (a response body / headers dump) to inPath. Returns null
/** Write arbitrary text (a response body / headers dump) to inPath. Returns null
on success, else the error message. */
fun writeTextFile(inPath: String, inText: String): String? = try {
systemFileSystem.write(inPath.trim().toPath()) { writeUtf8(inText) }
@@ -74,7 +74,7 @@ fun writeTextFile(inPath: String, inText: String): String? = try {
e.message ?: e.toString()
}
/* Write raw bytes (a binary response body, e.g. an image) to inPath. */
/** Write raw bytes (a binary response body, e.g. an image) to inPath. */
fun writeBytesFile(inPath: String, inBytes: ByteArray): String? = try {
systemFileSystem.write(inPath.trim().toPath()) { write(inBytes) }
null
@@ -82,10 +82,10 @@ fun writeBytesFile(inPath: String, inBytes: ByteArray): String? = try {
e.message ?: e.toString()
}
/* Re-indent a JSON response for display; returns the input unchanged if it
/** Re-indent a JSON response for display; returns the input unchanged if it
isn't valid JSON (so plain-text / HTML responses still show). */
fun prettyJsonOrRaw(inText: String): String = try {
fJson.encodeToString(JsonElement.serializer(), fJson.parseToJsonElement(inText))
} catch (e: Throwable) {
} catch (_: Throwable) {
inText
}
+13 -10
View File
@@ -1,15 +1,17 @@
package apidemo
import apidemo.compat.appDataDir
import apidemo.compat.revealInFileManager
import apidemo.compat.systemFileSystem
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okio.FileSystem
import okio.Path.Companion.toPath
// ==================
// MARK: Persisted app state
// ==================
/* Everything the app restores on relaunch: the theme, the global override env,
/** Everything the app restores on relaunch: the theme, the global override env,
every open pack (with its file path + dirty flag so unsaved work survives a
quit) and which pack was active. `launched` flips to true after the very
first run so the httpbin starter pack is only auto-loaded once. */
@@ -17,6 +19,7 @@ import okio.Path.Companion.toPath
data class AppState(
val launched: Boolean = false,
val dark: Boolean = true,
val theme: String = "purple", // selected voltic palette id (VolticPalette.id)
val globalEnv: List<KeyVal> = emptyList(), // session-level variables (base of the inheritance ladder)
val globalHeaders: List<KeyVal> = emptyList(), // session-level headers (inherited by every request)
val globalParams: List<KeyVal> = emptyList(), // session-level query params (inherited by every request)
@@ -34,7 +37,7 @@ data class AppState(
val activeReq: Int = -1,
)
/* A pack as stored in the app state: the pack content plus where it was last
/** A pack as stored in the app state: the pack content plus where it was last
saved (null = never saved to a file) and whether it has unsaved edits. */
@Serializable
data class SavedPack(
@@ -43,7 +46,7 @@ data class SavedPack(
val pack: Pack = Pack(),
)
/* A whole session, the export/import unit for the working set: every open 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. */
@Serializable
@@ -72,37 +75,37 @@ private const val kOrg = "ComposeDesktopNative"
private const val kApp = "ApiManager"
private const val kStateFile = "state.json"
/* Absolute path to the state file in the per-user app-data dir (created on
/** Absolute path to the state file in the per-user app-data dir (created on
demand by SDL_GetPrefPath). Null if no writable dir is available. */
private fun stateFilePath(): String? {
val vDir = appDataDir(kOrg, kApp) ?: return null // ends with a separator
return vDir + kStateFile
}
/* Read the persisted app state, or a fresh (first-launch) AppState if there's
/** Read the persisted app state, or a fresh (first-launch) AppState if there's
nothing saved yet or it can't be read. */
fun loadAppState(): AppState {
val vPath = stateFilePath() ?: return AppState()
return try {
val vText = systemFileSystem.read(vPath.toPath()) { readUtf8() }
fStateJson.decodeFromString<AppState>(vText)
} catch (e: Throwable) {
} catch (_: Throwable) {
AppState()
}
}
/* Write the app state. Best-effort — failures are swallowed (it's a cache of
/** Write the app state. Best-effort — failures are swallowed (it's a cache of
the session, not the user's exported .json packs). */
fun saveAppState(inState: AppState) {
val vPath = stateFilePath() ?: return
try {
systemFileSystem.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
} catch (e: Throwable) {
} catch (_: Throwable) {
// ignore — nothing the user can act on, and packs can still be exported
}
}
/* Open the per-user app-data folder where the state file lives in the OS file
/** Open the per-user app-data folder where the state file lives in the OS file
manager. Revealing the state file opens its containing folder, which SDL has
already created even when nothing's been saved yet. Fire-and-forget — the
reveal launches off the main thread (see revealInFileManager). */
+50 -10
View File
@@ -1,5 +1,3 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package apidemo
import androidx.compose.foundation.background
@@ -32,12 +30,18 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
internal fun OptionsMenu(
inDark: Boolean,
inOnToggleTheme: () -> Unit,
inPalette: VolticPalette,
inOnPalette: (VolticPalette) -> Unit,
) {
val c = LocalAppColors.current
var vOpen by remember { mutableStateOf(false) }
Box {
IconBtn(MaterialSymbols.Settings, "Options") { vOpen = true }
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }, modifier = Modifier.widthIn(min = 200.dp)) {
DropdownMenu(
expanded = vOpen,
onDismissRequest = { vOpen = false },
modifier = Modifier.widthIn(min = 200.dp)
) {
DropdownMenuItem(text = {
Text("Dark mode", color = if (inDark) c.accent else c.text, fontSize = 13.sp)
}, onClick = { if (!inDark) inOnToggleTheme(); vOpen = false })
@@ -45,6 +49,32 @@ internal fun OptionsMenu(
Text("Light mode", color = if (!inDark) c.accent else c.text, fontSize = 13.sp)
}, onClick = { if (inDark) inOnToggleTheme(); vOpen = false })
HorizontalDivider(color = c.border)
// Palette picker — a swatch of each theme's primary + a check on the active one.
Box(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 2.dp)) {
Text("Theme", color = c.dim, fontSize = 11.sp)
}
VolticPalette.entries.forEach { vPal ->
val vSel = vPal == inPalette
DropdownMenuItem(text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier.size(12.dp).clip(RoundedCornerShape(3.dp))
.background(vPal.scheme.light.primary)
)
Text(
vPal.label,
color = if (vSel) c.accent else c.text,
fontSize = 13.sp,
modifier = Modifier.weight(1f)
)
if (vSel) MaterialSymbolsOutlined(MaterialSymbols.Check, tint = c.accent, size = 16.dp)
}
}, onClick = { inOnPalette(vPal); vOpen = false })
}
HorizontalDivider(color = c.border)
DropdownMenuItem(text = {
MenuRow(MaterialSymbols.Folder, "Open settings folder")
}, onClick = { vOpen = false; openSettingsFolder() })
@@ -56,7 +86,7 @@ internal fun OptionsMenu(
// MARK: Method picker (dropdown)
// ==================
/* Panel 2 — one unified bar: a method dropdown (coloured label + unfold arrows),
/** Panel 2 — one unified bar: a method dropdown (coloured label + unfold arrows),
a borderless URL field that melts into the bar, and Send (or Cancel). */
@Composable
internal fun UrlBar(
@@ -99,7 +129,9 @@ 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)
@@ -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,7 +24,7 @@ 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,
/** Tabs to edit query params, headers and the body, a Preview toggle top-right,
and a body-type dropdown pinned at the bottom. */
@Composable
internal fun RequestBuilder(
@@ -40,7 +40,7 @@ internal fun RequestBuilder(
inEdit: ((ApiRequest) -> ApiRequest) -> Unit,
) {
val c = LocalAppColors.current
val vBodySet = inReq.method.allowsBody && when (inReq.bodyType) {
val vBodySet = true && when (inReq.bodyType) {
BodyType.NONE -> false
BodyType.FORM -> inReq.form.any { it.enabled && it.key.isNotBlank() }
else -> inReq.body.isNotBlank()
@@ -53,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,7 +145,7 @@ internal fun RequestBuilder(
}
}
/* The body editing area for the current body type (driven by the bottom menu). A
/** The body editing area for the current body type (driven by the bottom menu). A
Text body is highlighted per its chosen format (bodyFormat). */
@Composable
internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
@@ -157,19 +157,20 @@ internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest)
inOnChange = { v -> inEdit { it.copy(body = v) } },
inFormat = inReq.bodyFormat,
)
BodyType.FORM -> KeyValEditor(inReq.form) { v -> inEdit { it.copy(form = v) } }
BodyType.FILE -> FileBody(inReq) { v -> inEdit(v) }
}
}
/* Magic-wand button that pretty-prints the current body in place (JSON /
/** Magic-wand button that pretty-prints the current body in place (JSON /
XML). Lives at the right end of the body type/format bar. */
@Composable
internal fun FormatButton(inOnClick: () -> Unit) {
HoverIconBtn(MaterialSymbols.AutoFixHigh, "Format (pretty-print)", inOnClick)
}
/* Icon button with the toolbar's standard hover treatment — accent-tinted
/** Icon button with the toolbar's standard hover treatment — accent-tinted
background + accent icon on hover (matching the TLS chain button). inActive
keeps it lit, for toggles such as the preview eye. */
@Composable
@@ -202,13 +203,15 @@ 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
/** 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
@@ -227,7 +230,10 @@ internal fun TabSizeSelector() {
Row(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(6.dp))
.background(
if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent,
RoundedCornerShape(6.dp)
)
.hoverable(vHoverSrc)
.clickable { vOpen = true }
.padding(horizontal = 8.dp, vertical = 5.dp),
@@ -235,7 +241,11 @@ internal fun TabSizeSelector() {
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Text("Tab $vSize", color = if (vHover) c.accent else c.dim, fontSize = 12.sp)
MaterialSymbolsOutlined(MaterialSymbols.UnfoldMore, tint = if (vHover) c.accent else c.dim, size = 14.dp)
MaterialSymbolsOutlined(
MaterialSymbols.UnfoldMore,
tint = if (vHover) c.accent else c.dim,
size = 14.dp
)
}
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
for (vN in listOf(2, 4, 8)) {
@@ -248,7 +258,7 @@ internal fun TabSizeSelector() {
}
}
/* FILE body — pick a file; its path is stored in body and sent as raw bytes. */
/** FILE body — pick a file; its path is stored in body and sent as raw bytes. */
@Composable
internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
val c = LocalAppColors.current
@@ -261,12 +271,17 @@ internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) ->
}
}
/* Client-certificate (mTLS) editor — certificate + optional separate key +
/** Client-certificate (mTLS) editor — certificate + optional separate key +
passphrase. PEM/DER are used directly by OpenSSL on macOS/Linux; on Windows
they're imported into the certificate store for the request then removed
(see CurlMtls). PKCS#12 bundles its own private key. */
@Composable
internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client certificate (mTLS)", inOverrideSource: String? = null, inOnChange: (CertConfig) -> Unit) {
internal fun CertConfigEditor(
inCert: CertConfig,
inHeading: String = "Client certificate (mTLS)",
inOverrideSource: String? = null,
inOnChange: (CertConfig) -> Unit
) {
val c = LocalAppColors.current
val vHasCert = inCert.certPath.isNotBlank()
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -291,7 +306,11 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
inOnChange(CertConfig())
}
}
Text(if (vHasCert) inCert.certPath else "No certificate selected.", color = if (vHasCert) c.text else c.dim, fontSize = 12.sp)
Text(
if (vHasCert) inCert.certPath else "No certificate selected.",
color = if (vHasCert) c.text else c.dim,
fontSize = 12.sp
)
// ============
// Private key (PKCS#12 carries its own key)
@@ -302,10 +321,17 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
showOpenFileDialog { vPath -> if (vPath != null) inOnChange(inCert.copy(keyPath = vPath)) }
}
CertFormatMenu(inCert.keyFormat) { vF -> inOnChange(inCert.copy(keyFormat = vF)) }
if (inCert.keyPath.isNotBlank()) IconBtn(MaterialSymbols.Close, "Clear key") { inOnChange(inCert.copy(keyPath = "", keyFormat = CertFormat.PEM)) }
if (inCert.keyPath.isNotBlank()) IconBtn(MaterialSymbols.Close, "Clear key") {
inOnChange(
inCert.copy(
keyPath = "",
keyFormat = CertFormat.PEM
)
)
}
}
Text(
if (inCert.keyPath.isNotBlank()) inCert.keyPath else "Optional — only if the key is in a separate file.",
inCert.keyPath.ifBlank { "Optional — only if the key is in a separate file." },
color = if (inCert.keyPath.isNotBlank()) c.text else c.dim, fontSize = 12.sp,
)
}
@@ -313,17 +339,27 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
// ============
// Passphrase
Text("Passphrase", color = c.dim, fontSize = 12.sp)
ThinField(inCert.certPassword, { v -> inOnChange(inCert.copy(certPassword = v)) }, inPlaceholder = "Key / PKCS#12 password (optional)")
ThinField(
inCert.certPassword,
{ v -> inOnChange(inCert.copy(certPassword = v)) },
inPlaceholder = "Key / PKCS#12 password (optional)"
)
}
}
/* A read-only card for an inherited client cert: its source pill + path, plus an
/** A read-only card for an inherited client cert: its source pill + path, plus an
Override action (copy it into this scope) when this scope hasn't set its own. */
@Composable
internal fun InheritedCertCard(inInherited: InheritedCert, inOwnSet: Boolean, inReadOnly: Boolean, inOnOverride: () -> Unit) {
internal fun InheritedCertCard(
inInherited: InheritedCert,
inOwnSet: Boolean,
inReadOnly: Boolean,
inOnOverride: () -> Unit
) {
val c = LocalAppColors.current
Column(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).border(1.dp, c.border, RoundedCornerShape(8.dp)).padding(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
.border(1.dp, c.border, RoundedCornerShape(8.dp)).padding(10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -341,20 +377,32 @@ internal fun InheritedCertCard(inInherited: InheritedCert, inOwnSet: Boolean, in
}
}
/* The request's Cert tab: the inherited cert (from pack / session) shown read-only
/** The request's Cert tab: the inherited cert (from pack / session) shown read-only
with its source + an Override action when the request has none, then the
request's own cert editor (its own cert overrides the inherited one). */
@Composable
internal fun RequestCertTab(inReq: ApiRequest, inInheritedCert: InheritedCert?, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) {
internal fun RequestCertTab(
inReq: ApiRequest,
inInheritedCert: InheritedCert?,
inReadOnly: Boolean,
inEdit: (((ApiRequest) -> ApiRequest)) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (inInheritedCert != null) InheritedCertCard(inInheritedCert, inOwnSet = inReq.hasClientCert, inReadOnly = inReadOnly) {
if (inInheritedCert != null) InheritedCertCard(
inInheritedCert,
inOwnSet = inReq.hasClientCert,
inReadOnly = inReadOnly
) {
inEdit { it.withCert(inInheritedCert.cert) }
}
CertConfigEditor(inReq.certConfig(), inOverrideSource = if (inReq.hasClientCert) inInheritedCert?.path else null) { vCc -> inEdit { it.withCert(vCc) } }
CertConfigEditor(
inReq.certConfig(),
inOverrideSource = if (inReq.hasClientCert) inInheritedCert?.path else null
) { vCc -> inEdit { it.withCert(vCc) } }
}
}
/* Scope settings editor (Variables / Query / Headers / Cert sub-tabs) — shared by
/** Scope settings editor (Variables / Query / Headers / Cert sub-tabs) — shared by
the session settings tab and each pack's settings tab. Each sub-tab shows what
the scope inherits from above (source-tagged, with Override) over its own
editable list, mirroring the request panels. */
@@ -388,33 +436,50 @@ internal fun ScopeSettings(
when (inTab) {
0 -> {
Text(inVarHelp, color = c.dim, fontSize = 12.sp)
InheritedEditableTab(inInheritedVars, inVars, inCaseInsensitive = false, inReadOnly = false,
InheritedEditableTab(
inInheritedVars, inVars, inCaseInsensitive = false, inReadOnly = false,
inOwnTitle = "This scope's variables", inInheritedTitle = "Inherited variables",
inOnOverride = { vKv -> inOnVars(inVars + vKv) }, inOnChange = inOnVars)
inOnOverride = { vKv -> inOnVars(inVars + vKv) }, inOnChange = inOnVars
)
}
1 -> {
Text(inParamHelp, color = c.dim, fontSize = 12.sp)
InheritedEditableTab(inInheritedParams, inParams, inCaseInsensitive = false, inReadOnly = false,
InheritedEditableTab(
inInheritedParams, inParams, inCaseInsensitive = false, inReadOnly = false,
inOwnTitle = "This scope's query params", inInheritedTitle = "Inherited query params",
inOnOverride = { vKv -> inOnParams(inParams + vKv) }, inOnChange = inOnParams)
inOnOverride = { vKv -> inOnParams(inParams + vKv) }, inOnChange = inOnParams
)
}
2 -> {
Text(inHeaderHelp, color = c.dim, fontSize = 12.sp)
InheritedEditableTab(inInheritedHeaders, inHeaders, inCaseInsensitive = true, inReadOnly = false,
InheritedEditableTab(
inInheritedHeaders, inHeaders, inCaseInsensitive = true, inReadOnly = false,
inOwnTitle = "This scope's headers", inInheritedTitle = "Inherited headers",
inOnOverride = { vKv -> inOnHeaders(inHeaders + vKv) }, inOnChange = inOnHeaders)
inOnOverride = { vKv -> inOnHeaders(inHeaders + vKv) }, inOnChange = inOnHeaders
)
}
else -> {
Text(inCertHelp, color = c.dim, fontSize = 12.sp)
val vOwnSet = inCert?.isSet == true
if (inInheritedCert != null) InheritedCertCard(inInheritedCert, inOwnSet = vOwnSet, inReadOnly = false) { inOnCert(inInheritedCert.cert) }
CertConfigEditor(inCert ?: CertConfig(), inCertHeading, inOverrideSource = if (vOwnSet) inInheritedCert?.path else null) { vCc -> inOnCert(vCc.takeIf { it.isSet }) }
if (inInheritedCert != null) InheritedCertCard(
inInheritedCert,
inOwnSet = vOwnSet,
inReadOnly = false
) { inOnCert(inInheritedCert.cert) }
CertConfigEditor(
inCert ?: CertConfig(),
inCertHeading,
inOverrideSource = if (vOwnSet) inInheritedCert?.path else null
) { vCc -> inOnCert(vCc.takeIf { it.isSet }) }
}
}
}
}
/* Certificate / key encoding dropdown (PEM / DER / PKCS#12). */
/** Certificate / key encoding dropdown (PEM / DER / PKCS#12). */
@Composable
internal fun CertFormatMenu(inFormat: CertFormat, inOnPick: (CertFormat) -> Unit) {
val c = LocalAppColors.current
@@ -23,6 +23,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import apidemo.compat.x
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
@@ -30,7 +31,7 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// MARK: Request tab strip (open requests; drag a tab to reorder)
// ==================
/* Strip of open requests above the editor. Each tab is clickable to select
/** Strip of open requests above the editor. Each tab is clickable to select
and drag-reorderable. While dragging, the grabbed tab lifts (zIndex),
fades, and follows the cursor via a graphicsLayer translation — its
neighbours don't shuffle; the reorder commits on drop, to the slot whose
@@ -93,7 +94,8 @@ internal fun RequestTabStrip(
// translation), so `.alpha().graphicsLayer(translationX = X)` clips the drag
// ghost inside its original bounds and the ghost disappears as it slides. One
// layer with both properties = clip stays false, alpha modulates via layerPaint.
if (vDragged) vMod = vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = vDragDx, translationY = 0f)
if (vDragged) vMod =
vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = vDragDx, translationY = 0f)
vMod = vMod
.clip(RoundedCornerShape(7.dp))
.background(if (vSel) c.accent.copy(alpha = 0.20f) else c.field, RoundedCornerShape(7.dp))
@@ -130,7 +132,10 @@ internal fun RequestTabStrip(
val vd = vDragKey
if (vd != null) {
val vFrom = inTabs.indexOfFirst { it.tabKey == vd }
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(vFrom, vDragTarget)
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(
vFrom,
vDragTarget
)
}
vDragKey = null; vDragDx = 0f; vDragTarget = -1
},
@@ -150,6 +155,7 @@ internal fun RequestTabStrip(
vEv.changes.firstOrNull()?.consume()
inOnClose(vTab)
}
PointerButton.Secondary -> {
vEv.changes.firstOrNull()?.consume()
vMenuTab = vTab; vMenu = true
@@ -169,12 +175,27 @@ internal fun RequestTabStrip(
val vRs = vTab.req
if (vRs == null) {
// Session / pack settings tab.
MaterialSymbolsOutlined(MaterialSymbols.Tune, tint = if (vSel) c.accent else c.dim, size = 13.dp)
Text(if (vTab.isSession) "Session" else (vTab.pack?.name ?: ""), color = if (vSel) c.text else c.dim, fontSize = 13.sp)
MaterialSymbolsOutlined(
MaterialSymbols.Tune,
tint = if (vSel) c.accent else c.dim,
size = 13.dp
)
Text(
if (vTab.isSession) "Session" else (vTab.pack?.name ?: ""),
color = if (vSel) c.text else c.dim,
fontSize = 13.sp
)
} else {
Box(modifier = Modifier.size(7.dp).background(methodColor(vRs.req.method), RoundedCornerShape(4.dp)))
Box(
modifier = Modifier.size(7.dp)
.background(methodColor(vRs.req.method), RoundedCornerShape(4.dp))
)
Text(vRs.req.name, color = if (vSel) c.text else c.dim, fontSize = 13.sp)
if (vRs.loading) CircularProgressIndicator(modifier = Modifier.size(12.dp), color = c.accent, strokeWidth = 1.5.dp)
if (vRs.loading) CircularProgressIndicator(
modifier = Modifier.size(12.dp),
color = c.accent,
strokeWidth = 1.5.dp
)
}
IconBtn(MaterialSymbols.Close, "Close", inSize = 13.dp) { inOnClose(vTab) }
}
@@ -190,9 +211,15 @@ internal fun RequestTabStrip(
onDismissRequest = { vMenu = false },
modifier = Modifier.widthIn(min = 188.dp),
) {
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Close, "Close tab") }, onClick = { vMenu = false; inOnClose(vMTab) })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Clear, "Close other tabs") }, onClick = { vMenu = false; inOnCloseOthers(vMTab) })
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Clear, "Close all tabs") }, onClick = { vMenu = false; inOnCloseAll() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Close, "Close tab") },
onClick = { vMenu = false; inOnClose(vMTab) })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Clear, "Close other tabs") },
onClick = { vMenu = false; inOnCloseOthers(vMTab) })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Clear, "Close all tabs") },
onClick = { vMenu = false; inOnCloseAll() })
}
}
}
@@ -201,11 +228,19 @@ internal fun RequestTabStrip(
if (vScroll.maxValue > 0) {
Box(modifier = Modifier.padding(end = 4.dp)) {
IconBtn(MaterialSymbols.ExpandMore, "All tabs") { vListOpen = true }
DropdownMenu(expanded = vListOpen, onDismissRequest = { vListOpen = false }, modifier = Modifier.widthIn(min = 240.dp)) {
DropdownMenu(
expanded = vListOpen,
onDismissRequest = { vListOpen = false },
modifier = Modifier.widthIn(min = 240.dp)
) {
Column(modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState())) {
inTabs.forEach { vTab ->
DropdownMenuItem(text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val vSel = vTab.tabKey == inActiveKey
val vRs = vTab.req
if (vRs == null) {
@@ -214,11 +249,28 @@ internal fun RequestTabStrip(
tint = if (vSel) c.accent else c.dim,
size = 13.dp
)
Text(if (vTab.isSession) "Session" else "${vTab.pack?.name} · var", color = if (vSel) c.accent else c.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
Text(
if (vTab.isSession) "Session" else "${vTab.pack?.name} · var",
color = if (vSel) c.accent else c.text,
fontSize = 13.sp,
modifier = Modifier.weight(1f)
)
} else {
Box(modifier = Modifier.size(7.dp).background(methodColor(vRs.req.method), RoundedCornerShape(4.dp)))
Text(vRs.req.name, color = if (vSel) c.accent else c.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
if (vRs.loading) CircularProgressIndicator(modifier = Modifier.size(12.dp), color = c.accent, strokeWidth = 1.5.dp)
Box(
modifier = Modifier.size(7.dp)
.background(methodColor(vRs.req.method), RoundedCornerShape(4.dp))
)
Text(
vRs.req.name,
color = if (vSel) c.accent else c.text,
fontSize = 13.sp,
modifier = Modifier.weight(1f)
)
if (vRs.loading) CircularProgressIndicator(
modifier = Modifier.size(12.dp),
color = c.accent,
strokeWidth = 1.5.dp
)
}
}
}, onClick = { vListOpen = false; inOnSelect(vTab) })
@@ -230,11 +282,14 @@ internal fun RequestTabStrip(
}
}
/* Thin accent bar marking where a dragged tab will be inserted. Rendered in-flow
/** Thin accent bar marking where a dragged tab will be inserted. Rendered in-flow
between tabs so the Row centres it vertically and reserves a little space. */
@Composable
internal fun DropBar() {
val c = LocalAppColors.current
Box(modifier = Modifier.width(3.dp).height(24.dp).clip(RoundedCornerShape(2.dp)).background(c.accent, RoundedCornerShape(2.dp)))
Box(
modifier = Modifier.width(3.dp).height(24.dp).clip(RoundedCornerShape(2.dp))
.background(c.accent, RoundedCornerShape(2.dp))
)
}
+131 -40
View File
@@ -1,5 +1,4 @@
@file:OptIn(
androidx.compose.material3.ExperimentalMaterial3Api::class,
androidx.compose.ui.ExperimentalComposeUiApi::class,
)
@@ -31,6 +30,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import apidemo.compat.fileManagerName
import apidemo.compat.revealInFileManager
import apidemo.compat.y
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
@@ -38,7 +40,7 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// MARK: Pack colour (palette dot + picker)
// ==================
/* A small rounded square in the pack's palette colour (neutral when unset).
/** A small rounded square in the pack's palette colour (neutral when unset).
The pack's visual identity in the sidebar's foldable pack header. */
@Composable
internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
@@ -47,7 +49,7 @@ internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
Box(Modifier.size(inSize).background(vCol, RoundedCornerShape(3.dp)))
}
/* A 5×4 grid of swatches; tapping one sets the pack's colour (1-based index).
/** A 5×4 grid of swatches; tapping one sets the pack's colour (1-based index).
The current colour gets a ring. Sized to fit inside the (fixed-width) ⋮ menu. */
@Composable
internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
@@ -77,7 +79,7 @@ internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
// MARK: Add-pack menu (header '+')
// ==================
/* The header '+' — a small menu to add a pack to the open session, either blank
/** The header '+' — a small menu to add a pack to the open session, either blank
or imported from a .json file. Importing a pack always lands it in the session. */
@Composable
internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnImport: () -> Unit) {
@@ -85,7 +87,11 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
var vOpen by remember { mutableStateOf(false) }
Box {
IconBtn(MaterialSymbols.Add, "Add", inSize = 18.dp) { vOpen = true }
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }, modifier = Modifier.widthIn(min = 200.dp)) {
DropdownMenu(
expanded = vOpen,
onDismissRequest = { vOpen = false },
modifier = Modifier.widthIn(min = 200.dp)
) {
DropdownMenuItem(text = {
MenuRow(
MaterialSymbols.Send,
@@ -93,7 +99,9 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
)
}, onClick = { vOpen = false; inOnNewRequest() })
HorizontalDivider(color = c.border)
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Add, "New pack") }, onClick = { vOpen = false; inOnNew() })
DropdownMenuItem(
text = { MenuRow(MaterialSymbols.Add, "New pack") },
onClick = { vOpen = false; inOnNew() })
DropdownMenuItem(text = {
MenuRow(
MaterialSymbols.Download,
@@ -108,11 +116,11 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
// MARK: Session menu (save / open / new / recent)
// ==================
/* The last path segment of inPath (the file name), tolerant of either slash. */
/** The last path segment of inPath (the file name), tolerant of either slash. */
internal fun fileLeaf(inPath: String): String =
inPath.trimEnd('/', '\\').substringAfterLast('/').substringAfterLast('\\')
/* Shortens a long path to inMax chars keeping the start and end (so the root
/** Shortens a long path to inMax chars keeping the start and end (so the root
and file stay visible), joined by an ellipsis. The menu has no text-overflow
primitive, so we trim the string ourselves. */
internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
@@ -122,7 +130,7 @@ 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
/** 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. */
@@ -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,7 +303,7 @@ internal fun SessionMenu(
// MARK: Pack tree (recursive sidebar rendering)
// ==================
/* The per-pack operations a PackSection needs, bundled so PackTree can bind them
/** The per-pack operations a PackSection needs, bundled so PackTree can bind them
recursively for sub-packs while PackSection itself stays callback-driven. */
internal class PackOps(
val onEnv: (PackState) -> Unit,
@@ -307,11 +330,18 @@ internal class PackOps(
val packDropEnd: () -> Unit,
)
/* Renders a pack then its sub-packs recursively, each indented by its depth.
/** Renders a pack then its sub-packs recursively, each indented by its depth.
inSiblings is the list this pack belongs to (top-level vPacks, or a parent's
subPacks) — used to position the pack-reorder drop bars by index. */
@Composable
internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: Int, inOps: PackOps, inActive: PackState?, inEnvActive: Boolean) {
internal fun PackTree(
inPack: PackState,
inSiblings: List<PackState>,
inDepth: Int,
inOps: PackOps,
inActive: PackState?,
inEnvActive: Boolean
) {
val vDrag = inOps.drag
val vMyIndex = inSiblings.indexOf(inPack)
val vMoving = vDrag.draggingPack && vDrag.engaged && vDrag.dropInto == null
@@ -351,7 +381,16 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
)
}
}
if (inPack.expanded) inPack.subPacks.forEach { PackTree(it, inPack.subPacks, inDepth + 1, inOps, inActive, inEnvActive) }
if (inPack.expanded) inPack.subPacks.forEach {
PackTree(
it,
inPack.subPacks,
inDepth + 1,
inOps,
inActive,
inEnvActive
)
}
if (vBarAfter) Box(modifier = vPad) { RowDropBar() }
}
@@ -359,7 +398,7 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
// MARK: Pack section (foldable pack in the sidebar)
// ==================
/* One open pack rendered as a foldable section: a header (fold chevron, colour
/** One open pack rendered as a foldable section: a header (fold chevron, colour
box, name, dirty dot, request count, ⋮ menu) over the pack's request list
when expanded. Clicking the header body selects the pack (so the Env tab and
main panel follow it); the chevron only folds. The request list drags to
@@ -410,7 +449,8 @@ internal fun PackSection(
.onSizeChanged { inDrag.headH[inPack] = it.height }
// See RequestTabStrip note: alpha + translation on the SAME graphicsLayer so
// clip=false and the drag ghost isn't clipped to its original bounds.
if (vHeaderDragged) vHeadMod = vHeadMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
if (vHeaderDragged) vHeadMod =
vHeadMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = 0f, translationY = inDrag.dy)
vHeadMod = vHeadMod.clip(RoundedCornerShape(6.dp))
.background(
when {
@@ -462,9 +502,15 @@ internal fun PackSection(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
IconBtn(if (inPack.expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight, "Fold", inSize = 18.dp, inPadding = 3.dp) { inOnToggle() }
IconBtn(
if (inPack.expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight,
"Fold",
inSize = 18.dp,
inPadding = 3.dp
) { inOnToggle() }
Row(
modifier = Modifier.weight(1f).clip(RoundedCornerShape(5.dp)).clickable { inOnSelect() }.padding(vertical = 4.dp),
modifier = Modifier.weight(1f).clip(RoundedCornerShape(5.dp)).clickable { inOnSelect() }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
) {
@@ -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,7 +613,8 @@ 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.
@@ -551,7 +629,8 @@ internal fun PackSection(
val vd = inDrag.dragReq ?: return@detectDragGestures
inDrag.dy += dragAmount.y
val vRelY = inDrag.pressRel + inDrag.dy.toInt()
if (!inDrag.engaged && kotlin.math.abs(inDrag.dy) >= kDragSlop) inDrag.engaged = true
if (!inDrag.engaged && kotlin.math.abs(inDrag.dy) >= kDragSlop) inDrag.engaged =
true
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
},
onDragEnd = { inOnReqDropEnd() },
@@ -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() })
}
}
}
@@ -10,14 +10,14 @@ 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)
/** Format of a body payload as shown in the viewer, and (for a TEXT request body)
the type the user picks on the right of the Body tab — it drives both syntax
highlighting and the sent Content-Type. RAW disables highlighting and falls back
to the selectable BasicTextField; other values pick a tokeniser. */
@Serializable
enum class BodyFormat { RAW, JSON, XML, YAML, HTML }
/* Short label for the format / "type" picker (RAW = plain text, no highlighting). */
/** Short label for the format / "type" picker (RAW = plain text, no highlighting). */
val BodyFormat.label: String
get() = when (this) {
BodyFormat.RAW -> "RAW"
@@ -27,7 +27,7 @@ val BodyFormat.label: String
BodyFormat.HTML -> "HTML"
}
/* The Content-Type a TEXT body of this format is sent with. */
/** The Content-Type a TEXT body of this format is sent with. */
val BodyFormat.contentType: String
get() = when (this) {
BodyFormat.RAW -> "text/plain"
@@ -37,7 +37,7 @@ val BodyFormat.contentType: String
BodyFormat.HTML -> "text/html"
}
/* Pick a format from a Content-Type header. JSON / XML / YAML / HTML
/** Pick a format from a Content-Type header. JSON / XML / YAML / HTML
match their canonical types and common variants; anything else
(image types, text/plain, …) falls back to RAW. */
fun autoFormatFor(inContentType: String?): BodyFormat {
@@ -55,7 +55,7 @@ fun autoFormatFor(inContentType: String?): BodyFormat {
// MARK: Highlight palette
// ==================
/* Per-token colours. Two preset themes: a dark-on-dark and a dark-on-
/** Per-token colours. Two preset themes: a dark-on-dark and a dark-on-
light variant matching VS Code's defaults. Caller picks based on
surrounding theme; auto-pick available via SyntaxPalette.forDark(). */
data class SyntaxPalette(
@@ -68,7 +68,7 @@ data class SyntaxPalette(
val punct: Color,
) {
companion object {
/* Light-on-dark — VS Code Dark+. */
/** Light-on-dark — VS Code Dark+. */
val Dark = SyntaxPalette(
key = Color(0xFF9CDCFE),
string = Color(0xFFCE9178),
@@ -78,7 +78,8 @@ data class SyntaxPalette(
comment = Color(0xFF6A9955),
punct = Color(0xFFCCCCCC),
)
/* Dark-on-light — VS Code Light+. Higher contrast on white. */
/** Dark-on-light — VS Code Light+. Higher contrast on white. */
val Light = SyntaxPalette(
key = Color(0xFF0451A5),
string = Color(0xFFA31515),
@@ -89,7 +90,7 @@ data class SyntaxPalette(
punct = Color(0xFF333333),
)
/* Pick a palette by the host theme's background brightness. */
/** Pick a palette by the host theme's background brightness. */
fun forDark(inIsDark: Boolean): SyntaxPalette = if (inIsDark) Dark else Light
}
}
@@ -98,7 +99,7 @@ data class SyntaxPalette(
// MARK: Entry point
// ==================
/* Render the given text with per-token colour spans for the requested
/** Render the given text with per-token colour spans for the requested
format. RAW returns an unstyled AnnotatedString. */
fun highlight(
inText: String,
@@ -115,7 +116,7 @@ fun highlight(
// MARK: JSON tokeniser — single-pass, error-tolerant
// ==================
/* Tokeniser for JSON. Walks the string once and emits style spans for
/** Tokeniser for JSON. Walks the string once and emits style spans for
strings, numbers, booleans, null, punctuation. Distinguishes "keys"
(strings followed by ':') from string values by peeking after each
string. Error-tolerant — malformed JSON still highlights as best
@@ -132,8 +133,12 @@ private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString =
val vStart = vI
var vJ = vI + 1
while (vJ < vN) {
if (inText[vJ] == '\\' && vJ + 1 < vN) { vJ += 2; continue }
if (inText[vJ] == '"') { vJ++; break }
if (inText[vJ] == '\\' && vJ + 1 < vN) {
vJ += 2; continue
}
if (inText[vJ] == '"') {
vJ++; break
}
vJ++
}
// Peek for ':' (skipping whitespace) → it's a key, not a value.
@@ -146,6 +151,7 @@ private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString =
pop()
vI = vJ
}
vC.isDigit() || (vC == '-' && vI + 1 < vN && inText[vI + 1].isDigit()) -> {
val vStart = vI
if (vC == '-') vI++
@@ -154,6 +160,7 @@ private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString =
append(inText.substring(vStart, vI))
pop()
}
vC.isLetter() -> {
val vStart = vI
while (vI < vN && inText[vI].isLetter()) vI++
@@ -166,13 +173,17 @@ private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString =
append(vWord)
}
}
vC in "{}[],:" -> {
pushStyle(SpanStyle(color = inP.punct))
append(vC)
pop()
vI++
}
else -> { append(vC); vI++ }
else -> {
append(vC); vI++
}
}
}
}
@@ -181,29 +192,30 @@ private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString =
// MARK: XML / HTML tokeniser
// ==================
/* Tokeniser for XML / HTML. Distinguishes tag names, attribute names,
/** Tokeniser for XML / HTML. Distinguishes tag names, attribute names,
attribute values (string), and comments. Inside tags, attr=value pairs
are recognised; outside tags text is left in the default colour. */
private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
val vN = inText.length
var vI = 0
while (vI < vN) {
val vC = inText[vI]
when {
// Comment block: <!-- ... -->
vC == '<' && inText.startsWith("<!--", vI) -> {
when (val vC = inText[vI]) {
'<' if inText.startsWith("<!--", vI) -> {
val vEnd = inText.indexOf("-->", vI + 4).let { if (it < 0) vN else it + 3 }
pushStyle(SpanStyle(color = inP.comment))
append(inText.substring(vI, vEnd))
pop()
vI = vEnd
}
vC == '<' -> {
'<' -> {
// Tag open: <[/]name attr=val attr2="val2">
val vGt = inText.indexOf('>', vI).let { if (it < 0) vN else it + 1 }
pushStyle(SpanStyle(color = inP.punct)); append('<'); pop()
var vJ = vI + 1
if (vJ < vN && inText[vJ] == '/') { pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++ }
if (vJ < vN && inText[vJ] == '/') {
pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++
}
// Tag name.
val vTagStart = vJ
while (vJ < vN && !inText[vJ].isWhitespace() && inText[vJ] != '>' && inText[vJ] != '/') vJ++
@@ -216,9 +228,18 @@ private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString =
while (vJ < vGt - 1) {
val vC2 = inText[vJ]
when {
vC2.isWhitespace() -> { append(vC2); vJ++ }
vC2 == '/' -> { pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++ }
vC2 == '=' -> { pushStyle(SpanStyle(color = inP.punct)); append('='); pop(); vJ++ }
vC2.isWhitespace() -> {
append(vC2); vJ++
}
vC2 == '/' -> {
pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++
}
vC2 == '=' -> {
pushStyle(SpanStyle(color = inP.punct)); append('='); pop(); vJ++
}
vC2 == '"' || vC2 == '\'' -> {
val vEnd = inText.indexOf(vC2, vJ + 1).let { if (it < 0) vGt - 1 else it + 1 }
pushStyle(SpanStyle(color = inP.string))
@@ -226,6 +247,7 @@ private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString =
pop()
vJ = vEnd
}
else -> {
val vAttrStart = vJ
while (vJ < vGt - 1 && !inText[vJ].isWhitespace() && inText[vJ] != '=' && inText[vJ] != '>' && inText[vJ] != '/') vJ++
@@ -240,7 +262,10 @@ private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString =
}
vI = vGt
}
else -> { append(vC); vI++ }
else -> {
append(vC); vI++
}
}
}
}
@@ -249,7 +274,7 @@ private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString =
// MARK: YAML tokeniser — line-based
// ==================
/* Tokeniser for YAML. Walks line by line because YAML's grammar is
/** Tokeniser for YAML. Walks line by line because YAML's grammar is
indent-sensitive and most of the interesting colour decisions
(key vs value, list item vs scalar) depend on position within a
line. Comments (#), keys (text before ':'), quoted strings, numbers
@@ -270,7 +295,9 @@ private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString =
val vRest = vCodePart.substring(vColon + 1)
// Lead whitespace + optional list marker, then key.
var vK = 0
while (vK < vPrefix.length && vPrefix[vK].isWhitespace()) { append(vPrefix[vK]); vK++ }
while (vK < vPrefix.length && vPrefix[vK].isWhitespace()) {
append(vPrefix[vK]); vK++
}
if (vK + 1 < vPrefix.length && vPrefix[vK] == '-' && vPrefix[vK + 1].isWhitespace()) {
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
append(vPrefix[vK + 1])
@@ -293,18 +320,21 @@ private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString =
}
}
/* Render the value portion of a YAML line — applies string / number /
/** Render the value portion of a YAML line — applies string / number /
keyword / list-marker colours where applicable. */
private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPalette) {
val vTrimmed = inSeg.trimStart()
val vPad = inSeg.substring(0, inSeg.length - vTrimmed.length)
append(vPad)
when {
vTrimmed.isEmpty() -> { /* nothing */ }
vTrimmed.isEmpty() -> { /* nothing */
}
vTrimmed.startsWith('-') && (vTrimmed.length == 1 || vTrimmed[1].isWhitespace()) -> {
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
appendYamlValue(vTrimmed.substring(1), inP)
}
vTrimmed.startsWith('"') || vTrimmed.startsWith('\'') -> {
val vQ = vTrimmed[0]
val vEnd = vTrimmed.indexOf(vQ, 1).let { if (it < 0) vTrimmed.length else it + 1 }
@@ -313,9 +343,11 @@ private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPa
pop()
append(vTrimmed.substring(vEnd))
}
vTrimmed == "true" || vTrimmed == "false" || vTrimmed == "null" || vTrimmed == "~" -> {
pushStyle(SpanStyle(color = inP.keyword)); append(vTrimmed); pop()
}
vTrimmed.first().let { it.isDigit() || it == '-' && vTrimmed.length > 1 && vTrimmed[1].isDigit() } -> {
var vJ = 0
if (vTrimmed[0] == '-') vJ++
@@ -325,38 +357,37 @@ private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPa
pop()
append(vTrimmed.substring(vJ))
}
else -> append(vTrimmed)
}
}
/* Find the index of the first '#' that isn't inside a quoted string,
/** Find the index of the first '#' that isn't inside a quoted string,
or -1 if there is none. Used to split a YAML line into code + comment. */
private fun findUnquotedHash(inLine: String): Int {
var vInSingle = false
var vInDouble = false
for (vI in inLine.indices) {
val vC = inLine[vI]
when {
vC == '"' && !vInSingle -> vInDouble = !vInDouble
vC == '\'' && !vInDouble -> vInSingle = !vInSingle
vC == '#' && !vInSingle && !vInDouble -> return vI
when (inLine[vI]) {
'"' if !vInSingle -> vInDouble = !vInDouble
'\'' if !vInDouble -> vInSingle = !vInSingle
'#' if !vInSingle && !vInDouble -> return vI
}
}
return -1
}
/* Locate the colon that separates a YAML key from its value at the
/** Locate the colon that separates a YAML key from its value at the
current indent level — the FIRST unquoted ':' that's followed by
either whitespace or end-of-line. Returns -1 if the line has no key. */
private fun indexOfKeyColon(inLine: String): Int {
var vInSingle = false
var vInDouble = false
for (vI in inLine.indices) {
val vC = inLine[vI]
when {
vC == '"' && !vInSingle -> vInDouble = !vInDouble
vC == '\'' && !vInDouble -> vInSingle = !vInSingle
vC == ':' && !vInSingle && !vInDouble -> {
when (inLine[vI]) {
'"' if !vInSingle -> vInDouble = !vInDouble
'\'' if !vInDouble -> vInSingle = !vInSingle
':' if !vInSingle && !vInDouble -> {
val vNext = if (vI + 1 < inLine.length) inLine[vI + 1] else ' '
if (vNext.isWhitespace() || vI + 1 == inLine.length) return vI
}
+6 -12
View File
@@ -4,22 +4,16 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// ==================
// MARK: Theme — switchable dark / light palette
// MARK: Theme — the app's legacy colour slots, derived from the active palette
// ==================
// AppColors is the app's own colour vocabulary (used by the custom widgets). It
// is now DERIVED from the selected Voltic M3 ColorScheme via appColorsFromScheme
// (VolticThemes.kt) and provided through LocalAppColors by App(). The default
// below (purple dark) only applies before that provider is installed.
class AppColors(
val bg: Color, val panel: Color, val field: Color, val border: Color,
val accent: Color, val text: Color, val dim: Color, val onAccent: Color,
)
internal val DarkColors = AppColors(
bg = Color(0xFF15161A), panel = Color(0xFF23252C), field = Color(0xFF2D2F37),
border = Color(0xFF474C57), accent = Color(0xFF9F88FF), text = Color(0xFFECEEF2),
dim = Color(0xFFAEB4BD), onAccent = Color(0xFFFFFFFF),
)
internal val LightColors = AppColors(
bg = Color(0xFFF3F4F7), panel = Color(0xFFFFFFFF), field = Color(0xFFFFFFFF),
border = Color(0xFFCED3DB), accent = Color(0xFF6B4BE6), text = Color(0xFF1B1D22),
dim = Color(0xFF5C636E), onAccent = Color(0xFFFFFFFF),
)
internal val LocalAppColors = staticCompositionLocalOf { DarkColors }
internal val LocalAppColors = staticCompositionLocalOf { appColorsFromScheme(VolticPalette.Purple.scheme.dark) }
+49 -15
View File
@@ -15,22 +15,25 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.Popup
import apidemo.compat.clipEntryOfText
import apidemo.compat.x
import apidemo.compat.y
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
// ==================
// MARK: TLS certificate-chain dialog + cert cards
// ==================
/* Dialog showing the server's TLS certificate chain (one card per cert, plus a
/** Dialog showing the server's TLS certificate chain (one card per cert, plus a
Copy PEM action). Fetched on demand by the lock button in the URL bar. */
@Composable
internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -> Unit) {
@@ -38,16 +41,28 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
Dialog(onDismissRequest = inOnDismiss) {
Surface(color = c.panel, shape = RoundedCornerShape(10.dp), modifier = Modifier.width(540.dp)) {
Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
MaterialSymbolsOutlined(MaterialSymbols.Lock, tint = c.accent, size = 20.dp)
Text("TLS certificate chain", color = c.text, fontSize = 16.sp)
}
val vCerts = inChain?.certs.orEmpty()
when {
inChain == null -> Text("Probing…", color = c.dim, fontSize = 13.sp)
vCerts.isEmpty() -> Text(inChain.error ?: "No chain reported.", color = kWarnColor, fontSize = 13.sp)
vCerts.isEmpty() -> Text(
inChain.error ?: "No chain reported.",
color = VolticTheme.extended.warning,
fontSize = 13.sp
)
else -> {
Text("${vCerts.size} certificate(s) presented by ${hostOf(inUrl)}", color = c.dim, fontSize = 12.sp)
Text(
"${vCerts.size} certificate(s) presented by ${hostOf(inUrl)}",
color = c.dim,
fontSize = 12.sp
)
Column(
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
@@ -55,7 +70,10 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
val vChainPem = vCerts.mapNotNull { certField(it.fields, "Cert") }.joinToString("\n")
if (vChainPem.isNotEmpty()) CopyButton(vChainPem, "Copy chain")
OutlinedButton(onClick = inOnDismiss) { Text("Close", color = c.text) }
@@ -66,7 +84,7 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
}
}
/* One certificate's summary card (subject / issuer / validity). Server-presented
/** One certificate's summary card (subject / issuer / validity). Server-presented
certs get a solid border; derived ones (issuer pulled from the OS store, or a
name-only placeholder) get an accent-coloured border + a label. A self-signed
cert shows "Self-signed" in green instead of repeating its issuer. */
@@ -86,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,7 +141,7 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
internal val kSelfSignedColor = Color(0xFF3FB950L)
/* Copy button — rounded, hover overlay, real click that copies inText and shows
/** Copy button — rounded, hover overlay, real click that copies inText and shows
a green check. Icon-only (per cert) flashes a small floating "Copied" bubble
for 2s; the labelled variant (Copy chain) flips its label to "Copied" instead.
Uses a non-catching Popup so it never dismisses the dialog or eats clicks. */
@@ -130,7 +156,11 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
var vHeight by remember { mutableStateOf(0) }
val vClipboard = LocalClipboard.current
val vScope = rememberCoroutineScope()
LaunchedEffect(vCopied) { if (vCopied) { delay(2000); vCopied = false } }
LaunchedEffect(vCopied) {
if (vCopied) {
delay(2000.milliseconds); vCopied = false
}
}
Row(
modifier = Modifier
.onGloballyPositioned { vX = it.x; vY = it.y }
@@ -152,7 +182,11 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
tint = if (vCopied) kSelfSignedColor else c.dim,
size = 15.dp,
)
if (inLabel != null) Text(if (vCopied) "Copied" else inLabel, color = if (vCopied) kSelfSignedColor else c.text, fontSize = 12.sp)
if (inLabel != null) Text(
if (vCopied) "Copied" else inLabel,
color = if (vCopied) kSelfSignedColor else c.text,
fontSize = 12.sp
)
}
// Icon-only: float a tiny "Copied" bubble (non-catching popup → no dismiss).
if (vCopied && inLabel == null) {
@@ -175,11 +209,11 @@ internal fun CertLine(inLabel: String, inValue: String, inValueColor: Color? = n
}
}
/* Look up a CURLINFO_CERTINFO field by name (case-insensitive). */
/** Look up a CURLINFO_CERTINFO field by name (case-insensitive). */
internal fun certField(inFields: List<Pair<String, String>>, inName: String): String? =
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
/* The CN= value out of a distinguished-name string. Handles every
/** The CN= value out of a distinguished-name string. Handles every
format the platform backends emit:
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
"/C=US/O=Let's Encrypt/CN=R3" (OpenSSL X509_NAME_oneline)
@@ -192,6 +226,6 @@ internal fun cnOf(inDn: String): String? {
return vMatch.groupValues[1].trim().ifBlank { null }
}
/* The host portion of a URL (for the chain dialog header). */
/** The host portion of a URL (for the chain dialog header). */
internal fun hostOf(inUrl: String): String =
inUrl.substringAfter("://", inUrl).substringBefore("/").substringBefore("?").ifBlank { inUrl }
+5 -5
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 /
/** 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
/** The server's TLS certificate chain. error is set instead when it couldn't be
fetched; fields vary by TLS backend. */
class TlsChain(val certs: List<ChainCert>, val error: String?)
/* Fetch the TLS certificate chain the request's server presents. */
/** Fetch the TLS certificate chain the request's server presents. */
expect fun inspectTlsChain(inReq: ApiRequest): TlsChain
/* Send the request with its client certificate attached (mTLS). */
/** Send the request with its client certificate attached (mTLS). */
expect fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
/* Remove temp client certs a previous crashed run left behind. Call once at
/** Remove temp client certs a previous crashed run left behind. Call once at
startup; no-op where the OS store isn't used. */
expect fun sweepTempClientCerts()
+35 -16
View File
@@ -4,11 +4,11 @@ package apidemo
// MARK: Variable substitution ({{name}})
// ==================
/* Matches a {{ name }} token. Names may contain letters, digits, _, . and -.
/** Matches a {{ name }} token. Names may contain letters, digits, _, . and -.
Surrounding whitespace inside the braces is tolerated. */
private val kVarRegex = Regex("""\{\{\s*([A-Za-z0-9_.\-]+)\s*\}\}""")
/* Replace every {{name}} token in inText with the matching enabled variable's
/** Replace every {{name}} token in inText with the matching enabled variable's
value. Unknown / disabled names are left untouched so the user can spot the
ones that won't resolve. */
fun substituteVars(inText: String, inVars: List<KeyVal>): String {
@@ -20,7 +20,7 @@ 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,
/** 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 {
@@ -28,17 +28,32 @@ fun resolveVars(inReq: ApiRequest, inVars: List<KeyVal>): ApiRequest {
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
/** 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
@@ -49,7 +64,7 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
append(inReq.url).append('\n')
inReq.params.forEach { append(it.key).append('\n').append(it.value).append('\n') }
inReq.headers.forEach { append(it.key).append('\n').append(it.value).append('\n') }
if (inReq.method.allowsBody) {
if (true) {
when (inReq.bodyType) {
BodyType.FORM -> inReq.form.forEach { append(it.key).append('\n').append(it.value).append('\n') }
else -> append(inReq.body)
@@ -67,7 +82,7 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
// MARK: Copy as cURL
// ==================
/* Render inReq as a runnable, multi-line curl command (pass an already-resolved
/** Render inReq as a runnable, multi-line curl command (pass an already-resolved
request so the output has real values, not {{tokens}}). Enabled query params
are folded into the URL; a Content-Type header is added for the body type
unless the request already sets one. */
@@ -91,27 +106,31 @@ fun toCurl(inReq: ApiRequest): String {
if (inReq.certPassword.isNotEmpty()) vSb.append(" \\\n --pass ").append(shellQuote(inReq.certPassword))
}
if (inReq.method.allowsBody && inReq.bodyType != BodyType.NONE) {
if (true && inReq.bodyType != BodyType.NONE) {
val vContentType = inReq.bodyContentType()
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
if (vContentType != null && !vHasCt) {
vSb.append(" \\\n -H ").append(shellQuote("Content-Type: $vContentType"))
}
when (inReq.bodyType) {
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ").append(shellQuote(formEncode(inReq.form)))
BodyType.FILE -> if (inReq.body.isNotBlank()) vSb.append(" \\\n --data-binary ").append(shellQuote("@${inReq.body}"))
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ")
.append(shellQuote(formEncode(inReq.form)))
BodyType.FILE -> if (inReq.body.isNotBlank()) vSb.append(" \\\n --data-binary ")
.append(shellQuote("@${inReq.body}"))
else -> if (inReq.body.isNotEmpty()) vSb.append(" \\\n --data ").append(shellQuote(inReq.body))
}
}
return vSb.toString()
}
/* URL-encode enabled form fields into an application/x-www-form-urlencoded body. */
/** URL-encode enabled form fields into an application/x-www-form-urlencoded body. */
fun formEncode(inForm: List<KeyVal>): String =
inForm.filter { it.enabled && it.key.isNotBlank() }
.joinToString("&") { "${urlEncode(it.key)}=${urlEncode(it.value)}" }
/* The request URL with its enabled query params appended (percent-encoded). */
/** The request URL with its enabled query params appended (percent-encoded). */
internal fun urlWithParams(inReq: ApiRequest): String {
val vTrimmed = inReq.url.trim()
val vEnabled = inReq.params.filter { it.enabled && it.key.isNotBlank() }
@@ -121,10 +140,10 @@ internal fun urlWithParams(inReq: ApiRequest): String {
return vTrimmed + vSep + vQuery
}
/* Single-quote a string for a POSIX shell, escaping any embedded quotes. */
/** Single-quote a string for a POSIX shell, escaping any embedded quotes. */
private fun shellQuote(inS: String): String = "'" + inS.replace("'", "'\\''") + "'"
/* Minimal RFC-3986 percent-encoding for query keys / values. */
/** Minimal RFC-3986 percent-encoding for query keys / values. */
private fun urlEncode(inS: String): String = buildString {
for (vByte in inS.encodeToByteArray()) {
val vCode = vByte.toInt() and 0xFF
+27 -21
View File
@@ -30,7 +30,7 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
// MARK: Small UI atoms (shared across panels / dialogs / sidebar)
// ==================
/* Method as a compact rounded badge (soft colour fill + coloured label), fixed
/** Method as a compact rounded badge (soft colour fill + coloured label), fixed
width so request names line up. */
@Composable
internal fun MethodTag(inMethod: ReqMethod) {
@@ -45,7 +45,7 @@ internal fun MethodTag(inMethod: ReqMethod) {
}
}
/* Thin horizontal accent line showing where a dragged request row will drop
/** Thin horizontal accent line showing where a dragged request row will drop
(the open-tab strip uses the vertical DropBar). */
@Composable
internal fun RowDropBar() {
@@ -53,7 +53,7 @@ internal fun RowDropBar() {
Box(modifier = Modifier.fillMaxWidth().height(2.dp).background(c.accent, RoundedCornerShape(1.dp)))
}
/* Tab indices listed in inDots get a small accent dot after their label — used
/** Tab indices listed in inDots get a small accent dot after their label — used
to flag a tab that holds content (e.g. a non-empty request body). */
@Composable
internal fun TabBar(inTabs: List<String>, inSelected: Int, inDots: Set<Int> = emptySet(), inOnSelect: (Int) -> Unit) {
@@ -90,10 +90,17 @@ internal fun TogglePill(inLabel: String, inSelected: Boolean, inOnClick: () -> U
) { Text(inLabel, color = if (inSelected) c.onAccent else c.dim, fontSize = 12.sp) }
}
/* Compact single-line (or fixed-height multi-line) input — a BasicTextField in
/** Compact single-line (or fixed-height multi-line) input — a BasicTextField in
a slim bordered box, much shorter than the 56 dp Material OutlinedTextField. */
@Composable
internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier: Modifier = Modifier, inPlaceholder: String = "", inSingleLine: Boolean = true, inOnEnter: (() -> Unit)? = null) {
internal fun ThinField(
inValue: String,
inOnChange: (String) -> Unit,
inModifier: Modifier = Modifier,
inPlaceholder: String = "",
inSingleLine: Boolean = true,
inOnEnter: (() -> Unit)? = null
) {
val c = LocalAppColors.current
// Box is an ancestor of the field, so it sees Enter the single-line field
// leaves unconsumed (it propagates up the focus → root chain).
@@ -102,7 +109,9 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
.border(1.dp, c.border, RoundedCornerShape(6.dp))
.padding(horizontal = 10.dp, vertical = 9.dp)
if (inOnEnter != null) vBoxMod = vBoxMod.onKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) { inOnEnter(); true } else false
if (ev.type == KeyEventType.KeyDown && (ev.key == Key.Enter || ev.key == Key.NumPadEnter)) {
inOnEnter(); true
} else false
}
Box(
modifier = vBoxMod,
@@ -128,10 +137,17 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
}
}
/* Small icon-only button (burger / pack / options / save / close). inPadding
/** Small icon-only button (burger / pack / options / save / close). inPadding
tightens the tap area for slimmer rows. */
@Composable
internal fun IconBtn(inIcon: Int, inDesc: String, inModifier: Modifier = Modifier, inSize: Dp = 18.dp, inPadding: Dp = 6.dp, inOnClick: () -> Unit) {
internal fun IconBtn(
inIcon: Int,
inDesc: String,
inModifier: Modifier = Modifier,
inSize: Dp = 18.dp,
inPadding: Dp = 6.dp,
inOnClick: () -> Unit
) {
val c = LocalAppColors.current
val vHoverSrc = remember { MutableInteractionSource() }
val vHover by vHoverSrc.collectIsHoveredAsState()
@@ -152,14 +168,14 @@ internal fun IconBtn(inIcon: Int, inDesc: String, inModifier: Modifier = Modifie
}
}
/* Outlined icon+label action (New request / Add row / pack actions). */
/** Outlined icon+label action (New request / Add row / pack actions). */
@Composable
internal fun OutlinedAction(inIcon: Int, inLabel: String, inOnClick: () -> Unit) {
val c = LocalAppColors.current
OutlinedButton(onClick = inOnClick) { BtnContent(inIcon, inLabel, c.accent) }
}
/* Filled red icon+label button for destructive / stop actions (delete, cancel, quit).
/** Filled red icon+label button for destructive / stop actions (delete, cancel, quit).
Uses the m3 Button so its size + ripple + interaction match every other
Button in the app — previously handrolled Box + background + clickable + padding
gave it a different height / horizontal padding, and the Discard button sat
@@ -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) {
+101 -96
View File
@@ -29,7 +29,6 @@ import androidx.compose.ui.input.pointer.isShiftPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.*
@@ -37,6 +36,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import apidemo.compat.*
import apidemo.compat.ScrollbarStyle
import apidemo.compat.rememberScrollbarAdapter
import com.compose.sdl.icons.MaterialSymbols
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
import kotlinx.coroutines.launch
@@ -45,7 +47,7 @@ import kotlinx.coroutines.launch
// MARK: Response
// ==================
/* Panel 4 — Request / Response viewer. Each tab stacks a HEADERS section over a
/** Panel 4 — Request / Response viewer. Each tab stacks a HEADERS section over a
BODY section. "Request" shows the resolved request that would be sent (the
Preview); "Response" shows the result (image-aware). A copy / save toolbar
acts on whichever view is showing. */
@@ -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).
@@ -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,7 +260,7 @@ 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
/** 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. */
@@ -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.
@@ -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,7 +652,7 @@ internal fun HttpFlowView(
// MARK: Body selection (editor-style, spans virtualized chunks)
// ==================
/* A response body's selection is a pair of character offsets in the whole body
/** A response body's selection is a pair of character offsets in the whole body
string: [anchor] is where the user pressed / started the selection, [caret] is
where they've moved to. When they differ, the selected range is [ min..max ].
When equal, it's a zero-width caret still useful as a starting point for
@@ -645,7 +668,7 @@ private data class BodySel(val anchor: Int, val caret: Int) {
val isEmpty get() = anchor == caret
}
/* Character offset of the start of the line containing [inOffset]. */
/** Character offset of the start of the line containing [inOffset]. */
private fun lineStartAt(inText: String, inOffset: Int): Int {
if (inOffset <= 0) return 0
val vClamped = inOffset.coerceAtMost(inText.length)
@@ -653,7 +676,7 @@ private fun lineStartAt(inText: String, inOffset: Int): Int {
return if (vPrev < 0) 0 else vPrev + 1
}
/* Character offset of the end of the line containing [inOffset] — the position of
/** Character offset of the end of the line containing [inOffset] — the position of
its '\n', or the length of [inText] if this is the last line. */
private fun lineEndAt(inText: String, inOffset: Int): Int {
val vClamped = inOffset.coerceIn(0, inText.length)
@@ -661,7 +684,7 @@ private fun lineEndAt(inText: String, inOffset: Int): Int {
return if (vNext < 0) inText.length else vNext
}
/* Move a caret one visual "row" down while trying to keep its column — i.e. the
/** Move a caret one visual "row" down while trying to keep its column — i.e. the
number of chars past the current line's start. If the target line is shorter,
the caret snaps to its end. Off-by-one at EOF collapses to the body length. */
private fun moveDown(inText: String, inOffset: Int): Int {
@@ -674,7 +697,7 @@ private fun moveDown(inText: String, inOffset: Int): Int {
return (vNextStart + vCol).coerceAtMost(vNextEnd)
}
/* The Up counterpart to [moveDown]. */
/** The Up counterpart to [moveDown]. */
private fun moveUp(inText: String, inOffset: Int): Int {
val vLineStart = lineStartAt(inText, inOffset)
if (vLineStart == 0) return 0
@@ -684,7 +707,7 @@ private fun moveUp(inText: String, inOffset: Int): Int {
return (vPrevStart + vCol).coerceAtMost(vPrevEnd)
}
/* Word boundary rule for double-click selection: contiguous run of "word chars"
/** Word boundary rule for double-click selection: contiguous run of "word chars"
(letters, digits, underscore) containing [inOffset], or an empty range at the
click point when it lands on non-word text (whitespace, punctuation). Matches
the boundary editors like VS Code / IntelliJ use for double-click select. */
@@ -704,7 +727,7 @@ private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
return vStart..vEnd
}
/* The index of the chunk in [inChunks] whose character range contains
/** 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? {
@@ -721,7 +744,7 @@ private fun chunkContaining(inChunks: List<BodyChunk>, inOffset: Int): Int? {
// measure at the viewport edges, large enough to slash node/selectable count.
private const val kLinesPerChunk = 48
/* One BLOCK of a read-only body: a right-aligned column of line numbers in the
/** One BLOCK of a read-only body: a right-aligned column of line numbers in the
gutter + the block's (syntax-coloured) text, each a single BasicText. Rendering
blocks (not one-BasicText-per-line) keeps the node / selectable / paragraph-set-up
count low as blocks scroll through the viewport. The gutter is pinned outside
@@ -822,7 +845,8 @@ private fun BodyChunkRow(
vSb.append(inChunk.firstLine + j)
if (vLen > 0) {
val vStart = inChunk.lineStartsInBlock[j].coerceIn(0, vMaxOff)
val vEnd = (if (j + 1 < inChunk.lineCount) inChunk.lineStartsInBlock[j + 1] - 1 else vMaxOff)
val vEnd =
(if (j + 1 < inChunk.lineCount) inChunk.lineStartsInBlock[j + 1] - 1 else vMaxOff)
.coerceIn(vStart, vMaxOff)
val vRows = layout.getLineForOffset(vEnd) - layout.getLineForOffset(vStart) + 1
repeat((vRows - 1).coerceAtLeast(0)) { vSb.append('\n') }
@@ -847,7 +871,7 @@ private fun BodyChunkRow(
}
}
/* A block of consecutive source lines: 1-based number of the first line, the line
/** A block of consecutive source lines: 1-based number of the first line, the line
count, each source line's start offset WITHIN the block text (to map a wrapped body
layout back to line numbers), the character offset of the block's first char in the
full body (used to map local text offsets to global ones for cross-chunk selection),
@@ -860,7 +884,7 @@ private class BodyChunk(
val body: AnnotatedString,
)
/* Slice a highlighted body into blocks of [inLinesPerChunk] lines. The O(total-spans)
/** Slice a highlighted body into blocks of [inLinesPerChunk] lines. The O(total-spans)
subSequence runs once per block HERE (build time, memoised) instead of once per
visible line every frame — the difference between smooth and janky on a big body. */
private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerChunk: Int): List<BodyChunk> {
@@ -882,7 +906,7 @@ private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerCh
return vOut
}
/* Offsets where each source line starts (line i spans [starts[i], starts[i+1]-1),
/** Offsets where each source line starts (line i spans [starts[ i], starts[i+1]-1),
the -1 dropping the '\n'; the last line runs to the string end). One cheap O(n)
scan, memoised — replaces the old gutter pre-pass that ran measurer.wrap() on
every one of N lines on each width change (the other half of the freeze). */
@@ -893,7 +917,7 @@ private fun lineStartOffsets(inText: String): IntArray {
return vStarts.toIntArray()
}
/* Header table: each row is (key | value) where the key column has a
/** Header table: each row is (key | value) where the key column has a
fixed width so values line up. Long keys wrap; long values wrap too.
Keys render in accent colour, values in regular text. */
@Composable
@@ -919,7 +943,7 @@ internal fun HeaderTable(inHeaders: List<Pair<String, String>>, modifier: Modifi
}
}
/* Body view with line numbers in the gutter — mimics the code panel
/** Body view with line numbers in the gutter — mimics the code panel
in httpie. Numbers are dim; body text uses the regular colour. */
@Composable
internal fun BodyView(
@@ -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,7 +1048,7 @@ internal fun BodyView(
}
}
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
LocalTextSelectionColors provides
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
@@ -1032,7 +1057,7 @@ internal fun BodyView(
BasicTextField(
value = inText,
onValueChange = inOnChange,
textStyle = androidx.compose.ui.text.TextStyle(
textStyle = TextStyle(
color = c.text,
fontSize = 12.sp,
fontFamily = monoFontFamily,
@@ -1071,7 +1096,7 @@ internal fun BodyView(
}
}
/* "Request" + colored "GET" tab. inAccent is shown next to the label
/** "Request" + colored "GET" tab. inAccent is shown next to the label
when non-null (e.g. method name once sent, status code once received);
when null the tab is just the label, no placeholder dash. Selected
tab gets full-strength labels and a 2dp underline; unselected tabs
@@ -1095,7 +1120,11 @@ internal fun ViewerTab(
) {
Text(inLabel, color = if (inSelected) c.dim else c.dim.copy(alpha = 0.6f), fontSize = 14.sp)
if (inAccent != null) {
Text(inAccent, color = if (inSelected) inAccentColor else inAccentColor.copy(alpha = 0.6f), fontSize = 14.sp)
Text(
inAccent,
color = if (inSelected) inAccentColor else inAccentColor.copy(alpha = 0.6f),
fontSize = 14.sp
)
}
}
Spacer(Modifier.height(4.dp))
@@ -1112,7 +1141,7 @@ internal fun ViewerTab(
// MARK: Helpers — header parsing / formatting
// ==================
/* "HTTP/1.1 200 OK" status line for a Response, with the protocol
/** "HTTP/1.1 200 OK" status line for a Response, with the protocol
token dimmed (it's structural) and the status code + reason in the
status colour. Just "HTTP/1.1" pre-response — no placeholder. Triple-
spaced to match the request-line formatting. */
@@ -1142,14 +1171,15 @@ internal fun formatStatusLine(inResp: ApiResponse?, inColors: AppColors): Annota
}
}
/* "GET /path HTTP/1.1" status line for a Request. Each part takes
/** "GET /path HTTP/1.1" status line for a Request. Each part takes
its own colour: method in its canonical method colour, URL in the
default text colour, protocol token dimmed so the eye lands on the
request target first. Triple-spaced so the columns read at a glance.
The protocol string comes from the matching response when we have
one (so HTTP/2 servers show "HTTP/2"); fallback "HTTP/1.1" before
send. */
internal fun formatRequestLine(inReq: ApiRequest, inHttpVersion: String, inColors: AppColors): AnnotatedString = buildAnnotatedString {
internal fun formatRequestLine(inReq: ApiRequest, inHttpVersion: String, inColors: AppColors): AnnotatedString =
buildAnnotatedString {
pushStyle(SpanStyle(color = methodColor(inReq.method), fontWeight = FontWeight.Bold))
append(inReq.method.name)
pop()
@@ -1166,7 +1196,7 @@ internal fun formatRequestLine(inReq: ApiRequest, inHttpVersion: String, inColor
pop()
}
/* Pull the path (+ query) out of a URL, dropping scheme + host. The
/** 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). */
@@ -1178,7 +1208,7 @@ internal fun urlPathOnly(inUrl: String): String {
return if (vSlash < 0) "/" else vAfterScheme.substring(vSlash)
}
/* Extract the host (+ port) from a URL — used to synthesize a Host
/** Extract the host (+ port) from a URL — used to synthesize a Host
header for display when the engine didn't surface one. */
internal fun urlHost(inUrl: String): String? {
val vIdx = inUrl.indexOf("://")
@@ -1189,12 +1219,12 @@ internal fun urlHost(inUrl: String): String? {
return vAuthority.ifEmpty { null }
}
/* The user-agent string our Darwin engine sends is opaque to Ktor —
/** The user-agent string our Darwin engine sends is opaque to Ktor —
NSURLSession picks the default. Match what httpie does: identify
ourselves so the wire log isn't missing the field entirely. */
internal const val kUserAgent: String = "compose-apidemo/1.0"
/* Combine Ktor's reported request headers with the ones the engine
/** Combine Ktor's reported request headers with the ones the engine
adds at the wire level (Host, Content-Length, User-Agent) so the
Request tab shows the actual on-the-wire header set rather than
just the subset Ktor sees. Sorted alphabetically. */
@@ -1202,21 +1232,21 @@ internal fun synthesizeRequestHeaders(
inReq: ApiRequest,
inReported: List<Pair<String, String>>,
): List<Pair<String, String>> {
val vByKey = inReported.associate { it.first.lowercase() to it }.toMutableMap()
val vByKey = inReported.associateBy { it.first.lowercase() }.toMutableMap()
urlHost(inReq.url)?.let { vHost ->
vByKey.getOrPut("host") { "Host" to vHost }
}
if (!vByKey.containsKey("user-agent")) {
vByKey["user-agent"] = "User-Agent" to kUserAgent
}
if (inReq.method.allowsBody && inReq.bodyType != BodyType.NONE && !vByKey.containsKey("content-length")) {
if (true && inReq.bodyType != BodyType.NONE && !vByKey.containsKey("content-length")) {
val vLen = computedBodyLength(inReq)
if (vLen != null) vByKey["content-length"] = "Content-Length" to vLen.toString()
}
return vByKey.values.sortedBy { it.first.lowercase() }
}
/* Body length in bytes for the headers synthesis. JSON / TEXT use the
/** Body length in bytes for the headers synthesis. JSON / TEXT use the
raw UTF-8 byte count; FORM serialises and counts; FILE skips
(loading the file just for the count would be wasteful — the engine
sets the field anyway). */
@@ -1226,7 +1256,7 @@ internal fun computedBodyLength(inReq: ApiRequest): Int? = when (inReq.bodyType)
BodyType.FILE, BodyType.NONE -> null
}
/* Format the timing / size footer text shown bottom-right. */
/** Format the timing / size footer text shown bottom-right. */
internal fun formatTimingSize(inResp: ApiResponse): String {
val vSize = when {
inResp.sizeBytes < 1024 -> "${inResp.sizeBytes} B"
@@ -1240,7 +1270,7 @@ 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.
/** 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. */
@@ -1333,12 +1363,14 @@ internal fun ViewerOverflowMenu(
vOpen = false
})
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = c.border)
DropdownMenuItem(text = { Text("Clear", color = Color(0xFFFF5630)) }, onClick = { inOnClear(); vOpen = false })
DropdownMenuItem(
text = { Text("Clear", color = Color(0xFFFF5630)) },
onClick = { inOnClear(); vOpen = false })
}
}
}
/* Perceptual luminance check — true when the colour reads as "dark"
/** Perceptual luminance check — true when the colour reads as "dark"
(background gets a light foreground). Standard Rec. 709 weights. */
internal fun isDarkBg(inColor: Color): Boolean {
val vY = 0.299f * inColor.red + 0.587f * inColor.green + 0.114f * inColor.blue
@@ -1349,7 +1381,7 @@ 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
/** Format / "type" picker. inBordered renders it as a full dropdown matching
BodyTypeMenu (the Body-tab Text-type picker); otherwise it's the compact inline
control used by the response viewer. */
@Composable
@@ -1373,18 +1405,23 @@ internal fun BodyFormatSelector(
horizontalArrangement = Arrangement.spacedBy(if (inBordered) 5.dp else 4.dp),
) {
Text(vLabel, color = if (inBordered) c.text else c.dim, fontSize = if (inBordered) 13.sp else 11.sp)
MaterialSymbolsOutlined(if (inBordered) MaterialSymbols.UnfoldMore else MaterialSymbols.ArrowDropDown, tint = c.dim, size = if (inBordered) 15.dp else 14.dp)
MaterialSymbolsOutlined(
if (inBordered) MaterialSymbols.UnfoldMore else MaterialSymbols.ArrowDropDown,
tint = c.dim,
size = if (inBordered) 15.dp else 14.dp
)
}
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
for (vF in BodyFormat.values()) {
DropdownMenuItem(text = { Text(vF.label, color = if (vF == inSelected) c.accent else c.text) },
for (vF in BodyFormat.entries) {
DropdownMenuItem(
text = { Text(vF.label, color = if (vF == inSelected) c.accent else c.text) },
onClick = { inOnChange(vF); vOpen = false })
}
}
}
}
/* TLS validation indicator: only true when the URL is https AND we got
/** TLS validation indicator: only true when the URL is https AND we got
a real response back (i.e. the OS engine completed the TLS handshake
without an error). The engines we ship — NSURLSession on macOS,
WinHttp on Windows, libcurl on Linux — all reject an untrusted
@@ -1397,7 +1434,7 @@ internal fun isTlsValidated(inUrl: String, inResp: ApiResponse?): Boolean {
return inResp.status > 0
}
/* Parse "Key: value\nKey2: value2" into a list of pairs so the headers
/** Parse "Key: value\nKey2: value2" into a list of pairs so the headers
table renderer can lay them out as a key/value grid. */
internal fun parseHeaderLines(inText: String): List<Pair<String, String>> {
if (inText.isBlank() || inText == "(no headers)") return emptyList()
@@ -1407,7 +1444,7 @@ internal fun parseHeaderLines(inText: String): List<Pair<String, String>> {
}
}
/* "content-type" → "Content-Type"; preserves single-word keys; leaves
/** "content-type" → "Content-Type"; preserves single-word keys; leaves
already-correct ones unchanged. */
internal fun titleCaseHeader(inKey: String): String =
inKey.split('-').joinToString("-") { vWord ->
@@ -1415,7 +1452,7 @@ internal fun titleCaseHeader(inKey: String): String =
else vWord[0].uppercaseChar() + vWord.drop(1).lowercase()
}
/* Centered icon + label placeholder for the viewer (Not sent / Not received /
/** Centered icon + label placeholder for the viewer (Not sent / Not received /
No Body). Fills its parent unless a sized modifier is supplied. */
@Composable
internal fun ViewerEmpty(inIcon: Int, inText: String, inModifier: Modifier = Modifier.fillMaxSize()) {
@@ -1428,64 +1465,32 @@ internal fun ViewerEmpty(inIcon: Int, inText: String, inModifier: Modifier = Mod
}
}
/* A labelled, read-only (selectable) code block used by the viewer sections. */
@Composable
internal fun CodeSection(inLabel: String, inText: String) {
val c = LocalAppColors.current
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(inLabel, color = c.dim, fontSize = 11.sp)
Box(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
.background(c.bg, RoundedCornerShape(8.dp)).border(1.dp, c.border, RoundedCornerShape(8.dp)).padding(12.dp),
) {
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
androidx.compose.foundation.text.selection.TextSelectionColors(
handleColor = c.accent,
backgroundColor = c.accent.copy(alpha = 0.35f),
),
) {
BasicTextField(
value = inText.ifEmpty { "(empty)" }, onValueChange = {}, readOnly = true,
textStyle = androidx.compose.ui.text.TextStyle(color = c.text, fontSize = 12.sp),
cursorBrush = androidx.compose.ui.graphics.SolidColor(c.accent),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
/* Format an actual header list (key: value per line). */
/** Format an actual header list (key: value per line). */
internal fun headersText(inHeaders: List<Pair<String, String>>): String =
inHeaders.joinToString("\n") { (vK, vV) -> "$vK: $vV" }.ifEmpty { "(no headers)" }
/* The headers that *would* be sent — explicit enabled headers plus the inferred
/** The headers that *would* be sent — explicit enabled headers plus the inferred
Content-Type for the body type (unless one is already set). Used for Preview;
a sent request shows the real headers via headersText(response.requestHeaders). */
internal fun requestHeadersText(inReq: ApiRequest): String {
val vLines = mutableListOf<String>()
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { vLines.add("${it.key}: ${it.value}") }
val vCt = inReq.bodyContentType()
if (inReq.method.allowsBody && vCt != null && inReq.headers.none { it.enabled && it.key.equals("content-type", ignoreCase = true) }) {
if (true && vCt != null && inReq.headers.none {
it.enabled && it.key.equals(
"content-type",
ignoreCase = true
)
}) {
vLines.add("Content-Type: $vCt")
}
return vLines.joinToString("\n").ifEmpty { "(no headers)" }
}
/* The body that would be sent, rendered as text for the preview. */
/** The body that would be sent, rendered as text for the preview. */
internal fun requestBodyText(inReq: ApiRequest): String = when (inReq.bodyType) {
BodyType.NONE -> "(no body)"
BodyType.TEXT -> inReq.body.ifEmpty { "(empty)" }
BodyType.FORM -> formEncode(inReq.form).ifEmpty { "(empty form)" }
BodyType.FILE -> if (inReq.body.isBlank()) "(no file)" else "(file) ${inReq.body}"
}
@Composable
internal fun StatusPill(inStatus: Int, inLabel: String) {
val vC = statusColor(inStatus)
Box(modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(vC.copy(alpha = 0.20f), RoundedCornerShape(6.dp)).padding(horizontal = 12.dp, vertical = 6.dp)) {
Text(inLabel, color = vC, fontSize = 14.sp)
}
}
@@ -0,0 +1,212 @@
package apidemo
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// ==================
// MARK: Voltic themes — selectable Material 3 palettes
// ==================
// The three "voltic" brand palettes (Kotlin purple, orange, and a stock-M3
// purple), each a full light + dark M3 ColorScheme plus an EXTENDED group that
// M3 has no slot for (warning). The app runs on MaterialTheme(colorScheme = …)
// driven by the selected palette; the legacy AppColors abstraction is DERIVED
// from the active scheme (appColorsFromScheme) so existing widgets follow along.
/** Colors M3's ColorScheme has no slot for. Warning is the amber "unsaved /
undefined variable" accent; read it with VolticTheme.extended.warning. */
@Immutable
data class VolticExtendedColors(
val warning: Color,
val onWarning: Color,
val warningContainer: Color,
val onWarningContainer: Color,
)
/** One palette's four schemes: the M3 light/dark ColorSchemes + the matching
extended groups. */
@Immutable
class VolticScheme(
val light: ColorScheme,
val dark: ColorScheme,
val lightExtended: VolticExtendedColors,
val darkExtended: VolticExtendedColors,
)
/** The palettes the user can pick from. `id` is the stable persistence key. */
enum class VolticPalette(val id: String, val label: String) {
Purple("purple", "Kotlin Purple"),
Orange("orange", "Voltic Orange"),
PurpleStock("purple-stock", "Purple");
val scheme: VolticScheme
get() = when (this) {
Purple -> kPurpleScheme
Orange -> kOrangeScheme
PurpleStock -> kPurpleStockScheme
}
companion object {
fun fromId(inId: String?): VolticPalette = entries.firstOrNull { it.id == inId } ?: Purple
}
}
// Usage: MaterialTheme.colorScheme.primary, VolticTheme.extended.warning
val LocalVolticExtended = staticCompositionLocalOf { kPurpleScheme.darkExtended }
object VolticTheme {
val extended: VolticExtendedColors
@Composable get() = LocalVolticExtended.current
}
/** Maps the active M3 ColorScheme onto the app's legacy AppColors slots, so the
~50 existing `LocalAppColors.current` call sites theme with the palette:
panel = surface, field = surfaceVariant, border = the subtle outlineVariant,
dim = onSurfaceVariant, accent = primary. */
internal fun appColorsFromScheme(inScheme: ColorScheme): AppColors = AppColors(
bg = inScheme.background,
panel = inScheme.surface,
field = inScheme.surfaceVariant,
border = inScheme.outlineVariant,
accent = inScheme.primary,
text = inScheme.onSurface,
dim = inScheme.onSurfaceVariant,
onAccent = inScheme.onPrimary,
)
// ==================
// MARK: Palette definitions (from voltic-pack/theme)
// ==================
private val kPurpleScheme = VolticScheme(
light = lightColorScheme(
primary = Color(0xFF7F52FF), onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFE3D4FF), onPrimaryContainer = Color(0xFF2A1065),
secondary = Color(0xFF5F5873), onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFE5DFF3), onSecondaryContainer = Color(0xFF1B1626),
tertiary = Color(0xFFF5B437), onTertiary = Color(0xFF3F2E00),
tertiaryContainer = Color(0xFFFFE8B8), onTertiaryContainer = Color(0xFF2F2000),
error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002),
background = Color(0xFFFAF8FF), onBackground = Color(0xFF1B1626),
surface = Color(0xFFFFFFFF), onSurface = Color(0xFF1B1626),
surfaceVariant = Color(0xFFECE6F6), onSurfaceVariant = Color(0xFF453D5C),
outline = Color(0xFF6A5F8A), outlineVariant = Color(0xFFD7CDEB),
inverseSurface = Color(0xFF2E2840), inverseOnSurface = Color(0xFFF3EFFB),
inversePrimary = Color(0xFF9D7BFF), surfaceTint = Color(0xFF7F52FF), scrim = Color(0xFF000000),
),
dark = darkColorScheme(
primary = Color(0xFF9D7BFF), onPrimary = Color(0xFF1F0068),
primaryContainer = Color(0xFF4A22C4), onPrimaryContainer = Color(0xFFE3D4FF),
secondary = Color(0xFFC9C1DC), onSecondary = Color(0xFF2E2840),
secondaryContainer = Color(0xFF453D5C), onSecondaryContainer = Color(0xFFE5DFF3),
tertiary = Color(0xFFF5B437), onTertiary = Color(0xFF3F2E00),
tertiaryContainer = Color(0xFF6D5000), onTertiaryContainer = Color(0xFFFFE8B8),
error = Color(0xFFFFB4AB), onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF0F0D17), onBackground = Color(0xFFE6E1F2),
surface = Color(0xFF171423), onSurface = Color(0xFFE6E1F2),
surfaceVariant = Color(0xFF37304A), onSurfaceVariant = Color(0xFFCFC6E4),
outline = Color(0xFF8F86A8), outlineVariant = Color(0xFF37304A),
inverseSurface = Color(0xFFE6E1F2), inverseOnSurface = Color(0xFF171423),
inversePrimary = Color(0xFF7F52FF), surfaceTint = Color(0xFF9D7BFF), scrim = Color(0xFF000000),
),
lightExtended = VolticExtendedColors(
warning = Color(0xFFB26A00), onWarning = Color(0xFFFFFFFF),
warningContainer = Color(0xFFFFDF9E), onWarningContainer = Color(0xFF2A1800),
),
darkExtended = VolticExtendedColors(
warning = Color(0xFFFFC960), onWarning = Color(0xFF3F2E00),
warningContainer = Color(0xFF5C4300), onWarningContainer = Color(0xFFFFDF9E),
),
)
private val kOrangeScheme = VolticScheme(
light = lightColorScheme(
primary = Color(0xFFE86A33), onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFFFDBCB), onPrimaryContainer = Color(0xFF380D00),
secondary = Color(0xFF9C4E2A), onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFFFE0D2), onSecondaryContainer = Color(0xFF3A0F00),
tertiary = Color(0xFF8F6A00), onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFFBE08A), onTertiaryContainer = Color(0xFF2D2000),
error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFF8F5), onBackground = Color(0xFF221A15),
surface = Color(0xFFFFF8F5), onSurface = Color(0xFF221A15),
surfaceVariant = Color(0xFFF4DED4), onSurfaceVariant = Color(0xFF52443C),
outline = Color(0xFF85736B), outlineVariant = Color(0xFFD7C2B8),
inverseSurface = Color(0xFF382E29), inverseOnSurface = Color(0xFFFFEDE5),
inversePrimary = Color(0xFFFFB598), surfaceTint = Color(0xFFE86A33), scrim = Color(0xFF000000),
),
dark = darkColorScheme(
primary = Color(0xFFFFB598), onPrimary = Color(0xFF5B1B00),
primaryContainer = Color(0xFFC1531F), onPrimaryContainer = Color(0xFFFFDBCB),
secondary = Color(0xFFFFB599), onSecondary = Color(0xFF5E1A00),
secondaryContainer = Color(0xFF7D3715), onSecondaryContainer = Color(0xFFFFE0D2),
tertiary = Color(0xFFF5B437), onTertiary = Color(0xFF3F2E00),
tertiaryContainer = Color(0xFF6D5000), onTertiaryContainer = Color(0xFFFBE08A),
error = Color(0xFFFFB4AB), onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF101B28), onBackground = Color(0xFFE3E7ED),
surface = Color(0xFF16283A), onSurface = Color(0xFFE3E7ED),
surfaceVariant = Color(0xFF33414F), onSurfaceVariant = Color(0xFFC9D3DD),
outline = Color(0xFF7C8A97), outlineVariant = Color(0xFF33414F),
inverseSurface = Color(0xFFE3E7ED), inverseOnSurface = Color(0xFF16283A),
inversePrimary = Color(0xFFE86A33), surfaceTint = Color(0xFFFFB598), scrim = Color(0xFF000000),
),
lightExtended = VolticExtendedColors(
warning = Color(0xFF7D5800), onWarning = Color(0xFFFFFFFF),
warningContainer = Color(0xFFFFDF9E), onWarningContainer = Color(0xFF271900),
),
darkExtended = VolticExtendedColors(
warning = Color(0xFFF2C13D), onWarning = Color(0xFF3E2E00),
warningContainer = Color(0xFF5E4600), onWarningContainer = Color(0xFFFFDF9E),
),
)
private val kPurpleStockScheme = VolticScheme(
light = lightColorScheme(
primary = Color(0xFF7F52FF), onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFE9DDFF), onPrimaryContainer = Color(0xFF22005D),
secondary = Color(0xFF615B71), onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFE7DEF8), onSecondaryContainer = Color(0xFF1D192B),
tertiary = Color(0xFF9A25AE), onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFFFD6FE), onTertiaryContainer = Color(0xFF35003F),
error = Color(0xFFBA1A1A), onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002),
background = Color(0xFFFDF7FF), onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFDF7FF), onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFE7E0EB), onSurfaceVariant = Color(0xFF49454E),
outline = Color(0xFF7A757F), outlineVariant = Color(0xFFCBC4CF),
inverseSurface = Color(0xFF313035), inverseOnSurface = Color(0xFFF4EFF4),
inversePrimary = Color(0xFFCFBCFF), surfaceTint = Color(0xFF7F52FF), scrim = Color(0xFF000000),
),
dark = darkColorScheme(
primary = Color(0xFFCFBCFF), onPrimary = Color(0xFF3A0AA5),
primaryContainer = Color(0xFF6236E0), onPrimaryContainer = Color(0xFFE9DDFF),
secondary = Color(0xFFCBC2DB), onSecondary = Color(0xFF332D41),
secondaryContainer = Color(0xFF494458), onSecondaryContainer = Color(0xFFE7DEF8),
tertiary = Color(0xFFF9ABFF), onTertiary = Color(0xFF570066),
tertiaryContainer = Color(0xFF7B0E8F), onTertiaryContainer = Color(0xFFFFD6FE),
error = Color(0xFFFFB4AB), onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF141220), onBackground = Color(0xFFE6E1E9),
surface = Color(0xFF1D1A2C), onSurface = Color(0xFFE6E1E9),
surfaceVariant = Color(0xFF3B3548), onSurfaceVariant = Color(0xFFCBC4CF),
outline = Color(0xFF948F99), outlineVariant = Color(0xFF3B3548),
inverseSurface = Color(0xFFE6E1E9), inverseOnSurface = Color(0xFF1D1A2C),
inversePrimary = Color(0xFF7F52FF), surfaceTint = Color(0xFFCFBCFF), scrim = Color(0xFF000000),
),
lightExtended = VolticExtendedColors(
warning = Color(0xFFB26A00), onWarning = Color(0xFFFFFFFF),
warningContainer = Color(0xFFFFDF9E), onWarningContainer = Color(0xFF2A1800),
),
darkExtended = VolticExtendedColors(
warning = Color(0xFFFFC960), onWarning = Color(0xFF3F2E00),
warningContainer = Color(0xFF5C4300), onWarningContainer = Color(0xFFFFDF9E),
),
)
+16 -16
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,46 +15,46 @@ 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
/** Rows the given line occupies when soft-wrapped to inMaxWidthPx at
inFontPx (already density-scaled). Used by the body gutter so its line
numbers stay aligned with the wrapped body text. */
expect fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int
/* Editor tab width in spaces — how wide a typed '\t' renders. On the native
/** Editor tab width in spaces — how wide a typed '\t' renders. On the native
stack this drives the project text pipeline (TextLayoutConfig); the jvm
parity app only stores the preference. */
expect var editorTabWidth: Int
@@ -62,15 +62,15 @@ expect var editorTabWidth: Int
// ============
// Infrastructure
/* okio's FileSystem.SYSTEM is declared per-platform, not in its common
/** okio's FileSystem.SYSTEM is declared per-platform, not in its common
metadata — surface it through a seam. */
internal expect val systemFileSystem: FileSystem
/* The app's Ktor client: Curl on native (bundled libcurl — same TLS stack as
/** The app's Ktor client: Curl on native (bundled libcurl — same TLS stack as
the mTLS path), CIO on the jvm parity target. */
expect fun createApiHttpClient(): HttpClient
/* Window-shell integration: an intercepting close handler (return true to
/** Window-shell integration: an intercepting close handler (return true to
allow the close) and an app-wide key-shortcut handler; both cleared when
the composition leaves. */
@Composable
@@ -1,4 +1,4 @@
package apidemo
package apidemo.compat
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.positionInWindow
@@ -9,8 +9,8 @@ import androidx.compose.ui.layout.positionInWindow
// its node is mid-recycle, and positionInWindow() throws on a detached
// coordinator.
/* Logical-point X of this layout in the window. */
/** Logical-point X of this layout in the window. */
val LayoutCoordinates.x: Int get() = if (isAttached) positionInWindow().x.toInt() else 0
/* Logical-point Y of this layout in the window. */
/** Logical-point Y of this layout in the window. */
val LayoutCoordinates.y: Int get() = if (isAttached) positionInWindow().y.toInt() else 0
@@ -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,7 +38,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
@@ -75,7 +67,7 @@ class ScrollbarStyle(
val hoverColor: Color,
)
/* Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
/** Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
provide a white-tinted style via LocalScrollbarStyle or the `style` param. */
fun defaultScrollbarStyle(): ScrollbarStyle = ScrollbarStyle(
minimalHeight = 16.dp,
@@ -92,7 +84,7 @@ val LocalScrollbarStyle = compositionLocalOf { defaultScrollbarStyle() }
// MARK: ScrollbarAdapter
// ==================
/* Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
/** Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
contentSize / viewportSize are along the scroll axis; the thumb is sized by
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
interface ScrollbarAdapter {
@@ -106,7 +98,7 @@ interface ScrollbarAdapter {
// internals (ScrollState.contentSize/viewportSize) that a copy outside
// :foundation cannot access. apidemo only scrollbars a LazyColumn.)
/* Adapter for a virtualized LazyColumn / LazyRow. A lazy list only knows the
/** Adapter for a virtualized LazyColumn / LazyRow. A lazy list only knows the
size of its VISIBLE items, so exact pixel geometry of off-screen content is
unknowable — we estimate it from the average visible item size × total item
count (the same approach as Compose Desktop). Accurate enough for a thumb
@@ -171,16 +163,7 @@ fun VerticalScrollbar(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = true)
@Composable
fun HorizontalScrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean = false,
style: ScrollbarStyle = LocalScrollbarStyle.current,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = false)
/* Shared impl. The root Box is the track (fixed thickness on the cross axis,
/** Shared impl. The root Box is the track (fixed thickness on the cross axis,
filling the main axis from the caller's modifier); a child Box is the thumb,
positioned with an offset. Drag + track-click are handled on the track in
track-relative coordinates (a stable origin), so the grab point stays under
@@ -325,7 +308,7 @@ private fun Scrollbar(
}
}
/* Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
/** Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
comes from the caller pre-converted via LocalDensity (Option-B: layout
coords are physical px, so px-to-dp goes through the current density). */
private fun Modifier.matchTrackSize(inVertical: Boolean, inTrackDp: Dp, inThickness: Dp): Modifier =
@@ -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
@@ -105,68 +94,6 @@ fun HorizontalSplitPane(
}
}
@Composable
fun VerticalSplitPane(
modifier: Modifier = Modifier,
initialFirstSize: Dp = 150.dp,
minFirstSize: Dp = 50.dp,
minSecondSize: Dp = 50.dp,
dividerColor: Color = Color(0x33FFFFFFL),
dividerHoverColor: Color = Color(0x66FFFFFFL),
first: @Composable () -> Unit,
second: @Composable () -> Unit,
) {
val vDensity = LocalDensity.current
val vDividerPx = with(vDensity) { SplitPaneDefaults.DividerThickness.toPx().toInt() }
val vMinFirstPx = with(vDensity) { minFirstSize.toPx().toInt() }
val vMinSecondPx = with(vDensity) { minSecondSize.toPx().toInt() }
val vInitialFirstPx = with(vDensity) { initialFirstSize.toPx().toInt() }
var vTotalHeightPx by remember { mutableStateOf(0) }
var vFirstHeightPx by remember { mutableStateOf(vInitialFirstPx) }
val vDividerSource = remember { MutableInteractionSource() }
val vDividerHover by vDividerSource.collectIsHoveredAsState()
var vDragging by remember { mutableStateOf(false) }
var vPressStartFirstPx by remember { mutableStateOf(0) }
Column(
modifier = modifier
.fillMaxSize()
.onSizeChanged { vTotalHeightPx = it.height },
) {
val vClampedFirstPx = if (vTotalHeightPx > 0)
vFirstHeightPx.coerceIn(vMinFirstPx, (vTotalHeightPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx))
else vFirstHeightPx
val vSecondHeightPx = (vTotalHeightPx - vClampedFirstPx - vDividerPx).coerceAtLeast(0)
Box(modifier = Modifier.height(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxWidth()) { first() }
// Solid divider that fills its slot; hover/drag only changes the colour
// (no size change → no layout shift, no flicker mid-drag).
val vActive = vDividerHover || vDragging
Box(
modifier = Modifier
.height(SplitPaneDefaults.DividerThickness)
.fillMaxWidth()
.background(if (vActive) dividerHoverColor else dividerColor)
.hoverable(vDividerSource)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { vDragging = true },
onDragEnd = { vDragging = false },
onDragCancel = { vDragging = false },
onDrag = { _, delta ->
val vMax = (vTotalHeightPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx)
vFirstHeightPx = (vFirstHeightPx + delta.y.toInt()).coerceIn(vMinFirstPx, vMax)
},
)
},
)
Box(modifier = Modifier.height(with(vDensity) { vSecondHeightPx.toDp() }).fillMaxWidth()) { second() }
}
}
object SplitPaneDefaults {
val DividerThickness: Dp = 4.dp // divider width = hit/drag area (solid; what you see is what you grab)
}
-162
View File
@@ -1,162 +0,0 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package apidemo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.loadSvgPainter
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.createFontFamilyResolver
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import java.awt.Desktop
import java.awt.EventQueue
import java.awt.FileDialog
import java.awt.Frame
import java.awt.datatransfer.StringSelection
import java.io.File
import org.jetbrains.skia.Image
// ==================
// MARK: JVM actuals — AWT + upstream Compose Desktop
// ==================
private val kIsWindows = System.getProperty("os.name").startsWith("Windows")
private val kIsMac = System.getProperty("os.name").startsWith("Mac")
internal actual val systemFileSystem: okio.FileSystem = okio.FileSystem.SYSTEM
actual fun createApiHttpClient(): io.ktor.client.HttpClient =
io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO)
// ============
// Window hooks — the shared App installs them; MainJvm wires them into the
// upstream Window() (onCloseRequest / onPreviewKeyEvent).
internal var jvmOnCloseRequest: (() -> Boolean)? = null
internal var jvmOnKeyShortcut: ((KeyEvent) -> Boolean)? = null
@Composable
actual fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean) {
androidx.compose.runtime.DisposableEffect(inOnCloseRequest, inOnKeyShortcut) {
jvmOnCloseRequest = inOnCloseRequest
jvmOnKeyShortcut = inOnKeyShortcut
onDispose { jvmOnCloseRequest = null; jvmOnKeyShortcut = null }
}
}
/* Mirrors SDL_GetPrefPath's layout: <os user-data dir>/<org>/<app>/ with a
trailing separator, created on demand. */
actual fun appDataDir(inOrg: String, inApp: String): String? {
val vHome = System.getProperty("user.home") ?: return null
val vBase = when {
kIsWindows -> System.getenv("APPDATA") ?: "$vHome/AppData/Roaming"
kIsMac -> "$vHome/Library/Application Support"
else -> System.getenv("XDG_DATA_HOME") ?: "$vHome/.local/share"
}
val vDir = File(File(vBase, inOrg), inApp)
if (!vDir.exists() && !vDir.mkdirs()) return null
return vDir.absolutePath + File.separator
}
/* Opens the path's containing folder (AWT has no cross-platform "select in
file manager"); fire-and-forget off the EDT. */
actual fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)?) {
Thread {
val vOk = runCatching {
val vFile = File(inPath)
Desktop.getDesktop().open(if (vFile.isDirectory) vFile else vFile.parentFile ?: vFile)
}.isSuccess
inOnResult?.invoke(vOk)
}.apply { isDaemon = true }.start()
}
actual fun fileManagerName(): String = when {
kIsWindows -> "Explorer"
kIsMac -> "Finder"
else -> "Files"
}
/* AWT FileDialog runs its own nested event loop, so invoking from the EDT
(where Compose Desktop also runs) keeps the UI serviced while modal. */
actual fun showSaveFileDialog(inDefaultName: String?, inOnResult: (String?) -> Unit) {
EventQueue.invokeLater {
val vDlg = FileDialog(null as Frame?, "Save", FileDialog.SAVE)
inDefaultName?.let { vDlg.file = it }
vDlg.isVisible = true
inOnResult(vDlg.file?.let { vDlg.directory + it })
}
}
actual fun showOpenFileDialog(inOnResult: (String?) -> Unit) {
EventQueue.invokeLater {
val vDlg = FileDialog(null as Frame?, "Open", FileDialog.LOAD)
vDlg.isVisible = true
inOnResult(vDlg.file?.let { vDlg.directory + it })
}
}
/* Upstream desktop's ClipEntry wraps an AWT Transferable. */
actual fun clipEntryOfText(inText: String): ClipEntry = ClipEntry(StringSelection(inText))
// ============
// In-memory response images
private val kMemoryImages = HashMap<String, ByteArray>()
actual fun registerMemoryImage(inKey: String, inBytes: ByteArray) { kMemoryImages[inKey] = inBytes }
actual fun removeMemoryImage(inKey: String) { kMemoryImages.remove(inKey) }
@Composable
actual fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter {
val vDensity = LocalDensity.current
return remember(inKey, inSvg) {
val vBytes = kMemoryImages[inKey]
runCatching {
if (vBytes == null) error("no bytes for $inKey")
if (inSvg) loadSvgPainter(vBytes.inputStream(), vDensity)
else BitmapPainter(Image.makeFromEncoded(vBytes).toComposeImageBitmap())
}.getOrElse { BitmapPainter(ImageBitmap(1, 1)) }
}
}
// ============
// Text layout
/* Upstream TextMeasurer at density 1 so the px-valued font size maps 1:1
(the caller passes an already density-scaled size). */
private val kRowMeasurer: TextMeasurer by lazy {
TextMeasurer(createFontFamilyResolver(), Density(1f), LayoutDirection.Ltr)
}
actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int =
runCatching {
kRowMeasurer.measure(
AnnotatedString(inText),
TextStyle(fontSize = inFontPx.sp, fontFamily = monoFontFamily ?: FontFamily.Monospace),
constraints = Constraints(maxWidth = inMaxWidthPx),
).lineCount
}.getOrDefault(1)
/* The jvm parity app only stores the preference — upstream's text pipeline has
no global tab-width knob. Backed by state so menu checkmarks recompose. */
private var fTabWidth by mutableStateOf(4)
actual var editorTabWidth: Int
get() = fTabWidth
set(value) { fTabWidth = value }
+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()
-1
View File
@@ -13,7 +13,6 @@ private const val kNativeOnly =
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain = TlsChain(emptyList(), kNativeOnly)
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse = ApiResponse(
ok = false,
status = 0,
statusText = "",
timeMs = 0,
@@ -0,0 +1,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
}
@@ -3,7 +3,7 @@ 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,
@@ -14,9 +14,9 @@ actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
cleanup = {},
)
/* No temporary certificate store off Windows. */
/** No temporary certificate store off Windows. */
actual fun sweepTempClientCerts() {}
/* No OS-store issuer resolution here — just continue with the name-only issuer. */
/** No OS-store issuer resolution here — just continue with the name-only issuer. */
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> =
serverChainWithIssuerName(inServerCerts)
@@ -3,7 +3,6 @@ package apidemo
import kotlinx.cinterop.*
import platform.windows.*
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
// ==================
// MARK: Windows client-certificate store import (Schannel mTLS)
@@ -32,14 +31,12 @@ private const val kCryptUserKeyset = 0x00001000u // CRYPT_USER_KEYSET (no
private val kEncoding: UInt = X509_ASN_ENCODING.toUInt() or PKCS_7_ASN_ENCODING.toUInt()
/* Import the request's certificate into CurrentUser\MY and return a libcurl
/** Import the request's certificate into CurrentUser\MY and return a libcurl
store reference plus a cleanup that removes it again. */
@OptIn(ExperimentalForeignApi::class)
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert
{
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert {
val vCertBytes = readFileBytes(inReq.certPath)
val (vThumb, vContainer) = when (inReq.certFormat)
{
val (vThumb, vContainer) = when (inReq.certFormat) {
CertFormat.PKCS12 -> importPkcs12(vCertBytes, inReq.certPassword)
else -> importPemDerRsa(vCertBytes, inReq)
}
@@ -57,60 +54,69 @@ actual fun prepareClientCert(inReq: ApiRequest): PreparedCert
// PEM / DER (RSA) → store
// ============
/* Build a cert context from PEM/DER, import its RSA private key into a named
/** Build a cert context from PEM/DER, import its RSA private key into a named
container, link the two, tag and add to CurrentUser\MY. Returns
(thumbprintHex, containerName). */
@OptIn(ExperimentalForeignApi::class)
private fun importPemDerRsa(inCertBytes: ByteArray, inReq: ApiRequest): Pair<String, String?>
{
private fun importPemDerRsa(inCertBytes: ByteArray, inReq: ApiRequest): Pair<String, String?> {
val vCertDer = derFromMaybePem(inCertBytes, "CERTIFICATE")
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
val vKeyDer = privateKeyDer(vKeyBytes)
?: throw RuntimeException("No private key found — PEM/DER on Windows needs the RSA key (a key file, or a key block in the certificate file).")
val vBlob = try { rsaKeyDerToBlob(vKeyDer) }
catch (e: Throwable) { throw RuntimeException("Unsupported private key (RSA PKCS#1/PKCS#8 is supported; EC isn't yet). Convert to PKCS#12: openssl pkcs12 -export -inkey key.pem -in cert.pem -out client.p12") }
val vBlob = try {
rsaKeyDerToBlob(vKeyDer)
} catch (e: Throwable) {
throw RuntimeException("Unsupported private key (RSA PKCS#1/PKCS#8 is supported; EC isn't yet). Convert to PKCS#12: openssl pkcs12 -export -inkey key.pem -in cert.pem -out client.p12")
}
val vCert = vCertDer.usePinned { vPin ->
CertCreateCertificateContext(kEncoding, vPin.addressOf(0).reinterpret(), vCertDer.size.convert())
} ?: winError("CertCreateCertificateContext")
try
{
try {
val vThumb = thumbprintHex(vCert)
val vContainer = kTagPrefix + vThumb
importRsaIntoContainer(vContainer, vBlob)
try
{
try {
linkKeyToCert(vCert, vContainer)
tagFriendlyName(vCert, kTagPrefix + vThumb)
addToStore(vCert)
return vThumb to vContainer
} catch (e: Throwable) {
deleteContainer(vContainer); throw e
}
catch (e: Throwable) { deleteContainer(vContainer); throw e }
} finally {
CertFreeCertificateContext(vCert)
}
finally { CertFreeCertificateContext(vCert) }
}
/* Create a fresh named key container and import the RSA blob into it. */
/** Create a fresh named key container and import the RSA blob into it. */
@OptIn(ExperimentalForeignApi::class)
private fun importRsaIntoContainer(inContainer: String, inBlob: ByteArray) = memScoped {
deleteContainer(inContainer) // drop a stale one if it somehow exists
val vProv = alloc<ULongVar>()
if (CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_NEWKEYSET.toUInt()) == 0)
winError("CryptAcquireContext(NEWKEYSET)")
try
{
try {
val vKey = alloc<ULongVar>()
val vOk = inBlob.usePinned { vPin ->
CryptImportKey(vProv.value, vPin.addressOf(0).reinterpret(), inBlob.size.convert(), 0uL, CRYPT_EXPORTABLE.toUInt(), vKey.ptr)
CryptImportKey(
vProv.value,
vPin.addressOf(0).reinterpret(),
inBlob.size.convert(),
0uL,
CRYPT_EXPORTABLE.toUInt(),
vKey.ptr
)
}
if (vOk == 0) winError("CryptImportKey")
CryptDestroyKey(vKey.value)
} finally {
CryptReleaseContext(vProv.value, 0u)
}
finally { CryptReleaseContext(vProv.value, 0u) }
}
/* Attach the named container to the cert so Schannel can find its key. */
/** Attach the named container to the cert so Schannel can find its key. */
@OptIn(ExperimentalForeignApi::class)
private fun linkKeyToCert(inCert: CPointer<CERT_CONTEXT>, inContainer: String) = memScoped {
val vInfo = alloc<CRYPT_KEY_PROV_INFO>()
@@ -129,63 +135,86 @@ private fun linkKeyToCert(inCert: CPointer<CERT_CONTEXT>, inContainer: String) =
// RSA key ASN.1 → CryptoAPI PRIVATEKEYBLOB
// ============
/* Minimal DER reader for the RSA key structures. */
private class Der(val b: ByteArray)
{
/** Minimal DER reader for the RSA key structures. */
private class Der(val b: ByteArray) {
var p = 0
fun tag(): Int = b[p++].toInt() and 0xFF
fun len(): Int
{
fun len(): Int {
val vFirst = b[p++].toInt() and 0xFF
if (vFirst and 0x80 == 0) return vFirst
var vLen = 0
repeat(vFirst and 0x7F) { vLen = (vLen shl 8) or (b[p++].toInt() and 0xFF) }
return vLen
}
fun sequence() { require(tag() == 0x30) { "SEQUENCE expected" }; len() }
fun sequence() {
require(tag() == 0x30) { "SEQUENCE expected" }; len()
}
fun peek(): Int = b[p].toInt() and 0xFF
fun skip() { tag(); val vL = len(); p += vL }
fun octetString(): ByteArray { require(tag() == 0x04) { "OCTET STRING expected" }; val vL = len(); return b.copyOfRange(p, p + vL).also { p += vL } }
fun skip() {
tag()
val vL = len(); p += vL
}
fun octetString(): ByteArray {
require(tag() == 0x04) { "OCTET STRING expected" }
val vL = len(); return b.copyOfRange(p, p + vL).also { p += vL }
}
// An INTEGER's magnitude (big-endian, leading zero stripped).
fun integer(): ByteArray
{
fun integer(): ByteArray {
require(tag() == 0x02) { "INTEGER expected" }
val vL = len(); val vV = b.copyOfRange(p, p + vL); p += vL
val vL = len()
val vV = b.copyOfRange(p, p + vL); p += vL
var vS = 0; while (vS < vV.size - 1 && vV[vS].toInt() == 0) vS++
return vV.copyOfRange(vS, vV.size)
}
}
/* Turn a PKCS#1 or PKCS#8 RSA private-key DER into a CryptoAPI PRIVATEKEYBLOB.
/** Turn a PKCS#1 or PKCS#8 RSA private-key DER into a CryptoAPI PRIVATEKEYBLOB.
Throws if it isn't an RSA key we can lay out. */
private fun rsaKeyDerToBlob(inDer: ByteArray): ByteArray
{
private fun rsaKeyDerToBlob(inDer: ByteArray): ByteArray {
val vR = Der(inDer)
vR.sequence(); vR.integer() // outer version
return if (vR.peek() == 0x30)
{
return if (vR.peek() == 0x30) {
// PKCS#8 PrivateKeyInfo: skip AlgorithmIdentifier, parse the inner key.
vR.skip()
parsePkcs1(vR.octetString())
}
else
} else
// PKCS#1: n, e, d, p, q, dp, dq, iqmp follow the version we just read.
buildBlob(vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer())
buildBlob(
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer()
)
}
private fun parsePkcs1(inDer: ByteArray): ByteArray
{
private fun parsePkcs1(inDer: ByteArray): ByteArray {
val vR = Der(inDer)
vR.sequence(); vR.integer() // version
return buildBlob(vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer())
return buildBlob(
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer(),
vR.integer()
)
}
/* Lay out the MS PRIVATEKEYBLOB (all little-endian) from the RSA components. */
/** Lay out the MS PRIVATEKEYBLOB (all little-endian) from the RSA components. */
private fun buildBlob(
inN: ByteArray, inE: ByteArray, inD: ByteArray,
inP: ByteArray, inQ: ByteArray, inDp: ByteArray, inDq: ByteArray, inIq: ByteArray,
): ByteArray
{
): ByteArray {
val vMod = inN.size // modulus length in bytes
val vHalf = vMod / 2
val vOut = ArrayList<Byte>(20 + vMod * 2 + vHalf * 5)
@@ -204,19 +233,22 @@ private fun buildBlob(
return vOut.toByteArray()
}
/* A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
private fun leField(inBe: ByteArray, inSize: Int): ByteArray
{
/** A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
private fun leField(inBe: ByteArray, inSize: Int): ByteArray {
val vOut = ByteArray(inSize)
for (vI in inBe.indices) if (vI < inSize) vOut[vI] = inBe[inBe.size - 1 - vI]
return vOut
}
private fun le32(inV: UInt): ByteArray =
byteArrayOf((inV and 0xFFu).toByte(), ((inV shr 8) and 0xFFu).toByte(), ((inV shr 16) and 0xFFu).toByte(), ((inV shr 24) and 0xFFu).toByte())
byteArrayOf(
(inV and 0xFFu).toByte(),
((inV shr 8) and 0xFFu).toByte(),
((inV shr 16) and 0xFFu).toByte(),
((inV shr 24) and 0xFFu).toByte()
)
private fun beToUInt(inBe: ByteArray): UInt
{
private fun beToUInt(inBe: ByteArray): UInt {
var vV = 0u
for (vB in inBe) vV = (vV shl 8) or (vB.toInt() and 0xFF).toUInt()
return vV
@@ -226,7 +258,7 @@ private fun beToUInt(inBe: ByteArray): UInt
// PKCS#12 → store
// ============
/* Import a .p12/.pfx into a memory store, then copy its cert (with the key
/** Import a .p12/.pfx into a memory store, then copy its cert (with the key
container PFXImportCertStore created) into CurrentUser\MY. */
@OptIn(ExperimentalForeignApi::class)
private fun importPkcs12(inBytes: ByteArray, inPassword: String): Pair<String, String?> = memScoped {
@@ -236,27 +268,40 @@ private fun importPkcs12(inBytes: ByteArray, inPassword: String): Pair<String, S
vBlob.pbData = vPin.addressOf(0).reinterpret()
val vMem = PFXImportCertStore(vBlob.ptr, inPassword, kCryptUserKeyset or CRYPT_EXPORTABLE.toUInt())
?: winError("PFXImportCertStore (wrong password, or not a PKCS#12 file?)")
try
{
val vSrc = CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
try {
val vSrc =
CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
val vThumb = thumbprintHex(vSrc)
val vContainer = containerNameOf(vSrc)
tagFriendlyName(vSrc, kTagPrefix + vThumb)
addToStore(vSrc)
vThumb to vContainer
} finally {
CertCloseStore(vMem, 0u)
}
finally { CertCloseStore(vMem, 0u) }
}
}
/* The key-container name PFXImportCertStore assigned to a cert (from its
/** The key-container name PFXImportCertStore assigned to a cert (from its
CERT_KEY_PROV_INFO), so cleanup can delete it. */
@OptIn(ExperimentalForeignApi::class)
private fun containerNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
val vLen = alloc<UIntVar>()
if (CertGetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), null, vLen.ptr) == 0) return@memScoped null
if (CertGetCertificateContextProperty(
inCert,
CERT_KEY_PROV_INFO_PROP_ID.toUInt(),
null,
vLen.ptr
) == 0
) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CertGetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
if (CertGetCertificateContextProperty(
inCert,
CERT_KEY_PROV_INFO_PROP_ID.toUInt(),
vBuf,
vLen.ptr
) == 0
) return@memScoped null
vBuf.reinterpret<CRYPT_KEY_PROV_INFO>().pointed.pwszContainerName?.toKStringFromUtf16()
}
@@ -264,7 +309,7 @@ private fun containerNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped
// Shared store helpers
// ============
/* Tag a cert with our friendly-name prefix so the startup sweep recognises it. */
/** Tag a cert with our friendly-name prefix so the startup sweep recognises it. */
@OptIn(ExperimentalForeignApi::class)
private fun tagFriendlyName(inCert: CPointer<CERT_CONTEXT>, inName: String) = memScoped {
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
@@ -273,38 +318,40 @@ private fun tagFriendlyName(inCert: CPointer<CERT_CONTEXT>, inName: String) = me
CertSetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), 0u, vBlob.ptr)
}
/* Add a cert context to CurrentUser\MY (replacing any same-thumbprint entry). */
/** Add a cert context to CurrentUser\MY (replacing any same-thumbprint entry). */
@OptIn(ExperimentalForeignApi::class)
private fun addToStore(inCert: CPointer<CERT_CONTEXT>)
{
private fun addToStore(inCert: CPointer<CERT_CONTEXT>) {
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
try
{
try {
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
winError("CertAddCertificateContextToStore")
} finally {
CertCloseStore(vStore, 0u)
}
finally { CertCloseStore(vStore, 0u) }
}
/* Open CurrentUser\MY (the current user's Personal store). */
/** Open CurrentUser\MY (the current user's Personal store). */
@OptIn(ExperimentalForeignApi::class)
private fun openMyStore() = CertOpenSystemStoreW(0.convert(), kStoreName)
/* The SHA-1 thumbprint of a cert as uppercase hex. */
/** The SHA-1 thumbprint of a cert as uppercase hex. */
@OptIn(ExperimentalForeignApi::class)
private fun thumbprintHex(inCert: CPointer<CERT_CONTEXT>): String = memScoped {
val vLen = alloc<UIntVar>().apply { value = 20u }
val vBuf = allocArray<UByteVar>(20)
if (CertGetCertificateContextProperty(inCert, CERT_SHA1_HASH_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0)
winError("CertGetCertificateContextProperty(SHA1)")
buildString { for (vI in 0 until vLen.value.toInt()) append(vBuf[vI].toInt().toString(16).uppercase().padStart(2, '0')) }
buildString {
for (vI in 0 until vLen.value.toInt()) append(
vBuf[vI].toInt().toString(16).uppercase().padStart(2, '0')
)
}
}
/* Remove a temporary cert (by thumbprint) from CurrentUser\MY and delete its
/** Remove a temporary cert (by thumbprint) from CurrentUser\MY and delete its
key container. Called per-request in finally; ignores what's already gone. */
@OptIn(ExperimentalForeignApi::class)
private fun deleteTempCert(inThumbHex: String, inContainer: String?)
{
private fun deleteTempCert(inThumbHex: String, inContainer: String?) {
val vStore = openMyStore()
if (vStore != null) memScoped {
val vHash = hexToBytes(inThumbHex)
@@ -320,23 +367,21 @@ private fun deleteTempCert(inThumbHex: String, inContainer: String?)
if (inContainer != null) deleteContainer(inContainer)
}
/* Delete a named key container (best-effort). */
/** Delete a named key container (best-effort). */
@OptIn(ExperimentalForeignApi::class)
private fun deleteContainer(inContainer: String) = memScoped {
val vProv = alloc<ULongVar>()
CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_DELETEKEYSET.toUInt())
}
/* Remove every temp cert we ever added (prefix-tagged) plus its container — for
/** Remove every temp cert we ever added (prefix-tagged) plus its container — for
clearing crash leftovers at startup. The user's own certs are never matched. */
@OptIn(ExperimentalForeignApi::class)
actual fun sweepTempClientCerts()
{
actual fun sweepTempClientCerts() {
val vStore = openMyStore() ?: return
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
var vCert = CertEnumCertificatesInStore(vStore, null)
while (vCert != null)
{
while (vCert != null) {
val vFriendly = friendlyNameOf(vCert)
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
@@ -346,13 +391,25 @@ actual fun sweepTempClientCerts()
vDoomed.forEach { deleteTempCert(it.first, it.second) }
}
/* A cert's friendly name, or null. */
/** A cert's friendly name, or null. */
@OptIn(ExperimentalForeignApi::class)
private fun friendlyNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
val vLen = alloc<UIntVar>()
if (CertGetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), null, vLen.ptr) == 0) return@memScoped null
if (CertGetCertificateContextProperty(
inCert,
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
null,
vLen.ptr
) == 0
) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CertGetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
if (CertGetCertificateContextProperty(
inCert,
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
vBuf,
vLen.ptr
) == 0
) return@memScoped null
vBuf.reinterpret<UShortVar>().toKStringFromUtf16()
}
@@ -360,61 +417,58 @@ private fun friendlyNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped
// Chain extension via the OS certificate store
// ============
/* Continue the server's chain by asking the OS to build it from the leaf — this
/** Continue the server's chain by asking the OS to build it from the leaf — this
pulls intermediates/roots from the Windows store with full info. Falls back to
a name-only issuer if the OS can't (or the leaf can't be parsed). */
@OptIn(ExperimentalForeignApi::class)
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert>
{
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> {
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
?: return serverChainWithIssuerName(inServerCerts)
val vOs = runCatching { osChain(vLeafPem, inServerCerts.size) }.getOrNull()
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
}
/* Build the full chain from the leaf via CertGetCertificateChain (OS store). */
/** Build the full chain from the leaf via CertGetCertificateChain (OS store). */
@OptIn(ExperimentalForeignApi::class)
private fun osChain(inLeafPem: String, inServerCount: Int): List<ChainCert> = memScoped {
val vDer = derFromMaybePem(inLeafPem.encodeToByteArray(), "CERTIFICATE")
val vLeaf = vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
val vLeaf =
vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
?: return@memScoped emptyList()
try
{
try {
val vPara = alloc<CERT_CHAIN_PARA>()
vPara.cbSize = sizeOf<CERT_CHAIN_PARA>().convert()
val vChainPtr = alloc<CPointerVar<CERT_CHAIN_CONTEXT>>()
if (CertGetCertificateChain(null, vLeaf, null, null, vPara.ptr, 0u, null, vChainPtr.ptr) == 0)
return@memScoped emptyList()
val vChain = vChainPtr.value ?: return@memScoped emptyList()
try
{
try {
val vSimple = vChain.pointed.rgpChain?.get(0)?.pointed ?: return@memScoped emptyList()
val vElems = vSimple.rgpElement ?: return@memScoped emptyList()
val vResult = ArrayList<ChainCert>()
for (vI in 0 until vSimple.cElement.toInt())
{
for (vI in 0 until vSimple.cElement.toInt()) {
val vCtx = vElems[vI]?.pointed?.pCertContext ?: continue
vResult.add(ChainCert(certFieldsFromContext(vCtx), vI < inServerCount))
}
// If the chain didn't reach a self-signed root, end with the name only.
val vLast = vResult.lastOrNull()
if (vLast != null)
{
if (vLast != null) {
val vSub = certFieldOf(vLast.fields, "Subject")
val vIss = certFieldOf(vLast.fields, "Issuer")
if (!vIss.isNullOrBlank() && vIss != vSub) vResult.add(ChainCert(listOf("Subject" to vIss), false))
}
vResult
} finally {
CertFreeCertificateChain(vChain)
}
finally { CertFreeCertificateChain(vChain) }
} finally {
CertFreeCertificateContext(vLeaf)
}
finally { CertFreeCertificateContext(vLeaf) }
}
/* Subject / Issuer (RDN strings) + the PEM, read from a cert context. */
/** Subject / Issuer (RDN strings) + the PEM, read from a cert context. */
@OptIn(ExperimentalForeignApi::class)
private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<String, String>>
{
private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<String, String>> {
val vFields = ArrayList<Pair<String, String>>()
certNameString(inCtx, 0u)?.let { vFields.add("Subject" to it) }
certNameString(inCtx, CERT_NAME_ISSUER_FLAG.toUInt())?.let { vFields.add("Issuer" to it) }
@@ -422,7 +476,7 @@ private fun certFieldsFromContext(inCtx: CPointer<CERT_CONTEXT>): List<Pair<Stri
return vFields
}
/* A cert's Subject (inFlags=0) or Issuer (CERT_NAME_ISSUER_FLAG) as an X.500 string. */
/** A cert's Subject (inFlags=0) or Issuer (CERT_NAME_ISSUER_FLAG) as an X.500 string. */
@OptIn(ExperimentalForeignApi::class)
private fun certNameString(inCtx: CPointer<CERT_CONTEXT>, inFlags: UInt): String? = memScoped {
val vType = alloc<UIntVar>().apply { value = CERT_X500_NAME_STR.toUInt() }
@@ -433,15 +487,29 @@ private fun certNameString(inCtx: CPointer<CERT_CONTEXT>, inFlags: UInt): String
vBuf.toKString()
}
/* The PEM (base64-with-header) of a cert context. */
/** The PEM (base64-with-header) of a cert context. */
@OptIn(ExperimentalForeignApi::class)
private fun certPem(inCtx: CPointer<CERT_CONTEXT>): String? = memScoped {
val vData = inCtx.pointed.pbCertEncoded ?: return@memScoped null
val vSize = inCtx.pointed.cbCertEncoded
val vLen = alloc<UIntVar>()
if (CryptBinaryToStringA(vData, vSize, CRYPT_STRING_BASE64HEADER.toUInt(), null, vLen.ptr) == 0) return@memScoped null
if (CryptBinaryToStringA(
vData,
vSize,
CRYPT_STRING_BASE64HEADER.toUInt(),
null,
vLen.ptr
) == 0
) return@memScoped null
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
if (CryptBinaryToStringA(vData, vSize, CRYPT_STRING_BASE64HEADER.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
if (CryptBinaryToStringA(
vData,
vSize,
CRYPT_STRING_BASE64HEADER.toUInt(),
vBuf,
vLen.ptr
) == 0
) return@memScoped null
vBuf.toKString()
}
@@ -449,47 +517,43 @@ private fun certPem(inCtx: CPointer<CERT_CONTEXT>): String? = memScoped {
// Small helpers
// ============
/* Decode a PEM block (by label) to DER, or pass bytes through if already DER. */
@OptIn(ExperimentalEncodingApi::class)
private fun derFromMaybePem(inBytes: ByteArray, inLabel: String): ByteArray
{
/** Decode a PEM block (by label) to DER, or pass bytes through if already DER. */
private fun derFromMaybePem(inBytes: ByteArray, inLabel: String): ByteArray {
val vText = inBytes.decodeToString()
if (!vText.contains("-----BEGIN")) return inBytes
val vBegin = vText.indexOf("-----BEGIN $inLabel-----")
val vEnd = vText.indexOf("-----END $inLabel-----")
val vBlock = if (vBegin >= 0 && vEnd >= 0) vText.substring(vBegin, vEnd) else vText
return Base64.Default.decode(pemBody(vBlock))
return Base64.decode(pemBody(vBlock))
}
/* Extract + DER-decode the first PRIVATE KEY block from PEM text, or pass DER
/** Extract + DER-decode the first PRIVATE KEY block from PEM text, or pass DER
bytes through. Null if no key. */
@OptIn(ExperimentalEncodingApi::class)
private fun privateKeyDer(inBytes: ByteArray): ByteArray?
{
private fun privateKeyDer(inBytes: ByteArray): ByteArray? {
val vText = inBytes.decodeToString()
if (!vText.contains("-----BEGIN")) return inBytes // already DER
val vMatch = Regex("-----BEGIN [A-Z 0-9]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z 0-9]*PRIVATE KEY-----").find(vText)
?: return null
return Base64.Default.decode(pemBody(vMatch.value))
return Base64.decode(pemBody(vMatch.value))
}
/* The base64 body of a PEM block (drop the BEGIN/END lines and whitespace). */
/** The base64 body of a PEM block (drop the BEGIN/END lines and whitespace). */
private fun pemBody(inPem: String): String =
inPem.lineSequence().filterNot { it.startsWith("-----") }.joinToString("") { it.trim() }
private fun hexToBytes(inHex: String): ByteArray =
ByteArray(inHex.length / 2) { inHex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
/* Read a null-terminated UTF-16 (wide) string. */
/** Read a null-terminated UTF-16 (wide) string. */
@OptIn(ExperimentalForeignApi::class)
private fun CPointer<UShortVar>.toKStringFromUtf16(): String
{
private fun CPointer<UShortVar>.toKStringFromUtf16(): String {
val vSb = StringBuilder()
var vI = 0
while (true) { val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++ }
while (true) {
val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++
}
return vSb.toString()
}
@OptIn(ExperimentalForeignApi::class)
private fun winError(inWhat: String): Nothing =
throw RuntimeException("$inWhat failed (Windows error 0x${GetLastError().toString(16)})")
+113 -74
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,7 +21,7 @@ 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
/** A client certificate readied for libcurl: the CURLOPT_SSLCERT value plus the
other SSL options to set, and a cleanup to run once the request is done.
macOS/Linux (OpenSSL) just point curl at the files. Windows (Schannel) imports
the cert + key into CurrentUser\MY, references it by SHA-1 thumbprint, and the
@@ -35,7 +35,7 @@ class PreparedCert(
val cleanup: () -> Unit,
)
/* Ready the request's client certificate for libcurl (platform-specific).
/** Ready the request's client certificate for libcurl (platform-specific).
Throws with a user-facing message if the cert/key can't be loaded. */
expect fun prepareClientCert(inReq: ApiRequest): PreparedCert
@@ -44,25 +44,23 @@ 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
/** Continue a server-presented chain with its issuer(s). Where possible the
issuer is resolved from the OS certificate store with full info; otherwise the
chain ends with a name-only placeholder. Every derived (non-server) cert is
flagged fromServer = false. Platform-specific (OS store on Windows). */
expect fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert>
/* A CURLINFO_CERTINFO field by name (case-insensitive). */
/** A CURLINFO_CERTINFO field by name (case-insensitive). */
internal fun certFieldOf(inFields: List<Pair<String, String>>, inName: String): String? =
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
/* Server certs as ChainCerts; if the last one isn't self-signed, append a
/** Server certs as ChainCerts; if the last one isn't self-signed, append a
dotted, name-only issuer so the chain visibly continues. The cross-platform
fallback when the OS store can't (or isn't able to) resolve the real issuer. */
internal fun serverChainWithIssuerName(inServer: List<List<Pair<String, String>>>): List<ChainCert>
{
internal fun serverChainWithIssuerName(inServer: List<List<Pair<String, String>>>): List<ChainCert> {
val vOut = inServer.map { ChainCert(it, true) }.toMutableList()
val vLast = inServer.lastOrNull()
if (vLast != null)
{
if (vLast != null) {
val vSubject = certFieldOf(vLast, "Subject")
val vIssuer = certFieldOf(vLast, "Issuer")
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
@@ -71,51 +69,65 @@ internal fun serverChainWithIssuerName(inServer: List<List<Pair<String, String>>
return vOut
}
/* Per-transfer accumulators handed to the C callbacks through a StableRef. */
private class CurlSink
{
/** Per-transfer accumulators handed to the C callbacks through a StableRef. */
private class CurlSink {
val body = Buffer() // response body bytes (already decompressed by curl)
val headerRaw = StringBuilder() // raw response header lines, all responses incl. redirects
}
/* Body write callback — appends each chunk to the sink's buffer. */
/** Body write callback — appends each chunk to the sink's buffer. */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlBody(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t
{
private fun onCurlBody(
inBuffer: CPointer<ByteVar>,
inSize: size_t,
inCount: size_t,
inUserdata: COpaquePointer
): size_t {
val vSink = inUserdata.asStableRef<CurlSink>().get()
val vLen = (inSize * inCount).toLong()
if (vLen > 0) vSink.body.write(inBuffer.readBytes(vLen.toInt()))
return vLen.convert()
}
/* Header callback — one parsed header line per call; accumulated for parsing. */
/** Header callback — one parsed header line per call; accumulated for parsing. */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlHeader(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t
{
private fun onCurlHeader(
inBuffer: CPointer<ByteVar>,
inSize: size_t,
inCount: size_t,
inUserdata: COpaquePointer
): size_t {
val vSink = inUserdata.asStableRef<CurlSink>().get()
val vLen = (inSize * inCount).toLong()
if (vLen > 0) vSink.headerRaw.append(inBuffer.readBytes(vLen.toInt()).decodeToString())
return vLen.convert()
}
/* Sink callback that discards everything (used by the TLS-chain probe so the
/** Sink callback that discards everything (used by the TLS-chain probe so the
response body/headers don't spill to stdout). */
@OptIn(ExperimentalForeignApi::class)
private fun onCurlDiscard(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t =
private fun onCurlDiscard(
inBuffer: CPointer<ByteVar>,
inSize: size_t,
inCount: size_t,
inUserdata: COpaquePointer
): size_t =
(inSize * inCount)
/* Open a TLS connection to the request's URL (handshake only — no body) and
/** Open a TLS connection to the request's URL (handshake only — no body) and
return the server's certificate chain via CURLINFO_CERTINFO. Reuses the
request's client certificate if it has one (so mTLS endpoints work too).
inReq is already var-resolved. */
@OptIn(ExperimentalForeignApi::class)
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain
{
val vCert = try { if (inReq.hasClientCert) prepareClientCert(inReq) else null }
catch (e: Throwable) { return TlsChain(emptyList(), e.message ?: "Client certificate error") }
val vCurl = curl_easy_init() ?: run { vCert?.cleanup(); return TlsChain(emptyList(), "Could not initialize libcurl") }
try
{
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain {
val vCert = try {
if (inReq.hasClientCert) prepareClientCert(inReq) else null
} catch (e: Throwable) {
return TlsChain(emptyList(), e.message ?: "Client certificate error")
}
val vCurl =
curl_easy_init() ?: run { vCert?.cleanup(); return TlsChain(emptyList(), "Could not initialize libcurl") }
try {
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L) // just the handshake + headers
curl_easy_setopt(vCurl, CURLOPT_CERTINFO, 1L)
@@ -133,17 +145,18 @@ actual fun inspectTlsChain(inReq: ApiRequest): TlsChain
if (vCode != CURLE_OK)
return TlsChain(emptyList(), curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode")
val vRaw = readCertInfo(vCurl)
if (vRaw.isEmpty()) return TlsChain(emptyList(), "No certificate chain reported (not an HTTPS URL, or the TLS backend didn't expose it).")
if (vRaw.isEmpty()) return TlsChain(
emptyList(),
"No certificate chain reported (not an HTTPS URL, or the TLS backend didn't expose it)."
)
return TlsChain(extendChain(vRaw), null)
}
finally
{
} finally {
curl_easy_cleanup(vCurl)
vCert?.cleanup()
}
}
/* Read CURLINFO_CERTINFO off a performed handle into per-cert field lists. */
/** Read CURLINFO_CERTINFO off a performed handle into per-cert field lists. */
@OptIn(ExperimentalForeignApi::class)
private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>>> = memScoped {
val vPtr = alloc<COpaquePointerVar>()
@@ -151,15 +164,12 @@ private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>
val vInfo = vPtr.value?.reinterpret<curl_certinfo>()?.pointed ?: return@memScoped emptyList()
val vArr = vInfo.certinfo ?: return@memScoped emptyList()
val vResult = ArrayList<List<Pair<String, String>>>()
for (vI in 0 until vInfo.num_of_certs)
{
for (vI in 0 until vInfo.num_of_certs) {
val vFields = ArrayList<Pair<String, String>>()
var vNode = vArr[vI]
while (vNode != null)
{
while (vNode != null) {
val vData = vNode.pointed.data?.toKString()
if (vData != null)
{
if (vData != null) {
val vColon = vData.indexOf(':')
if (vColon > 0) vFields.add(vData.substring(0, vColon) to vData.substring(vColon + 1))
else vFields.add("" to vData)
@@ -171,23 +181,29 @@ private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>
vResult
}
/* Send a client-certificate request through libcurl and adapt the result into
/** Send a client-certificate request through libcurl and adapt the result into
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
@OptIn(ExperimentalForeignApi::class)
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
{
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse {
val vMark = TimeSource.Monotonic.markNow()
val vCert = try { prepareClientCert(inReq) }
catch (e: Throwable) { return errorResponse(e.message ?: "Client certificate error", vMark.elapsedNow().inWholeMilliseconds) }
val vCert = try {
prepareClientCert(inReq)
} catch (e: Throwable) {
return errorResponse(e.message ?: "Client certificate error", vMark.elapsedNow().inWholeMilliseconds)
}
val vCurl = curl_easy_init()
?: run { vCert.cleanup(); return errorResponse("Could not initialize libcurl", vMark.elapsedNow().inWholeMilliseconds) }
?: run {
vCert.cleanup(); return errorResponse(
"Could not initialize libcurl",
vMark.elapsedNow().inWholeMilliseconds
)
}
val vSink = CurlSink()
val vSinkRef = StableRef.create(vSink)
var vHeaders: CPointer<curl_slist>? = null
try
{
try {
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 1L)
curl_easy_setopt(vCurl, CURLOPT_ACCEPT_ENCODING, "") // advertise + auto-decompress
@@ -199,8 +215,7 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
else
curl_easy_setopt(vCurl, CURLOPT_CUSTOMREQUEST, inReq.method.name)
if (vBody != null && vBody.bytes.isNotEmpty())
{
if (vBody != null && vBody.bytes.isNotEmpty()) {
curl_easy_setopt(vCurl, CURLOPT_POSTFIELDSIZE_LARGE, vBody.bytes.size.toLong())
vBody.bytes.usePinned { curl_easy_setopt(vCurl, CURLOPT_COPYPOSTFIELDS, it.addressOf(0)) }
}
@@ -240,9 +255,27 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
return errorResponse(curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode", vMs)
val vBytes = vSink.body.readByteArray()
val vStatus = memScoped { val p = alloc<LongVar>(); curl_easy_getinfo(vCurl, CURLINFO_RESPONSE_CODE, p.ptr); p.value.toInt() }
val vCt = memScoped { val p = alloc<CPointerVar<ByteVar>>(); curl_easy_getinfo(vCurl, CURLINFO_CONTENT_TYPE, p.ptr); p.value?.toKString() }
val vVer = memScoped { val p = alloc<LongVar>(); curl_easy_getinfo(vCurl, CURLINFO_HTTP_VERSION, p.ptr); httpVersionName(p.value) }
val vStatus = memScoped {
val p = alloc<LongVar>(); curl_easy_getinfo(
vCurl,
CURLINFO_RESPONSE_CODE,
p.ptr
); p.value.toInt()
}
val vCt = memScoped {
val p = alloc<CPointerVar<ByteVar>>(); curl_easy_getinfo(
vCurl,
CURLINFO_CONTENT_TYPE,
p.ptr
); p.value?.toKString()
}
val vVer = memScoped {
val p = alloc<LongVar>(); curl_easy_getinfo(
vCurl,
CURLINFO_HTTP_VERSION,
p.ptr
); httpVersionName(p.value)
}
val vIsImage = vCt?.startsWith("image/", ignoreCase = true) == true
val vBinary = !vIsImage && isBinaryBody(vCt, vBytes)
@@ -254,7 +287,6 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
}.sortedBy { it.first.lowercase() }
return ApiResponse(
ok = true,
status = vStatus,
statusText = runCatching { HttpStatusCode.fromValue(vStatus).description }.getOrDefault(""),
timeMs = vMs,
@@ -270,9 +302,7 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
requestHeaders = vSent,
httpVersion = vVer,
)
}
finally
{
} finally {
vHeaders?.let { curl_slist_free_all(it) }
curl_easy_cleanup(vCurl)
vSinkRef.dispose()
@@ -280,37 +310,47 @@ actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
}
}
/* A failed-request ApiResponse with a message (mirrors HttpRunner's catch). */
/** A failed-request ApiResponse with a message (mirrors HttpRunner's catch). */
private fun errorResponse(inMessage: String, inMs: Long): ApiResponse =
ApiResponse(ok = false, status = 0, statusText = "—", timeMs = inMs, sizeBytes = 0, headers = emptyList(), body = "", error = inMessage)
ApiResponse(
status = 0,
statusText = "—",
timeMs = inMs,
sizeBytes = 0,
headers = emptyList(),
body = "",
error = inMessage
)
/* The encoded body for the request's body type, with the Content-Type curl
/** The encoded body for the request's body type, with the Content-Type curl
should send (null when there's no body). Mirrors HttpRunner.run(). */
private class CurlBody(val contentType: String?, val bytes: ByteArray)
private fun requestBodyBytes(inReq: ApiRequest): CurlBody?
{
private fun requestBodyBytes(inReq: ApiRequest): CurlBody? {
if (inReq.bodyType == BodyType.NONE) return null
return when (inReq.bodyType)
{
return when (inReq.bodyType) {
BodyType.TEXT -> CurlBody(inReq.bodyContentType(), inReq.body.encodeToByteArray())
BodyType.FORM -> CurlBody(inReq.bodyContentType(), formEncode(inReq.form).encodeToByteArray())
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(inReq.bodyContentType(), readFileBytes(inReq.body))
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(
inReq.bodyContentType(),
readFileBytes(inReq.body)
)
BodyType.NONE -> null
}
}
/* Parse libcurl's accumulated raw header text into ordered key/value pairs,
/** 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>>
{
private fun parseRawHeaders(inRaw: String): List<Pair<String, String>> {
var vCurrent = mutableListOf<Pair<String, String>>()
for (vRawLine in inRaw.split("\r\n", "\n"))
{
for (vRawLine in inRaw.split("\r\n", "\n")) {
val vLine = vRawLine.trimEnd()
if (vLine.isEmpty()) continue
if (vLine.startsWith("HTTP/", ignoreCase = true)) { vCurrent = mutableListOf(); continue }
if (vLine.startsWith("HTTP/", ignoreCase = true)) {
vCurrent = mutableListOf(); continue
}
val vColon = vLine.indexOf(':')
if (vColon <= 0) continue
vCurrent.add(vLine.substring(0, vColon).trim() to vLine.substring(vColon + 1).trim())
@@ -318,10 +358,9 @@ private fun parseRawHeaders(inRaw: String): List<Pair<String, String>>
return vCurrent.sortedBy { it.first.lowercase() }
}
/* libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
/** libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
@OptIn(ExperimentalForeignApi::class)
private fun httpVersionName(inCode: Long): String = when (inCode)
{
private fun httpVersionName(inCode: Long): String = when (inCode) {
CURL_HTTP_VERSION_1_0.toLong() -> "HTTP/1.0"
CURL_HTTP_VERSION_1_1.toLong() -> "HTTP/1.1"
CURL_HTTP_VERSION_2_0.toLong() -> "HTTP/2"
+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,19 +47,21 @@ 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
actual var editorTabWidth: Int
get() = TextLayoutConfig.tabWidth
set(value) { TextLayoutConfig.tabWidth = value }
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) {
+32 -18
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,6 +151,9 @@ 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"))
}
}
}
+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")
}
}
}
@@ -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
@@ -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. */
@@ -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 =
@@ -14,7 +14,7 @@ import androidx.compose.ui.unit.TextUnit
// MARK: IconText — project-only text composable for icon fonts
// ==================
/*
/**
A minimal project text composable that renders a codepoint through a named
icon font with optional variable-font axis settings (Material Symbols).
@@ -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,
)
@@ -34,6 +34,8 @@ 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
@@ -47,7 +49,7 @@ import sdl3.SDL_WaitEventTimeout
// MARK: Application entry — nativeComposeApp + Window()
// ==================
/*
/**
Multi-window entry point, shaped like Compose Desktop's `application {}`:
nativeComposeApp {
@@ -71,12 +73,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(). */
@@ -87,6 +89,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,
) {
@@ -100,6 +103,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.
@@ -114,7 +118,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) {
@@ -174,11 +178,14 @@ 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)
}
}
@@ -284,7 +291,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
SDL_Quit()
}
/* CDN_PROFILE=1 — per-phase timings, printed every ~2s of rendered frames.
/** 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
@@ -352,29 +359,30 @@ internal object FrameProfiler {
}
}
/* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
their native memory (see the main loop's native-memory nudge). */
/** 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
/** 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
/** 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
/** 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
@@ -401,22 +409,25 @@ private fun animationClockNanos(): Long =
// 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
/** 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,
) {
@@ -427,6 +438,7 @@ fun nativeComposeWindow(
width = width,
height = height,
gpu = gpu,
icon = icon,
onFrame = onFrame,
content = content,
)
@@ -441,7 +453,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. */
@@ -465,10 +477,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).
@@ -513,11 +526,16 @@ 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)
val backend = SDL3Backend(
initialTitle, inWidth, inHeight, gpuMode = gpuMode,
iconLightResourcePaths = inIcon?.light ?: emptyList(),
iconDarkResourcePaths = inIcon?.dark ?: emptyList(),
)
private var renderBackend: RenderBackend? = null
lateinit var host: ComposeRootHost
private set
@@ -575,6 +593,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
@@ -688,6 +707,9 @@ internal class WindowInstance(
}
},
) {
// 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.
@@ -697,6 +719,7 @@ internal class WindowInstance(
}
}
}
}
// First composition done (setContent is synchronous) — promote the
// window lifecycle from CREATED to the focus/visibility-derived state.
// Composition itself runs at CREATED so enableSavedStateHandles()
@@ -712,7 +735,7 @@ 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() {
@@ -721,6 +744,9 @@ internal class WindowInstance(
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()
}
// ============
@@ -753,6 +779,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()
@@ -772,13 +809,44 @@ 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.
// 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) {
needsFrame = true
@@ -802,7 +870,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
@@ -818,7 +892,7 @@ internal class WindowInstance(
fun shouldRender(): Boolean =
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null || kForceRender
/* True while this window still has pending work: a state/layout/draw invalidation
/** 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. */
@@ -826,9 +900,6 @@ internal class WindowInstance(
needsFrame || recomposer?.hasPendingWork == true ||
frameClock.hasAwaiters || host.hasAnimationAwaiters()
// TEMP measurement: CDN_FORCERENDER=1 forces every frame to render so
// sustained steady-state timings can be measured on otherwise-idle screens.
fun renderFrame() {
val vRender = renderBackend ?: return
needsFrame = false
@@ -837,6 +908,14 @@ 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")
@@ -910,7 +989,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
@@ -958,7 +1037,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) {
@@ -979,7 +1058,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, …). */
@@ -995,7 +1074,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
@@ -4,7 +4,7 @@ import androidx.compose.runtime.BroadcastFrameClock
import androidx.compose.runtime.MonotonicFrameClock
import sdl3.SDL_GetTicksNS
/* MonotonicFrameClock for the SDL main loop, with upstream frame semantics:
/** MonotonicFrameClock for the SDL main loop, with upstream frame semantics:
- withFrameNanos ALWAYS suspends until the NEXT sendFrame — never completes
with a stale frame. Deferred animations (AnimatedVisibility's enter/exit
@@ -10,7 +10,7 @@ import sdl3.SDL_GetCurrentThreadID
// MARK: Sdl3MainDispatcher
// ==================
/* Single-threaded Main dispatcher driven by the SDL3 main loop. Kotlin/Native
/** Single-threaded Main dispatcher driven by the SDL3 main loop. Kotlin/Native
ships no Dispatchers.Main on Linux/Windows targets (and the Darwin one
posts to GCD's main queue, which only drains when the run loop pumps —
our SDL_Delay-based loop doesn't reliably pump it). nativeComposeWindow
@@ -48,7 +48,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
fQueue.trySend(block)
}
/* Drains all pending tasks. Called from the SDL3 main loop once per
/** Drains all pending tasks. Called from the SDL3 main loop once per
frame (after event dispatch, before layout) so withContext(Main) {}
resumptions land before the recomposer kicks off the next frame's
composition. */
@@ -61,7 +61,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
}
}
/* Closes the queue. After this, dispatch() drops new posts silently.
/** Closes the queue. After this, dispatch() drops new posts silently.
Called from nativeComposeWindow as part of shutdown. */
fun close() {
fQueue.close()
@@ -69,7 +69,7 @@ internal class Sdl3MainDispatcher : MainCoroutineDispatcher() {
override fun toString(): String = "Sdl3MainDispatcher"
/* Dispatchers.Main.immediate: inline when already on the SDL main thread
/** Dispatchers.Main.immediate: inline when already on the SDL main thread
(isDispatchNeeded = false), queued like the base dispatcher otherwise. */
private inner class ImmediateDispatcher : MainCoroutineDispatcher() {
override val immediate: MainCoroutineDispatcher get() = this
-28
View File
@@ -1,5 +1,4 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import java.net.URI
// :core — the renderer-agnostic Compose base + both renderer pipelines.
//
@@ -253,30 +252,3 @@ kotlin {
)
}
}
// ==================
// MARK: Default + monospace fonts (Google Noto, downloaded at build time)
// ==================
val notoSansFont = layout.buildDirectory.file("fonts/NotoSans.ttf")
val notoSansMonoFont = layout.buildDirectory.file("fonts/NotoSansMono.ttf")
val downloadNotoFonts = tasks.register("downloadNotoFonts") {
description = "Download Google NotoSans and NotoSansMono variable fonts to build/fonts/ for use in SDL3 text rendering"
val vDownloads = listOf(
"https://raw.githubusercontent.com/google/fonts/main/ofl/notosans/NotoSans%5Bwdth%2Cwght%5D.ttf"
to notoSansFont.get().asFile,
"https://raw.githubusercontent.com/google/fonts/main/ofl/notosansmono/NotoSansMono%5Bwdth%2Cwght%5D.ttf"
to notoSansMonoFont.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) } }
println("Saved ${vOut.length() / 1024} KiB to $vOut")
}
}
}
@@ -8,7 +8,7 @@ import androidx.compose.runtime.setValue
// MARK: TextLayoutConfig
// ==================
/* Global text-layout knobs read by the renderers. `tabWidth` is how many spaces
/** Global text-layout knobs read by the renderers. `tabWidth` is how many spaces
a literal '\t' expands to for both measurement and drawing — i.e. the editor's
"tab size". Snapshot-backed so UI exposing it recomposes when it changes,
while the per-frame layout/draw pass reads the live value (the renderers key
@@ -4,10 +4,10 @@ package com.compose.sdl.graphics
// MARK: NativeFinishableCanvas
// ==================
/*
/**
A renderer canvas that must be flushed once drawing into it is done — SDL batches
geometry and only submits on flush; an offscreen SDL canvas must flush before its
backing texture is read. Implemented by Sdl3Canvas (real flush) and SkiaCanvas
backing texture is read. Implemented by Sdl3Canvas (real flush) and SkiaBackedCanvas
(no-op — Skia save/restore is balanced per call). Lets renderer-agnostic code
(e.g. GraphicsLayer.toImageBitmap) commit an offscreen render without knowing the
concrete canvas type.
@@ -7,19 +7,17 @@ import androidx.compose.ui.graphics.Outline
// MARK: NativeShadowCanvas (render bridge)
// ==================
/* Drop-shadow bridge, same shape as NativeTextCanvas / NativePainterCanvas:
ProjectOwnedLayer calls it when a graphicsLayer carries shadowElevation > 0
/** Drop-shadow bridge, same shape as NativeTextCanvas / NativePainterCanvas:
the render nodes call it when a layer carries shadowElevation > 0
(Modifier.shadow, m3 Surface tonal+shadow elevation). The outline is the
layer's resolved shape in LAYER-LOCAL pixels — the canvas is already
translated to the layer origin when this is invoked, and the shadow must
paint BEFORE the layer's clip (it lives outside the bounds).
Implementations:
- SkiaCanvas — a real Gaussian blur MaskFilter on the outline.
- Sdl3Canvas — stacked, expanding alpha rings through the tessellator
(the SDL 2D renderer has no shader/blur primitive; alpha accumulation
over quadratically-spaced rings + the per-ring AA fringe reads as a
soft Gaussian at UI sizes). */
- SkiaBackedCanvas — a real Gaussian blur MaskFilter on the outline.
- Sdl3Canvas — cached shadow tiles (Sdl3ShadowCache) blitted via 9-slice
(the SDL 2D renderer has no shader/blur primitive). */
interface NativeShadowCanvas {
fun drawDropShadow(
inOutline: Outline,
@@ -7,9 +7,9 @@ import com.compose.sdl.res.ResourceKind
// MARK: NativePainterCanvas bridge
// ==================
/*
Phase 9 B5 — image drawing bridge. Renderer Canvas backends (Sdl3Canvas /
future SkiaCanvas) implement this interface to paint a bundled resource by
/**
Image drawing bridge. Renderer Canvas backends (Sdl3Canvas / SkiaBackedCanvas)
implement this interface to paint a bundled resource by
path + kind. [ResourcePainter] (upstream `Painter` subclass in the res
pipeline) casts the current Canvas to this and calls drawNativePainter from
inside its `Painter.onDraw()` — reached via upstream `Modifier.paint(...)`
@@ -17,7 +17,7 @@ import com.compose.sdl.res.ResourceKind
vendored upstream engine.
contentScale is FillBounds when called through PainterModifier (which
pre-scales the size); other values are supported for legacy callers.
pre-scales the size); other values are supported for direct callers.
*/
interface NativePainterCanvas {
fun drawNativePainter(
@@ -4,7 +4,7 @@ package com.compose.sdl.graphics
// MARK: PathCommand
// ==================
/* Project-only render-bridge sealed type — the building blocks of our reduced
/** Project-only render-bridge sealed type — the building blocks of our reduced
Path implementation. Upstream's Path is an `expect class` backed by Skia /
Android Canvas internals; our `androidx.compose.ui.graphics.Path` instead
carries a `commands: List<PathCommand>` that the SDL3 / Skia renderers walk
@@ -6,19 +6,18 @@ import androidx.compose.ui.geometry.RoundRect
// MARK: NativeShapeClipCanvas bridge
// ==================
/*
/**
Side-channel for renderer Canvas backends that can clip subsequent drawing to
a rounded-rect / circle OUTLINE (not just its bounding box).
The Skia backend does NOT implement this: Skia's `Canvas.clipPath` already
clips to a rounded path natively, so `ProjectOwnedLayer` just builds a path and
calls `clipPath` there. The SDL3 backend has no path-clip primitive (only a
rectangular render clip), so `Sdl3Canvas` implements this by rendering the
clipped subtree into an offscreen target and masking it to the rounded shape.
clips to a rounded path natively. The SDL3 backend has no path-clip primitive
(only a rectangular render clip), so `Sdl3Canvas` implements this by rendering
the clipped subtree into an offscreen target and masking it to the rounded shape
(lazily — see the pending-clip machinery there).
`ProjectOwnedLayer.drawLayer` checks `canvas is NativeShapeClipCanvas` when a
layer has an `Outline.Rounded` clip and routes here; otherwise it falls back to
`clipPath`. The pushed clip is scoped to the enclosing `Canvas.save()` /
The render nodes check `canvas is NativeShapeClipCanvas` when a layer has an
`Outline.Rounded` clip and route here; otherwise they fall back to `clipPath`. The pushed clip is scoped to the enclosing `Canvas.save()` /
`Canvas.restore()` — the backend composites and pops it on the matching
`restore()`.
*/

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