Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa96b9eb10 | ||
|
|
5a891cd121 | ||
|
|
ad08453859 | ||
|
|
5822061770 | ||
|
|
7920587cf5 | ||
|
|
65aef4e036 | ||
|
|
3843d94e29 | ||
|
|
73a4f75fa5 | ||
|
|
55fadc1a69 | ||
|
|
3432562567 | ||
|
|
a9a3d42e77 | ||
|
|
a71e5dc7e8 | ||
|
|
28e4f63447 | ||
|
|
5eff726f0e | ||
|
|
da22b1da8a | ||
|
|
62538d6b59 | ||
|
|
83d5448b94 | ||
|
|
e609c0fead | ||
|
|
4e9bb87975 | ||
|
|
495fa27b9a | ||
|
|
efaf20d943 | ||
|
|
169a51f04d | ||
|
|
3518521ca2 | ||
|
|
774271eb93 | ||
|
|
5df21b1ede | ||
|
|
dbcdcc031f | ||
|
|
e05c5a3016 | ||
|
|
aa25f0e016 | ||
|
|
2104617b82 | ||
|
|
ea22004e6d | ||
|
|
acea50d7e9 | ||
|
|
500c49e41e | ||
|
|
bad23073f4 | ||
|
|
1fcc08289a | ||
|
|
5d03711f80 | ||
|
|
30cbbc4be1 | ||
|
|
4a196c9c23 | ||
|
|
a62a17a7bf | ||
|
|
b0530cdccb | ||
|
|
e483a4b683 | ||
|
|
23eaa5bc8b | ||
|
|
18286f689b | ||
|
|
319c550af4 | ||
|
|
c7608b6404 | ||
|
|
c71febbb23 | ||
|
|
5ab3bd6b97 | ||
|
|
c59caf7272 | ||
|
|
c36f11ab4f | ||
|
|
7189433e5b | ||
|
|
dadb9e45d4 | ||
|
|
c1fabc5b5c | ||
|
|
5def89f182 | ||
|
|
4bd9ca234e | ||
|
|
924229fafe | ||
|
|
2314fca8ee | ||
|
|
2210d1fa01 | ||
|
|
746d3cca9c | ||
|
|
7f5773fc3c | ||
|
|
90f56143c4 | ||
|
|
ab4b82e492 | ||
|
|
4a3d8e5ca2 | ||
|
|
7f7f57d06d | ||
|
|
c45aed105c | ||
|
|
7e829ef87b | ||
|
|
cc9940c705 | ||
|
|
bb0b5a9a27 | ||
|
|
5686e8785f | ||
|
|
3da62b654e | ||
|
|
b6fb974264 | ||
|
|
07bfd84810 | ||
|
|
2db54e0e19 | ||
|
|
a2252a2ede | ||
|
|
fcc581d48b | ||
|
|
ec3bc51346 | ||
|
|
08a14d79cb | ||
|
|
812063a320 | ||
|
|
983cafc9b9 | ||
|
|
5e05f0d354 | ||
|
|
73d98a3dc3 | ||
|
|
7a78e5e7a6 | ||
|
|
f6164e7cf0 | ||
|
|
43f88c7866 | ||
|
|
70602e4c91 | ||
|
|
653f32e08c | ||
|
|
a98068ba09 | ||
|
|
b2a23d60b8 | ||
|
|
8fddb0a94c | ||
|
|
15e12ec2b9 | ||
|
|
c5945f1a94 | ||
|
|
d4cb543090 | ||
|
|
63b430873d | ||
|
|
3a584bbe8b | ||
|
|
2873df25ae | ||
|
|
bff6b484e5 | ||
|
|
4371335b37 | ||
|
|
468c347357 | ||
|
|
65503e60b8 | ||
|
|
a832de5ce1 | ||
|
|
5caef6b2b3 | ||
|
|
b50081014e | ||
|
|
5dc3a4525a | ||
|
|
716a005da6 | ||
|
|
5890dd0fe3 | ||
|
|
29856d9bea | ||
|
|
5184d3ec24 | ||
|
|
0610460ebb | ||
|
|
8081fc13f6 | ||
|
|
3fec62fbc2 | ||
|
|
3f76f77e5b | ||
|
|
1f33506c13 | ||
|
|
44e09aa89f | ||
|
|
0870cd923a | ||
|
|
49b14337eb | ||
|
|
74f186136b | ||
|
|
bbaa04354c | ||
|
|
86c97cbad1 | ||
|
|
5a05663050 | ||
|
|
ebe1d67620 | ||
|
|
7eaebeb787 | ||
|
|
2f1f7d5ba5 | ||
|
|
92454ae8a1 | ||
|
|
aad70f342b | ||
|
|
cf597c12e0 | ||
|
|
cbddf8ca0b | ||
|
|
ad738a643e | ||
|
|
8f5c59bd64 | ||
|
|
914bab840d | ||
|
|
8f96ba9478 | ||
|
|
5a9e7b0f7f | ||
|
|
5dfd9200f8 | ||
|
|
8c7168a1a9 | ||
|
|
377f3aa775 | ||
|
|
662975c802 | ||
|
|
3b420a7bad | ||
|
|
2dad6c9bcc | ||
|
|
723f80c8af | ||
|
|
450247175f | ||
|
|
5d6f772a1c | ||
|
|
575c19f921 | ||
|
|
328663b9e7 | ||
|
|
c9ddd3488d | ||
|
|
9958386792 | ||
|
|
f5fae6d32e | ||
|
|
b94f1f7bbb | ||
|
|
ffbb87eddd | ||
|
|
63fad4083f | ||
|
|
872d740ce7 | ||
|
|
4c2d46429d | ||
|
|
120abbad93 | ||
|
|
a39002f1b8 | ||
|
|
fb470ecf0c | ||
|
|
2b6ac65a1f | ||
|
|
1b2e83a155 | ||
|
|
8a6cb868d9 | ||
|
|
4a7080b313 | ||
|
|
1afb13aae0 | ||
|
|
3d519bcfcd | ||
|
|
72287fc995 | ||
|
|
0b4a170910 | ||
|
|
72e6f145b3 | ||
|
|
367ed94c01 | ||
|
|
fa9bd93ada | ||
|
|
d1551bc9c1 | ||
|
|
e9527ee8c2 | ||
|
|
6040816cf7 | ||
|
|
6f3bc915b2 | ||
|
|
1ba84a3a2c | ||
|
|
e807ecf973 | ||
|
|
dc35f5bd37 | ||
|
|
4888fdcda0 | ||
|
|
85e0179590 |
@@ -18,9 +18,8 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
# Each host runs only the publish tasks Gradle actually generated for it.
|
# Each host runs only the publish tasks Gradle actually generated for it.
|
||||||
# macOS also publishes the shared kotlinMultiplatform metadata module.
|
# WINDOWS also publishes the shared kotlinMultiplatform metadata modules
|
||||||
# No library module declares jvm() today — apps do their own JVM parity
|
# (see its matrix entry) + the jvm jar + the bridge plugin.
|
||||||
# build against upstream Compose, so JVM isn't part of the publication.
|
|
||||||
# `target` is the Gradle K/N target token (Capitalised) — one per host,
|
# `target` is the Gradle K/N target token (Capitalised) — one per host,
|
||||||
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
|
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
|
||||||
# skipped for the app build because SDL3 static libs on the ubuntu runner
|
# skipped for the app build because SDL3 static libs on the ubuntu runner
|
||||||
@@ -32,7 +31,6 @@ jobs:
|
|||||||
target: MacosArm64
|
target: MacosArm64
|
||||||
exe_ext: kexe
|
exe_ext: kexe
|
||||||
tasks: >-
|
tasks: >-
|
||||||
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
|
||||||
publishMacosArm64PublicationToGitHubPackagesRepository
|
publishMacosArm64PublicationToGitHubPackagesRepository
|
||||||
- runner: ubuntu-22.04
|
- runner: ubuntu-22.04
|
||||||
host: linux
|
host: linux
|
||||||
@@ -45,8 +43,18 @@ jobs:
|
|||||||
host: windows
|
host: windows
|
||||||
target: MingwX64
|
target: MingwX64
|
||||||
exe_ext: exe
|
exe_ext: exe
|
||||||
|
# WINDOWS owns the root KotlinMultiplatform metadata modules: it is
|
||||||
|
# the ONLY host that declares every target (vHostSupportsMingw is
|
||||||
|
# host-gated), so only its generated .module files carry the full
|
||||||
|
# variant table — the macOS-published roots of v0.1.15 had NO
|
||||||
|
# mingwX64 variants and Windows consumers could not resolve them.
|
||||||
|
# The host-independent jvm jar (:material-symbols) and the bridge
|
||||||
|
# plugin ride along on the same job.
|
||||||
tasks: >-
|
tasks: >-
|
||||||
publishMingwX64PublicationToGitHubPackagesRepository
|
publishMingwX64PublicationToGitHubPackagesRepository
|
||||||
|
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
||||||
|
publishJvmPublicationToGitHubPackagesRepository
|
||||||
|
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
|
||||||
|
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
@@ -95,23 +103,33 @@ jobs:
|
|||||||
# intrinsic K/N's LLD can't resolve is aliased to msvcrt's _setjmpex at
|
# intrinsic K/N's LLD can't resolve is aliased to msvcrt's _setjmpex at
|
||||||
# link time via --defsym in sdl3.def.
|
# link time via --defsym in sdl3.def.
|
||||||
|
|
||||||
# Build static SDL3 / SDL3_ttf / SDL3_image / FreeType from
|
# Build the static native deps from scripts/build-sdl/build-sdl.properties.
|
||||||
# scripts/build-sdl/build-sdl.properties. Cached against the manifest +
|
# Only Windows ships the SDL3 renderer (no Skiko on mingw), so only it needs
|
||||||
# scripts so version bumps invalidate cleanly.
|
# SDL3_ttf / SDL3_image / FreeType (SDL3_ttf text, SDL3_image decode, FreeType
|
||||||
# `cache_version` is a manual bump for when the toolchain (runner OS,
|
# icon-axis rasterisation). macOS / Linux publish the Skia renderer — Skiko
|
||||||
# compiler flavour, K/N sysroot expectations) changes without any
|
# handles text / images / icons — so they only need base SDL3 for the main
|
||||||
# scripts/build-sdl/ file changing. Increment to force a rebuild.
|
# loop (windowing / input / audio / filesystem). Matches the cinterop guard
|
||||||
|
# in compose/ui/ui/build.gradle.kts, which skips those three cinterops on the
|
||||||
|
# Skia targets. Cached against the manifest + scripts so version bumps
|
||||||
|
# invalidate cleanly; bump the `v3` key when the toolchain (runner OS,
|
||||||
|
# compiler flavour, K/N sysroot) changes without a scripts/build-sdl/ edit.
|
||||||
- name: Cache libs/
|
- name: Cache libs/
|
||||||
id: cache-libs
|
id: cache-libs
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: libs
|
path: libs
|
||||||
key: sdl-libs-v2-${{ matrix.host }}-${{ hashFiles('scripts/build-sdl/**') }}
|
key: sdl-libs-v3-${{ matrix.host }}-${{ hashFiles('scripts/build-sdl/**') }}
|
||||||
|
|
||||||
- name: Build static SDL / TTF / image / FreeType
|
# Windows: full SDL3 renderer stack. macOS / Linux (Skia): base SDL3 only.
|
||||||
|
- name: Build static native deps
|
||||||
if: steps.cache-libs.outputs.cache-hit != 'true'
|
if: steps.cache-libs.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: bash scripts/build-sdl/build-all.sh
|
run: |
|
||||||
|
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||||
|
python3 scripts/build-sdl/build-all.py
|
||||||
|
else
|
||||||
|
python3 scripts/build-sdl/build-all.py sdl3
|
||||||
|
fi
|
||||||
|
|
||||||
# src/vendor/ trees are gitignored — a fresh checkout has zero vendored
|
# src/vendor/ trees are gitignored — a fresh checkout has zero vendored
|
||||||
# androidx.compose.* files. Sync copies them in from the upstream refs
|
# androidx.compose.* files. Sync copies them in from the upstream refs
|
||||||
@@ -121,16 +139,65 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: bash scripts/compose-fork/sync.sh
|
run: bash scripts/compose-fork/sync.sh
|
||||||
|
|
||||||
# GitHub Packages sometimes returns 409 Conflict on a fresh upload when
|
# GitHub Packages FORBIDS overwriting a file that already exists in a
|
||||||
# the same coordinate was recently deleted (soft-delete propagation).
|
# version, and maven-publish uploads each publication's files
|
||||||
# `--continue` lets Gradle finish the rest of the publish tasks past a
|
# SEQUENTIALLY from the first one. So when an upload dies mid-publication
|
||||||
# single 409, and the outer retry re-runs the whole thing (Gradle skips
|
# (the v0.1.13 macOS run lost DNS mid-publish), a blind retry hits 409
|
||||||
# already-uploaded artifacts). Two attempts is enough for the transient
|
# Conflict on the first already-uploaded file and aborts BEFORE reaching
|
||||||
# cases we've hit; the third pass falls through as a hard failure.
|
# the missing tail files — the version is poisoned and can never
|
||||||
|
# complete. A 409 is therefore only benign when the publication had
|
||||||
|
# already completed; you can't tell from the 409 alone. The fix: between
|
||||||
|
# attempts, DELETE this host's package versions for the current release
|
||||||
|
# and re-upload from a clean slate. Each host owns DISJOINT package
|
||||||
|
# names (roots / -mingwx64 / -jvm / bridge on Windows, -macosarm64 on
|
||||||
|
# macOS, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
|
||||||
|
# each other's uploads. Uses the run's GITHUB_TOKEN (packages: write);
|
||||||
|
# a 403 on delete just degrades to the old blind-retry behaviour.
|
||||||
- name: Publish to GitHub Packages
|
- name: Publish to GitHub Packages
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
|
VER="${PUBLISH_VERSION#v}"
|
||||||
|
OWNER="${GITHUB_REPOSITORY%%/*}"
|
||||||
|
# Keep in sync with the module list in settings.gradle.kts.
|
||||||
|
MODULES="ui ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple window material-symbols navigation3-ui components-resources"
|
||||||
|
case "${{ matrix.host }}" in
|
||||||
|
macos) SUFFIXES="-macosarm64" ;;
|
||||||
|
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
|
||||||
|
windows) SUFFIXES="_ROOT_ -mingwx64" ;;
|
||||||
|
esac
|
||||||
|
host_packages() {
|
||||||
|
for m in $MODULES; do
|
||||||
|
for s in $SUFFIXES; do
|
||||||
|
if [ "$s" = "_ROOT_" ]; then echo "com.bitsycore.compose.sdl.$m"; else echo "com.bitsycore.compose.sdl.$m$s"; fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||||
|
echo "com.bitsycore.compose.sdl.material-symbols-jvm"
|
||||||
|
echo "com.bitsycore.compose.sdl.compose-desktop-native-bridge"
|
||||||
|
echo "com.bitsycore.compose-desktop-native.bridge.com.bitsycore.compose-desktop-native.bridge.gradle.plugin"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
cleanup_host_versions() {
|
||||||
|
echo "Deleting this host's partially-published $VER package versions"
|
||||||
|
host_packages | while read -r pkg; do
|
||||||
|
vid=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/users/$OWNER/packages/maven/$pkg/versions?per_page=100" \
|
||||||
|
| jq -r --arg v "$VER" '[.[]? | select(.name==$v) | .id] | first // empty')
|
||||||
|
[ -z "$vid" ] && continue
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/users/$OWNER/packages/maven/$pkg/versions/$vid")
|
||||||
|
if [ "$code" != "204" ]; then
|
||||||
|
# Deleting the LAST version of a package is refused (400) —
|
||||||
|
# a package new in this release: drop the whole package.
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/users/$OWNER/packages/maven/$pkg")
|
||||||
|
fi
|
||||||
|
echo " $pkg@$VER -> HTTP $code"
|
||||||
|
done
|
||||||
|
}
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "::group::Publish attempt $attempt"
|
echo "::group::Publish attempt $attempt"
|
||||||
if ./gradlew ${{ matrix.tasks }} --continue --no-daemon --stacktrace; then
|
if ./gradlew ${{ matrix.tasks }} --continue --no-daemon --stacktrace; then
|
||||||
@@ -138,16 +205,21 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
[ $attempt -lt 3 ] && { echo "::warning::Publish attempt $attempt failed — sleeping 60s and retrying"; sleep 60; }
|
if [ $attempt -lt 3 ]; then
|
||||||
|
echo "::warning::Publish attempt $attempt failed — cleaning this host's $VER packages and retrying"
|
||||||
|
cleanup_host_versions || true
|
||||||
|
sleep 30
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
echo "::error::Publish failed after 3 attempts"
|
echo "::error::Publish failed after 3 attempts"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
# Release binaries for the host target — the binary plus the data.kres
|
# Release binaries for the host target — the binary plus the data.kres
|
||||||
# (bundled composeResources + fonts + subset icon-font). Each :demo /
|
# (bundled composeResources + fonts + subset icon-font). Each app's
|
||||||
# :apidemo has its linkReleaseExecutable<Target> task wired to auto-run
|
# linkReleaseExecutable<Target> auto-runs its data.kres zip task —
|
||||||
# the matching copy<Variant>ComposeResources<Target> so data.kres is
|
# :demo's own copy<Variant>ComposeResources<Target>, :apidemo's
|
||||||
# produced next to the .kexe / .exe. On Linux only linuxX64 is built —
|
# 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
|
# linuxArm64 is skipped since the SDL3 static libs on the ubuntu runner
|
||||||
# are host-arch (x64).
|
# are host-arch (x64).
|
||||||
- name: Build demo + apidemo release binaries
|
- name: Build demo + apidemo release binaries
|
||||||
@@ -167,15 +239,11 @@ jobs:
|
|||||||
target_dir=$(echo "${{ matrix.target }}" | awk '{print tolower(substr($0,1,1)) substr($0,2)}')
|
target_dir=$(echo "${{ matrix.target }}" | awk '{print tolower(substr($0,1,1)) substr($0,2)}')
|
||||||
version="${GITHUB_REF_NAME#v}"
|
version="${GITHUB_REF_NAME#v}"
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
# Windows Git Bash has no `zip`; every runner has JDK 21 → use `jar cf`
|
|
||||||
# which writes a valid ZIP file. `-C dir file` = enter dir then add
|
|
||||||
# `file` at the archive root, so the archive stays flat like `zip -j`.
|
|
||||||
for app in demo apidemo; do
|
for app in demo apidemo; do
|
||||||
src="$app/build/bin/$target_dir/releaseExecutable"
|
src="$app/build/bin/$target_dir/releaseExecutable"
|
||||||
zip_path="$(pwd)/dist/$app-$version-$target_dir.zip"
|
zip_path="$(pwd)/dist/$app-$version-$target_dir.zip"
|
||||||
jar --create --no-manifest --file "$zip_path" \
|
python3 -c 'import sys,os,zipfile; z=zipfile.ZipFile(sys.argv[1],"w",zipfile.ZIP_DEFLATED,compresslevel=9); [z.write(f,os.path.basename(f)) for f in sys.argv[2:]]; z.close()' \
|
||||||
-C "$src" "$app.${{ matrix.exe_ext }}" \
|
"$zip_path" "$src/$app.${{ matrix.exe_ext }}" "$src/data.kres"
|
||||||
-C "$src" "data.kres"
|
|
||||||
done
|
done
|
||||||
ls -la dist/
|
ls -la dist/
|
||||||
|
|
||||||
|
|||||||
@@ -61,3 +61,6 @@ bin/
|
|||||||
### Python ###
|
### Python ###
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# Probe artifacts written to the repo root by demo --nav3test / --searchesctest / --dialoganimtest
|
||||||
|
/*.bmp
|
||||||
|
|||||||
@@ -3,6 +3,21 @@
|
|||||||
Guidance for Claude Code working in this repository. This is the primary
|
Guidance for Claude Code working in this repository. This is the primary
|
||||||
context — read it first, then look at the files it points to.
|
context — read it first, then look at the files it points to.
|
||||||
|
|
||||||
|
## Documentation map
|
||||||
|
|
||||||
|
- [README.md](README.md) — public overview + quickstart (bridge plugin,
|
||||||
|
`nativeComposeWindow`, sample apps).
|
||||||
|
- [RENDERER.md](RENDERER.md) — the rendering layer: architecture, the retained-
|
||||||
|
layer engine, convergence decisions, learnings, and open work. Read it before
|
||||||
|
touching any renderer, graphics-actual, or layer-engine code.
|
||||||
|
- [TOOLING.md](TOOLING.md) — build/vendor/verify scripts and workflows
|
||||||
|
(build-sdl, sync + drift checks, parity, probe, profiler, coverage,
|
||||||
|
verify-mac) + the version map and the ref-bump / release runbooks.
|
||||||
|
- [TODO.md](TODO.md) — audited list of no-ops, stubs, and hardcodes left in the
|
||||||
|
port, with severity, plus what's missing for a stable 1.12 release.
|
||||||
|
- This file — architecture, module layout, vendoring rules, source-set
|
||||||
|
hierarchy, density flow, conventions, and common pitfalls.
|
||||||
|
|
||||||
## What this project is
|
## What this project is
|
||||||
|
|
||||||
**ComposeNativeSDL3** — a Kotlin/Native port of Compose Multiplatform running
|
**ComposeNativeSDL3** — a Kotlin/Native port of Compose Multiplatform running
|
||||||
@@ -42,7 +57,12 @@ compose/
|
|||||||
│ ├── ui-util/ → :ui-util — androidx.compose.ui.util.* (+ Experimental/InternalComposeUiApi)
|
│ ├── ui-util/ → :ui-util — androidx.compose.ui.util.* (+ Experimental/InternalComposeUiApi)
|
||||||
│ ├── ui-geometry/ → :ui-geometry — androidx.compose.ui.geometry.*
|
│ ├── ui-geometry/ → :ui-geometry — androidx.compose.ui.geometry.*
|
||||||
│ ├── ui-unit/ → :ui-unit — androidx.compose.ui.unit.*
|
│ ├── ui-unit/ → :ui-unit — androidx.compose.ui.unit.*
|
||||||
│ └── ui-backhandler/ → :ui-backhandler — androidx.compose.ui.backhandler.*
|
│ ├── ui-backhandler/ → :ui-backhandler — androidx.compose.ui.backhandler.*
|
||||||
|
│ └── ui-tooling-preview/ → :ui-tooling-preview — androidx.compose.ui.tooling.preview.*
|
||||||
|
│ (the common @Preview + PreviewParameterProvider,
|
||||||
|
│ vendored verbatim; the Maven artifact ships no
|
||||||
|
│ mingwX64/linux klibs). IDE-only metadata — previews
|
||||||
|
│ render through the apps' jvm parity targets.
|
||||||
├── animation/
|
├── animation/
|
||||||
│ ├── animation-core/ → :animation-core — androidx.compose.animation.core.*
|
│ ├── animation-core/ → :animation-core — androidx.compose.animation.core.*
|
||||||
│ ├── animation/ → :animation — androidx.compose.animation.* (non-core)
|
│ ├── animation/ → :animation — androidx.compose.animation.* (non-core)
|
||||||
@@ -60,8 +80,22 @@ compose/
|
|||||||
|
|
||||||
utils/
|
utils/
|
||||||
└── material-symbols/ → :material-symbols — codepoints + all three style objects
|
└── material-symbols/ → :material-symbols — codepoints + all three style objects
|
||||||
(Outlined / Rounded / Sharp). Apps get one dep;
|
(Outlined / Rounded / Sharp). COMMON API (usable from
|
||||||
the consumer Zip task bundles only the fonts used.
|
shared app code) + per-stack actuals: native renders
|
||||||
|
via :foundation IconFontIcon (IconFont handles the
|
||||||
|
SDL3/Skia split), jvm() via Skiko directly
|
||||||
|
(Typeface.makeClone per axes — upstream's FontCache
|
||||||
|
drops variationSettings from its key). Its commonMain
|
||||||
|
declares official Maven compose coords — the root
|
||||||
|
build's FULL-COMMONIZATION BRIDGE substitutes the
|
||||||
|
whole ui / foundation / animation / material3 /
|
||||||
|
nav3-ui / 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
|
||||||
|
bundles only the fonts used (native) and
|
||||||
|
jvmProcessResources stages the same fonts (jvm).
|
||||||
|
|
||||||
components/
|
components/
|
||||||
└── resources/library/ → :components-resources — the OFFICIAL Compose resources
|
└── resources/library/ → :components-resources — the OFFICIAL Compose resources
|
||||||
@@ -93,12 +127,54 @@ demo/ → :demo — flagship showcase app (30+ screens) + th
|
|||||||
MULTIPLATFORM: also has a jvm() target running the SAME shared
|
MULTIPLATFORM: also has a jvm() target running the SAME shared
|
||||||
screens on stock JVM Compose Desktop (`./gradlew :demo:run`,
|
screens on stock JVM Compose Desktop (`./gradlew :demo:run`,
|
||||||
MainJvmKt) — the parity reference; differences vs native = port bugs
|
MainJvmKt) — the parity reference; differences vs native = port bugs
|
||||||
apidemo/ → :apidemo — Postman-style REST API manager
|
apidemo/ → :apidemo — Postman-style REST API manager. MULTIPLATFORM like :demo
|
||||||
scripts/ → vendor-sync + python helper scripts (compose-fidelity/coverage,
|
(`./gradlew :apidemo:run`): the whole UI lives in commonMain
|
||||||
material-symbols generate/subset) + compose-fork/;
|
against the official Maven coords; SDL-backed APIs go through
|
||||||
scripts/build-sdl/ = Windows static-lib build scripts (bash)
|
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/ → the CONSUMER-side bridge as a published Gradle plugin
|
||||||
|
(id com.bitsycore.compose-desktop-native.bridge, applies to
|
||||||
|
Settings or Project). An INCLUDED build (pluginManagement.
|
||||||
|
includeBuild in settings.gradle.kts), NOT a subproject — a
|
||||||
|
plugins{} block can only resolve plugins from repositories or
|
||||||
|
included builds; :demo and :apidemo apply it from source
|
||||||
|
(dogfooding: data.kres packaging — and apidemo's app icon —
|
||||||
|
run through the plugin).
|
||||||
|
Three halves: (1) substitution — third-party apps declare
|
||||||
|
OFFICIAL CMP coords in commonMain and native configurations
|
||||||
|
swap in the published com.bitsycore.compose.sdl klibs
|
||||||
|
(version defaults to the plugin's own; override:
|
||||||
|
composeDesktopNative.version property; disable:
|
||||||
|
composeDesktopNative.substitution=false — set repo-wide in
|
||||||
|
gradle.properties since the root build's FULL-COMMONIZATION
|
||||||
|
BRIDGE substitutes to project modules in-repo); (2) data.kres
|
||||||
|
packaging (package<Variant>ComposeResources<Target> per native
|
||||||
|
executable, honours -PcompressResources); (3) the
|
||||||
|
compose.desktop.native { entryPoint / icon {} } DSL (.rgba
|
||||||
|
runtime icons + windres .exe embed — injected into
|
||||||
|
hand-declared executables too). Published by the WINDOWS
|
||||||
|
publish job.
|
||||||
|
scripts/ → vendor-sync + python helper scripts (compose-coverage = API
|
||||||
|
coverage/fidelity vs upstream, material-symbols generate/subset)
|
||||||
|
+ compose-fork/;
|
||||||
|
scripts/build-sdl/ = static-lib build script (python)
|
||||||
libs/ → gitignored per-host static SDL3 / SDL3_ttf / SDL3_image / FreeType
|
libs/ → gitignored per-host static SDL3 / SDL3_ttf / SDL3_image / FreeType
|
||||||
output of scripts/build-sdl/build-*.sh on Windows
|
output of scripts/build-sdl/build-all.py on Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
Module PATHS stay short (`:ui`, `:foundation`, `:window`, …) —
|
Module PATHS stay short (`:ui`, `:foundation`, `:window`, …) —
|
||||||
@@ -194,10 +270,23 @@ Two categories of code live in each module:
|
|||||||
NON-IDEMPOTENT.** Move it OUT of `src/vendor/` into the corresponding
|
NON-IDEMPOTENT.** Move it OUT of `src/vendor/` into the corresponding
|
||||||
`src/{commonMain,nativeMain,…}` tree, add a header comment noting
|
`src/{commonMain,nativeMain,…}` tree, add a header comment noting
|
||||||
which upstream file it derived from and what changed, and comment
|
which upstream file it derived from and what changed, and comment
|
||||||
its line out of `compose-fork.txt`. Now it's a project file — the
|
its line out of `compose-fork.txt`. **ALWAYS also add the
|
||||||
next sync won't overwrite it, and future upstream changes to that
|
machine-readable provenance line** so the copy can be diffed against
|
||||||
file need to be reconciled by hand. This is fine; do it when the
|
its base and against later upstream versions:
|
||||||
edit is small and the file is unlikely to churn upstream.
|
|
||||||
|
```
|
||||||
|
// VENDOR-BASE: <upstream-repo-relative-path> @ <pinned-tag>
|
||||||
|
// VENDOR-BASE(COMPOSE_REF): <path> @ <tag> ← for umbrella-repo files
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/compose-fork/check-vendor-drift.py` reads these at every ref
|
||||||
|
bump: it flags any file whose recorded base lags the current pin and
|
||||||
|
(with the local clone) reports whether the upstream base ACTUALLY
|
||||||
|
changed base..pin — i.e. whether the copy needs hand-reconciling or
|
||||||
|
just a ref re-stamp. Now it's a project file — the next sync won't
|
||||||
|
overwrite it, and future upstream changes to that file need to be
|
||||||
|
reconciled by hand. This is fine; do it when the edit is small and
|
||||||
|
the file is unlikely to churn upstream.
|
||||||
|
|
||||||
4. **Skiko-specific things go in `:ui/src/skikoRendererMain/`, with
|
4. **Skiko-specific things go in `:ui/src/skikoRendererMain/`, with
|
||||||
an SDL3 equivalent in `:ui/src/sdlRendererMain/`.** When upstream
|
an SDL3 equivalent in `:ui/src/sdlRendererMain/`.** When upstream
|
||||||
@@ -241,11 +330,15 @@ commonMain
|
|||||||
attached to: mingwX64Main always; macOS/Linux when -Prenderer=sdl3.
|
attached to: mingwX64Main always; macOS/Linux when -Prenderer=sdl3.
|
||||||
```
|
```
|
||||||
|
|
||||||
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are declared identically
|
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are `expect`s in
|
||||||
in both `skikoRendererMain` and `sdlRendererMain`. `:window` calls them straight
|
`:ui`'s nativeMain with `actual`s in BOTH `skikoRendererMain` and
|
||||||
from `:ui` — no `expect`/`actual`, no factory layer — and the right impl
|
`sdlRendererMain` — unambiguous because **only one of the two renderer source
|
||||||
resolves because **only one of the two renderer source sets is attached to a
|
sets is attached to a given target**. (They used to be plain duplicate
|
||||||
given target**. Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
|
declarations with no expect; that compiled per-target but shared nativeMain
|
||||||
|
METADATA couldn't see them on a host whose targets span both renderers, which
|
||||||
|
blocked the WINDOWS host from producing :window's KotlinMultiplatform
|
||||||
|
publication — and Windows must publish the root modules, see the publish
|
||||||
|
workflow.) Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
|
||||||
**not even created**, so Gradle has nothing to warn about and Skiko is never
|
**not even created**, so Gradle has nothing to warn about and Skiko is never
|
||||||
pulled in.
|
pulled in.
|
||||||
|
|
||||||
@@ -307,26 +400,19 @@ gradlew.bat :apidemo:runDebugExecutableMingwX64
|
|||||||
./gradlew :demo:runDebugExecutableMacosArm64 -Prenderer=sdl3
|
./gradlew :demo:runDebugExecutableMacosArm64 -Prenderer=sdl3
|
||||||
|
|
||||||
# Stock JVM Compose Desktop (any host) — the parity reference: the SAME shared
|
# Stock JVM Compose Desktop (any host) — the parity reference: the SAME shared
|
||||||
# demo screens on upstream Compose; differences vs the native build = port bugs.
|
# screens on upstream Compose; differences vs the native build = port bugs.
|
||||||
./gradlew :demo:run
|
./gradlew :demo:run
|
||||||
|
./gradlew :apidemo:run
|
||||||
```
|
```
|
||||||
|
|
||||||
### System dependencies
|
### System dependencies
|
||||||
|
|
||||||
**macOS (default Skia build):** `brew install sdl3`. Only need `sdl3_ttf`
|
SDL3 + SDL3_ttf + SDL3_image + FreeType (+ image codecs) are **built from
|
||||||
if you set `-Prenderer=sdl3`. Skiko klibs come from Maven.
|
source as static libraries on every OS** and linked straight into the
|
||||||
|
executable — no brew/apt SDL packages, no runtime .dll/.so/.dylib; a
|
||||||
**Linux (default Skia build):** `sudo apt install libsdl3-dev`. Same caveat
|
distributable is `<app>` + `data.kres`. Build them once per host with
|
||||||
for SDL3_ttf.
|
`python scripts/build-sdl/build-all.py`. Per-host toolchain requirements and
|
||||||
|
the step breakdown are in [TOOLING.md](TOOLING.md#native-libraries).
|
||||||
**Windows (mingwX64 — always uses SDL3 + SDL3_ttf + SDL3_image + FreeType):**
|
|
||||||
these four libraries + image codecs are **linked statically into the
|
|
||||||
executable** — no runtime DLLs, distributable is just `<app>.exe` +
|
|
||||||
`data.kres`. They're not downloaded — `scripts/build-sdl/build-all.sh` (from Git
|
|
||||||
Bash) builds them from source as static libs into a gitignored, in-repo `libs/`
|
|
||||||
folder. Needs: git, cmake, mingw-w64 gcc/g++ on PATH, plus curl + python for
|
|
||||||
ninja fetch. `scripts/build-sdl/build-all.sh` = `build-freetype.sh` → `build-sdl3.sh` →
|
|
||||||
`build-sdl3-image.sh` → `build-sdl3-ttf.sh`.
|
|
||||||
|
|
||||||
## Runtime bundling — data.kres
|
## Runtime bundling — data.kres
|
||||||
|
|
||||||
@@ -334,37 +420,38 @@ Every app ships `<app>.exe` + `data.kres` (a STORED zip alongside the
|
|||||||
executable, loaded via `SDL_GetBasePath()`). Contents:
|
executable, loaded via `SDL_GetBasePath()`). Contents:
|
||||||
|
|
||||||
- App drawables + files under `composeResources/{drawable,files}/`
|
- App drawables + files under `composeResources/{drawable,files}/`
|
||||||
- `font/NotoSans.ttf` — the default variable font, downloaded once by
|
- `font/NotoSans.ttf` — the default variable font. Bundling it is the APP's
|
||||||
`:ui:downloadNotoFonts` into `compose/ui/build/fonts/`. Pass
|
job: each app opts in via `registerComposeFontBundling { bundleNotoSans =
|
||||||
`-PbundleDefaultFont=false` to skip.
|
true; … }` (buildSrc — every flag is opt-in, no flag = no-op), which
|
||||||
- `font/NotoSansMono.ttf` — mono body font (apidemo only)
|
registers its `downloadNotoFonts` task (into `<app>/build/fonts/`); the
|
||||||
- Material Symbols fonts for the styles the app **actually uses** — the
|
library ships no download task. Pass `-PbundleDefaultFont=false` to skip.
|
||||||
Zip task scans the app's Kotlin sources for `MaterialSymbolsOutlined` /
|
- `font/NotoSansMono.ttf` — `autoDetectNotoSansMono = true` bundles it when
|
||||||
`Rounded` / `Sharp` and only bundles the fonts referenced.
|
`FontFamily.Monospace` appears in the app sources (demo);
|
||||||
- `-PsubsetIcons=true` (default on): `scripts/subset-material-symbols.py`
|
`bundleNotoSansMono = true` forces it (apidemo's body font, loaded through
|
||||||
scans app sources for `MaterialSymbols.<Name>` usage and hb-subsets each
|
its own seam)
|
||||||
bundled font down to just those glyphs. Needs `hb-subset` on PATH
|
- Material Symbols fonts for the styles the app **actually uses**
|
||||||
(`brew install harfbuzz` / `apt install harfbuzz-utils`) — falls back
|
(`bundleMaterialSymbols = true`) — the Zip task scans the app's Kotlin
|
||||||
to the full font if absent.
|
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
|
## Vendor sync workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sync every module's compose-fork.txt against the pinned upstream ref.
|
# Sync every module's compose-fork.txt against the pinned upstream ref.
|
||||||
scripts/compose-fork/sync.sh
|
scripts/compose-fork/sync.sh
|
||||||
|
|
||||||
# Sync one module by direct manifest path (module names use ':' → '/' → skip
|
|
||||||
# 'compose/' prefix — nested modules like compose/sdl/window pass the full path).
|
|
||||||
scripts/compose-fork/sync.sh compose/ui/compose-fork.txt
|
|
||||||
|
|
||||||
# Re-format a manifest (align columns, group by upstream folder).
|
|
||||||
scripts/compose-fork/format-manifest.py --discover ../cmp-ref \
|
|
||||||
--manifest compose/ui/compose-fork.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Upstream ref: `scripts/compose-fork/compose.properties` — set to a specific commit
|
Upstream ref: `scripts/compose-fork/compose.properties` — set to a durable tag
|
||||||
of `JetBrains/compose-multiplatform-core`. Bump the ref → re-sync → let the
|
of `JetBrains/compose-multiplatform-core`. Bump the ref → re-sync → let the
|
||||||
build tell you what broke.
|
build tell you what broke. Per-module sync, manifest re-formatting, and the
|
||||||
|
drift / vendor-clean guardrails are in
|
||||||
|
[TOOLING.md](TOOLING.md#vendoring-upstream-compose).
|
||||||
|
|
||||||
## Key files by area — start here when you need to find something
|
## Key files by area — start here when you need to find something
|
||||||
|
|
||||||
@@ -418,6 +505,21 @@ build tell you what broke.
|
|||||||
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
|
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
|
||||||
ship drop-in equivalents for our anchor / scrim patterns).
|
ship drop-in equivalents for our anchor / scrim patterns).
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
|
||||||
|
The full tooling reference is [TOOLING.md](TOOLING.md): the static-lib build,
|
||||||
|
vendor sync + drift/clean guardrails, API coverage, the `verify-mac` runbook,
|
||||||
|
the native-vs-JVM parity harness, the interaction probe, and the frame
|
||||||
|
profiler. Quick rules of thumb:
|
||||||
|
|
||||||
|
- Whole-project renderer/layout change → run **parity** (`scripts/parity/parity.py`),
|
||||||
|
the broad net.
|
||||||
|
- Chasing one reported interaction → run the **probe** (`scripts/probe/`), targeted.
|
||||||
|
- Slow frame → **profiler** first (`CDN_PROFILE=1`), optimize second.
|
||||||
|
- Any renderer change → the **`verify-mac`** runbook gates both legs before commit.
|
||||||
|
|
||||||
|
See [RENDERER.md](RENDERER.md) for the renderer work these support.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
Kotlin standard style — plain `camelCase` for parameters, local variables,
|
Kotlin standard style — plain `camelCase` for parameters, local variables,
|
||||||
@@ -469,6 +571,18 @@ that should surface in tooling.
|
|||||||
toggling the renderer property may not invalidate it. Delete
|
toggling the renderer property may not invalidate it. Delete
|
||||||
`.gradle/configuration-cache/` between switches if you see weird
|
`.gradle/configuration-cache/` between switches if you see weird
|
||||||
"couldn't find sdl3_ttf" errors.
|
"couldn't find sdl3_ttf" errors.
|
||||||
|
- **Substituted Maven modules hide their transitives from common metadata** —
|
||||||
|
when the bridge swaps an official coord for a project module on native
|
||||||
|
configs, KGP's granular-metadata visibility check drops that Maven module's
|
||||||
|
TRANSITIVES from the commonMain classpath (symptom: `Unresolved reference
|
||||||
|
'Color'` / `Cannot access class ...` in `compileCommonMainKotlinMetadata`,
|
||||||
|
while per-target compilation is fine). Declare EVERY artifact the common
|
||||||
|
code touches DIRECTLY (ui-graphics, ui-text, ui-unit, …) and give each its
|
||||||
|
own bridge rule. Note only the WINDOWS publish job compiles common metadata
|
||||||
|
(it owns the root KotlinMultiplatform publications — the only host that
|
||||||
|
declares every target, so only its .module files carry the full variant
|
||||||
|
table; macOS-published roots left v0.1.15 without mingwX64 variants) —
|
||||||
|
test with `gradlew :<module>:compileCommonMainKotlinMetadata` before tagging.
|
||||||
- **`Path()` in commonMain returns different actuals per renderer** —
|
- **`Path()` in commonMain returns different actuals per renderer** —
|
||||||
the Skia renderer produces a `SkiaBackedPath` (wraps
|
the Skia renderer produces a `SkiaBackedPath` (wraps
|
||||||
`org.jetbrains.skia.Path`), the SDL renderer produces a project
|
`org.jetbrains.skia.Path`), the SDL renderer produces a project
|
||||||
|
|||||||
@@ -1,196 +1,180 @@
|
|||||||
[](https://kotlinlang.org)
|
[](https://kotlinlang.org)
|
||||||
|
[](LICENSE.md)
|
||||||
|
|
||||||
# Compose Desktop Native
|
# Compose Desktop Native
|
||||||
|
|
||||||
A Kotlin/Native port of **Compose Multiplatform** running on **SDL3** for
|
Run [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform)
|
||||||
windowing and input — **no JVM**. Compiles to native binaries for macOS
|
as a native binary, with no JVM. Compose Desktop Native compiles to a single
|
||||||
(arm64), Linux (x64/arm64), Windows (mingwX64).
|
executable for macOS (arm64), Linux (x64/arm64), and Windows (mingwX64), using
|
||||||
|
[SDL3](https://libsdl.org) for windowing and input.
|
||||||
|
|
||||||
Rendering is pluggable behind one `RenderBackend`:
|
<img src="screenshots/demo.png" width="100%" alt="Compose Desktop Native demo" />
|
||||||
|
|
||||||
- **Skia** (via Skiko klibs) on macOS + Linux — Metal / OpenGL / CPU raster.
|
## How it works
|
||||||
- **SDL3** (`SDL3_ttf` + `SDL_RenderGeometry`) on Windows, and on macOS/Linux
|
|
||||||
when `-Prenderer=sdl3` is passed.
|
|
||||||
|
|
||||||
The Compose **runtime** is the official
|
- **The runtime is the real one.** Composition, snapshots, and the recomposer
|
||||||
`org.jetbrains.compose.runtime:runtime` klibs from Maven — this project
|
are the official `org.jetbrains.compose.runtime` klibs from Maven, never
|
||||||
re-implements the layers on top of it (`androidx.compose.ui.*`,
|
reimplemented.
|
||||||
`.foundation.*`, `.animation.*`, `.material3.*`) by **vendoring
|
- **The UI layers are vendored upstream.** `androidx.compose.ui`, `foundation`,
|
||||||
upstream Compose Multiplatform verbatim** whenever possible and hand-rolling
|
`animation`, and `material3` are copied from Compose Multiplatform verbatim
|
||||||
project actuals + SDL3 / Skia glue only where needed.
|
wherever they compile as is, with project code filling in only the native glue.
|
||||||
|
- **Rendering is pluggable** behind one `RenderBackend`: Skia (via Skiko) on
|
||||||
|
macOS and Linux, and a from-scratch SDL3 renderer (`SDL3_ttf` +
|
||||||
|
`SDL_RenderGeometry`) on Windows, or anywhere with `-Prenderer=sdl3`.
|
||||||
|
- **The platform is SDL3.** Windowing, input, audio, filesystem, file dialogs,
|
||||||
|
and clipboard all go through SDL3, so one code path covers every OS.
|
||||||
|
- **No runtime dependencies.** SDL3 and its codecs are built as static
|
||||||
|
libraries and linked in. A distributable is just the executable plus a
|
||||||
|
`data.kres` resource bundle.
|
||||||
|
|
||||||
## Module layout
|
| Platform | Gradle target | Default renderer |
|
||||||
|
|----------|---------------|------------------|
|
||||||
|
| macOS arm64 | `macosArm64` | Skia (Metal) |
|
||||||
|
| Linux x64 / arm64 | `linuxX64` / `linuxArm64` | Skia (OpenGL) |
|
||||||
|
| Windows | `mingwX64` | SDL3 |
|
||||||
|
|
||||||
One Gradle module per upstream Compose artifact — the directory tree mirrors
|
## Quickstart
|
||||||
upstream's `compose/` layout, gradle paths stay short via `projectDir`
|
|
||||||
redirects.
|
|
||||||
|
|
||||||
```
|
### A window
|
||||||
compose/
|
|
||||||
├── ui/
|
|
||||||
│ ├── ui/ → :ui (androidx.compose.ui.* + renderers + cinterops)
|
|
||||||
│ ├── ui-util/ → :ui-util
|
|
||||||
│ ├── ui-geometry/ → :ui-geometry
|
|
||||||
│ ├── ui-unit/ → :ui-unit
|
|
||||||
│ └── ui-backhandler/ → :ui-backhandler
|
|
||||||
├── animation/
|
|
||||||
│ ├── animation-core/ → :animation-core
|
|
||||||
│ ├── animation/ → :animation
|
|
||||||
│ └── animation-graphics/ → :animation-graphics
|
|
||||||
├── foundation/
|
|
||||||
│ ├── foundation/ → :foundation
|
|
||||||
│ └── foundation-layout/ → :foundation-layout
|
|
||||||
├── material3/material3/ → :material3
|
|
||||||
├── material/material-ripple/ → :material-ripple
|
|
||||||
└── sdl/ (project modules — the SDL integration layer)
|
|
||||||
└── window/ → :window (nativeComposeApp { Window(...) } + SDL3 main loop,
|
|
||||||
per-window Lifecycle/ViewModelStore/SavedState owners)
|
|
||||||
|
|
||||||
utils/
|
|
||||||
└── material-symbols/ → :material-symbols (codepoints + Outlined/Rounded/Sharp icon fonts)
|
|
||||||
|
|
||||||
components/
|
|
||||||
└── resources/library/ → :components-resources (vendored official compose resources runtime —
|
|
||||||
painterResource/stringResource/Res codegen; the Maven
|
|
||||||
artifact ships no mingwX64/linux klibs)
|
|
||||||
|
|
||||||
navigation3/
|
|
||||||
└── navigation3-ui/ → :navigation3-ui (vendored NavDisplay + scene machinery — the one
|
|
||||||
Navigation 3 layer without a K/N desktop artifact)
|
|
||||||
|
|
||||||
demo/ → :demo (showcase + CLI probe suite; multiplatform — the same screens
|
|
||||||
also build for stock JVM Compose Desktop as a parity reference)
|
|
||||||
apidemo/ → :apidemo (Postman-style REST client)
|
|
||||||
```
|
|
||||||
|
|
||||||
## demo — widget & feature showcase
|
|
||||||
|
|
||||||
<img src="screenshots/demo.png" width="100%" alt="ComposeNativeSDL3 demo" />
|
|
||||||
|
|
||||||
`:demo` is a full tour of the re-implemented Compose + Material 3 surface —
|
|
||||||
30+ sidebar screens covering buttons, text fields, layout, modifiers, shapes,
|
|
||||||
images, state & recomposition, scrolling & lazy lists, dialogs, icons, canvas,
|
|
||||||
graphics layers, custom layout, animation and gestures.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
|
|
||||||
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
|
|
||||||
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (SDL3)
|
|
||||||
./gradlew :demo:run # stock JVM Compose Desktop (parity reference)
|
|
||||||
```
|
|
||||||
|
|
||||||
The same shared screens drive both stacks — the JVM target runs them on
|
|
||||||
upstream Compose Desktop unchanged, so any visual or behavioural difference
|
|
||||||
against the native build is a porting bug, not a demo artifact.
|
|
||||||
|
|
||||||
CLI: `--gpu=…`, `--screen=<Name>` (one screen, no sidebar),
|
|
||||||
`--screenshot=out.bmp --frames=N`, `--width=W --height=H`.
|
|
||||||
|
|
||||||
## apidemo — API Manager
|
|
||||||
|
|
||||||
<img src="screenshots/apidemo.png" width="100%" alt="ComposeNativeSDL3 API Manager" />
|
|
||||||
|
|
||||||
`:apidemo` is a Postman-style REST client built entirely on the library:
|
|
||||||
request collections (**packs**, nested sub-packs, linked copies), a
|
|
||||||
**Session → Pack → Request** inheritance ladder for variables / headers /
|
|
||||||
query params / client certs, syntax-highlighted JSON / XML / YAML / HTML body
|
|
||||||
editors, a response viewer with timing, size and TLS-chain inspection, mTLS
|
|
||||||
client certificates, drag-and-drop tree management, request history and
|
|
||||||
file-based sessions.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew :apidemo:runDebugExecutableMacosArm64
|
|
||||||
./gradlew :apidemo:runDebugExecutableLinuxX64
|
|
||||||
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `-Prenderer=sdl3` on macOS/Linux to drop Skiko and use the pure-SDL3
|
|
||||||
renderer everywhere.
|
|
||||||
|
|
||||||
## Minimal app
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import com.compose.sdl.nativeComposeWindow
|
import com.compose.sdl.nativeComposeWindow
|
||||||
|
|
||||||
fun main() = nativeComposeWindow(title = "Hello") {
|
fun main() = nativeComposeWindow(title = "Hello") {
|
||||||
Text("Hello from ComposeNativeSDL3")
|
Text("Hello from Compose Desktop Native")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The lambda runs with a `ComposeWindowScope` receiver exposing
|
The content lambda runs with a `ComposeWindowScope` receiver that exposes
|
||||||
`window: ComposeNativeWindow` (`setTitle` / `setSize` / `minimize` /
|
`window: ComposeNativeWindow` (`setTitle`, `setSize`, `minimize`, `maximize`,
|
||||||
`maximize` / `setFullscreen` / `close` / …); the same handle is reachable
|
`setFullscreen`, `close`). The same handle is reachable from any nested
|
||||||
from any nested composable via `LocalComposeNativeWindow.current`.
|
composable via `LocalComposeNativeWindow.current`.
|
||||||
|
|
||||||
Add these to your module's `commonMain.dependencies`:
|
For multiple windows, wrap them in `nativeComposeApp`, where the set of
|
||||||
|
`Window(...)` calls is state driven (Compose Desktop style):
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
implementation(project(":window")) // window + main loop
|
fun main() = nativeComposeApp {
|
||||||
implementation(project(":material3")) // Material 3 widgets
|
Window(title = "Main", onCloseRequest = ::exitApplication) { /* ... */ }
|
||||||
implementation(project(":material-symbols")) // icon-font composables (optional)
|
if (showInspector) {
|
||||||
|
Window(title = "Inspector", onCloseRequest = { showInspector = false }) { /* ... */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each window gets its own `Lifecycle`, `ViewModelStore`, and `SavedStateRegistry`
|
||||||
|
owners, driven by real SDL focus and visibility events, so ViewModels and
|
||||||
|
saved state behave as they do on Android.
|
||||||
|
|
||||||
|
### Building in this repo
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(project(":window")) // window shell + main loop
|
||||||
|
implementation(project(":material3")) // Material 3 widgets
|
||||||
|
implementation(project(":material-symbols")) // icon-font composables (optional)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building from your own project: the bridge plugin
|
||||||
|
|
||||||
|
The klibs publish to GitHub Packages under `com.bitsycore.compose.sdl:*`. Apply
|
||||||
|
the bridge Gradle plugin once, declare the **official** Compose Multiplatform
|
||||||
|
coordinates, and the plugin swaps in the port's klibs on native desktop targets
|
||||||
|
while android, jvm, iOS, and wasm keep resolving the official artifacts.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts, official coordinates everywhere
|
||||||
|
plugins {
|
||||||
|
kotlin("multiplatform")
|
||||||
|
id("org.jetbrains.compose")
|
||||||
|
id("com.bitsycore.compose-desktop-native.bridge") version "<release>"
|
||||||
|
}
|
||||||
|
|
||||||
|
commonMain.dependencies {
|
||||||
|
// The plugin exposes the exact Compose versions the port tracks, so you
|
||||||
|
// never hand-match them (material3 is versioned separately upstream).
|
||||||
|
implementation("org.jetbrains.compose.runtime:runtime:${composeDesktopNative.composeRuntime}")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui:${composeDesktopNative.compose}")
|
||||||
|
implementation("org.jetbrains.compose.foundation:foundation:${composeDesktopNative.compose}")
|
||||||
|
implementation("org.jetbrains.compose.material3:material3:${composeDesktopNative.composeMaterial3}")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Repositories, credentials, and version pinning:
|
||||||
|
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
|
||||||
|
|
||||||
|
For a complete project that applies the bridge and builds one shared UI for
|
||||||
|
Android, JVM, and native desktop, see the example repo:
|
||||||
|
[bitsycore/compose-desktop-native-bridge-example](https://github.com/bitsycore/compose-desktop-native-bridge-example).
|
||||||
|
|
||||||
|
## Sample apps
|
||||||
|
|
||||||
|
Both live in `commonMain` and also build for stock JVM Compose Desktop, which
|
||||||
|
serves as the visual and behavioural reference: any difference against the
|
||||||
|
native build is a porting bug.
|
||||||
|
|
||||||
|
**`:demo`** is a tour of the re-implemented Compose and Material 3 surface,
|
||||||
|
30-plus screens covering text, layout, shapes, images, state, lazy lists,
|
||||||
|
dialogs, canvas, graphics layers, animation, and gestures.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
|
||||||
|
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
|
||||||
|
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (SDL3)
|
||||||
|
./gradlew :demo:run # JVM Compose Desktop (reference)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`:apidemo`** is a Postman-style REST client built entirely on the library:
|
||||||
|
request collections, a session inheritance ladder, syntax-highlighted body
|
||||||
|
editors, a response viewer with timing and TLS-chain inspection, and mTLS
|
||||||
|
client certificates.
|
||||||
|
|
||||||
|
<img src="screenshots/apidemo.png" width="100%" alt="Compose Desktop Native API Manager" />
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :apidemo:runDebugExecutableMacosArm64
|
||||||
|
./gradlew :apidemo:run # JVM Compose Desktop (reference)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `-Prenderer=sdl3` on macOS or Linux to drop Skiko and use the pure SDL3
|
||||||
|
renderer everywhere.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
One Gradle module per upstream Compose artifact, mirroring the upstream
|
||||||
|
`compose/` tree. The renderer and cinterops live in `:ui`; `:window` is the SDL
|
||||||
|
integration layer. `:material-symbols`, `:components-resources`, and
|
||||||
|
`:navigation3-ui` are project or vendored modules that the official Maven
|
||||||
|
artifacts do not cover for Kotlin/Native desktop.
|
||||||
|
|
||||||
|
Most of the androidx architecture stack (lifecycle, viewmodel, navigation3,
|
||||||
|
savedstate, navigationevent) ships real Kotlin/Native desktop klibs and runs on
|
||||||
|
the port unmodified. The full module map, dependency graph, and the list of
|
||||||
|
compatible artifacts are in [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
- **macOS:** `brew install sdl3` (Skia is the default; Skiko klibs come from
|
Build the native libraries once per machine, then build any app target:
|
||||||
Maven).
|
|
||||||
- **Linux:** `sudo apt install libsdl3-dev`.
|
|
||||||
- **Windows:** SDL3, SDL3_ttf, SDL3_image and FreeType are built from source
|
|
||||||
as **static** libraries by `scripts/build-sdl/build-all.sh` into the gitignored
|
|
||||||
in-repo `libs/`, then linked into the executable — the Windows
|
|
||||||
distributable is just `<app>.exe` + `data.kres`, no runtime DLLs.
|
|
||||||
|
|
||||||
## Vendoring
|
|
||||||
|
|
||||||
The bulk of the `androidx.compose.*` code is vendored byte-for-byte from
|
|
||||||
`JetBrains/compose-multiplatform-core` and lives under
|
|
||||||
`<module>/src/vendor/` (gitignored — you re-sync on demand). Each module
|
|
||||||
carries a `compose-fork.txt` manifest listing which upstream files it pulls
|
|
||||||
in.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/compose-fork/sync.sh # sync every module
|
python3 scripts/build-sdl/build-all.py # SDL3, SDL3_ttf, SDL3_image, FreeType (static)
|
||||||
scripts/compose-fork/sync.sh compose/ui/compose-fork.txt # one module
|
./gradlew :demo:runDebugExecutableMacosArm64
|
||||||
```
|
```
|
||||||
|
|
||||||
Upstream ref pinned in `scripts/compose-fork/compose.properties`; manifests can
|
Every host needs `git`, `cmake`, and Python 3. Per host: macOS needs the Xcode
|
||||||
pin additional upstream repos inline (`SET_REPO=<url>@<ref>` — the resources
|
command line tools; Linux needs gcc/g++ and the X11 / Wayland / audio dev
|
||||||
runtime vendors from the compose-multiplatform umbrella repo this way).
|
headers; Windows needs a mingw-w64 g++ on PATH. See [TOOLING.md](TOOLING.md) for
|
||||||
|
the full build and verification workflow.
|
||||||
|
|
||||||
See [CLAUDE.md](CLAUDE.md) for the full architecture, source-set hierarchy,
|
## Documentation
|
||||||
vendoring rules, density flow (physical-pixel Option B), and per-area file
|
|
||||||
map.
|
|
||||||
|
|
||||||
## Known Compatible — official artifacts that just work
|
- [TOOLING.md](TOOLING.md): building the native libs, vendoring, and verification.
|
||||||
|
- [CLAUDE.md](CLAUDE.md): architecture, source-set hierarchy, vendoring rules,
|
||||||
A surprising amount of the androidx architecture stack ships real
|
density flow, and a per-area file map.
|
||||||
Kotlin/Native desktop klibs (mingwX64, linuxX64/arm64, macosArm64) and runs
|
- [gradle-plugin/compose-desktop-native-bridge](gradle-plugin/compose-desktop-native-bridge/README.md):
|
||||||
on this port **unmodified** — no reimplementation, no vendoring. Sometimes
|
consuming the port from another build.
|
||||||
the Google coordinates (`androidx.*`) carry the K/N variant, sometimes the
|
|
||||||
JetBrains ones (`org.jetbrains.*`) — check both.
|
|
||||||
|
|
||||||
Verified working in-tree:
|
|
||||||
|
|
||||||
| Artifact | Notes |
|
|
||||||
|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------|
|
|
||||||
| `org.jetbrains.compose.runtime:runtime`, `runtime-saveable` | the Compose runtime itself — never reimplemented |
|
|
||||||
| `androidx.compose.runtime:runtime-retain` | google coordinates |
|
|
||||||
| `androidx.lifecycle:lifecycle-runtime-compose` | `LocalLifecycleOwner`, `rememberLifecycleOwner`, `repeatOnLifecycle` |
|
|
||||||
| `androidx.lifecycle:lifecycle-viewmodel(-compose, -savedstate)` | full ViewModel + SavedStateHandle stack |
|
|
||||||
| `androidx.lifecycle:lifecycle-viewmodel-navigation3` | per-entry ViewModel scoping for Navigation 3 |
|
|
||||||
| `androidx.savedstate:savedstate`, `savedstate-compose` | |
|
|
||||||
| `androidx.navigation3:navigation3-runtime` | backstack / NavEntry / decorators (only the UI layer needed vendoring → `:navigation3-ui`) |
|
|
||||||
| `androidx.navigationevent:navigationevent-compose` | predictive back / BackHandler plumbing |
|
|
||||||
| `androidx.collection:collection` | |
|
|
||||||
|
|
||||||
The window layer supplies what these expect from a host: per-window
|
|
||||||
`LifecycleOwner` (driven by real SDL focus/visibility events — focused →
|
|
||||||
RESUMED, unfocused → STARTED, minimized → CREATED), `ViewModelStoreOwner`
|
|
||||||
(the `activityViewModels()` analog, `SavedStateHandle`-enabled) and
|
|
||||||
`SavedStateRegistryOwner`, plus a `Dispatchers.Main` with a true `immediate`.
|
|
||||||
ViewModels scope per nav entry, per window, or anywhere in between — same
|
|
||||||
semantics as Android.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# RENDERER.md
|
||||||
|
|
||||||
|
The durable reference for the rendering layer: how it is structured, the
|
||||||
|
convergence work that shaped it, the decisions worth remembering, and what is
|
||||||
|
left. Read this before touching anything under `renderer/`, the graphics
|
||||||
|
actuals, or the layer engine.
|
||||||
|
|
||||||
|
For build and verification commands see [TOOLING.md](TOOLING.md). For module
|
||||||
|
layout, source-set hierarchy, and vendoring rules see [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
|
## 1. The two renderer legs
|
||||||
|
|
||||||
|
Rendering is pluggable behind one `RenderBackend` interface. Exactly one leg is
|
||||||
|
active per target:
|
||||||
|
|
||||||
|
- **Skia leg** (`skikoRendererMain`, macOS + Linux). Draws through upstream
|
||||||
|
Compose Multiplatform's own Skia stack, vendored verbatim: `SkiaBackedCanvas`,
|
||||||
|
`SkiaBackedPaint`, `SkiaShader`, `actual class GraphicsLayer` backed by
|
||||||
|
`org.jetbrains.skiko.node.RenderNode`, `SkiaGraphicsContext`. On this leg the
|
||||||
|
layer and draw engine internals are literally upstream.
|
||||||
|
- **SDL leg** (`sdlRendererMain`, Windows always; macOS/Linux under
|
||||||
|
`-Prenderer=sdl3`). A from-scratch renderer on `SDL_RenderGeometry` +
|
||||||
|
`SDL3_ttf` + FreeType. SDL is a triangle blitter, so this leg carries a
|
||||||
|
permanent bespoke surface (about 3,800 lines): `Sdl3Canvas`,
|
||||||
|
`Sdl3TextRenderer`, `FreeTypeIcons`, and the SDL render nodes.
|
||||||
|
|
||||||
|
Both legs share one project-owned text and image pipeline (`SdlParagraph`,
|
||||||
|
`NativeTextMeasurer`, `IconFont`). Text layout, line-breaking, metrics, and
|
||||||
|
hit-test are the port's engine on both legs; only glyph rasterization differs
|
||||||
|
(Skia vs SDL3_ttf/FreeType). This is a deliberate axiom, not a gap: see the
|
||||||
|
text decision in section 4.
|
||||||
|
|
||||||
|
The seam is kept as narrow and low as possible. Code flows
|
||||||
|
`Common (upstream) -> shared native engine -> Skia actual / SDL actual`. What we
|
||||||
|
minimize is hand-rolled actual surface, not actual-side line count: a fat
|
||||||
|
vendored-upstream actual (the Skia leg carrying upstream's whole GraphicsLayer
|
||||||
|
stack) is preferred over a thin hand-written one.
|
||||||
|
|
||||||
|
## 2. The retained-layer engine (the model we copied)
|
||||||
|
|
||||||
|
Upstream skiko skips work at three levels. The port has all three.
|
||||||
|
|
||||||
|
- **L1 frame scheduling.** A frame schedules the next only if still dirty.
|
||||||
|
Ours: `ComposeWindow.shouldRender()` gates `renderFrame()`; the loop blocks on
|
||||||
|
`SDL_WaitEventTimeout` when idle.
|
||||||
|
- **L2 measure/layout only dirty nodes.** Vendored `MeasureAndLayoutDelegate`
|
||||||
|
used verbatim through `ComposeOwner.measureAndLayout()`.
|
||||||
|
- **L3 draw record-once / replay** (the expensive one). Each isolated
|
||||||
|
`LayoutNode` gets an `OwnedLayer` (`GraphicsLayerOwnerLayer`) owning a
|
||||||
|
`GraphicsLayer`. The display list re-records only when content is dirty;
|
||||||
|
otherwise it replays.
|
||||||
|
|
||||||
|
The critical property: **transform, alpha, and clip changes do NOT re-record.**
|
||||||
|
Moving, scaling, rotating, or fading a layer replays cached content under a new
|
||||||
|
transform. Only a genuine content change (a state read inside the draw block, or
|
||||||
|
a resize) re-records. Upstream replays the whole scene every frame with no
|
||||||
|
dirty-region present; the entire win is not re-recording clean layers, not
|
||||||
|
drawing less screen. Dirty-region rendering is therefore an explicit non-goal.
|
||||||
|
|
||||||
|
The compositing-strategy contract (`requiresLayer()`, from
|
||||||
|
`SkiaGraphicsLayer.skiko.kt`) must match on both legs. It decides when a layer
|
||||||
|
needs an offscreen:
|
||||||
|
|
||||||
|
| Condition | Auto | Offscreen | ModulateAlpha |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `alpha < 1` | offscreen | offscreen | per-op alpha multiply (no offscreen) |
|
||||||
|
| `colorFilter != null` | offscreen | offscreen | offscreen |
|
||||||
|
| `blendMode != SrcOver` | offscreen | offscreen | offscreen |
|
||||||
|
| `renderEffect != null` | offscreen | offscreen | offscreen |
|
||||||
|
| none of the above | replay in place | always offscreen | replay in place |
|
||||||
|
|
||||||
|
## 3. Current state
|
||||||
|
|
||||||
|
- **Skia leg runs upstream's engine.** Canvas (`SkiaBackedCanvas`) and
|
||||||
|
GraphicsLayer/GraphicsContext (`org.jetbrains.skiko.node.RenderNode`) are
|
||||||
|
vendored verbatim. The Skia leg gets real display-list caching and correct
|
||||||
|
clip/shadow/renderEffect for free from upstream. After this landed, Skia
|
||||||
|
`draw` on LazyColumn fell from 1.75 ms to 0.2 ms.
|
||||||
|
- **SDL leg ships the geo node as the Windows default.** `SdlDisplayListRenderNode`
|
||||||
|
records a leaf's layer-local tessellated geometry, text runs, and icon glyphs
|
||||||
|
by params (not texture pointers), then re-emits through the layer transform.
|
||||||
|
Crisp under any transform, bit-exact, deterministic (no render-target state),
|
||||||
|
and eviction-safe (replay re-looks-up via the run LRU / FreeType glyph cache).
|
||||||
|
0.000% across a 57-screen self-consistency sweep bar a sub-0.06% rotated-edge
|
||||||
|
AA fringe. LazyColumn steady-state `draw` fell 57% versus the un-cached
|
||||||
|
baseline. Fallbacks: `CDN_LAYERCACHE=off|texture`.
|
||||||
|
- **Parity is a golden-master on the Skia leg** (about 2% median, dominated by
|
||||||
|
the shared text engine's small metric delta) and a bounded, ranked signal on
|
||||||
|
the SDL leg. See TOOLING.md for how to read it.
|
||||||
|
- **Memory is stable.** The historical composition leak is fixed (see section 6)
|
||||||
|
and guarded by the `--soaktest` gate in the verify runbook.
|
||||||
|
|
||||||
|
## 4. Decisions to remember
|
||||||
|
|
||||||
|
- **Goal is G1, cheap upstream-tracking.** The target is low per-bump
|
||||||
|
reconciliation cost when following upstream, not Windows pixel-parity or
|
||||||
|
feature completeness. The JVM Compose Desktop target (`:demo:run`) is the
|
||||||
|
documented Windows fidelity/feature tier: real Skia, full fidelity, on any
|
||||||
|
host. That makes SDL-leg fidelity work (B3) and real-Skia-on-Windows (Track A)
|
||||||
|
optional polish, not obligations.
|
||||||
|
- **Vendor, do not hand-roll.** Copy upstream verbatim wherever it compiles.
|
||||||
|
Edit-to-compile becomes a manual vendor with a `// VENDOR-BASE:` header so the
|
||||||
|
drift tripwire can track it. The litmus test for any divergence: "Is this what
|
||||||
|
upstream does? If not, what real platform constraint forces the difference?"
|
||||||
|
Valid answers name a constraint (no Skiko on Windows K/N; SDL is a triangle
|
||||||
|
blitter). "It was easier" is not valid.
|
||||||
|
- **B2 convergence is complete.** The Skia leg was migrated off the port's
|
||||||
|
hand-rolled GraphicsLayer onto upstream's own. This was a source-set migration,
|
||||||
|
not a file-flip: the SDL node cluster relocated `nativeMain -> sdlRendererMain`,
|
||||||
|
`GraphicsContext` forked per-leg behind a `createGraphicsContext()` seam, and
|
||||||
|
the upstream skiko files were un-refused in `compose-fork.txt`. Measured
|
||||||
|
sync-tax reduction on the skiko path: about 8.5x (roughly 1240 hand-rolled
|
||||||
|
lines down to about 145 lines of drift-tracked edits; roughly 1006 lines now
|
||||||
|
auto-sync verbatim on a ref bump). The beta02 -> beta03 bump proved it: zero
|
||||||
|
reconciliation, all manual-vendor bases unchanged.
|
||||||
|
- **Text stays the port's engine (B6.3 skipped).** Making Skia-leg text truly
|
||||||
|
upstream (`SkiaParagraph`) is a font-subsystem replacement, not a canvas swap:
|
||||||
|
two coexisting font-identity models (the port's name-to-bytes `IconFont` +
|
||||||
|
no-op resolver vs upstream `PlatformFont`/`FontCache`/`FontCollection`), about
|
||||||
|
13 skiko files to re-vendor, about 10 shared actuals to split, a
|
||||||
|
`data.kres`-to-`FontCache` bridge, no green intermediate, and it discards the
|
||||||
|
P3.1 metrics work. ROI is low: the Skia leg already measures text with skiko
|
||||||
|
`Font` metrics (via `currentTextMeasurer` -> `SkiaTextRenderer`), which is how
|
||||||
|
parity reached about 2%. Revisit only if complex-script/bidi fidelity on the
|
||||||
|
native Skia leg becomes a hard requirement.
|
||||||
|
- **`GraphicsLayerOwnerLayer` stays shared.** The fork point is `GraphicsLayer` /
|
||||||
|
`GraphicsContext`, not the owner layer. The shared owner layer compiles
|
||||||
|
against both `GraphicsLayer` actuals (upstream skiko + port SDL) only if both
|
||||||
|
satisfy the commonMain `expect class GraphicsLayer` API. That API-parity
|
||||||
|
invariant is enforced by `:ui:compileCommonMainKotlinMetadata` on every build.
|
||||||
|
- **Lifetime model.** The SDL leg uses GC / release-queue for layer lifetime and
|
||||||
|
does NOT vendor `ChildLayerDependenciesTracker`. The Skia leg uses upstream's
|
||||||
|
`SkiaGraphicsLayer`, which inherently uses the tracker (already vendored in
|
||||||
|
commonMain). A per-leg lifetime divergence is the class of bug that caused an
|
||||||
|
early navigation crash, so any change here goes to both legs plus a soak gate.
|
||||||
|
- **Module split (`:ui-graphics` / `:ui-text`) is shelved as infeasible.** Not
|
||||||
|
cosmetic churn as originally scoped: the `sdl3` cinterop is a shared substrate
|
||||||
|
used by 11 graphics-side files AND 12 platform/windowing/node/resources files
|
||||||
|
that stay in `:ui`. Kotlin/Native cannot cleanly share a cinterop klib across a
|
||||||
|
module boundary, which is exactly why they were merged. A real split needs a
|
||||||
|
new non-upstream `:ui-cinterop` base module plus cross-module cinterop
|
||||||
|
api-exposure, which is bigger and more divergent than the "match upstream
|
||||||
|
artifacts" goal it was meant to serve. The one keeper from the attempt:
|
||||||
|
`RenderBackend.drawRoot` now takes `(Canvas) -> Unit`, decoupling the backends
|
||||||
|
from `ComposeRootHost` (a genuine improvement, retained).
|
||||||
|
- **Track A (real Skia on Windows K/N) is shelved.** Kotlin/Native mingwX64 is
|
||||||
|
GNU-ABI; skia-pack Windows is MSVC-ABI, which cannot be statically fused into a
|
||||||
|
GNU-ABI binary. Building current C++20 Skia to a GNU/mingw static archive is an
|
||||||
|
open-ended fork (only abandoned Mozilla-era precedent). The only working route
|
||||||
|
is a runtime DLL, which breaks the no-DLL invariant and adds tens of MB on the
|
||||||
|
one platform it targets. The SDL leg is the permanent Windows renderer; JVM is
|
||||||
|
the fidelity escape hatch.
|
||||||
|
|
||||||
|
## 5. Convergence status
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Guardrails (parity gate, verify-mac runbook, drift + vendor-clean checks) | Done |
|
||||||
|
| B2: Skia leg on upstream GraphicsLayer/GraphicsContext | Done |
|
||||||
|
| B6.1: Skia leg on upstream `SkiaBackedCanvas` + paint/shader | Done |
|
||||||
|
| B6.2: upstream `GraphicsLayer` + delete transient port cluster | Done |
|
||||||
|
| B6.3: upstream text (`SkiaParagraph`) on the Skia leg | Skipped (decision) |
|
||||||
|
| B5: engine-convergence deltas audit | Done (clean wins spent by B6) |
|
||||||
|
| P2.2: composition memory leak | Fixed + soak-gated |
|
||||||
|
| P2.3: outsets / blur / renderEffect | Done via upstream `SkiaGraphicsLayer` |
|
||||||
|
| P3.1: SDL text metrics parity (17% median -> 2%) | Done |
|
||||||
|
| B3: further SDL fidelity | Capped (parity-ranked wins only) |
|
||||||
|
| Module split | Shelved (infeasible as specified) |
|
||||||
|
| Track A: real Skia on Windows | Shelved |
|
||||||
|
|
||||||
|
## 6. Remaining and future work
|
||||||
|
|
||||||
|
Nothing here blocks day-to-day work. These are the open threads worth
|
||||||
|
remembering.
|
||||||
|
|
||||||
|
- **WIN-SMOKE (pre-ship, Windows only).** The Mac runbook covers both renderers
|
||||||
|
but cannot cover the shipped mingwX64 binary, the Windows-only `PrintWindow`
|
||||||
|
probe, or the common-metadata publish job. Run these on a Windows host before
|
||||||
|
any release. This is the only outstanding verification.
|
||||||
|
- **Stabilization at Compose 1.12.0 stable.** The vendored refs are pinned to
|
||||||
|
`v1.12.0-beta03+dev4483` (no clean beta03 tag exists yet, and it is not on
|
||||||
|
Maven). The native side leads the JVM parity leg (forced to beta02, the latest
|
||||||
|
published) by a documented skew. When 1.12.0 stable ships: re-pin both refs to
|
||||||
|
the clean tag, bump the `vComposeJvmVersion` forcing in demo/apidemo/
|
||||||
|
material-symbols to close the skew, then cut the release. The concrete
|
||||||
|
step-by-step (the version map, the ref-bump flow, and the release flow) lives
|
||||||
|
in [TOOLING.md](TOOLING.md#versioning-and-releasing).
|
||||||
|
- **Deferred engine deltas (D2-D6, all low ROI under G1).**
|
||||||
|
- D2: split `GraphicsLayerOwnerLayer` per-leg to restore upstream's exact
|
||||||
|
`setLightingInfo` shadow lighting. Feasible (the old RootNodeOwner-coupling
|
||||||
|
blocker was outdated) but high blast radius for a marginal gain; elevation
|
||||||
|
shadows already match JVM.
|
||||||
|
- D3: dedupe `LayerTransformationMatrix.kt` against the now-vendored
|
||||||
|
`Matrices.skiko`. Blocked on D2 (the shared owner-layer hit-test needs a
|
||||||
|
both-legs matrix fn).
|
||||||
|
- D4/D5: `SemanticsRegion` intersect/difference are stubs, and `CharHelpers`
|
||||||
|
is naive grapheme/bidi vs upstream ICU. Real fidelity gaps, gated on an
|
||||||
|
accessibility or complex-script roadmap.
|
||||||
|
- D6: `Focusability` / `PlatformVelocityTracker` are byte-equal to upstream;
|
||||||
|
vendoring them saves nothing.
|
||||||
|
- **SDL performance (opportunistic).** Shared glyph atlas to replace per-run
|
||||||
|
textures; `drawImageRect` capture in the geo node (journal rates it low-ROI).
|
||||||
|
- **SDL_GPU backend (long-term).** Real stencil clipping, pipelined batching,
|
||||||
|
shader gradients. The `NativeRenderNode` seam makes a GPU node a clean
|
||||||
|
drop-in.
|
||||||
|
- **Native-resource lifecycle.** Wire `GraphicsLayer`/RenderNode +
|
||||||
|
`SdlImageBitmap.close()` fully into cache eviction and the renderer
|
||||||
|
`destroy()` chain; demote the periodic GC nudge once ownership covers it.
|
||||||
|
|
||||||
|
## 7. Hard-won learnings (do not relearn)
|
||||||
|
|
||||||
|
- **Minimize-divergence is load-bearing.** A hand-written `notifyLayerIsDirty`
|
||||||
|
that diverged from upstream `OwnedLayerManagerImpl` removed a layer from
|
||||||
|
`dirtyLayers` mid-loop and crashed on any navigation. Screenshots missed it;
|
||||||
|
the interaction probe caught it. Match upstream verbatim; do not hand-roll
|
||||||
|
engine plumbing from a summary.
|
||||||
|
- **The composition memory leak (root cause).** The port never called
|
||||||
|
`OwnerSnapshotObserver.clearInvalidObservations()`, which upstream
|
||||||
|
`RootNodeOwner` runs after every measure. Snapshot read-observations for
|
||||||
|
scopes invalidated on dispose lingered forever, each pinning its observed
|
||||||
|
object graph via a Kotlin/Native `ExternalRCRef` (a K/N-heap leak, vmmap
|
||||||
|
Memory Tag 246). Most visible on ripple/indication draws. Fix:
|
||||||
|
`ComposeRootHost.measureAndLayout()` sweeps `clearInvalidObservations()` after
|
||||||
|
layout, plus `ComposeOwner.onDetach` clears the detached node's observations.
|
||||||
|
Found by exact live-counters, static-mode isolation, macOS `leaks`/`heap`/
|
||||||
|
`vmmap`, and component bisection. RSS alone cannot pinpoint a referenced leak;
|
||||||
|
budget heap tooling for this class.
|
||||||
|
- **Offscreen-texture caching is timing-nondeterministic on complex screens.**
|
||||||
|
This is why the geo (no-render-target) node is the robust default, not the
|
||||||
|
texture node.
|
||||||
|
- **Rounded/path layer clips must be applied or deferred on the fast path.** The
|
||||||
|
geo path submits raw geometry, which clips only to a rect; rounded clips are a
|
||||||
|
lazy offscreen mask the fast replay bypasses. Rect clip on the fast path;
|
||||||
|
rounded/generic clips fall back to block-replay.
|
||||||
|
- **Screenshots miss crashes and settle-timing.** Free-running screenshots on
|
||||||
|
animated/settling screens give false signals. Use render-to-quiescence +
|
||||||
|
virtual frame time for parity, the probe for interaction/crash coverage.
|
||||||
|
- **The profiler `present` phase is vsync-capped by the display refresh.**
|
||||||
|
Profile on the target monitor before concluding a frame-rate gap.
|
||||||
|
|
||||||
|
## 8. Explicit non-goals
|
||||||
|
|
||||||
|
- **Dirty-region / partial present.** Upstream replays the whole scene; the win
|
||||||
|
is not re-recording, not redrawing less.
|
||||||
|
- **A custom `cacheKey` API.** Superseded by the real per-node display list.
|
||||||
|
- **Vendoring `RootNodeOwner` / the `ComposeScene` stack.** Coupled to skiko's
|
||||||
|
`SkiaLayer` and windowing; it fights the `:window` SDL loop. The port borrows
|
||||||
|
only the layer engine.
|
||||||
|
|
||||||
|
## 9. Key files
|
||||||
|
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../RenderBackend.kt`: the interface.
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../GpuMode.kt`: renderer / driver picker.
|
||||||
|
- `compose/ui/ui/src/skikoRendererMain/.../renderer/skia/SkiaRenderBackend.kt`.
|
||||||
|
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3RenderBackend.kt`.
|
||||||
|
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/FreeTypeIcons.kt`:
|
||||||
|
variable-font axis rasterization.
|
||||||
|
- `compose/ui/ui/src/commonMain/.../node/ComposeRootHost.kt`: root host,
|
||||||
|
hit-test, event dispatch, snapshot observer sweep.
|
||||||
|
- `compose/ui/ui/src/commonMain/.../node/impl/ComposeOwner.kt`: the project
|
||||||
|
`Owner` + `GraphicsLayerOwnerLayer` bridge.
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../ui/text/SdlParagraph.native.kt`: the bridged
|
||||||
|
`Paragraph` (measurement, hit-test, line metrics, span painting).
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# TODO.md
|
||||||
|
|
||||||
|
Everything the port currently leaves as a no-op, stub, hardcode, or partial
|
||||||
|
implementation, plus the gaps worth closing before a stable 1.12 release. This
|
||||||
|
is an audit of PROJECT code only (the `*.native.kt` / `*.sdl.kt` /
|
||||||
|
`com.compose.sdl.*` actuals); vendored upstream under `src/vendor/` is out of
|
||||||
|
scope.
|
||||||
|
|
||||||
|
Severity is a rough guide, not a mandate:
|
||||||
|
|
||||||
|
- **Blocker** for stable: a capability a typical desktop app expects that is
|
||||||
|
currently absent or broken.
|
||||||
|
- **Nice-to-have**: real gap, but the app works without it.
|
||||||
|
- **Cosmetic**: minor fidelity or edge-case behavior.
|
||||||
|
|
||||||
|
Context for prioritizing: the project goal is **G1, cheap upstream-tracking**
|
||||||
|
(see [RENDERER.md](RENDERER.md)). The **JVM Compose Desktop target is the
|
||||||
|
documented Windows fidelity/feature tier**, so SDL-leg visual gaps (section 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. **No copy/paste context menu** (section B). Keyboard copy works; there is no
|
||||||
|
right-click or selection toolbar (the desktop path is the right-click context
|
||||||
|
menu, upstream TODO CMP-7819).
|
||||||
|
2. **Accessibility is entirely absent** (section A). Decide whether stable
|
||||||
|
requires any screen-reader support at all.
|
||||||
|
3. Fonts (section D): compose-resources `Font(Res.font.x)` works 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Accessibility (entirely absent)
|
||||||
|
|
||||||
|
No accessibility bridge exists. A real `SemanticsOwner` is built but never
|
||||||
|
surfaced to any OS accessibility API (no NSAccessibility / UIA / AT-SPI, no SDL
|
||||||
|
a11y). Screen readers get nothing.
|
||||||
|
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:340` · `semanticsOwner` is instantiated but never read; no traversal into a platform a11y tree. A real a11y controller would walk the semantics tree and map roles/labels/actions to a native AT bridge. **Blocker for a11y.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../platform/CompositionLocals.native.kt:31` · `LocalPlatformScreenReader` default throws (`error(...)`); never provided by any window. Screen-reader-active queries are unavailable. **Blocker for a11y.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:297` · `accessibilityManager` implements only `calculateRecommendedTimeoutMillis` (passthrough); no announce/focus/event surface. **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../semantics/SemanticsRegion.native.kt:14` · `StubSemanticsRegion.intersect()`/`difference()` hardcode `false`; only `set`/`bounds` work. Region math for semantics culling is inert. **Nice-to-have.**
|
||||||
|
|
||||||
|
## B. Text toolbar and context menus (copy / paste / select-all)
|
||||||
|
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:311` · `textToolbar` is a stub: `showMenu`/`hide` NOP, `status` always `Hidden`. NOTE: the floating toolbar is gated on `isInTouchMode`, so on desktop (mouse) it never shows anyway — the desktop path is the right-click context menu below. **Nice-to-have (touch only).**
|
||||||
|
- `compose/foundation/.../text/selection/SelectionActuals.native.kt:42` · `addSelectionContainerTextContextMenuComponents` NOP (upstream TODO CMP-7819). No right-click copy/select-all menu for `SelectionContainer`. **Blocker for desktop text UX.**
|
||||||
|
- `compose/foundation/.../text/selection/TextFieldSelectionManager.native.kt:24` and `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:36` · `addBasicTextFieldTextContextMenuComponents` NOP for both the legacy and state-based `BasicTextField`. **Blocker for desktop text UX.**
|
||||||
|
|
||||||
|
## C. Locale / i18n tail (needs locale-aware DATA, no ICU on K/N)
|
||||||
|
|
||||||
|
`Locale.current` / `LocaleList.current` are wired to the OS (see Completed). The
|
||||||
|
rest needs month/weekday name tables and date/number patterns, which Kotlin/Native
|
||||||
|
has no ICU for; SDL reports language + country only (no script subtag, no
|
||||||
|
calendar/clock/number prefs). These require a bundled CLDR subset or a K/N i18n lib.
|
||||||
|
|
||||||
|
- `compose/material3/.../CalendarLocale.native.kt:20` · locale-agnostic stub: `toString()` fixed `"en"`, single shared instance, `equals` treats all instances as equal. A separate type from `ui.text.intl.Locale`; needs its own wiring so `CalendarModel`/`PlatformDateFormat` can vary by locale. Inert until `PlatformDateFormat` is localized. **Nice-to-have (DatePicker/TimePicker).**
|
||||||
|
- `compose/material3/.../internal/PlatformDateFormat.native.kt` · multiple hardcodes:
|
||||||
|
- `:27` `weekdayNames` hardcoded English full/short names (DatePicker shows English day initials regardless of locale). **Blocker for localized DatePicker.**
|
||||||
|
- `:32` `formatWithPattern`/`formatWithSkeleton` ignore the pattern/skeleton and always emit ISO `yyyy-MM-dd`. **Blocker for localized DatePicker.**
|
||||||
|
- `:25` `firstDayOfWeek` hardcoded `1` (Sunday); locale-dependent. **Nice-to-have.**
|
||||||
|
- `:44` `parse()` only accepts ISO `yyyy-MM-dd`, ignoring pattern/locale. **Nice-to-have.**
|
||||||
|
- `:62` `getDateInputFormat()` hardcoded `yyyy-MM-dd` / `'-'`. **Nice-to-have.**
|
||||||
|
- `:65` `is24HourFormat()` hardcoded `true`. **Cosmetic.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../text/platform/NativeStringDelegate.native.kt:17` · `toUpperCase`/`toLowerCase` use locale-independent stdlib casing; the `locale` param is ignored (wrong for Turkish dotted/dotless i, etc.). **Cosmetic.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt` · `Locale.isRtl()` not implemented; the pipeline is LTR-only (`SdlParagraph.native.kt` `getBidiRunDirection` always returns `Ltr`; owners/DrawScopes default `LayoutDirection.Ltr`). No RTL shaping. **Nice-to-have.**
|
||||||
|
- No `NumberFormat` / currency / decimal-grouping actual exists; numbers render via `toString()` with `.` decimal and no grouping regardless of locale. **Cosmetic.**
|
||||||
|
|
||||||
|
## D. Font resolution
|
||||||
|
|
||||||
|
**Custom fonts via compose-resources work.** `org.jetbrains.compose.resources.Font(Res.font.x)`
|
||||||
|
loads the bytes and registers them with the project font registry (IconFont → NamedFont)
|
||||||
|
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.
|
||||||
|
|
||||||
|
- `FontFamily.Serif` / `FontFamily.Cursive` still collapse to the default sans — no bundled
|
||||||
|
serif/cursive font yet (`downloadNotoFonts` fetches Sans + SansMono only). Register one under
|
||||||
|
`generic:serif` / `generic:cursive` the same way to enable them. **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:32` · `SdlPlatformFontLoader.loadBlocking`/`awaitLoad` are NOP; the androidx `PlatformFontLoader` path (`ResourceFont` / `Font(bytes)` / `LoadedFontFamily`) doesn't load. Use the working compose-resources `Font()` instead. **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:38` · `PlatformFontFamilyTypefaceAdapter.resolve` returns `TypefaceResult.Immutable(Unit)` (the renderer resolves by name, so harmless). **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:69` · deprecated `createFontFamilyResolver(fontResourceLoader)` ignores the supplied loader. **Cosmetic.**
|
||||||
|
|
||||||
|
## E. Text rendering details
|
||||||
|
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../text/SdlParagraph.native.kt:376` · `drawNativeText` accepts and ignores text `Shadow`, stroke `DrawStyle`, and non-`SrcOver` blend, so those never render on text. **Cosmetic.**
|
||||||
|
- `compose/foundation/.../text/StringHelpers.native.kt:31` and `compose/ui/ui/src/nativeMain/.../text/CharHelpers.native.kt:14` · `findPrecedingBreak`/`findFollowingBreak` (and `offsetByCodePoints`) do a codepoint/surrogate walk only, with no ICU grapheme clusters, so caret movement and backspace split combining marks and emoji ZWJ sequences. **Nice-to-have.**
|
||||||
|
|
||||||
|
## F. Drag and drop
|
||||||
|
|
||||||
|
Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
|
||||||
|
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:38` · `requestDragAndDropTransfer` is a NOP and `isRequestDragAndDropTransferRequired` returns `false`; `Modifier.dragAndDropSource` compiles but no OS-level drag leaves the window. Needs per-OS native work (NSDraggingSession / DoDragDrop / XDND); SDL3 has no portable start-drag. **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:119` · incoming transfer carries only file paths + a text blob; no image/URL/custom-MIME payloads. **Cosmetic.**
|
||||||
|
- `compose/foundation/.../draganddrop/DragAndDropSource.native.kt:36` · no drag-shadow / drag-image feedback for internal DnD (upstream skiko renders one). **Cosmetic.**
|
||||||
|
|
||||||
|
## G. Window and platform info
|
||||||
|
|
||||||
|
- Multi-monitor: no display enumeration or per-monitor placement anywhere (`SDL_GetDisplays`/`SDL_GetDisplayBounds`/`SDL_GetDisplayForWindow` unused). **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../platform/PrefetchLocals.native.kt:18` · `LocalPlatformPrefetchScheduler` default is a NOP scheduler; lazy lists skip ahead-of-time item composition (correctness fine, possible scroll-in jank). **Nice-to-have (perf).**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:324` · `autofill`/`autofillManager` are null; `requestAutofill` NOP. **Nice-to-have.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:279` · `hapticFeedBack.performHapticFeedback` NOP (no desktop pointer haptics API). **Cosmetic.**
|
||||||
|
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:287` · deprecated `clipboardManager` `setText`/`getText` NOP, and `clipboard.getClipEntry` null. Harmless: the real `LocalClipboard` (text + PNG image via SDL3) works. **Cosmetic.**
|
||||||
|
- `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:44` · `ClipboardPasteState.hasClip` aliased to `hasText`, so image-only clipboard is not detected for the paste affordance. **Cosmetic.**
|
||||||
|
|
||||||
|
## H. 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. (Several
|
||||||
|
SDL fidelity gaps are now closed — see **Completed** above.)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- **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.**
|
||||||
|
|
||||||
|
## 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.**
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Tooling
|
||||||
|
|
||||||
|
Helper scripts and workflows used to build, vendor, and verify Compose Desktop
|
||||||
|
Native. Each has a focused job; the deeper ones link to their own README.
|
||||||
|
|
||||||
|
For architecture, source-set layout, and vendoring rules, see
|
||||||
|
[CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
|
## Native libraries
|
||||||
|
|
||||||
|
SDL3, SDL3_ttf, SDL3_image, and FreeType are built from source as static
|
||||||
|
libraries and linked straight into the executable. One script does it on every
|
||||||
|
OS. Output lands in the gitignored `libs/`; versions are pinned in
|
||||||
|
`scripts/build-sdl/build-sdl.properties`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/build-sdl/build-all.py # freetype, sdl3, sdl3-image, sdl3-ttf
|
||||||
|
python3 scripts/build-sdl/build-all.py sdl3-ttf # rebuild one step
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs `git`, `cmake`, and Python 3 on every host (`ninja` is fetched when
|
||||||
|
absent). Run it once per machine, or after bumping the pinned versions.
|
||||||
|
|
||||||
|
## Vendoring upstream Compose
|
||||||
|
|
||||||
|
Most `androidx.compose.*` code is copied byte for byte from
|
||||||
|
`JetBrains/compose-multiplatform-core` into each module's gitignored
|
||||||
|
`src/vendor/`. A `compose-fork.txt` manifest per module lists which upstream
|
||||||
|
files it pulls; the pinned ref lives in `scripts/compose-fork/compose.properties`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/compose-fork/sync.sh # re-sync every module
|
||||||
|
scripts/compose-fork/sync.sh compose/ui/ui/compose-fork.txt # one module
|
||||||
|
```
|
||||||
|
|
||||||
|
Reach for this after a fresh checkout or a ref bump. Details:
|
||||||
|
[scripts/compose-fork/README.md](scripts/compose-fork/README.md).
|
||||||
|
|
||||||
|
Guardrails that keep the vendor tree honest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/compose-fork/check-vendor-clean.py # src/vendor matches the pinned refs
|
||||||
|
python3 scripts/compose-fork/check-vendor-drift.py # hand-edited "manual vendors" match their pin
|
||||||
|
```
|
||||||
|
|
||||||
|
`check-vendor-clean` fails if a hand-edit ever leaks into `src/vendor/`.
|
||||||
|
`check-vendor-drift` reads the `// VENDOR-BASE:` header on every edited copy and,
|
||||||
|
using the local upstream clone, reports whether the base actually changed since
|
||||||
|
the pin (needs reconciling) or is merely stale (safe to re-stamp). Run both on
|
||||||
|
every ref bump.
|
||||||
|
|
||||||
|
### Coverage against upstream
|
||||||
|
|
||||||
|
How much of upstream's public API the port covers is measured, not guessed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
|
||||||
|
python3 scripts/compose-coverage.py --missing ui-text # list uncovered declarations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### One-command gate
|
||||||
|
|
||||||
|
`verify-mac.sh` is the runbook to run after any renderer or layout change. On
|
||||||
|
macOS or Linux it exercises BOTH renderer legs (Skia by default, then
|
||||||
|
`-Prenderer=sdl3`) and exits non-zero on any failure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/verify-mac.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs, per leg: the vendor drift and clean checks, a build of `:demo` and
|
||||||
|
`:apidemo`, the interaction probes, the parity sweep, a memory soak, and a
|
||||||
|
frame-time spot check. The Windows target is verified separately (see below).
|
||||||
|
|
||||||
|
### Parity: native vs JVM
|
||||||
|
|
||||||
|
`:demo` renders the same shared screens on two stacks: this port, and stock JVM
|
||||||
|
Compose Desktop. `parity.py` screenshots every screen on both and pixel-diffs
|
||||||
|
them, so a screen that diverges is a porting bug. It gates against per-screen
|
||||||
|
golden baselines.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/parity/parity.py # all screens
|
||||||
|
python3 scripts/parity/parity.py Buttons Shapes # a subset
|
||||||
|
python3 scripts/parity/parity.py --no-build # reuse the last renders
|
||||||
|
```
|
||||||
|
|
||||||
|
How to read the diff and the percentage: [scripts/parity/README.md](scripts/parity/README.md).
|
||||||
|
|
||||||
|
### Interaction probe
|
||||||
|
|
||||||
|
`probe.py` launches a native window, sends window-relative input (click, hover,
|
||||||
|
hold), and captures the client area. It packages the rigs used to reproduce
|
||||||
|
interaction bugs. Windows-only. Details:
|
||||||
|
[scripts/probe/README.md](scripts/probe/README.md).
|
||||||
|
|
||||||
|
### Frame profiler
|
||||||
|
|
||||||
|
Set `CDN_PROFILE=1` (or `CDN_PROFILE=<path>`) and run any app. Every couple of
|
||||||
|
seconds it writes per-phase main-loop timings (events, app pump, render, with
|
||||||
|
draw sub-phases and per-frame draw counters) to a file. `CDN_FORCERENDER=1`
|
||||||
|
renders every frame so steady-state timings read on an otherwise idle screen.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CDN_PROFILE=1 CDN_FORCERENDER=1 ./demo/build/bin/macosArm64/debugExecutable/demo.kexe
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `present` is vsync-blocking, so profile on the target refresh rate before
|
||||||
|
drawing conclusions about a frame-rate gap.
|
||||||
|
|
||||||
|
### Demo probes
|
||||||
|
|
||||||
|
`:demo` ships headless regression probes driven by CLI flags, used by the
|
||||||
|
verify runbook and in isolation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
demo.kexe --nav3test # Navigation 3 + ViewModel + lifecycle
|
||||||
|
demo.kexe --backtest # predictive-back / BackHandler
|
||||||
|
demo.kexe --clicktest # pointer -> upstream clickable
|
||||||
|
demo.kexe --scrolltest # wheel -> scrollable
|
||||||
|
demo.kexe --multiwintest # multi-window lifecycle
|
||||||
|
demo.kexe --soaktest # memory soak (CDN_SOAK_SCREEN, CDN_SOAK_STATIC, CDN_SOAK_CYCLES)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows smoke
|
||||||
|
|
||||||
|
The Mac runbook cannot cover the Windows target. Before shipping, on a Windows
|
||||||
|
host: build the mingwX64 executables, run `scripts/probe/`, and run a publish
|
||||||
|
dry run (only Windows compiles the full common-metadata variant table).
|
||||||
|
|
||||||
|
```bat
|
||||||
|
gradlew.bat :demo:runDebugExecutableMingwX64
|
||||||
|
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consuming the port
|
||||||
|
|
||||||
|
To build a third-party app against the published klibs, apply the bridge Gradle
|
||||||
|
plugin and declare official Compose Multiplatform coordinates; the plugin swaps
|
||||||
|
in the port's klibs on native desktop targets. Setup:
|
||||||
|
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
|
||||||
|
|
||||||
|
## Versioning and releasing
|
||||||
|
|
||||||
|
### Version map
|
||||||
|
|
||||||
|
Versions live in a few places, some of which move in lockstep. There is no
|
||||||
|
single file to edit; know which axis you are changing.
|
||||||
|
|
||||||
|
| Version | Where | Notes |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| Project release version | The git tag `vX.Y.Z`. `PUBLISH_VERSION` (from the tag) feeds `vPublishVersion` in `build.gradle.kts`, which strips the leading `v`. Group is `com.bitsycore.compose.sdl`. | Set by the tag, not edited by hand. A non-publish build is `0.0.0-SNAPSHOT`. |
|
||||||
|
| Vendored Compose (native side) | `COMPOSE_CORE_REF` and `COMPOSE_REF` in `scripts/compose-fork/compose.properties`, plus `compose` in `gradle/libs.versions.toml`. | Pin to a durable tag (not a `+dev` commit upstream may GC). Re-sync after changing. |
|
||||||
|
| JVM parity forcing | `vComposeJvmVersion` in `demo`, `apidemo`, and `material-symbols` `build.gradle.kts`. | Must be a version PUBLISHED to Maven Central. It may lag the vendored native ref (a documented skew) until the matching version is published. |
|
||||||
|
| Skiko | `skiko` in `gradle/libs.versions.toml`. | Must expose the `org.jetbrains.skiko.node` `RenderNode` / `GraphicsContext` API the vendored compose-core uses. Verify with a throwaway `skikoRendererMain` compile if unsure. |
|
||||||
|
| SDL3 / SDL3_ttf / SDL3_image / FreeType | `scripts/build-sdl/build-sdl.properties`. | Rebuild `libs/` with `build-all.py` after any change. |
|
||||||
|
| Bridge substituted version | Defaults to the bridge plugin's own published version; consumers override with the `composeDesktopNative.version` Gradle property. | The plugin publishes with the release tag, so a consumer on the matching plugin version resolves the right klibs automatically. |
|
||||||
|
|
||||||
|
### Bump the upstream Compose ref
|
||||||
|
|
||||||
|
Run this on each upstream bump; it is the flow that keeps the sync tax low.
|
||||||
|
|
||||||
|
1. Edit `COMPOSE_CORE_REF` / `COMPOSE_REF` in `compose.properties` (and `compose`
|
||||||
|
in `libs.versions.toml` if the coordinate version moved).
|
||||||
|
2. `scripts/compose-fork/sync.sh`
|
||||||
|
3. `python3 scripts/compose-fork/check-vendor-drift.py`. For any manual vendor
|
||||||
|
whose upstream base actually changed, reconcile by hand; otherwise re-stamp
|
||||||
|
its `// VENDOR-BASE:` header to the new ref.
|
||||||
|
4. Build both legs and fix any fallout.
|
||||||
|
5. `scripts/verify-mac.sh` (both legs green, including the soak and parity gates).
|
||||||
|
6. If a matching Compose version is now published to Maven, bump
|
||||||
|
`vComposeJvmVersion` in demo/apidemo/material-symbols to close the skew.
|
||||||
|
7. Run WIN-SMOKE on a Windows host.
|
||||||
|
8. Re-seed parity baselines only if metrics legitimately moved
|
||||||
|
(`parity.py --update-baselines`), and record why.
|
||||||
|
|
||||||
|
### Cut a release
|
||||||
|
|
||||||
|
1. Complete the ref-bump flow above and confirm it is green. For a STABLE
|
||||||
|
release, `vComposeJvmVersion` should match the vendored ref (no skew).
|
||||||
|
2. On a Windows host, run `gradlew :window:compileCommonMainKotlinMetadata`.
|
||||||
|
Only Windows compiles the full common-metadata variant table, and the root
|
||||||
|
KotlinMultiplatform publications live there; a macOS-only publish leaves the
|
||||||
|
roots without mingwX64 variants (this bit v0.1.15).
|
||||||
|
3. `git tag vX.Y.Z && git push origin vX.Y.Z`. This triggers `.github/workflows/publish.yml`.
|
||||||
|
|
||||||
|
What the publish job does, so you can read a failure:
|
||||||
|
|
||||||
|
- Each host publishes only its own target's publications to GitHub Packages
|
||||||
|
under `com.bitsycore.compose.sdl:*`. Windows additionally publishes the root
|
||||||
|
KotlinMultiplatform metadata, the `jvm` publication, and the bridge plugin
|
||||||
|
(only Windows declares every target).
|
||||||
|
- The `MODULES` list in the workflow must stay in sync with the modules in
|
||||||
|
`settings.gradle.kts`.
|
||||||
|
- A partial publish (for example a dropped connection mid-upload) deletes this
|
||||||
|
host's half-published versions for the tag and retries, so a rerun is safe.
|
||||||
|
- The demo and apidemo release binaries are zipped and attached to the GitHub
|
||||||
|
Release for the tag.
|
||||||
|
|
||||||
|
4. Verify a consumer build against the new version resolves through the bridge.
|
||||||
@@ -1,76 +1,75 @@
|
|||||||
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
import com.bitsycore.compose.sdl.build.registerComposeFontBundling
|
||||||
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.
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
alias(libs.plugins.kotlin.plugin.compose)
|
alias(libs.plugins.kotlin.plugin.compose)
|
||||||
alias(libs.plugins.compose.multiplatform)
|
alias(libs.plugins.compose.multiplatform)
|
||||||
alias(libs.plugins.kotlin.plugin.serialization)
|
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 isHostMingwCompatible = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||||
val vHostSupportsMingw: Boolean by rootProject.extra
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Targets
|
||||||
|
// ==================
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
linuxArm64()
|
jvm()
|
||||||
linuxX64()
|
|
||||||
macosArm64()
|
|
||||||
if (vHostSupportsMingw) mingwX64()
|
|
||||||
|
|
||||||
targets.withType<KotlinNativeTarget>().all {
|
buildList {
|
||||||
val isMingw = name == "mingwX64"
|
add(linuxArm64())
|
||||||
val isLinux = name.startsWith("linux")
|
add(linuxX64())
|
||||||
val isApple = name.startsWith("macos") || name.startsWith("ios")
|
add(macosArm64())
|
||||||
binaries.executable {
|
if (isHostMingwCompatible) add(mingwX64())
|
||||||
if (buildType == NativeBuildType.RELEASE) {
|
}.forEach {
|
||||||
// --gc-sections is a GNU ld/lld flag; Apple's ld64 rejects it
|
it.binaries {
|
||||||
// (it dead-strips at -O anyway). Only pass it on GNU-ld targets.
|
executable {
|
||||||
if (!isApple) linkerOpts("-Wl,--gc-sections")
|
when {
|
||||||
compilerOptions {
|
target.name == "mingwX64" -> linkerOpts(
|
||||||
freeCompilerArgs.add("-opt")
|
// crypt32: client-cert (mTLS) import into the Windows cert store.
|
||||||
|
"-lcrypt32",
|
||||||
|
"-Wl,--gc-sections", "-Wl,-s",
|
||||||
|
// GUI subsystem (no console window), keeping the C `main` entry.
|
||||||
|
"-Wl,--subsystem,windows", "-Wl,-e,mainCRTStartup",
|
||||||
|
)
|
||||||
|
|
||||||
|
target.name.startsWith("linux") -> linkerOpts(
|
||||||
|
"-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib/aarch64-linux-gnu",
|
||||||
|
"-lfontconfig", "-lGL", "-lX11",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entryPoint = "apidemo.main"
|
|
||||||
// SDL3 + SDL3_ttf + SDL3_image + FreeType + image codecs come from
|
|
||||||
// :ui's cinterop klibs (staticLibraries directive in each .def); the
|
|
||||||
// system libs they need are in sdl3.def's linkerOpts.<target>. Only
|
|
||||||
// shell-level flags and app-specific extras (crypt32 for mTLS) live
|
|
||||||
// here. Ktor's Curl engine bundles its own libcurl + TLS stack.
|
|
||||||
if (isMingw) linkerOpts(
|
|
||||||
// crypt32: client-cert (mTLS) import into the Windows cert store
|
|
||||||
// (CertOpenStore / PFXImportCertStore / CertAddCertificateContextToStore…).
|
|
||||||
"-lcrypt32",
|
|
||||||
// Code-shrink: drop unused sections and strip symbols.
|
|
||||||
"-Wl,--gc-sections", "-Wl,-s",
|
|
||||||
// GUI subsystem so launching the .exe pops no console window.
|
|
||||||
// Keep the standard C `main` entry — ld would otherwise default
|
|
||||||
// a GUI-subsystem PE to WinMainCRTStartup (needs WinMain) and
|
|
||||||
// fail to link, since Kotlin/Native emits `main`.
|
|
||||||
"-Wl,--subsystem,windows", "-Wl,-e,mainCRTStartup",
|
|
||||||
)
|
|
||||||
// Skia (default renderer) references the system graphics stack.
|
|
||||||
// K/N's LLD sysroot on linuxX64 doesn't include the host's multi-arch
|
|
||||||
// dir, so add the -L for both x86_64 and aarch64 (harmless if either
|
|
||||||
// is absent — LLD skips missing dirs).
|
|
||||||
if (isLinux) linkerOpts(
|
|
||||||
"-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib/aarch64-linux-gnu",
|
|
||||||
"-lfontconfig", "-lGL", "-lX11",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyDefaultHierarchyTemplate()
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain {
|
commonMain {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":window"))
|
val vComposeJvmVersion = libs.versions.compose.get()
|
||||||
implementation(project(":material3"))
|
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
|
||||||
|
|
||||||
|
// Official Maven coords for everything the shared UI touches; native
|
||||||
|
// configurations substitute them for the port modules automatically
|
||||||
|
// (root bridge in-repo, the bridge plugin for consumers).
|
||||||
|
implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui-graphics:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui-text:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui-unit:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui-geometry:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.ui:ui-util:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.foundation:foundation:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.foundation:foundation-layout:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.animation:animation:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.animation:animation-core:$vComposeJvmVersion")
|
||||||
|
implementation("org.jetbrains.compose.material3:material3:$vComposeM3JvmVersion")
|
||||||
|
|
||||||
|
// Common Material Symbols Utility
|
||||||
implementation(project(":material-symbols"))
|
implementation(project(":material-symbols"))
|
||||||
|
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
@@ -80,17 +79,19 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Single HTTP engine on every desktop target: Ktor's Curl engine. It
|
nativeMain {
|
||||||
// bundles a static libcurl per target (Schannel on Windows, OpenSSL on
|
dependencies {
|
||||||
// macOS/Linux) embedded in its cinterop klib — no runtime DLL, one copy.
|
implementation(project(":window"))
|
||||||
// We use Curl everywhere (not WinHttp/Darwin) so that an upcoming
|
implementation(libs.ktor.client.curl)
|
||||||
// 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.
|
jvmMain {
|
||||||
if (vHostSupportsMingw) getByName("mingwX64Main").dependencies { implementation(libs.ktor.client.curl) }
|
dependencies {
|
||||||
getByName("macosArm64Main").dependencies { implementation(libs.ktor.client.curl) }
|
implementation(compose.desktop.currentOs)
|
||||||
getByName("linuxX64Main").dependencies { implementation(libs.ktor.client.curl) }
|
implementation(libs.ktor.client.cio)
|
||||||
getByName("linuxArm64Main").dependencies { implementation(libs.ktor.client.curl) }
|
implementation(libs.kotlinx.coroutines.swing)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
@@ -99,181 +100,46 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SDL3 / SDL3_ttf / SDL3_image / FreeType are linked statically into the
|
compose.desktop {
|
||||||
// executable (see the mingw linkerOpts above), so there are no runtime DLLs to
|
application {
|
||||||
// bundle — the distributable is just <app>.exe + data.kres.
|
mainClass = "apidemo.MainJvmKt"
|
||||||
val variants = listOf("debug", "release")
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Bundle the default font (data.kres) next to each native executable
|
|
||||||
// ==================
|
|
||||||
// Default UI font (Noto Sans) + monospace body font (Noto Sans Mono) are
|
|
||||||
// downloaded by :core and exposed through these generic "extra" handles (the
|
|
||||||
// same handshake the :material-symbols:* modules use). The renderers load
|
|
||||||
// NotoSans (variable) as the default; apidemo registers NotoSansMono (variable)
|
|
||||||
// with IconFont under "noto-mono" for the body editor.
|
|
||||||
val nativeTargets = listOf("macosArm64", "linuxX64", "linuxArm64", "mingwX64")
|
|
||||||
// :core downloads the variable Noto fonts into its build/fonts. Reference them
|
|
||||||
// by layout (lazy providers, no evaluation of :core needed) and depend on the
|
|
||||||
// download task by path so ordering doesn't rely on :core being configured first.
|
|
||||||
val coreBuildDir = rootProject.project(":ui").layout.buildDirectory
|
|
||||||
val notoSansFile = coreBuildDir.file("fonts/NotoSans.ttf")
|
|
||||||
val notoMonoFile = coreBuildDir.file("fonts/NotoSansMono.ttf")
|
|
||||||
|
|
||||||
// Which Material Symbols style(s) this app uses = which style call sites appear
|
|
||||||
// in its Kotlin sources. `MaterialSymbolsOutlined(...)` → outlined font,
|
|
||||||
// `MaterialSymbolsRounded(...)` → rounded, `MaterialSymbolsSharp(...)` → sharp.
|
|
||||||
// The Zip task below downloads + bundles ONLY the fonts a style actually
|
|
||||||
// referenced — an app that only ever calls MaterialSymbolsOutlined never pays
|
|
||||||
// for Rounded/Sharp .ttf bytes (each is ~600KB uncompressed).
|
|
||||||
val kAllStyles = listOf(
|
|
||||||
"Outlined" to Regex("\\bMaterialSymbolsOutlined\\b"),
|
|
||||||
"Rounded" to Regex("\\bMaterialSymbolsRounded\\b"),
|
|
||||||
"Sharp" to Regex("\\bMaterialSymbolsSharp\\b"),
|
|
||||||
)
|
|
||||||
fun detectUsedStyles(): List<String> {
|
|
||||||
val vSrcRoot = layout.projectDirectory.dir("src").asFile
|
|
||||||
val vUsed = mutableSetOf<String>()
|
|
||||||
if (!vSrcRoot.exists()) return emptyList()
|
|
||||||
vSrcRoot.walk().filter { it.isFile && it.extension == "kt" }.forEach { vFile ->
|
|
||||||
val vText = vFile.readText()
|
|
||||||
for ((vStyle, vRegex) in kAllStyles) {
|
|
||||||
if (vStyle !in vUsed && vRegex.containsMatchIn(vText)) vUsed.add(vStyle)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return vUsed.toList()
|
|
||||||
}
|
|
||||||
val vUsedStyles: List<String> = detectUsedStyles()
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Material Symbols subsetting (-PsubsetIcons=true)
|
|
||||||
// ==================
|
|
||||||
// scripts/subset-material-symbols.py scans this module's Kotlin sources for
|
|
||||||
// MaterialSymbols.<Name> references and writes build/icons/usage-codepoint.txt.
|
|
||||||
// When -PsubsetIcons=true is passed, each icon-font module's downloaded TTF
|
|
||||||
// is hb-subset'd against that file and the Zip below bundles the trimmed
|
|
||||||
// font instead of the full one — typically 80-95% smaller.
|
|
||||||
//
|
|
||||||
// Needs: python3 + hb-subset on PATH (brew install harfbuzz /
|
|
||||||
// apt install harfbuzz-utils).
|
|
||||||
val subsetIcons = (findProperty("subsetIcons") as? String)?.toBoolean() ?: false
|
|
||||||
val iconsBuildDir = layout.buildDirectory.dir("icons")
|
|
||||||
|
|
||||||
val findMaterialSymbolsUsage = tasks.register<Exec>("findMaterialSymbolsUsage") {
|
|
||||||
description = "Scan src/ for MaterialSymbols.<Name> usages → usage-codepoint.txt."
|
|
||||||
val vScript = rootProject.layout.projectDirectory.file("scripts/subset-material-symbols.py").asFile
|
|
||||||
val vConstants = rootProject.project(":material-symbols").layout.projectDirectory
|
|
||||||
.file("src/commonMain/kotlin/com/compose/sdl/icons/MaterialSymbols.kt").asFile
|
|
||||||
val vUsageFile = iconsBuildDir.get().file("usage-codepoint.txt").asFile
|
|
||||||
inputs.files(fileTree("src") { include("**/*.kt") })
|
|
||||||
inputs.file(vConstants)
|
|
||||||
outputs.file(vUsageFile)
|
|
||||||
commandLine(
|
|
||||||
"python3", vScript.absolutePath,
|
|
||||||
"--src", layout.projectDirectory.dir("src").asFile.absolutePath,
|
|
||||||
"--constants", vConstants.absolutePath,
|
|
||||||
"--out", vUsageFile.absolutePath,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Register `subsetMaterialSymbols<Style>` for a style — hb-subsets the style's
|
compose.desktop.native {
|
||||||
downloaded TTF (owned by :material-symbols) to only the codepoints in
|
entryPoint = "apidemo.main"
|
||||||
usage-codepoint.txt and writes the output under this app's build/icons/ dir. */
|
icon {
|
||||||
fun registerSubsetTask(inStyle: String): TaskProvider<*> {
|
light.from(
|
||||||
val vSymbolsProject = rootProject.project(":material-symbols")
|
layout.projectDirectory.file("icons/voltic-icon-32.png"),
|
||||||
return tasks.register("subsetMaterialSymbols$inStyle") {
|
layout.projectDirectory.file("icons/voltic-icon-128.png"),
|
||||||
description = "hb-subset the $inStyle Material Symbols font to icons actually used."
|
)
|
||||||
@Suppress("UNCHECKED_CAST")
|
dark.from(
|
||||||
val vInputProvider = vSymbolsProject.extra["iconFontFile$inStyle"] as org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>
|
layout.projectDirectory.file("icons/voltic-icon-dark-32.png"),
|
||||||
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$inStyle"] as TaskProvider<*>
|
layout.projectDirectory.file("icons/voltic-icon-dark-128.png"),
|
||||||
val vOut = iconsBuildDir.get().file("MaterialSymbols$inStyle.subset.ttf").asFile
|
)
|
||||||
val vUsage = iconsBuildDir.get().file("usage-codepoint.txt").asFile
|
exeIcon.from(
|
||||||
inputs.file(vInputProvider)
|
listOf(16, 32, 48, 64, 128, 256).map {
|
||||||
inputs.file(vUsage)
|
layout.projectDirectory.file("icons/voltic-icon-$it.png")
|
||||||
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()
|
embedWindowsIcon = providers.gradleProperty("embedWindowsIcon").map { it.toBoolean() }
|
||||||
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
|
// MARK: Fonts (shared pipeline — buildSrc ComposeFontBundling.kt)
|
||||||
// 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) {
|
registerComposeFontBundling {
|
||||||
for (target in nativeTargets) {
|
bundleNotoSans = true
|
||||||
val variantCap = variant.replaceFirstChar { it.uppercase() }
|
// The mono body font is loaded through the app's own seam (Fonts.kt), not via
|
||||||
val targetCap = target.replaceFirstChar { it.uppercase() }
|
// FontFamily.Monospace call sites — auto-detection can't see it, bundle explicitly.
|
||||||
val outDir = layout.buildDirectory.dir("bin/$target/${variant}Executable")
|
bundleNotoSansMono = true
|
||||||
val copyTask = tasks.register<Zip>("copy${variantCap}ComposeResources${targetCap}") {
|
bundleMaterialSymbols = true
|
||||||
archiveFileName.set("data.kres")
|
enableIconSubsetting = true
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parity-window icon (cosmetic : the harness compares the canvas, not chrome).
|
||||||
|
tasks.named<ProcessResources>("jvmProcessResources") {
|
||||||
|
from(layout.projectDirectory.file("icons/voltic-icon-256.png")) { into("icon") }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 605 B |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 367 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 692 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 432 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -2,7 +2,8 @@ package apidemo
|
|||||||
|
|
||||||
import kotlinx.cinterop.*
|
import kotlinx.cinterop.*
|
||||||
import platform.CoreFoundation.*
|
import platform.CoreFoundation.*
|
||||||
import platform.Foundation.*
|
import platform.Foundation.CFBridgingRelease
|
||||||
|
import platform.Foundation.NSString
|
||||||
import platform.Security.*
|
import platform.Security.*
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
@@ -10,173 +11,173 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
// macOS: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
|
// macOS: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
|
||||||
// certificate and key files directly — no certificate-store dance needed.
|
// certificate and key files directly — no certificate-store dance needed.
|
||||||
|
|
||||||
/* Point libcurl straight at the certificate / key files. */
|
/** Point libcurl straight at the certificate / key files. */
|
||||||
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
|
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
|
||||||
PreparedCert(
|
PreparedCert(
|
||||||
sslCert = inReq.certPath,
|
sslCert = inReq.certPath,
|
||||||
sslCertType = inReq.certFormat.curlName,
|
sslCertType = inReq.certFormat.curlName,
|
||||||
sslKey = inReq.keyPath.ifBlank { null },
|
sslKey = inReq.keyPath.ifBlank { null },
|
||||||
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
||||||
keyPassword = inReq.certPassword.ifEmpty { null },
|
keyPassword = inReq.certPassword.ifEmpty { null },
|
||||||
cleanup = {},
|
cleanup = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
/* No temporary certificate store off Windows. */
|
/** No temporary certificate store off Windows. */
|
||||||
actual fun sweepTempClientCerts() {}
|
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.
|
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
|
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
|
the chain (e.g. the server's leaf doesn't parse, or its issuer isn't
|
||||||
in any keychain). */
|
in any keychain). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@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") }
|
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
|
||||||
?: return serverChainWithIssuerName(inServerCerts)
|
?: return serverChainWithIssuerName(inServerCerts)
|
||||||
val vOs = runCatching { osChainApple(vLeafPem, inServerCerts.size, inServerCerts) }.getOrNull()
|
val vOs = runCatching { osChainApple(vLeafPem, inServerCerts.size, inServerCerts) }.getOrNull()
|
||||||
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
|
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Chain walk via Security.framework
|
// 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
|
an SSL policy — Apple then walks Keychain, pulling intermediates and
|
||||||
the root in. Returns the resolved chain with fromServer=true for the
|
the root in. Returns the resolved chain with fromServer=true for the
|
||||||
first inServerCount entries (those came down on the wire) and false
|
first inServerCount entries (those came down on the wire) and false
|
||||||
for anything the OS added. */
|
for anything the OS added. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun osChainApple(
|
private fun osChainApple(
|
||||||
inLeafPem: String,
|
inLeafPem: String,
|
||||||
inServerCount: Int,
|
inServerCount: Int,
|
||||||
inServerCerts: List<List<Pair<String, String>>>,
|
inServerCerts: List<List<Pair<String, String>>>,
|
||||||
): List<ChainCert> {
|
): List<ChainCert> {
|
||||||
// Parse every cert the server presented, not just the leaf — Apple's
|
// Parse every cert the server presented, not just the leaf — Apple's
|
||||||
// chain builder uses them as candidate intermediates when stitching to
|
// chain builder uses them as candidate intermediates when stitching to
|
||||||
// a root, instead of relying solely on AIA-fetch which is off by default.
|
// a root, instead of relying solely on AIA-fetch which is off by default.
|
||||||
val vServerCerts = inServerCerts.mapNotNull {
|
val vServerCerts = inServerCerts.mapNotNull {
|
||||||
certFieldOf(it, "Cert")?.let { vPem -> derFromPem(vPem) }
|
certFieldOf(it, "Cert")?.let { vPem -> derFromPem(vPem) }
|
||||||
}
|
}
|
||||||
if (vServerCerts.isEmpty()) return emptyList()
|
if (vServerCerts.isEmpty()) return emptyList()
|
||||||
|
|
||||||
val vCertRefs = vServerCerts.map { createSecCertificate(it) ?: return emptyList() }
|
val vCertRefs = vServerCerts.map { createSecCertificate(it) ?: return emptyList() }
|
||||||
val vCertArray = createCFArray(vCertRefs)
|
val vCertArray = createCFArray(vCertRefs)
|
||||||
|
|
||||||
// SSL policy lets evaluateTrust pull a root out of Keychain.
|
// SSL policy lets evaluateTrust pull a root out of Keychain.
|
||||||
val vPolicy = SecPolicyCreateSSL(true, null) ?: run {
|
val vPolicy = SecPolicyCreateSSL(true, null) ?: run {
|
||||||
vCertRefs.forEach { CFRelease(it) }
|
vCertRefs.forEach { CFRelease(it) }
|
||||||
CFRelease(vCertArray)
|
CFRelease(vCertArray)
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val vTrustVar = nativeHeap.alloc<SecTrustRefVar>()
|
val vTrustVar = nativeHeap.alloc<SecTrustRefVar>()
|
||||||
val vCreateStatus = SecTrustCreateWithCertificates(vCertArray, vPolicy, vTrustVar.ptr)
|
val vCreateStatus = SecTrustCreateWithCertificates(vCertArray, vPolicy, vTrustVar.ptr)
|
||||||
CFRelease(vPolicy)
|
CFRelease(vPolicy)
|
||||||
if (vCreateStatus != errSecSuccess || vTrustVar.value == null) {
|
if (vCreateStatus != errSecSuccess || vTrustVar.value == null) {
|
||||||
vCertRefs.forEach { CFRelease(it) }
|
vCertRefs.forEach { CFRelease(it) }
|
||||||
CFRelease(vCertArray)
|
CFRelease(vCertArray)
|
||||||
nativeHeap.free(vTrustVar)
|
nativeHeap.free(vTrustVar)
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
val vTrust = vTrustVar.value!!
|
val vTrust = vTrustVar.value!!
|
||||||
|
|
||||||
// Evaluate even if it fails — we still want the partial chain back so
|
// Evaluate even if it fails — we still want the partial chain back so
|
||||||
// the UI can show what it could resolve. SecTrustEvaluateWithError
|
// the UI can show what it could resolve. SecTrustEvaluateWithError
|
||||||
// stops at the highest cert it found.
|
// stops at the highest cert it found.
|
||||||
val vErr = nativeHeap.alloc<CFErrorRefVar>()
|
val vErr = nativeHeap.alloc<CFErrorRefVar>()
|
||||||
SecTrustEvaluateWithError(vTrust, vErr.ptr)
|
SecTrustEvaluateWithError(vTrust, vErr.ptr)
|
||||||
vErr.value?.let { CFRelease(it) }
|
vErr.value?.let { CFRelease(it) }
|
||||||
nativeHeap.free(vErr)
|
nativeHeap.free(vErr)
|
||||||
|
|
||||||
val vCount = SecTrustGetCertificateCount(vTrust).toInt()
|
val vCount = SecTrustGetCertificateCount(vTrust).toInt()
|
||||||
val vResolved = ArrayList<ChainCert>(vCount)
|
val vResolved = ArrayList<ChainCert>(vCount)
|
||||||
val vSubjects = ArrayList<String>(vCount)
|
val vSubjects = ArrayList<String>(vCount)
|
||||||
|
|
||||||
for (vI in 0 until vCount) {
|
for (vI in 0 until vCount) {
|
||||||
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
||||||
val vSubject = secSubjectSummary(vCert)
|
val vSubject = secSubjectSummary(vCert)
|
||||||
vSubjects.add(vSubject ?: "")
|
vSubjects.add(vSubject ?: "")
|
||||||
}
|
}
|
||||||
for (vI in 0 until vCount) {
|
for (vI in 0 until vCount) {
|
||||||
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
val vCert = SecTrustGetCertificateAtIndex(vTrust, vI.convert()) ?: continue
|
||||||
val vSubject = vSubjects[vI].ifBlank { null }
|
val vSubject = vSubjects[vI].ifBlank { null }
|
||||||
// The issuer is the next cert in the chain's subject (or this
|
// The issuer is the next cert in the chain's subject (or this
|
||||||
// cert's own subject if it's the self-signed root).
|
// cert's own subject if it's the self-signed root).
|
||||||
val vIssuer = when {
|
val vIssuer = when {
|
||||||
vI + 1 < vCount -> vSubjects[vI + 1].ifBlank { null }
|
vI + 1 < vCount -> vSubjects[vI + 1].ifBlank { null }
|
||||||
vSubject != null -> vSubject // root: self-signed
|
vSubject != null -> vSubject // root: self-signed
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
val vPem = secCertPem(vCert)
|
val vPem = secCertPem(vCert)
|
||||||
val vFields = buildList {
|
val vFields = buildList {
|
||||||
vSubject?.let { add("Subject" to "CN=$it") }
|
vSubject?.let { add("Subject" to "CN=$it") }
|
||||||
vIssuer?.let { add("Issuer" to "CN=$it") }
|
vIssuer?.let { add("Issuer" to "CN=$it") }
|
||||||
vPem?.let { add("Cert" to it) }
|
vPem?.let { add("Cert" to it) }
|
||||||
}
|
}
|
||||||
vResolved.add(ChainCert(vFields, vI < inServerCount))
|
vResolved.add(ChainCert(vFields, vI < inServerCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
CFRelease(vTrust)
|
CFRelease(vTrust)
|
||||||
vCertRefs.forEach { CFRelease(it) }
|
vCertRefs.forEach { CFRelease(it) }
|
||||||
CFRelease(vCertArray)
|
CFRelease(vCertArray)
|
||||||
nativeHeap.free(vTrustVar)
|
nativeHeap.free(vTrustVar)
|
||||||
return vResolved
|
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. */
|
valid X.509 cert. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun createSecCertificate(inDer: ByteArray): SecCertificateRef? {
|
private fun createSecCertificate(inDer: ByteArray): SecCertificateRef? {
|
||||||
val vData = inDer.usePinned { vPin ->
|
val vData = inDer.usePinned { vPin ->
|
||||||
CFDataCreate(null, vPin.addressOf(0).reinterpret(), inDer.size.convert())
|
CFDataCreate(null, vPin.addressOf(0).reinterpret(), inDer.size.convert())
|
||||||
} ?: return null
|
} ?: return null
|
||||||
val vCert = SecCertificateCreateWithData(null, vData)
|
val vCert = SecCertificateCreateWithData(null, vData)
|
||||||
CFRelease(vData)
|
CFRelease(vData)
|
||||||
return vCert
|
return vCert
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CFArray of SecCertificateRef — the input to SecTrustCreateWithCertificates. */
|
/** CFArray of SecCertificateRef — the input to SecTrustCreateWithCertificates. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
|
private fun createCFArray(inCerts: List<SecCertificateRef>): CFArrayRef {
|
||||||
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
|
val vPtrs = nativeHeap.allocArray<COpaquePointerVar>(inCerts.size)
|
||||||
for (vI in inCerts.indices) vPtrs[vI] = inCerts[vI]
|
for (vI in inCerts.indices) vPtrs[vI] = inCerts[vI]
|
||||||
val vArr = CFArrayCreate(null, vPtrs, inCerts.size.convert(), kCFTypeArrayCallBacks.ptr)!!
|
val vArr = CFArrayCreate(null, vPtrs, inCerts.size.convert(), kCFTypeArrayCallBacks.ptr)!!
|
||||||
nativeHeap.free(vPtrs)
|
nativeHeap.free(vPtrs)
|
||||||
return vArr
|
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
|
organisation when no CN is present (matches what Keychain Access
|
||||||
displays in its cert list). */
|
displays in its cert list). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun secSubjectSummary(inCert: SecCertificateRef): String? {
|
private fun secSubjectSummary(inCert: SecCertificateRef): String? {
|
||||||
val vCf = SecCertificateCopySubjectSummary(inCert) ?: return null
|
val vCf = SecCertificateCopySubjectSummary(inCert) ?: return null
|
||||||
val vNs = CFBridgingRelease(vCf) as? NSString
|
val vNs = CFBridgingRelease(vCf) as? NSString
|
||||||
return vNs?.toString()
|
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. */
|
with -----BEGIN CERTIFICATE-----/-----END----- headers. */
|
||||||
@OptIn(ExperimentalForeignApi::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalForeignApi::class, ExperimentalEncodingApi::class)
|
||||||
private fun secCertPem(inCert: SecCertificateRef): String? {
|
private fun secCertPem(inCert: SecCertificateRef): String? {
|
||||||
val vData = SecCertificateCopyData(inCert) ?: return null
|
val vData = SecCertificateCopyData(inCert) ?: return null
|
||||||
val vLen = CFDataGetLength(vData).toInt()
|
val vLen = CFDataGetLength(vData).toInt()
|
||||||
val vBytes = ByteArray(vLen)
|
val vBytes = ByteArray(vLen)
|
||||||
val vPtr = CFDataGetBytePtr(vData)
|
val vPtr = CFDataGetBytePtr(vData)
|
||||||
if (vPtr != null) for (vI in 0 until vLen) vBytes[vI] = vPtr[vI].toByte()
|
if (vPtr != null) for (vI in 0 until vLen) vBytes[vI] = vPtr[vI].toByte()
|
||||||
CFRelease(vData)
|
CFRelease(vData)
|
||||||
val vB64 = Base64.Default.encode(vBytes)
|
val vB64 = Base64.encode(vBytes)
|
||||||
val vWrapped = vB64.chunked(64).joinToString("\n")
|
val vWrapped = vB64.chunked(64).joinToString("\n")
|
||||||
return "-----BEGIN CERTIFICATE-----\n$vWrapped\n-----END CERTIFICATE-----"
|
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)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
private fun derFromPem(inPem: String): ByteArray? {
|
private fun derFromPem(inPem: String): ByteArray? {
|
||||||
if (!inPem.contains("-----BEGIN")) return inPem.encodeToByteArray()
|
if (!inPem.contains("-----BEGIN")) return inPem.encodeToByteArray()
|
||||||
val vBody = inPem.lineSequence()
|
val vBody = inPem.lineSequence()
|
||||||
.filterNot { it.startsWith("-----") }
|
.filterNot { it.startsWith("-----") }
|
||||||
.joinToString("") { it.trim() }
|
.joinToString("") { it.trim() }
|
||||||
return runCatching { Base64.Default.decode(vBody) }.getOrNull()
|
return runCatching { Base64.decode(vBody) }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,6 @@ import androidx.compose.ui.graphics.Color
|
|||||||
// MARK: App-wide constants + pure color / asset mappers
|
// 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) {
|
internal fun methodColor(inM: ReqMethod): Color = when (inM) {
|
||||||
ReqMethod.GET -> Color(0xFF4C9AFF)
|
ReqMethod.GET -> Color(0xFF4C9AFF)
|
||||||
ReqMethod.POST -> Color(0xFF36B37E)
|
ReqMethod.POST -> Color(0xFF36B37E)
|
||||||
@@ -35,11 +24,11 @@ internal val kPackColors: List<Long> = listOf(
|
|||||||
0xFFFF7043, 0xFF8D6E63, 0xFF78909C, 0xFFBDBDBD, 0xFF5C7CFA,
|
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? =
|
internal fun packColor(inIndex: Int): Color? =
|
||||||
if (inIndex in 1..kPackColors.size) Color(kPackColors[inIndex - 1]) else null
|
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 {
|
internal fun imageFileName(inContentType: String?): String = when {
|
||||||
inContentType == null -> "image.bin"
|
inContentType == null -> "image.bin"
|
||||||
inContentType.contains("png", ignoreCase = true) -> "image.png"
|
inContentType.contains("png", ignoreCase = true) -> "image.png"
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Working state (session / pack / request / tabs / drag)
|
// 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
|
cancel) and which sub-tabs show. Stable identity lets the sidebar and the
|
||||||
open-tab strip reference the same request without index juggling. */
|
open-tab strip reference the same request without index juggling. */
|
||||||
internal class ReqState(inInitial: ApiRequest) {
|
internal class ReqState(inInitial: ApiRequest) {
|
||||||
var req by mutableStateOf(inInitial)
|
var req by mutableStateOf(inInitial)
|
||||||
var response by mutableStateOf<ApiResponse?>(null)
|
var response by mutableStateOf<ApiResponse?>(null)
|
||||||
@@ -26,6 +28,7 @@ internal class ReqState(inInitial: ApiRequest) {
|
|||||||
var preview by mutableStateOf(false) // panel 4 showing the resolved, not-yet-sent request
|
var 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 sentReq by mutableStateOf<ApiRequest?>(null) // resolved request actually sent (Request tab)
|
||||||
var imageKey: String? = null // memory-resource key when the response is an image
|
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.
|
// null = auto-detect from Content-Type each frame; non-null = user-pinned override.
|
||||||
var respFormatOverride by mutableStateOf<BodyFormat?>(null)
|
var respFormatOverride by mutableStateOf<BodyFormat?>(null)
|
||||||
var bodyWrap by mutableStateOf(true) // body view: true = soft-wrap, false = no wrap + horizontal scroll
|
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
|
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
|
last file save), and its own requests, variables and open-tab strip. Switching
|
||||||
packs swaps the whole working set. */
|
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 name by mutableStateOf(inPack.name)
|
||||||
var path by mutableStateOf(inPath)
|
var path by mutableStateOf(inPath)
|
||||||
var dirty by mutableStateOf(inDirty)
|
var dirty by mutableStateOf(inDirty)
|
||||||
var color by mutableStateOf(inPack.color)
|
var color by mutableStateOf(inPack.color)
|
||||||
|
|
||||||
// A linked copy mirrors its source's requests read-only; non-linked packs own
|
// A linked copy mirrors its source's requests read-only; non-linked packs own
|
||||||
// their requests. `requests` resolves to whichever applies.
|
// their requests. `requests` resolves to whichever applies.
|
||||||
var linkedSource by mutableStateOf<PackState?>(null)
|
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>
|
val requests: androidx.compose.runtime.snapshots.SnapshotStateList<ReqState>
|
||||||
get() = linkedSource?.requests ?: fOwnRequests
|
get() = linkedSource?.requests ?: fOwnRequests
|
||||||
val isLinked: Boolean get() = linkedSource != null
|
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 {
|
val subPacks = mutableStateListOf<PackState>().apply {
|
||||||
inPack.subPacks.forEach { add(PackState(it, null, false).also { vSp -> vSp.parent = this@PackState }) }
|
inPack.subPacks.forEach { add(PackState(it, null, false).also { vSp -> vSp.parent = this@PackState }) }
|
||||||
}
|
}
|
||||||
val variables = mutableStateListOf<KeyVal>().apply { addAll(inPack.variables) }
|
val variables = mutableStateListOf<KeyVal>().apply { addAll(inPack.variables) }
|
||||||
val headers = mutableStateListOf<KeyVal>().apply { addAll(inPack.headers) } // pack-level, inherited by requests
|
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)
|
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 id: String =
|
||||||
val isRoot: Boolean = inPack.isRoot // the session-root "pack" (loose requests)
|
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.
|
// No auto-open: tabs start empty and are restored from persisted state only.
|
||||||
val openTabs = mutableStateListOf<ReqState>().apply {
|
val openTabs = mutableStateListOf<ReqState>().apply {
|
||||||
inOpenTabs.forEach { vIdx -> requests.getOrNull(vIdx)?.let { add(it) } }
|
inOpenTabs.forEach { vIdx -> requests.getOrNull(vIdx)?.let { add(it) } }
|
||||||
@@ -73,39 +88,48 @@ internal class PackState(inPack: Pack, inPath: String?, inDirty: Boolean, inOpen
|
|||||||
fun toPack(): Pack = Pack(
|
fun toPack(): Pack = Pack(
|
||||||
name = name,
|
name = name,
|
||||||
requests = if (isLinked) emptyList() else requests.map { it.req }, // linked: requests live in the source
|
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,
|
variables = variables.toList(),
|
||||||
id = id, linkedTo = linkedSource?.id, isRoot = isRoot,
|
color = color,
|
||||||
|
headers = headers.toList(),
|
||||||
|
params = params.toList(),
|
||||||
|
cert = cert,
|
||||||
|
id = id,
|
||||||
|
linkedTo = linkedSource?.id,
|
||||||
|
isRoot = isRoot,
|
||||||
subPacks = subPacks.map { it.toPack() },
|
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). */
|
across save / load). */
|
||||||
internal fun newPackId(): String = "pk-" + kotlin.random.Random.nextLong().toULong().toString(16)
|
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
|
req == null, or the session settings tab when isSession (pack == null). The
|
||||||
underlying ReqState / PackState (or the session key) is the stable identity. */
|
underlying ReqState / PackState (or the session key) is the stable identity. */
|
||||||
internal const val kSessionTabKey = "session-settings"
|
internal const val kSessionTabKey = "session-settings"
|
||||||
|
|
||||||
internal class StripTab(val pack: PackState?, val req: ReqState?, val isSession: Boolean = false)
|
internal class StripTab(val pack: PackState?, val req: ReqState?, val isSession: Boolean = false)
|
||||||
/* Identity of a strip tab. A linked pack shares the source's ReqState objects,
|
|
||||||
so the key must include the pack — otherwise the source's and linked's tabs
|
/** Identity of a strip tab. A linked pack shares the source's ReqState objects,
|
||||||
for the same request collide (double selection, duplicate key()). Data class →
|
so the key must include the pack — otherwise the source's and linked's tabs
|
||||||
structural equality, so compare keys with == (not ===). */
|
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 data class TabKey(val pack: PackState?, val req: ReqState?)
|
||||||
|
|
||||||
internal val StripTab.tabKey: Any get() = if (isSession) kSessionTabKey else TabKey(pack, req)
|
internal val StripTab.tabKey: Any get() = if (isSession) kSessionTabKey else TabKey(pack, req)
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Cross-pack drag (sidebar tree)
|
// 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
|
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
|
drop target. Two drag kinds: a request row (dragReq, owned by dragReqOwner) or
|
||||||
a whole pack header (dragPack). Every rendered row / header registers its
|
a whole pack header (dragPack). Every rendered row / header registers its
|
||||||
absolute window position + height here so onDrag can hit-test across packs,
|
absolute window position + height here so onDrag can hit-test across packs,
|
||||||
even into collapsed or empty ones. The resolved drop target is published back
|
even into collapsed or empty ones. The resolved drop target is published back
|
||||||
so the target section draws the indicator. */
|
so the target section draws the indicator. */
|
||||||
internal class TreeDrag {
|
internal class TreeDrag {
|
||||||
// What's being dragged (at most one is non-null while a drag is live).
|
// What's being dragged (at most one is non-null while a drag is live).
|
||||||
var dragReq by mutableStateOf<ReqState?>(null)
|
var dragReq by mutableStateOf<ReqState?>(null)
|
||||||
@@ -113,6 +137,7 @@ internal class TreeDrag {
|
|||||||
var dragPack by mutableStateOf<PackState?>(null)
|
var dragPack by mutableStateOf<PackState?>(null)
|
||||||
var dy by mutableStateOf(0f) // draw-only follow offset for the grabbed element
|
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)
|
var pressRel by mutableStateOf(0) // relY at capture (cursor = grabbed-element top + relY)
|
||||||
|
|
||||||
// A press doesn't become a drag until the pointer moves past kDragSlop — so a
|
// A press doesn't become a drag until the pointer moves past kDragSlop — so a
|
||||||
// plain click (which carries a pixel or two of jitter) still selects / opens.
|
// plain click (which carries a pixel or two of jitter) still selects / opens.
|
||||||
var engaged by mutableStateOf(false)
|
var engaged by mutableStateOf(false)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Minimal X.509 / DER parser
|
||||||
|
// ==================
|
||||||
|
// Some TLS backends (notably Schannel on Windows) expose only Subject / Issuer /
|
||||||
|
// the PEM through CURLINFO_CERTINFO — not the parsed fields (validity, serial,
|
||||||
|
// SAN, algorithms). Since every backend gives us the PEM, we parse it ourselves
|
||||||
|
// to fill in the detail. Just enough ASN.1 to walk a Certificate; anything odd
|
||||||
|
// is swallowed and the field is simply omitted.
|
||||||
|
|
||||||
|
/** Parsed certificate detail, all nullable/empty when not found or unparseable. */
|
||||||
|
class CertDetails(
|
||||||
|
val version: Int?, // X.509 version (1/2/3)
|
||||||
|
val serial: String?, // colon-separated hex
|
||||||
|
val sigAlg: String?, // signature algorithm (friendly name or OID)
|
||||||
|
val keyAlg: String?, // public-key algorithm (friendly name or OID)
|
||||||
|
val notBefore: String?, // "YYYY-MM-DD HH:MM:SS UTC"
|
||||||
|
val notAfter: String?,
|
||||||
|
val sans: List<String>, // dNSName SANs
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// DER reader: each next() returns [tag, contentStart, contentEnd] and advances.
|
||||||
|
private class DerReader(val b: ByteArray, var pos: Int, val end: Int) {
|
||||||
|
fun hasMore() = pos < end
|
||||||
|
fun next(): IntArray {
|
||||||
|
val vTag = b[pos++].toInt() and 0xFF
|
||||||
|
var vLen = b[pos++].toInt() and 0xFF
|
||||||
|
if (vLen and 0x80 != 0) {
|
||||||
|
val vN = vLen and 0x7F
|
||||||
|
vLen = 0
|
||||||
|
repeat(vN) { vLen = (vLen shl 8) or (b[pos++].toInt() and 0xFF) }
|
||||||
|
}
|
||||||
|
val vStart = pos
|
||||||
|
pos += vLen
|
||||||
|
return intArrayOf(vTag, vStart, vStart + vLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the PEM into the fields the chain dialog shows. Returns null on any
|
||||||
|
structural surprise (the UI just falls back to whatever the backend gave). */
|
||||||
|
fun parseCertDetails(inPem: String): CertDetails? {
|
||||||
|
val vDer = pemToDer(inPem) ?: return null
|
||||||
|
return try {
|
||||||
|
val vCert = DerReader(vDer, 0, vDer.size).next() // Certificate SEQUENCE
|
||||||
|
val vCertInner = DerReader(vDer, vCert[1], vCert[2])
|
||||||
|
val vTbs = vCertInner.next() // tbsCertificate
|
||||||
|
val vSigAlg = firstOidName(vDer, vCertInner.next()) // signatureAlgorithm
|
||||||
|
|
||||||
|
val vT = DerReader(vDer, vTbs[1], vTbs[2])
|
||||||
|
var vE = vT.next()
|
||||||
|
var vVersion: Int? = null
|
||||||
|
if (vE[0] == 0xA0) // [0] EXPLICIT version
|
||||||
|
{
|
||||||
|
val vVi = DerReader(vDer, vE[1], vE[2]).next()
|
||||||
|
if (vVi[0] == 0x02 && vVi[2] > vVi[1]) vVersion = (vDer[vVi[1]].toInt() and 0xFF) + 1
|
||||||
|
vE = vT.next()
|
||||||
|
}
|
||||||
|
val vSerial = if (vE[0] == 0x02) bytesToHex(vDer, vE[1], vE[2]) else null
|
||||||
|
vT.next() // signature
|
||||||
|
vT.next() // issuer
|
||||||
|
val vValidity = vT.next() // validity SEQUENCE
|
||||||
|
val vVr = DerReader(vDer, vValidity[1], vValidity[2])
|
||||||
|
val vNotBefore = parseAsn1Time(vDer, vVr.next())
|
||||||
|
val vNotAfter = parseAsn1Time(vDer, vVr.next())
|
||||||
|
vT.next() // subject
|
||||||
|
val vSpki = vT.next() // subjectPublicKeyInfo SEQUENCE
|
||||||
|
// SPKI = SEQUENCE { algorithm AlgorithmIdentifier SEQUENCE { OID … }, key BIT STRING }
|
||||||
|
val vKeyAlg = firstOidName(vDer, DerReader(vDer, vSpki[1], vSpki[2]).next())
|
||||||
|
val vSans = ArrayList<String>()
|
||||||
|
while (vT.hasMore()) {
|
||||||
|
val vX = vT.next()
|
||||||
|
if (vX[0] == 0xA3) parseSans(vDer, vX, vSans) // extensions [3]
|
||||||
|
}
|
||||||
|
CertDetails(vVersion, vSerial, vSigAlg, vKeyAlg, vNotBefore, vNotAfter, vSans)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
private fun pemToDer(inPem: String): ByteArray? {
|
||||||
|
val vBody = inPem.lineSequence().filterNot { it.contains("-----") }.joinToString("") { it.trim() }
|
||||||
|
if (vBody.isBlank()) return null
|
||||||
|
return try {
|
||||||
|
Base64.decode(vBody)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The friendly name (or dotted OID) of the first child OID of a SEQUENCE — used
|
||||||
|
for signatureAlgorithm and subjectPublicKeyInfo's AlgorithmIdentifier. */
|
||||||
|
private fun firstOidName(inB: ByteArray, inSeq: IntArray): String? {
|
||||||
|
val vR = DerReader(inB, inSeq[1], inSeq[2])
|
||||||
|
val vOid = vR.next()
|
||||||
|
if (vOid[0] != 0x06) return null
|
||||||
|
return oidName(oidToString(inB, vOid[1], vOid[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull dNSName entries out of the SubjectAltName extension under [3]. */
|
||||||
|
private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<String>) {
|
||||||
|
val vSeq = DerReader(inB, inExt3[1], inExt3[2]).next() // SEQUENCE OF Extension
|
||||||
|
val vEs = DerReader(inB, vSeq[1], vSeq[2])
|
||||||
|
while (vEs.hasMore()) {
|
||||||
|
val vExtn = vEs.next()
|
||||||
|
val vEr = DerReader(inB, vExtn[1], vExtn[2])
|
||||||
|
val vOid = vEr.next() // extnID
|
||||||
|
// SAN OID 2.5.29.17 == bytes 55 1D 11
|
||||||
|
val vIsSan = vOid[2] - vOid[1] == 3 &&
|
||||||
|
(inB[vOid[1]].toInt() and 0xFF) == 0x55 &&
|
||||||
|
(inB[vOid[1] + 1].toInt() and 0xFF) == 0x1D &&
|
||||||
|
(inB[vOid[1] + 2].toInt() and 0xFF) == 0x11
|
||||||
|
var vVal = vEr.next()
|
||||||
|
if (vVal[0] == 0x01) vVal = vEr.next() // skip critical BOOLEAN
|
||||||
|
if (vIsSan && vVal[0] == 0x04) // OCTET STRING wrapping GeneralNames
|
||||||
|
{
|
||||||
|
val vGnSeq = DerReader(inB, vVal[1], vVal[2]).next()
|
||||||
|
val vGn = DerReader(inB, vGnSeq[1], vGnSeq[2])
|
||||||
|
while (vGn.hasMore()) {
|
||||||
|
val vName = vGn.next()
|
||||||
|
if (vName[0] == 0x82) inOut.add(inB.copyOfRange(vName[1], vName[2]).decodeToString()) // [2] dNSName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ASN.1 UTCTime (YYMMDD…) / GeneralizedTime (YYYYMMDD…) → readable UTC string. */
|
||||||
|
private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String? {
|
||||||
|
val vS = inB.copyOfRange(inTlv[1], inTlv[2]).decodeToString()
|
||||||
|
return try {
|
||||||
|
val vYear: String
|
||||||
|
val vRest: String
|
||||||
|
if (inTlv[0] == 0x18) // GeneralizedTime
|
||||||
|
{
|
||||||
|
vYear = vS.substring(0, 4); vRest = vS.substring(4)
|
||||||
|
} else // UTCTime — RFC 5280 pivot at 50
|
||||||
|
{
|
||||||
|
val vYy = vS.substring(0, 2).toInt()
|
||||||
|
vYear = (if (vYy >= 50) "19" else "20") + vS.substring(0, 2)
|
||||||
|
vRest = vS.substring(2)
|
||||||
|
}
|
||||||
|
val vMo = vRest.substring(0, 2)
|
||||||
|
val vDa = vRest.substring(2, 4)
|
||||||
|
val vHh = vRest.substring(4, 6)
|
||||||
|
val vMi = vRest.substring(6, 8)
|
||||||
|
val vSs = if (vRest.length >= 10 && vRest[8].isDigit()) vRest.substring(8, 10) else "00"
|
||||||
|
"$vYear-$vMo-$vDa $vHh:$vMi:$vSs UTC"
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
vS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colon-separated hex of a byte range (serial number rendering). */
|
||||||
|
private fun bytesToHex(inB: ByteArray, inFrom: Int, inTo: Int): String =
|
||||||
|
(inFrom until inTo).joinToString(":") { (inB[it].toInt() and 0xFF).toString(16).padStart(2, '0') }
|
||||||
|
|
||||||
|
/** Decode an OID's content bytes to dotted-decimal. */
|
||||||
|
private fun oidToString(inB: ByteArray, inFrom: Int, inTo: Int): String {
|
||||||
|
if (inTo <= inFrom) return ""
|
||||||
|
val vSb = StringBuilder()
|
||||||
|
val vFirst = inB[inFrom].toInt() and 0xFF
|
||||||
|
vSb.append(vFirst / 40).append('.').append(vFirst % 40)
|
||||||
|
var vAcc = 0L
|
||||||
|
for (vI in (inFrom + 1) until inTo) {
|
||||||
|
val vByte = inB[vI].toInt() and 0xFF
|
||||||
|
vAcc = (vAcc shl 7) or (vByte and 0x7F).toLong()
|
||||||
|
if (vByte and 0x80 == 0) {
|
||||||
|
vSb.append('.').append(vAcc); vAcc = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vSb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Friendly name for the common signature / public-key OIDs, else the OID. */
|
||||||
|
private fun oidName(inOid: String): String = when (inOid) {
|
||||||
|
"1.2.840.113549.1.1.1" -> "RSA"
|
||||||
|
"1.2.840.113549.1.1.5" -> "SHA1withRSA"
|
||||||
|
"1.2.840.113549.1.1.11" -> "SHA256withRSA"
|
||||||
|
"1.2.840.113549.1.1.12" -> "SHA384withRSA"
|
||||||
|
"1.2.840.113549.1.1.13" -> "SHA512withRSA"
|
||||||
|
"1.2.840.113549.1.1.10" -> "RSASSA-PSS"
|
||||||
|
"1.2.840.10045.2.1" -> "EC"
|
||||||
|
"1.2.840.10045.4.3.2" -> "ECDSAwithSHA256"
|
||||||
|
"1.2.840.10045.4.3.3" -> "ECDSAwithSHA384"
|
||||||
|
"1.2.840.10045.4.3.4" -> "ECDSAwithSHA512"
|
||||||
|
"1.3.101.112" -> "Ed25519"
|
||||||
|
"1.3.101.113" -> "Ed448"
|
||||||
|
else -> inOid
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Monospace body font (seam)
|
||||||
|
// ==================
|
||||||
|
// Family name the body editor passes for monospace text. Resolves to the
|
||||||
|
// bundled Noto Sans Mono once registered; null means the font isn't shipped
|
||||||
|
// (data.kres on native, classpath font/ on jvm), in which case the body falls
|
||||||
|
// back to the default proportional font.
|
||||||
|
const val kMonoFamily = "noto-mono"
|
||||||
|
|
||||||
|
/** The raw family name string — used by wrappedRowCount, which measures via the
|
||||||
|
platform text pipeline (accepts a name string). Null when not bundled. */
|
||||||
|
expect val monoFontFamilyName: String?
|
||||||
|
|
||||||
|
/** The material3 Text / BasicTextField-shaped FontFamily for the mono family.
|
||||||
|
Null when not bundled. */
|
||||||
|
expect val monoFontFamily: FontFamily?
|
||||||
@@ -25,6 +25,7 @@ fun formatBody(inText: String, inFormat: BodyFormat): String =
|
|||||||
BodyFormat.JSON -> runCatching {
|
BodyFormat.JSON -> runCatching {
|
||||||
fPrettyJson.encodeToString(JsonElement.serializer(), fPrettyJson.parseToJsonElement(inText))
|
fPrettyJson.encodeToString(JsonElement.serializer(), fPrettyJson.parseToJsonElement(inText))
|
||||||
}.getOrDefault(inText)
|
}.getOrDefault(inText)
|
||||||
|
|
||||||
BodyFormat.XML -> runCatching { formatXml(inText) }.getOrDefault(inText)
|
BodyFormat.XML -> runCatching { formatXml(inText) }.getOrDefault(inText)
|
||||||
else -> inText
|
else -> inText
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import io.ktor.client.*
|
import apidemo.compat.createApiHttpClient
|
||||||
import io.ktor.client.engine.curl.Curl
|
import apidemo.compat.systemFileSystem
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import okio.FileSystem
|
|
||||||
import okio.GzipSource
|
import okio.GzipSource
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
@@ -17,13 +16,13 @@ import kotlin.time.TimeSource
|
|||||||
// MARK: HttpRunner — executes an ApiRequest through Ktor
|
// MARK: HttpRunner — executes an ApiRequest through Ktor
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Holds one Ktor HttpClient for the app's lifetime. One engine on every desktop
|
/** 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
|
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.
|
macOS/Linux). Same TLS stack as the client-cert path in CurlMtls.kt.
|
||||||
run() is a suspend fun — call it off the UI dispatcher. */
|
run() is a suspend fun — call it off the UI dispatcher. */
|
||||||
class HttpRunner {
|
class HttpRunner {
|
||||||
|
|
||||||
private val fClient = HttpClient(Curl)
|
private val fClient = createApiHttpClient()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Clear any temporary client certs a prior crash left in the Windows
|
// Clear any temporary client certs a prior crash left in the Windows
|
||||||
@@ -45,7 +44,7 @@ class HttpRunner {
|
|||||||
inReq.headers
|
inReq.headers
|
||||||
.filter { it.enabled && it.key.isNotBlank() }
|
.filter { it.enabled && it.key.isNotBlank() }
|
||||||
.forEach { header(it.key, it.value) }
|
.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
|
// Content-Type comes from the body type / text format, unless the
|
||||||
// request already carries an explicit Content-Type header.
|
// request already carries an explicit Content-Type header.
|
||||||
val vCt = inReq.bodyContentType()
|
val vCt = inReq.bodyContentType()
|
||||||
@@ -73,7 +72,6 @@ class HttpRunner {
|
|||||||
// so show a short note instead and let the user Save the raw bytes.
|
// so show a short note instead and let the user Save the raw bytes.
|
||||||
val vBinary = !vIsImage && isBinaryBody(vContentType, vBody)
|
val vBinary = !vIsImage && isBinaryBody(vContentType, vBody)
|
||||||
ApiResponse(
|
ApiResponse(
|
||||||
ok = true,
|
|
||||||
status = vResp.status.value,
|
status = vResp.status.value,
|
||||||
statusText = vResp.status.description,
|
statusText = vResp.status.description,
|
||||||
timeMs = vMark.elapsedNow().inWholeMilliseconds,
|
timeMs = vMark.elapsedNow().inWholeMilliseconds,
|
||||||
@@ -104,7 +102,6 @@ class HttpRunner {
|
|||||||
throw e
|
throw e
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
ApiResponse(
|
ApiResponse(
|
||||||
ok = false,
|
|
||||||
status = 0,
|
status = 0,
|
||||||
statusText = "—",
|
statusText = "—",
|
||||||
timeMs = vMark.elapsedNow().inWholeMilliseconds,
|
timeMs = vMark.elapsedNow().inWholeMilliseconds,
|
||||||
@@ -119,30 +116,30 @@ class HttpRunner {
|
|||||||
fun close() = fClient.close()
|
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
|
javascript, csv, form) are shown as text; everything else, or bytes with
|
||||||
embedded NULs, is treated as binary. */
|
embedded NULs, is treated as binary. */
|
||||||
internal fun isBinaryBody(inContentType: String?, inBytes: ByteArray): Boolean {
|
internal fun isBinaryBody(inContentType: String?, inBytes: ByteArray): Boolean {
|
||||||
val vCt = inContentType?.lowercase() ?: ""
|
val vCt = inContentType?.lowercase() ?: ""
|
||||||
val vTextual = vCt.startsWith("text/") ||
|
val vTextual = vCt.startsWith("text/") ||
|
||||||
vCt.contains("json") || vCt.contains("xml") || vCt.contains("html") ||
|
vCt.contains("json") || vCt.contains("xml") || vCt.contains("html") ||
|
||||||
vCt.contains("javascript") || vCt.contains("csv") || vCt.contains("x-www-form-urlencoded")
|
vCt.contains("javascript") || vCt.contains("csv") || vCt.contains("x-www-form-urlencoded")
|
||||||
if (vTextual) return false
|
if (vTextual) return false
|
||||||
if (vCt.isNotEmpty()) return true // a non-textual content type → binary
|
if (vCt.isNotEmpty()) return true // a non-textual content type → binary
|
||||||
return inBytes.take(1024).any { it.toInt() == 0 } // no type: sniff for NUL bytes
|
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). */
|
gzip magic number (1f 8b). */
|
||||||
private fun isGzip(inEncoding: String?, inBytes: ByteArray): Boolean =
|
private fun isGzip(inEncoding: String?, inBytes: ByteArray): Boolean =
|
||||||
inEncoding?.contains("gzip", ignoreCase = true) == true ||
|
inEncoding?.contains("gzip", ignoreCase = true) == true ||
|
||||||
(inBytes.size >= 2 && inBytes[0] == 0x1f.toByte() && inBytes[1] == 0x8b.toByte())
|
(inBytes.size >= 2 && inBytes[0] == 0x1f.toByte() && inBytes[1] == 0x8b.toByte())
|
||||||
|
|
||||||
/* Read a file's raw bytes (used for a FILE request body). */
|
/** Read a file's raw bytes (used for a FILE request body). */
|
||||||
internal fun readFileBytes(inPath: String): ByteArray =
|
internal fun readFileBytes(inPath: String): ByteArray =
|
||||||
FileSystem.SYSTEM.read(inPath.trim().toPath()) { readByteArray() }
|
systemFileSystem.read(inPath.trim().toPath()) { readByteArray() }
|
||||||
|
|
||||||
/* Inflate a gzip stream to its original bytes via okio's GzipSource. */
|
/** Inflate a gzip stream to its original bytes via okio's GzipSource. */
|
||||||
private fun gunzip(inBytes: ByteArray): ByteArray {
|
private fun gunzip(inBytes: ByteArray): ByteArray {
|
||||||
val vGz = GzipSource(Buffer().write(inBytes)).buffer()
|
val vGz = GzipSource(Buffer().write(inBytes)).buffer()
|
||||||
val vResult = vGz.readByteArray()
|
val vResult = vGz.readByteArray()
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -21,10 +19,10 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
|||||||
// MARK: Headers tab (inherited read-only + own editable)
|
// 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
|
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 /
|
rows that shadow an inherited one get an OverrideMark. Used by Query / Var /
|
||||||
Headers — the only differences are the key case-sensitivity and the labels. */
|
Headers — the only differences are the key case-sensitivity and the labels. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun InheritedEditableTab(
|
internal fun InheritedEditableTab(
|
||||||
inInherited: List<InheritedKv>,
|
inInherited: List<InheritedKv>,
|
||||||
@@ -40,8 +38,10 @@ internal fun InheritedEditableTab(
|
|||||||
fun norm(inKey: String) = if (inCaseInsensitive) inKey.lowercase() else inKey
|
fun norm(inKey: String) = if (inCaseInsensitive) inKey.lowercase() else inKey
|
||||||
val vOwnKeys = inOwn.filter { it.key.isNotBlank() }.map { norm(it.key) }.toSet()
|
val vOwnKeys = inOwn.filter { it.key.isNotBlank() }.map { norm(it.key) }.toSet()
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
InheritedKvSection(inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
|
InheritedKvSection(
|
||||||
inOnOverride = if (inReadOnly) null else inOnOverride)
|
inInheritedTitle, inInherited, vOwnKeys, inCaseInsensitive,
|
||||||
|
inOnOverride = if (inReadOnly) null else inOnOverride
|
||||||
|
)
|
||||||
if (inInherited.any { it.kv.key.isNotBlank() }) {
|
if (inInherited.any { it.kv.key.isNotBlank() }) {
|
||||||
HorizontalDivider(color = c.border)
|
HorizontalDivider(color = c.border)
|
||||||
Text(inOwnTitle, color = c.dim, fontSize = 11.sp)
|
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
|
@Composable
|
||||||
internal fun HeadersTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
|
internal fun HeadersTab(
|
||||||
InheritedEditableTab(inInherited, inReq.headers, inCaseInsensitive = true, inReadOnly = inReadOnly, inOwnTitle = "Request headers",
|
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) } },
|
inOnOverride = { vKv -> inEdit { it.copy(headers = it.headers + vKv) } },
|
||||||
inOnChange = { v -> inEdit { it.copy(headers = v) } })
|
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
|
@Composable
|
||||||
internal fun QueryTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
|
internal fun QueryTab(
|
||||||
InheritedEditableTab(inInherited, inReq.params, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request query params",
|
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) } },
|
inOnOverride = { vKv -> inEdit { it.copy(params = it.params + vKv) } },
|
||||||
inOnChange = { v -> inEdit { it.copy(params = v) } })
|
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 /
|
own. A request's own variable overrides the same name inherited from a pack /
|
||||||
the session when the request is sent. */
|
the session when the request is sent. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun VarTab(inReq: ApiRequest, inInherited: List<InheritedKv>, inReadOnly: Boolean, inEdit: (((ApiRequest) -> ApiRequest)) -> Unit) =
|
internal fun VarTab(
|
||||||
InheritedEditableTab(inInherited, inReq.variables, inCaseInsensitive = false, inReadOnly = inReadOnly, inOwnTitle = "Request variables",
|
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) } },
|
inOnOverride = { vKv -> inEdit { it.copy(variables = it.variables + vKv) } },
|
||||||
inOnChange = { v -> inEdit { it.copy(variables = v) } })
|
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)
|
// 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
|
/ "Pack"); hover shows the full path (e.g. "Methods / Nested"). The path tooltip
|
||||||
is skipped when it would just repeat the label (the session). */
|
is skipped when it would just repeat the label (the session). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SourceTag(inLabel: String, inPath: String) {
|
internal fun SourceTag(inLabel: String, inPath: String) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
val vPill = @Composable {
|
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)
|
Text(inLabel, color = c.accent, fontSize = 9.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,8 +131,8 @@ internal fun SourceTag(inLabel: String, inPath: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The tiny marker shown on an own value that shadows an inherited one — hover for
|
/** The tiny marker shown on an own value that shadows an inherited one — hover for
|
||||||
"Overrides <key> from <path>". */
|
"Overrides <key> from <path>". */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun OverrideMark(inKey: String, inPath: String) {
|
internal fun OverrideMark(inKey: String, inPath: String) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -120,9 +150,9 @@ internal fun OverrideMark(inKey: String, inPath: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Read-only list of inherited key/values, each tagged with its source. When not
|
/** 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
|
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. */
|
row offers a one-click Override that copies it into this scope's own list. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun InheritedKvSection(
|
internal fun InheritedKvSection(
|
||||||
inTitle: String,
|
inTitle: String,
|
||||||
@@ -139,15 +169,26 @@ internal fun InheritedKvSection(
|
|||||||
vShown.forEach { vIt ->
|
vShown.forEach { vIt ->
|
||||||
val vKey = if (inCaseInsensitive) vIt.kv.key.lowercase() else vIt.kv.key
|
val vKey = if (inCaseInsensitive) vIt.kv.key.lowercase() else vIt.kv.key
|
||||||
val vOverridden = vKey in inOwnKeys
|
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)
|
SourceTag(vIt.source, vIt.path)
|
||||||
Text(vIt.kv.key, color = c.dim, fontSize = 12.sp, modifier = Modifier.weight(0.42f))
|
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 {
|
when {
|
||||||
vOverridden -> Text("overridden", color = c.dim.copy(alpha = 0.6f), fontSize = 10.sp)
|
vOverridden -> Text("overridden", color = c.dim.copy(alpha = 0.6f), fontSize = 10.sp)
|
||||||
inOnOverride != null -> Box(
|
inOnOverride != null -> Box(
|
||||||
modifier = Modifier.clip(RoundedCornerShape(6.dp)).border(1.dp, c.border, RoundedCornerShape(6.dp))
|
modifier = Modifier.clip(RoundedCornerShape(6.dp))
|
||||||
.clickable { inOnOverride(KeyVal(vIt.kv.key, vIt.kv.value)) }.padding(horizontal = 8.dp, vertical = 3.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) }
|
) { Text("Override", color = c.accent, fontSize = 11.sp) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,11 +200,15 @@ internal fun InheritedKvSection(
|
|||||||
// MARK: Key/value editor
|
// 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
|
editor can flag overriding rows with an OverrideMark. It sits before inOnChange
|
||||||
so existing trailing-lambda calls (KeyValEditor(rows) { … }) still bind to onChange. */
|
so existing trailing-lambda calls (KeyValEditor(rows) { … }) still bind to onChange. */
|
||||||
@Composable
|
@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 c = LocalAppColors.current
|
||||||
val vAnyOverride = inRows.any { inOverrideInfo(it) != null }
|
val vAnyOverride = inRows.any { inOverrideInfo(it) != null }
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
@@ -188,15 +233,33 @@ internal fun KeyValEditor(inRows: List<KeyVal>, inOverrideInfo: (KeyVal) -> Stri
|
|||||||
Checkbox(
|
Checkbox(
|
||||||
checked = vKv.enabled,
|
checked = vKv.enabled,
|
||||||
onCheckedChange = null,
|
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) {
|
if (vAnyOverride) Box(modifier = Modifier.width(18.dp), contentAlignment = Alignment.Center) {
|
||||||
inOverrideInfo(vKv)?.let { vSrc -> OverrideMark(vKv.key, vSrc) }
|
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(
|
||||||
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")
|
vKv.key,
|
||||||
IconBtn(MaterialSymbols.Close, "Remove", inSize = 16.dp) { inOnChange(inRows.filterIndexed { vJ, _ -> vJ != vI }) }
|
{ 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()) }
|
OutlinedAction(MaterialSymbols.Add, "Add row") { inOnChange(inRows + KeyVal()) }
|
||||||
@@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
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
|
@Composable
|
||||||
internal fun MenuRow(inIcon: Int, inLabel: String, inColor: Color? = null) {
|
internal fun MenuRow(inIcon: Int, inLabel: String, inColor: Color? = null) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Pack model (serialized to / from .json)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** A "pack" is a savable collection of requests plus its environment variables
|
||||||
|
— the export/import unit. Variables are referenced as {{name}} in any URL,
|
||||||
|
query value, header or body and substituted just before the request is sent
|
||||||
|
(see resolveVars in Tools.kt). Packs may be empty; the rich starter set lives
|
||||||
|
in defaultSession(). */
|
||||||
|
@Serializable
|
||||||
|
data class Pack(
|
||||||
|
val name: String = "My Pack",
|
||||||
|
val requests: List<ApiRequest> = emptyList(),
|
||||||
|
val variables: List<KeyVal> = emptyList(),
|
||||||
|
val color: Int = 0, // 1-based index into the pack-colour palette; 0 = none
|
||||||
|
val headers: List<KeyVal> = emptyList(), // pack-level headers, inherited by requests
|
||||||
|
val params: List<KeyVal> = emptyList(), // pack-level query params, inherited by requests
|
||||||
|
val cert: CertConfig? = null, // pack-level client cert, inherited by requests
|
||||||
|
val id: String = "", // stable id (used by linked-copy packs)
|
||||||
|
val linkedTo: String? = null, // id of the source pack when this is a linked copy
|
||||||
|
val isRoot: Boolean = false, // the hidden session-root "pack" holding loose requests
|
||||||
|
val subPacks: List<Pack> = emptyList(), // nested packs (a pack tree)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A client-certificate (mutual TLS) configuration. Lives on a request today and
|
||||||
|
— with the pack tree — on the session / packs too (inherited downward). Maps
|
||||||
|
to libcurl's CURLOPT_SSLCERT / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. */
|
||||||
|
@Serializable
|
||||||
|
data class CertConfig(
|
||||||
|
val certPath: String = "",
|
||||||
|
val certFormat: CertFormat = CertFormat.PEM,
|
||||||
|
val keyPath: String = "",
|
||||||
|
val keyFormat: CertFormat = CertFormat.PEM,
|
||||||
|
val certPassword: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The starter SESSION loaded on first launch (and on demand from the session
|
||||||
|
menu) — a guided tour of every feature against httpbin.org: the inheritance
|
||||||
|
ladder (session → pack → sub-pack → request, for variables / query params /
|
||||||
|
headers / client cert, innermost wins), loose root requests, a nested sub-pack,
|
||||||
|
per-request overrides, a client-cert pack, and a linked-copy pack. Each /get or
|
||||||
|
/anything endpoint echoes the args + headers it received, so the merged,
|
||||||
|
inherited result is visible in the response. Loaded as an unsaved session. */
|
||||||
|
fun defaultSession(): Session = Session(
|
||||||
|
activePack = 0,
|
||||||
|
// ── Session level — the base of every inheritance ladder ──
|
||||||
|
globalEnv = listOf(
|
||||||
|
KeyVal("baseUrl", "https://httpbin.org"),
|
||||||
|
KeyVal("token", "session-token-abc"),
|
||||||
|
KeyVal("user", "session-user"),
|
||||||
|
KeyVal("password", "passwd"),
|
||||||
|
KeyVal("apiVer", "v1"), // overridden by Methods (v2) then Nested (v3)
|
||||||
|
),
|
||||||
|
globalHeaders = listOf(KeyVal("X-Client", "compose-apidemo")), // every request sends it (echoed by /headers)
|
||||||
|
globalParams = listOf(
|
||||||
|
KeyVal(
|
||||||
|
"trace",
|
||||||
|
"session"
|
||||||
|
)
|
||||||
|
), // every request gets ?trace=session (echoed in /get args)
|
||||||
|
// No session-level cert: it would route every request through mTLS and fail
|
||||||
|
// without a real cert file. Client certs are shown on the "Secure (mTLS)" pack.
|
||||||
|
// ── Loose requests at the session root (in no pack — inherit session only) ──
|
||||||
|
root = Pack(
|
||||||
|
isRoot = true, name = "", requests = listOf(
|
||||||
|
ApiRequest(name = "Ping (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
|
||||||
|
ApiRequest(name = "Headers echo (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/headers"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
packs = listOf(
|
||||||
|
// ── Methods — pack header/param/var inherited by its requests, plus a
|
||||||
|
// nested sub-pack and per-request overrides of each kind. ──
|
||||||
|
SavedPack(
|
||||||
|
pack = Pack(
|
||||||
|
name = "Methods", id = "pk-methods", color = 1,
|
||||||
|
headers = listOf(KeyVal("Accept", "application/json")), // pack header, inherited by its requests
|
||||||
|
params = listOf(
|
||||||
|
KeyVal(
|
||||||
|
"source",
|
||||||
|
"methods"
|
||||||
|
)
|
||||||
|
), // pack query param, inherited (echoed in /get args)
|
||||||
|
variables = listOf(
|
||||||
|
KeyVal(
|
||||||
|
"apiVer",
|
||||||
|
"v2"
|
||||||
|
)
|
||||||
|
), // overrides the session's apiVer for this pack
|
||||||
|
requests = listOf(
|
||||||
|
ApiRequest(
|
||||||
|
name = "GET — echoes inherited", method = ReqMethod.GET, url = "{{baseUrl}}/get",
|
||||||
|
params = listOf(KeyVal("q", "compose"))
|
||||||
|
), // own q + inherited trace=session + source=methods
|
||||||
|
ApiRequest(
|
||||||
|
name = "GET — overrides query param", method = ReqMethod.GET, url = "{{baseUrl}}/get",
|
||||||
|
params = listOf(KeyVal("trace", "request"))
|
||||||
|
), // request's trace wins over the session's
|
||||||
|
ApiRequest(
|
||||||
|
name = "POST — overrides header",
|
||||||
|
method = ReqMethod.POST,
|
||||||
|
url = "{{baseUrl}}/post",
|
||||||
|
headers = listOf(KeyVal("Accept", "text/plain")), // request's Accept wins over the pack's
|
||||||
|
bodyType = BodyType.TEXT,
|
||||||
|
bodyFormat = BodyFormat.JSON,
|
||||||
|
body = "{\n \"user\": \"{{user}}\",\n \"api\": \"{{apiVer}}\"\n}"
|
||||||
|
),
|
||||||
|
ApiRequest(
|
||||||
|
name = "POST — overrides {{user}} var", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
|
||||||
|
variables = listOf(
|
||||||
|
KeyVal(
|
||||||
|
"user",
|
||||||
|
"request-user"
|
||||||
|
)
|
||||||
|
), // request var beats session/pack — see body echo
|
||||||
|
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"who\": \"{{user}}\" }"
|
||||||
|
),
|
||||||
|
ApiRequest(
|
||||||
|
name = "PUT — replace",
|
||||||
|
method = ReqMethod.PUT,
|
||||||
|
url = "{{baseUrl}}/put",
|
||||||
|
bodyType = BodyType.TEXT,
|
||||||
|
bodyFormat = BodyFormat.JSON,
|
||||||
|
body = "{ \"id\": 1, \"name\": \"updated\" }"
|
||||||
|
),
|
||||||
|
ApiRequest(name = "DELETE", method = ReqMethod.DELETE, url = "{{baseUrl}}/delete"),
|
||||||
|
ApiRequest(
|
||||||
|
name = "Anything echo", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
|
||||||
|
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"hello\": \"world\" }"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Nested sub-pack — inherits Session → Methods → here (apiVer becomes v3,
|
||||||
|
// plus its own X-Nested header; trace / source / Accept still flow down).
|
||||||
|
subPacks = listOf(
|
||||||
|
Pack(
|
||||||
|
name = "Nested", color = 4,
|
||||||
|
variables = listOf(KeyVal("apiVer", "v3")),
|
||||||
|
headers = listOf(KeyVal("X-Nested", "true")),
|
||||||
|
requests = listOf(
|
||||||
|
ApiRequest(
|
||||||
|
name = "GET — deep inheritance",
|
||||||
|
method = ReqMethod.GET,
|
||||||
|
url = "{{baseUrl}}/anything/{{apiVer}}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// ── Auth & status — uses the inherited {{token}} / {{user}} / {{password}} ──
|
||||||
|
SavedPack(
|
||||||
|
pack = Pack(
|
||||||
|
name = "Auth & status", color = 2, requests = listOf(
|
||||||
|
ApiRequest(
|
||||||
|
name = "Bearer auth", method = ReqMethod.GET, url = "{{baseUrl}}/bearer",
|
||||||
|
headers = listOf(KeyVal("Authorization", "Bearer {{token}}"))
|
||||||
|
),
|
||||||
|
ApiRequest(
|
||||||
|
name = "Basic auth",
|
||||||
|
method = ReqMethod.GET,
|
||||||
|
url = "{{baseUrl}}/basic-auth/{{user}}/{{password}}"
|
||||||
|
),
|
||||||
|
ApiRequest(name = "Status 200", method = ReqMethod.GET, url = "{{baseUrl}}/status/200"),
|
||||||
|
ApiRequest(name = "Status 500", method = ReqMethod.GET, url = "{{baseUrl}}/status/500"),
|
||||||
|
ApiRequest(name = "Redirect x3", method = ReqMethod.GET, url = "{{baseUrl}}/redirect/3"),
|
||||||
|
ApiRequest(name = "Delay 3s (try Cancel)", method = ReqMethod.GET, url = "{{baseUrl}}/delay/3"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// ── Formats — response viewer (JSON / XML / HTML / image) ──
|
||||||
|
SavedPack(
|
||||||
|
pack = Pack(
|
||||||
|
name = "Formats", color = 3, requests = listOf(
|
||||||
|
ApiRequest(name = "JSON", method = ReqMethod.GET, url = "{{baseUrl}}/json"),
|
||||||
|
ApiRequest(name = "XML", method = ReqMethod.GET, url = "{{baseUrl}}/xml"),
|
||||||
|
ApiRequest(name = "HTML", method = ReqMethod.GET, url = "{{baseUrl}}/html"),
|
||||||
|
ApiRequest(name = "UTF-8 text", method = ReqMethod.GET, url = "{{baseUrl}}/encoding/utf8"),
|
||||||
|
ApiRequest(name = "PNG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/png"),
|
||||||
|
ApiRequest(name = "SVG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/svg"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// ── Secure (mTLS) — a PACK-LEVEL client cert, inherited by its requests.
|
||||||
|
// Open a request's Cert tab to see the inherited cert (source pill +
|
||||||
|
// Override). Set real cert paths to actually send; the example paths
|
||||||
|
// won't load. The second request overrides with its own cert. ──
|
||||||
|
SavedPack(
|
||||||
|
pack = Pack(
|
||||||
|
name = "Secure (mTLS)", color = 5,
|
||||||
|
cert = CertConfig(
|
||||||
|
certPath = "/path/to/client.p12",
|
||||||
|
certFormat = CertFormat.PKCS12,
|
||||||
|
certPassword = "changeit"
|
||||||
|
),
|
||||||
|
requests = listOf(
|
||||||
|
ApiRequest(name = "Inherits the pack cert", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
|
||||||
|
ApiRequest(
|
||||||
|
name = "Overrides with own cert",
|
||||||
|
method = ReqMethod.GET,
|
||||||
|
url = "{{baseUrl}}/get",
|
||||||
|
certPath = "/path/to/other-client.pem",
|
||||||
|
certFormat = CertFormat.PEM,
|
||||||
|
keyPath = "/path/to/other-key.pem"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// ── Linked copy of "Methods": a read-only mirror of its requests, but with
|
||||||
|
// its own baseUrl var → the same calls run against the httpbingo sibling.
|
||||||
|
// Showcases linked packs + per-pack variable override. ──
|
||||||
|
SavedPack(
|
||||||
|
pack = Pack(
|
||||||
|
name = "Methods · httpbingo", color = 6, requests = emptyList(),
|
||||||
|
variables = listOf(KeyVal("baseUrl", "https://httpbingo.org")), linkedTo = "pk-methods"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApiRequest(
|
||||||
|
val name: String = "New request",
|
||||||
|
val method: ReqMethod = ReqMethod.GET,
|
||||||
|
val url: String = "https://",
|
||||||
|
val params: List<KeyVal> = emptyList(),
|
||||||
|
val variables: List<KeyVal> = emptyList(), // request-level vars ({{name}}); override inherited ones
|
||||||
|
val headers: List<KeyVal> = emptyList(),
|
||||||
|
val bodyType: BodyType = BodyType.NONE,
|
||||||
|
val body: String = "", // TEXT content, or the file path when bodyType == FILE
|
||||||
|
val bodyFormat: BodyFormat = BodyFormat.RAW, // for a TEXT body: syntax-highlight type + sent Content-Type
|
||||||
|
val form: List<KeyVal> = emptyList(), // key/value fields when bodyType == FORM
|
||||||
|
// Client certificate (mutual TLS). When certPath is set, the request is sent
|
||||||
|
// through the libcurl path (CurlMtls.kt) with these mapped to CURLOPT_SSLCERT
|
||||||
|
// / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. A PKCS#12 file bundles its
|
||||||
|
// own key, so keyPath is left blank for it.
|
||||||
|
val certPath: String = "",
|
||||||
|
val certFormat: CertFormat = CertFormat.PEM,
|
||||||
|
val keyPath: String = "",
|
||||||
|
val keyFormat: CertFormat = CertFormat.PEM,
|
||||||
|
val certPassword: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether this request carries a client certificate (and so is sent via the
|
||||||
|
libcurl mTLS path rather than the default engine). */
|
||||||
|
val ApiRequest.hasClientCert: Boolean
|
||||||
|
get() = certPath.isNotBlank()
|
||||||
|
|
||||||
|
/** This request's cert fields as a CertConfig (certPath may be blank = none). */
|
||||||
|
fun ApiRequest.certConfig(): CertConfig = CertConfig(certPath, certFormat, keyPath, keyFormat, certPassword)
|
||||||
|
|
||||||
|
/** This request with its cert fields replaced by inCert's. */
|
||||||
|
fun ApiRequest.withCert(inCert: CertConfig): ApiRequest =
|
||||||
|
copy(
|
||||||
|
certPath = inCert.certPath, certFormat = inCert.certFormat,
|
||||||
|
keyPath = inCert.keyPath, keyFormat = inCert.keyFormat, certPassword = inCert.certPassword
|
||||||
|
)
|
||||||
|
|
||||||
|
/** True when this config actually selects a certificate. */
|
||||||
|
val CertConfig.isSet: Boolean
|
||||||
|
get() = certPath.isNotBlank()
|
||||||
|
|
||||||
|
/** Certificate / private-key encodings, mapped to libcurl's CURLOPT_SSLCERTTYPE
|
||||||
|
/ CURLOPT_SSLKEYTYPE string values and to `curl --cert-type` / `--key-type`.
|
||||||
|
Runtime support depends on the TLS backend: OpenSSL (macOS/Linux) handles all
|
||||||
|
three; Schannel (Windows) effectively supports P12. */
|
||||||
|
@Serializable
|
||||||
|
enum class CertFormat(val curlName: String, val label: String) {
|
||||||
|
PEM("PEM", "PEM"),
|
||||||
|
DER("DER", "DER"),
|
||||||
|
PKCS12("P12", "PKCS#12"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A toggleable key/value row, used for both query params and headers. */
|
||||||
|
@Serializable
|
||||||
|
data class KeyVal(val key: String = "", val value: String = "", val enabled: Boolean = true)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ReqMethod { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class BodyType { NONE, TEXT, FORM, FILE }
|
||||||
|
|
||||||
|
/** Title-case label for the body-type picker. */
|
||||||
|
val BodyType.label: String
|
||||||
|
get() = when (this) {
|
||||||
|
BodyType.NONE -> "None"
|
||||||
|
BodyType.TEXT -> "Text"
|
||||||
|
BodyType.FORM -> "Form"
|
||||||
|
BodyType.FILE -> "File"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Content-Type the request body implies (null when there's no body). For a
|
||||||
|
TEXT body it comes from the chosen format (Text→text/plain, JSON→application/
|
||||||
|
json, …); FORM / FILE are fixed. Sent unless the request already sets one. */
|
||||||
|
fun ApiRequest.bodyContentType(): String? = when (bodyType) {
|
||||||
|
BodyType.NONE -> null
|
||||||
|
BodyType.TEXT -> bodyFormat.contentType
|
||||||
|
BodyType.FORM -> "application/x-www-form-urlencoded"
|
||||||
|
BodyType.FILE -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Response (runtime only — not part of a pack)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
class ApiResponse(
|
||||||
|
val status: Int,
|
||||||
|
val statusText: String,
|
||||||
|
val timeMs: Long,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
val headers: List<Pair<String, String>>,
|
||||||
|
val body: String,
|
||||||
|
val bytes: ByteArray = ByteArray(0),
|
||||||
|
val contentType: String? = null,
|
||||||
|
val requestHeaders: List<Pair<String, String>> = emptyList(), // headers actually sent
|
||||||
|
val httpVersion: String = "HTTP/1.1", // from Ktor's response.version, e.g. "HTTP/1.1" or "HTTP/2"
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** True when the response carries an image payload (rendered, not shown as text). */
|
||||||
|
val ApiResponse.isImage: Boolean
|
||||||
|
get() = contentType?.startsWith("image/", ignoreCase = true) == true
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
|
import apidemo.compat.systemFileSystem
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import okio.FileSystem
|
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
@@ -16,10 +16,10 @@ private val fJson = Json {
|
|||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Write the pack as pretty JSON to inPath. Returns null on success, else the
|
/** Write the pack as pretty JSON to inPath. Returns null on success, else the
|
||||||
error message. */
|
error message. */
|
||||||
fun exportPack(inPack: Pack, inPath: String): String? = try {
|
fun exportPack(inPack: Pack, inPath: String): String? = try {
|
||||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) {
|
systemFileSystem.write(inPath.trim().toPath()) {
|
||||||
writeUtf8(fJson.encodeToString(inPack))
|
writeUtf8(fJson.encodeToString(inPack))
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
@@ -27,17 +27,17 @@ fun exportPack(inPack: Pack, inPath: String): String? = try {
|
|||||||
e.message ?: e.toString()
|
e.message ?: e.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Read a pack back from inPath. */
|
/** Read a pack back from inPath. */
|
||||||
fun importPack(inPath: String): Result<Pack> = try {
|
fun importPack(inPath: String): Result<Pack> = try {
|
||||||
val vText = FileSystem.SYSTEM.read(inPath.trim().toPath()) { readUtf8() }
|
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
|
||||||
Result.success(fJson.decodeFromString<Pack>(vText))
|
Result.success(fJson.decodeFromString<Pack>(vText))
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Result.failure(e)
|
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 {
|
fun exportSession(inSession: Session, inPath: String): String? = try {
|
||||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) {
|
systemFileSystem.write(inPath.trim().toPath()) {
|
||||||
writeUtf8(fJson.encodeToString(inSession))
|
writeUtf8(fJson.encodeToString(inSession))
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
@@ -45,47 +45,47 @@ fun exportSession(inSession: Session, inPath: String): String? = try {
|
|||||||
e.message ?: e.toString()
|
e.message ?: e.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Read a session back from inPath. */
|
/** Read a session back from inPath. */
|
||||||
fun importSession(inPath: String): Result<Session> = try {
|
fun importSession(inPath: String): Result<Session> = try {
|
||||||
val vText = FileSystem.SYSTEM.read(inPath.trim().toPath()) { readUtf8() }
|
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
|
||||||
Result.success(fJson.decodeFromString<Session>(vText))
|
Result.success(fJson.decodeFromString<Session>(vText))
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Result.failure(e)
|
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. */
|
extension when the new name has none), returning the new path on success. */
|
||||||
fun renameFile(inPath: String, inNewName: String): Result<String> = try {
|
fun renameFile(inPath: String, inNewName: String): Result<String> = try {
|
||||||
val vSrc = inPath.trim().toPath()
|
val vSrc = inPath.trim().toPath()
|
||||||
val vLeaf = inNewName.trim().let { if (it.contains('.')) it else "$it.json" }
|
val vLeaf = inNewName.trim().let { if (it.contains('.')) it else "$it.json" }
|
||||||
val vDst = vSrc.parent?.div(vLeaf) ?: vLeaf.toPath()
|
val vDst = vSrc.parent?.div(vLeaf) ?: vLeaf.toPath()
|
||||||
FileSystem.SYSTEM.atomicMove(vSrc, vDst)
|
systemFileSystem.atomicMove(vSrc, vDst)
|
||||||
Result.success(vDst.toString())
|
Result.success(vDst.toString())
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Result.failure(e)
|
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. */
|
on success, else the error message. */
|
||||||
fun writeTextFile(inPath: String, inText: String): String? = try {
|
fun writeTextFile(inPath: String, inText: String): String? = try {
|
||||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) { writeUtf8(inText) }
|
systemFileSystem.write(inPath.trim().toPath()) { writeUtf8(inText) }
|
||||||
null
|
null
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
e.message ?: e.toString()
|
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 {
|
fun writeBytesFile(inPath: String, inBytes: ByteArray): String? = try {
|
||||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) { write(inBytes) }
|
systemFileSystem.write(inPath.trim().toPath()) { write(inBytes) }
|
||||||
null
|
null
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
e.message ?: e.toString()
|
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). */
|
isn't valid JSON (so plain-text / HTML responses still show). */
|
||||||
fun prettyJsonOrRaw(inText: String): String = try {
|
fun prettyJsonOrRaw(inText: String): String = try {
|
||||||
fJson.encodeToString(JsonElement.serializer(), fJson.parseToJsonElement(inText))
|
fJson.encodeToString(JsonElement.serializer(), fJson.parseToJsonElement(inText))
|
||||||
} catch (e: Throwable) {
|
} catch (_: Throwable) {
|
||||||
inText
|
inText
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import com.compose.sdl.appDataDir
|
import apidemo.compat.appDataDir
|
||||||
import com.compose.sdl.revealInFileManager
|
import apidemo.compat.revealInFileManager
|
||||||
|
import apidemo.compat.systemFileSystem
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okio.FileSystem
|
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Persisted app state
|
// 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
|
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
|
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. */
|
first run so the httpbin starter pack is only auto-loaded once. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppState(
|
data class AppState(
|
||||||
val launched: Boolean = false,
|
val launched: Boolean = false,
|
||||||
val dark: Boolean = true,
|
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 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 globalHeaders: List<KeyVal> = emptyList(), // session-level headers (inherited by every request)
|
||||||
val globalParams: List<KeyVal> = emptyList(), // session-level query params (inherited by every request)
|
val globalParams: List<KeyVal> = emptyList(), // session-level query params (inherited by every request)
|
||||||
@@ -36,8 +37,8 @@ data class AppState(
|
|||||||
val activeReq: Int = -1,
|
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. */
|
saved (null = never saved to a file) and whether it has unsaved edits. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SavedPack(
|
data class SavedPack(
|
||||||
val path: String? = null,
|
val path: String? = null,
|
||||||
@@ -45,9 +46,9 @@ data class SavedPack(
|
|||||||
val pack: Pack = Pack(),
|
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
|
(self-contained — full content embedded) plus the shared global env and which
|
||||||
pack was active. Only one session is open at a time. */
|
pack was active. Only one session is open at a time. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Session(
|
data class Session(
|
||||||
val packs: List<SavedPack> = emptyList(),
|
val packs: List<SavedPack> = emptyList(),
|
||||||
@@ -74,40 +75,40 @@ private const val kOrg = "ComposeDesktopNative"
|
|||||||
private const val kApp = "ApiManager"
|
private const val kApp = "ApiManager"
|
||||||
private const val kStateFile = "state.json"
|
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. */
|
demand by SDL_GetPrefPath). Null if no writable dir is available. */
|
||||||
private fun stateFilePath(): String? {
|
private fun stateFilePath(): String? {
|
||||||
val vDir = appDataDir(kOrg, kApp) ?: return null // ends with a separator
|
val vDir = appDataDir(kOrg, kApp) ?: return null // ends with a separator
|
||||||
return vDir + kStateFile
|
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. */
|
nothing saved yet or it can't be read. */
|
||||||
fun loadAppState(): AppState {
|
fun loadAppState(): AppState {
|
||||||
val vPath = stateFilePath() ?: return AppState()
|
val vPath = stateFilePath() ?: return AppState()
|
||||||
return try {
|
return try {
|
||||||
val vText = FileSystem.SYSTEM.read(vPath.toPath()) { readUtf8() }
|
val vText = systemFileSystem.read(vPath.toPath()) { readUtf8() }
|
||||||
fStateJson.decodeFromString<AppState>(vText)
|
fStateJson.decodeFromString<AppState>(vText)
|
||||||
} catch (e: Throwable) {
|
} catch (_: Throwable) {
|
||||||
AppState()
|
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). */
|
the session, not the user's exported .json packs). */
|
||||||
fun saveAppState(inState: AppState) {
|
fun saveAppState(inState: AppState) {
|
||||||
val vPath = stateFilePath() ?: return
|
val vPath = stateFilePath() ?: return
|
||||||
try {
|
try {
|
||||||
FileSystem.SYSTEM.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
|
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
|
// 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
|
manager. Revealing the state file opens its containing folder, which SDL has
|
||||||
already created even when nothing's been saved yet. Fire-and-forget — the
|
already created even when nothing's been saved yet. Fire-and-forget — the
|
||||||
reveal launches off the main thread (see revealInFileManager). */
|
reveal launches off the main thread (see revealInFileManager). */
|
||||||
fun openSettingsFolder() {
|
fun openSettingsFolder() {
|
||||||
val vPath = stateFilePath() ?: return
|
val vPath = stateFilePath() ?: return
|
||||||
revealInFileManager(vPath)
|
revealInFileManager(vPath)
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -32,12 +30,18 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
|||||||
internal fun OptionsMenu(
|
internal fun OptionsMenu(
|
||||||
inDark: Boolean,
|
inDark: Boolean,
|
||||||
inOnToggleTheme: () -> Unit,
|
inOnToggleTheme: () -> Unit,
|
||||||
|
inPalette: VolticPalette,
|
||||||
|
inOnPalette: (VolticPalette) -> Unit,
|
||||||
) {
|
) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
var vOpen by remember { mutableStateOf(false) }
|
var vOpen by remember { mutableStateOf(false) }
|
||||||
Box {
|
Box {
|
||||||
IconBtn(MaterialSymbols.Settings, "Options") { vOpen = true }
|
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 = {
|
DropdownMenuItem(text = {
|
||||||
Text("Dark mode", color = if (inDark) c.accent else c.text, fontSize = 13.sp)
|
Text("Dark mode", color = if (inDark) c.accent else c.text, fontSize = 13.sp)
|
||||||
}, onClick = { if (!inDark) inOnToggleTheme(); vOpen = false })
|
}, 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)
|
Text("Light mode", color = if (!inDark) c.accent else c.text, fontSize = 13.sp)
|
||||||
}, onClick = { if (inDark) inOnToggleTheme(); vOpen = false })
|
}, onClick = { if (inDark) inOnToggleTheme(); vOpen = false })
|
||||||
HorizontalDivider(color = c.border)
|
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 = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(MaterialSymbols.Folder, "Open settings folder")
|
MenuRow(MaterialSymbols.Folder, "Open settings folder")
|
||||||
}, onClick = { vOpen = false; openSettingsFolder() })
|
}, onClick = { vOpen = false; openSettingsFolder() })
|
||||||
@@ -56,8 +86,8 @@ internal fun OptionsMenu(
|
|||||||
// MARK: Method picker (dropdown)
|
// 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). */
|
a borderless URL field that melts into the bar, and Send (or Cancel). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun UrlBar(
|
internal fun UrlBar(
|
||||||
inReq: ApiRequest,
|
inReq: ApiRequest,
|
||||||
@@ -99,16 +129,18 @@ internal fun UrlBar(
|
|||||||
// Borderless URL field — no box, so it reads as part of the bar.
|
// Borderless URL field — no box, so it reads as part of the bar.
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.weight(1f).alpha(if (inReadOnly) 0.55f else 1f).onKeyEvent { ev ->
|
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)
|
if (inReq.url.isEmpty()) Text("https://example.com/path", color = c.dim, fontSize = 14.sp)
|
||||||
androidx.compose.runtime.CompositionLocalProvider(
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
|
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
|
||||||
androidx.compose.foundation.text.selection.TextSelectionColors(
|
androidx.compose.foundation.text.selection.TextSelectionColors(
|
||||||
handleColor = c.accent,
|
handleColor = c.accent,
|
||||||
backgroundColor = c.accent.copy(alpha = 0.35f),
|
backgroundColor = c.accent.copy(alpha = 0.35f),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = inReq.url,
|
value = inReq.url,
|
||||||
@@ -133,7 +165,10 @@ internal fun UrlBar(
|
|||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.clip(RoundedCornerShape(6.dp))
|
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)
|
.hoverable(vLockHoverSrc)
|
||||||
.clickable(onClick = inOnInspectChain)
|
.clickable(onClick = inOnInspectChain)
|
||||||
.padding(6.dp),
|
.padding(6.dp),
|
||||||
@@ -141,8 +176,10 @@ internal fun UrlBar(
|
|||||||
if (inChainLoading) {
|
if (inChainLoading) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = c.accent, strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = c.accent, strokeWidth = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
MaterialSymbolsOutlined(MaterialSymbols.Lock, contentDescription = "Inspect TLS chain",
|
MaterialSymbolsOutlined(
|
||||||
tint = if (vLockHover) c.accent else c.dim, size = 18.dp)
|
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.
|
// so the bar never changes height when toggling — Cancel is just red.
|
||||||
if (inLoading) Button(
|
if (inLoading) Button(
|
||||||
onClick = inOnCancel,
|
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) }
|
) { BtnContent(MaterialSymbols.Stop, "Cancel", Color.White) }
|
||||||
else Button(onClick = inOnSend) { BtnContent(MaterialSymbols.Send, "Send", c.onAccent) }
|
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
|
@Composable
|
||||||
internal fun BodyTypeMenu(inType: BodyType, inEnabled: Boolean, inOnPick: (BodyType) -> Unit) {
|
internal fun BodyTypeMenu(inType: BodyType, inEnabled: Boolean, inOnPick: (BodyType) -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
@@ -17,17 +15,17 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.compose.sdl.TextLayoutConfig
|
import apidemo.compat.editorTabWidth
|
||||||
|
import apidemo.compat.showOpenFileDialog
|
||||||
import com.compose.sdl.icons.MaterialSymbols
|
import com.compose.sdl.icons.MaterialSymbols
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||||
import com.compose.sdl.showOpenFileDialog
|
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Panel 3 — request builder (Query / Headers / Body)
|
// MARK: Panel 3 — request builder (Query / Headers / Body)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Tabs to edit query params, headers and the body, a Preview toggle top-right,
|
/** Tabs to edit query params, headers and the body, a Preview toggle top-right,
|
||||||
and a body-type dropdown pinned at the bottom. */
|
and a body-type dropdown pinned at the bottom. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RequestBuilder(
|
internal fun RequestBuilder(
|
||||||
inReq: ApiRequest,
|
inReq: ApiRequest,
|
||||||
@@ -42,7 +40,7 @@ internal fun RequestBuilder(
|
|||||||
inEdit: ((ApiRequest) -> ApiRequest) -> Unit,
|
inEdit: ((ApiRequest) -> ApiRequest) -> Unit,
|
||||||
) {
|
) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
val vBodySet = inReq.method.allowsBody && when (inReq.bodyType) {
|
val vBodySet = true && when (inReq.bodyType) {
|
||||||
BodyType.NONE -> false
|
BodyType.NONE -> false
|
||||||
BodyType.FORM -> inReq.form.any { it.enabled && it.key.isNotBlank() }
|
BodyType.FORM -> inReq.form.any { it.enabled && it.key.isNotBlank() }
|
||||||
else -> inReq.body.isNotBlank()
|
else -> inReq.body.isNotBlank()
|
||||||
@@ -55,10 +53,10 @@ internal fun RequestBuilder(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
MaterialSymbolsOutlined(MaterialSymbols.Warning, tint = kWarnColor, size = 15.dp)
|
MaterialSymbolsOutlined(MaterialSymbols.Warning, tint = VolticTheme.extended.warning, size = 15.dp)
|
||||||
Text(
|
Text(
|
||||||
"Undefined: ${inUnresolved.joinToString(", ") { "{{$it}}" }}",
|
"Undefined: ${inUnresolved.joinToString(", ") { "{{$it}}" }}",
|
||||||
color = kWarnColor,
|
color = VolticTheme.extended.warning,
|
||||||
fontSize = 11.sp
|
fontSize = 11.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -147,8 +145,8 @@ 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). */
|
Text body is highlighted per its chosen format (bodyFormat). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
|
internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
|
||||||
when (inReq.bodyType) {
|
when (inReq.bodyType) {
|
||||||
@@ -159,21 +157,22 @@ internal fun BodyContent(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest)
|
|||||||
inOnChange = { v -> inEdit { it.copy(body = v) } },
|
inOnChange = { v -> inEdit { it.copy(body = v) } },
|
||||||
inFormat = inReq.bodyFormat,
|
inFormat = inReq.bodyFormat,
|
||||||
)
|
)
|
||||||
|
|
||||||
BodyType.FORM -> KeyValEditor(inReq.form) { v -> inEdit { it.copy(form = v) } }
|
BodyType.FORM -> KeyValEditor(inReq.form) { v -> inEdit { it.copy(form = v) } }
|
||||||
BodyType.FILE -> FileBody(inReq) { v -> inEdit(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. */
|
XML). Lives at the right end of the body type/format bar. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun FormatButton(inOnClick: () -> Unit) {
|
internal fun FormatButton(inOnClick: () -> Unit) {
|
||||||
HoverIconBtn(MaterialSymbols.AutoFixHigh, "Format (pretty-print)", inOnClick)
|
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
|
background + accent icon on hover (matching the TLS chain button). inActive
|
||||||
keeps it lit, for toggles such as the preview eye. */
|
keeps it lit, for toggles such as the preview eye. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HoverIconBtn(
|
internal fun HoverIconBtn(
|
||||||
inIcon: Int,
|
inIcon: Int,
|
||||||
@@ -188,8 +187,8 @@ internal fun HoverIconBtn(
|
|||||||
val vHover by vHoverSrc.collectIsHoveredAsState()
|
val vHover by vHoverSrc.collectIsHoveredAsState()
|
||||||
val vBg = when {
|
val vBg = when {
|
||||||
inActive -> c.accent.copy(alpha = 0.20f)
|
inActive -> c.accent.copy(alpha = 0.20f)
|
||||||
vHover -> c.accent.copy(alpha = 0.18f)
|
vHover -> c.accent.copy(alpha = 0.18f)
|
||||||
else -> Color.Transparent
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
TooltipBox(
|
TooltipBox(
|
||||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||||
@@ -204,22 +203,24 @@ internal fun HoverIconBtn(
|
|||||||
.clickable(onClick = inOnClick)
|
.clickable(onClick = inOnClick)
|
||||||
.padding(6.dp),
|
.padding(6.dp),
|
||||||
) {
|
) {
|
||||||
MaterialSymbolsOutlined(inIcon, contentDescription = inTooltip,
|
MaterialSymbolsOutlined(
|
||||||
tint = if (inActive || vHover) c.accent else c.dim, size = inSize)
|
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
|
editor tab width via TextLayoutConfig — how wide a typed '\t' renders AND how
|
||||||
deep the formatter indents. Snapshot-backed, so the change is live. */
|
deep the formatter indents. Snapshot-backed, so the change is live. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TabSizeSelector() {
|
internal fun TabSizeSelector() {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
var vOpen by remember { mutableStateOf(false) }
|
var vOpen by remember { mutableStateOf(false) }
|
||||||
val vHoverSrc = remember { MutableInteractionSource() }
|
val vHoverSrc = remember { MutableInteractionSource() }
|
||||||
val vHover by vHoverSrc.collectIsHoveredAsState()
|
val vHover by vHoverSrc.collectIsHoveredAsState()
|
||||||
val vSize = TextLayoutConfig.tabWidth
|
val vSize = editorTabWidth
|
||||||
TooltipBox(
|
TooltipBox(
|
||||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||||
tooltip = { PlainTooltip { Text("Editor tab size") } },
|
tooltip = { PlainTooltip { Text("Editor tab size") } },
|
||||||
@@ -229,7 +230,10 @@ internal fun TabSizeSelector() {
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.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)
|
.hoverable(vHoverSrc)
|
||||||
.clickable { vOpen = true }
|
.clickable { vOpen = true }
|
||||||
.padding(horizontal = 8.dp, vertical = 5.dp),
|
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||||
@@ -237,20 +241,24 @@ internal fun TabSizeSelector() {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
) {
|
) {
|
||||||
Text("Tab $vSize", color = if (vHover) c.accent else c.dim, fontSize = 12.sp)
|
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 }) {
|
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
|
||||||
for (vN in listOf(2, 4, 8)) {
|
for (vN in listOf(2, 4, 8)) {
|
||||||
DropdownMenuItem(text = {
|
DropdownMenuItem(text = {
|
||||||
Text("$vN spaces", color = if (vN == vSize) c.accent else c.text)
|
Text("$vN spaces", color = if (vN == vSize) c.accent else c.text)
|
||||||
}, onClick = { TextLayoutConfig.tabWidth = vN; vOpen = false })
|
}, onClick = { editorTabWidth = vN; vOpen = false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FILE body — pick a file; its path is stored in body and sent as raw bytes. */
|
/** FILE body — pick a file; its path is stored in body and sent as raw bytes. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
|
internal fun FileBody(inReq: ApiRequest, inEdit: ((ApiRequest) -> ApiRequest) -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -263,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
|
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
|
they're imported into the certificate store for the request then removed
|
||||||
(see CurlMtls). PKCS#12 bundles its own private key. */
|
(see CurlMtls). PKCS#12 bundles its own private key. */
|
||||||
@Composable
|
@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 c = LocalAppColors.current
|
||||||
val vHasCert = inCert.certPath.isNotBlank()
|
val vHasCert = inCert.certPath.isNotBlank()
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
@@ -293,7 +306,11 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
|
|||||||
inOnChange(CertConfig())
|
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)
|
// Private key (PKCS#12 carries its own key)
|
||||||
@@ -304,10 +321,17 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
|
|||||||
showOpenFileDialog { vPath -> if (vPath != null) inOnChange(inCert.copy(keyPath = vPath)) }
|
showOpenFileDialog { vPath -> if (vPath != null) inOnChange(inCert.copy(keyPath = vPath)) }
|
||||||
}
|
}
|
||||||
CertFormatMenu(inCert.keyFormat) { vF -> inOnChange(inCert.copy(keyFormat = vF)) }
|
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(
|
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,
|
color = if (inCert.keyPath.isNotBlank()) c.text else c.dim, fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -315,17 +339,27 @@ internal fun CertConfigEditor(inCert: CertConfig, inHeading: String = "Client ce
|
|||||||
// ============
|
// ============
|
||||||
// Passphrase
|
// Passphrase
|
||||||
Text("Passphrase", color = c.dim, fontSize = 12.sp)
|
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. */
|
Override action (copy it into this scope) when this scope hasn't set its own. */
|
||||||
@Composable
|
@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
|
val c = LocalAppColors.current
|
||||||
Column(
|
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),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -343,23 +377,35 @@ internal fun InheritedCertCard(inInherited: InheritedCert, inOwnSet: Boolean, in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The request's Cert tab: the inherited cert (from pack / session) shown read-only
|
/** 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
|
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). */
|
request's own cert editor (its own cert overrides the inherited one). */
|
||||||
@Composable
|
@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)) {
|
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) }
|
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 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
|
the scope inherits from above (source-tagged, with Override) over its own
|
||||||
editable list, mirroring the request panels. */
|
editable list, mirroring the request panels. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ScopeSettings(
|
internal fun ScopeSettings(
|
||||||
inTab: Int,
|
inTab: Int,
|
||||||
@@ -390,33 +436,50 @@ internal fun ScopeSettings(
|
|||||||
when (inTab) {
|
when (inTab) {
|
||||||
0 -> {
|
0 -> {
|
||||||
Text(inVarHelp, color = c.dim, fontSize = 12.sp)
|
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",
|
inOwnTitle = "This scope's variables", inInheritedTitle = "Inherited variables",
|
||||||
inOnOverride = { vKv -> inOnVars(inVars + vKv) }, inOnChange = inOnVars)
|
inOnOverride = { vKv -> inOnVars(inVars + vKv) }, inOnChange = inOnVars
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
1 -> {
|
1 -> {
|
||||||
Text(inParamHelp, color = c.dim, fontSize = 12.sp)
|
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",
|
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 -> {
|
2 -> {
|
||||||
Text(inHeaderHelp, color = c.dim, fontSize = 12.sp)
|
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",
|
inOwnTitle = "This scope's headers", inInheritedTitle = "Inherited headers",
|
||||||
inOnOverride = { vKv -> inOnHeaders(inHeaders + vKv) }, inOnChange = inOnHeaders)
|
inOnOverride = { vKv -> inOnHeaders(inHeaders + vKv) }, inOnChange = inOnHeaders
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Text(inCertHelp, color = c.dim, fontSize = 12.sp)
|
Text(inCertHelp, color = c.dim, fontSize = 12.sp)
|
||||||
val vOwnSet = inCert?.isSet == true
|
val vOwnSet = inCert?.isSet == true
|
||||||
if (inInheritedCert != null) InheritedCertCard(inInheritedCert, inOwnSet = vOwnSet, inReadOnly = false) { inOnCert(inInheritedCert.cert) }
|
if (inInheritedCert != null) InheritedCertCard(
|
||||||
CertConfigEditor(inCert ?: CertConfig(), inCertHeading, inOverrideSource = if (vOwnSet) inInheritedCert?.path else null) { vCc -> inOnCert(vCc.takeIf { it.isSet }) }
|
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
|
@Composable
|
||||||
internal fun CertFormatMenu(inFormat: CertFormat, inOnPick: (CertFormat) -> Unit) {
|
internal fun CertFormatMenu(inFormat: CertFormat, inOnPick: (CertFormat) -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||||
|
|
||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.PointerButton
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import apidemo.compat.x
|
||||||
|
import com.compose.sdl.icons.MaterialSymbols
|
||||||
|
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Request tab strip (open requests; drag a tab to reorder)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Strip of open requests above the editor. Each tab is clickable to select
|
||||||
|
and drag-reorderable. While dragging, the grabbed tab lifts (zIndex),
|
||||||
|
fades, and follows the cursor via a graphicsLayer translation — its
|
||||||
|
neighbours don't shuffle; the reorder commits on drop, to the slot whose
|
||||||
|
neighbours' centres the cursor passed. key(tab) keeps each tab's
|
||||||
|
LayoutNode stable so the drag session survives the reorder. */
|
||||||
|
@Composable
|
||||||
|
internal fun RequestTabStrip(
|
||||||
|
inTabs: List<StripTab>,
|
||||||
|
inActiveKey: Any?,
|
||||||
|
inOnSelect: (StripTab) -> Unit,
|
||||||
|
inOnClose: (StripTab) -> Unit,
|
||||||
|
inOnCloseOthers: (StripTab) -> Unit,
|
||||||
|
inOnCloseAll: () -> Unit,
|
||||||
|
inOnReorder: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val c = LocalAppColors.current
|
||||||
|
val vScroll = rememberScrollState() // strip's horizontal scroll
|
||||||
|
val vLeft = remember { mutableStateMapOf<Any, Int>() } // window-x of each tab's left edge, by tab key
|
||||||
|
val vWidth = remember { mutableStateMapOf<Any, Int>() } // measured tab width, by tab key
|
||||||
|
var vDragKey by remember { mutableStateOf<Any?>(null) }
|
||||||
|
var vPressRelX by remember { mutableStateOf(0) }
|
||||||
|
var vDragDx by remember { mutableStateOf(0f) }
|
||||||
|
var vDragTarget by remember { mutableStateOf(-1) }
|
||||||
|
|
||||||
|
// One shared context menu, anchored to the tab that opened it (m3 DropdownMenu
|
||||||
|
// is parent-anchored, not cursor-anchored — the old x/y capture is gone).
|
||||||
|
var vMenu by remember { mutableStateOf(false) }
|
||||||
|
var vMenuTab by remember { mutableStateOf<StripTab?>(null) }
|
||||||
|
|
||||||
|
var vListOpen by remember { mutableStateOf(false) } // overflow list
|
||||||
|
|
||||||
|
val vShowBar = vDragKey != null && vDragDx != 0f
|
||||||
|
val vOthers = if (vShowBar) inTabs.filter { it.tabKey != vDragKey } else emptyList()
|
||||||
|
val vBarBeforeKey = if (vShowBar) vOthers.getOrNull(vDragTarget)?.tabKey else null
|
||||||
|
val vBarAtEnd = vShowBar && vDragTarget >= vOthers.size
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().background(c.panel),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
.horizontalScroll(vScroll).padding(horizontal = 6.dp, vertical = 5.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
inTabs.forEach { vTab ->
|
||||||
|
val vKey = vTab.tabKey
|
||||||
|
if (vKey == vBarBeforeKey) DropBar()
|
||||||
|
key(vKey) {
|
||||||
|
val vSel = vKey == inActiveKey
|
||||||
|
val vDragged = vKey == vDragKey
|
||||||
|
|
||||||
|
var vMod = Modifier
|
||||||
|
.onGloballyPositioned { vLeft[vKey] = it.x }
|
||||||
|
.onSizeChanged { vWidth[vKey] = it.width }
|
||||||
|
// NOTE: `alpha` and `translationX` MUST be on the same graphicsLayer.
|
||||||
|
// `Modifier.alpha` expands to `graphicsLayer(alpha, clip = true)` — clip is
|
||||||
|
// applied in the ALPHA layer's local coord space (before any child layer's
|
||||||
|
// translation), so `.alpha().graphicsLayer(translationX = X)` clips the drag
|
||||||
|
// ghost inside its original bounds and the ghost disappears as it slides. One
|
||||||
|
// layer with both properties = clip stays false, alpha modulates via layerPaint.
|
||||||
|
if (vDragged) vMod =
|
||||||
|
vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = vDragDx, translationY = 0f)
|
||||||
|
vMod = vMod
|
||||||
|
.clip(RoundedCornerShape(7.dp))
|
||||||
|
.background(if (vSel) c.accent.copy(alpha = 0.20f) else c.field, RoundedCornerShape(7.dp))
|
||||||
|
.border(1.dp, if (vSel || vDragged) c.accent else c.border, RoundedCornerShape(7.dp))
|
||||||
|
.pointerInput(vKey) {
|
||||||
|
// Accumulate the per-frame delta rather than reading change.position.
|
||||||
|
// The tab wears a graphicsLayer(translationX = vDragDx) while dragging,
|
||||||
|
// which transforms the modifier's local pointer frame — so position.x
|
||||||
|
// would compensate for the translation each frame and the tab would
|
||||||
|
// trail the mouse by roughly half its offset. dragAmount is the
|
||||||
|
// root-frame delta and doesn't feed back through the layer transform.
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { offset ->
|
||||||
|
vDragKey = vKey
|
||||||
|
vPressRelX = offset.x.toInt()
|
||||||
|
vDragDx = 0f
|
||||||
|
vDragTarget = inTabs.indexOfFirst { it.tabKey == vKey }
|
||||||
|
},
|
||||||
|
onDrag = { _, dragAmount ->
|
||||||
|
val vd = vDragKey ?: return@detectDragGestures
|
||||||
|
vDragDx += dragAmount.x
|
||||||
|
val vRelX = vPressRelX + vDragDx.toInt()
|
||||||
|
val vCursorX = (vLeft[vd] ?: 0) + vRelX
|
||||||
|
var vCount = 0
|
||||||
|
inTabs.forEach { vT ->
|
||||||
|
if (vT.tabKey != vd) {
|
||||||
|
val vCenter = (vLeft[vT.tabKey] ?: 0) + (vWidth[vT.tabKey] ?: 0) / 2
|
||||||
|
if (vCursorX > vCenter) vCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vDragTarget = vCount
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val vd = vDragKey
|
||||||
|
if (vd != null) {
|
||||||
|
val vFrom = inTabs.indexOfFirst { it.tabKey == vd }
|
||||||
|
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(
|
||||||
|
vFrom,
|
||||||
|
vDragTarget
|
||||||
|
)
|
||||||
|
}
|
||||||
|
vDragKey = null; vDragDx = 0f; vDragTarget = -1
|
||||||
|
},
|
||||||
|
onDragCancel = { vDragKey = null; vDragDx = 0f; vDragTarget = -1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Middle-click closes the tab; right-click opens the tab menu. Compose
|
||||||
|
// has no first-class match for either — inline pointerInput checks the
|
||||||
|
// pressed PointerButton at each Press.
|
||||||
|
.pointerInput(vTab) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val vEv = awaitPointerEvent()
|
||||||
|
if (vEv.type != PointerEventType.Press) continue
|
||||||
|
when (vEv.button) {
|
||||||
|
PointerButton.Tertiary -> {
|
||||||
|
vEv.changes.firstOrNull()?.consume()
|
||||||
|
inOnClose(vTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
PointerButton.Secondary -> {
|
||||||
|
vEv.changes.firstOrNull()?.consume()
|
||||||
|
vMenuTab = vTab; vMenu = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clickable { inOnSelect(vTab) }
|
||||||
|
.padding(start = 9.dp, top = 6.dp, bottom = 6.dp, end = 3.dp)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = vMod,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
val vRs = vTab.req
|
||||||
|
if (vRs == null) {
|
||||||
|
// Session / pack settings tab.
|
||||||
|
MaterialSymbolsOutlined(
|
||||||
|
MaterialSymbols.Tune,
|
||||||
|
tint = if (vSel) c.accent else c.dim,
|
||||||
|
size = 13.dp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (vTab.isSession) "Session" else (vTab.pack?.name ?: ""),
|
||||||
|
color = if (vSel) c.text else c.dim,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(7.dp)
|
||||||
|
.background(methodColor(vRs.req.method), RoundedCornerShape(4.dp))
|
||||||
|
)
|
||||||
|
Text(vRs.req.name, color = if (vSel) c.text else c.dim, fontSize = 13.sp)
|
||||||
|
if (vRs.loading) CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
color = c.accent,
|
||||||
|
strokeWidth = 1.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconBtn(MaterialSymbols.Close, "Close", inSize = 13.dp) { inOnClose(vTab) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vBarAtEnd) DropBar()
|
||||||
|
|
||||||
|
// Shared right-click menu, opened at the cursor over the clicked tab.
|
||||||
|
val vMTab = vMenuTab
|
||||||
|
if (vMenu && vMTab != null) {
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = true,
|
||||||
|
onDismissRequest = { vMenu = false },
|
||||||
|
modifier = Modifier.widthIn(min = 188.dp),
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Close, "Close tab") },
|
||||||
|
onClick = { vMenu = false; inOnClose(vMTab) })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Clear, "Close other tabs") },
|
||||||
|
onClick = { vMenu = false; inOnCloseOthers(vMTab) })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Clear, "Close all tabs") },
|
||||||
|
onClick = { vMenu = false; inOnCloseAll() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow → chevron opens a scrollable list of every open tab.
|
||||||
|
if (vScroll.maxValue > 0) {
|
||||||
|
Box(modifier = Modifier.padding(end = 4.dp)) {
|
||||||
|
IconBtn(MaterialSymbols.ExpandMore, "All tabs") { vListOpen = true }
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = vListOpen,
|
||||||
|
onDismissRequest = { vListOpen = false },
|
||||||
|
modifier = Modifier.widthIn(min = 240.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState())) {
|
||||||
|
inTabs.forEach { vTab ->
|
||||||
|
DropdownMenuItem(text = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
val vSel = vTab.tabKey == inActiveKey
|
||||||
|
val vRs = vTab.req
|
||||||
|
if (vRs == null) {
|
||||||
|
MaterialSymbolsOutlined(
|
||||||
|
MaterialSymbols.Tune,
|
||||||
|
tint = if (vSel) c.accent else c.dim,
|
||||||
|
size = 13.dp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (vTab.isSession) "Session" else "${vTab.pack?.name} · var",
|
||||||
|
color = if (vSel) c.accent else c.text,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(7.dp)
|
||||||
|
.background(methodColor(vRs.req.method), RoundedCornerShape(4.dp))
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
vRs.req.name,
|
||||||
|
color = if (vSel) c.accent else c.text,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
if (vRs.loading) CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
color = c.accent,
|
||||||
|
strokeWidth = 1.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, onClick = { vListOpen = false; inOnSelect(vTab) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thin accent bar marking where a dragged tab will be inserted. Rendered in-flow
|
||||||
|
between tabs so the Row centres it vertically and reserves a little space. */
|
||||||
|
@Composable
|
||||||
|
internal fun DropBar() {
|
||||||
|
val c = LocalAppColors.current
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.width(3.dp).height(24.dp).clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(c.accent, RoundedCornerShape(2.dp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@file:OptIn(
|
@file:OptIn(
|
||||||
androidx.compose.material3.ExperimentalMaterial3Api::class,
|
|
||||||
androidx.compose.ui.ExperimentalComposeUiApi::class,
|
androidx.compose.ui.ExperimentalComposeUiApi::class,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,18 +30,18 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import com.compose.sdl.fileManagerName
|
import apidemo.compat.fileManagerName
|
||||||
|
import apidemo.compat.revealInFileManager
|
||||||
|
import apidemo.compat.y
|
||||||
import com.compose.sdl.icons.MaterialSymbols
|
import com.compose.sdl.icons.MaterialSymbols
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||||
import com.compose.sdl.layout.y
|
|
||||||
import com.compose.sdl.revealInFileManager
|
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Pack colour (palette dot + picker)
|
// 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. */
|
The pack's visual identity in the sidebar's foldable pack header. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
|
internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -50,8 +49,8 @@ internal fun ColorDot(inColor: Int, inSize: Dp = 11.dp) {
|
|||||||
Box(Modifier.size(inSize).background(vCol, RoundedCornerShape(3.dp)))
|
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. */
|
The current colour gets a ring. Sized to fit inside the (fixed-width) ⋮ menu. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
|
internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -80,15 +79,19 @@ internal fun PackColorPicker(inSelected: Int, inOnPick: (Int) -> Unit) {
|
|||||||
// MARK: Add-pack menu (header '+')
|
// 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. */
|
or imported from a .json file. Importing a pack always lands it in the session. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnImport: () -> Unit) {
|
internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnImport: () -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
var vOpen by remember { mutableStateOf(false) }
|
var vOpen by remember { mutableStateOf(false) }
|
||||||
Box {
|
Box {
|
||||||
IconBtn(MaterialSymbols.Add, "Add", inSize = 18.dp) { vOpen = true }
|
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 = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
MaterialSymbols.Send,
|
MaterialSymbols.Send,
|
||||||
@@ -96,7 +99,9 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
|
|||||||
)
|
)
|
||||||
}, onClick = { vOpen = false; inOnNewRequest() })
|
}, onClick = { vOpen = false; inOnNewRequest() })
|
||||||
HorizontalDivider(color = c.border)
|
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 = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
MaterialSymbols.Download,
|
MaterialSymbols.Download,
|
||||||
@@ -111,13 +116,13 @@ internal fun AddPackMenu(inOnNewRequest: () -> Unit, inOnNew: () -> Unit, inOnIm
|
|||||||
// MARK: Session menu (save / open / new / recent)
|
// 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 =
|
internal fun fileLeaf(inPath: String): String =
|
||||||
inPath.trimEnd('/', '\\').substringAfterLast('/').substringAfterLast('\\')
|
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
|
and file stay visible), joined by an ellipsis. The menu has no text-overflow
|
||||||
primitive, so we trim the string ourselves. */
|
primitive, so we trim the string ourselves. */
|
||||||
internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
|
internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
|
||||||
if (inText.length <= inMax) return inText
|
if (inText.length <= inMax) return inText
|
||||||
val vKeep = inMax - 1
|
val vKeep = inMax - 1
|
||||||
@@ -125,10 +130,10 @@ internal fun ellipsizeMiddle(inText: String, inMax: Int = 46): String {
|
|||||||
return inText.take(vHead) + "…" + inText.takeLast(vKeep - vHead)
|
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
|
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.
|
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. */
|
Menu: Save / Save as… / Rename / Reveal / Open… / New + a recent list. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SessionMenu(
|
internal fun SessionMenu(
|
||||||
inPath: String?,
|
inPath: String?,
|
||||||
@@ -155,7 +160,10 @@ internal fun SessionMenu(
|
|||||||
// Highlight on hover and stay highlighted while the menu is open.
|
// Highlight on hover and stay highlighted while the menu is open.
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(6.dp))
|
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)
|
.hoverable(vHoverSrc)
|
||||||
.clickable { vOpen = true }.padding(horizontal = 6.dp, vertical = 4.dp),
|
.clickable { vOpen = true }.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -169,7 +177,11 @@ internal fun SessionMenu(
|
|||||||
// No file yet → a dot; once it has a file it auto-saves, so no dot.
|
// 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)))
|
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 = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
MaterialSymbols.Tune,
|
MaterialSymbols.Tune,
|
||||||
@@ -177,7 +189,9 @@ internal fun SessionMenu(
|
|||||||
)
|
)
|
||||||
}, onClick = { vOpen = false; inOnSettings() })
|
}, onClick = { vOpen = false; inOnSettings() })
|
||||||
HorizontalDivider(color = c.border)
|
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 = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
MaterialSymbols.Download,
|
MaterialSymbols.Download,
|
||||||
@@ -193,7 +207,8 @@ internal fun SessionMenu(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { vOpen = false; inOnRename() },
|
onClick = { vOpen = false; inOnRename() },
|
||||||
enabled = vSaved)
|
enabled = vSaved
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
@@ -203,7 +218,8 @@ internal fun SessionMenu(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { vOpen = false; inOnReveal() },
|
onClick = { vOpen = false; inOnReveal() },
|
||||||
enabled = vSaved)
|
enabled = vSaved
|
||||||
|
)
|
||||||
HorizontalDivider(color = c.border)
|
HorizontalDivider(color = c.border)
|
||||||
DropdownMenuItem(text = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
@@ -211,7 +227,9 @@ internal fun SessionMenu(
|
|||||||
"Default session"
|
"Default session"
|
||||||
)
|
)
|
||||||
}, onClick = { vOpen = false; inOnDefault() })
|
}, 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 = {
|
DropdownMenuItem(text = {
|
||||||
MenuRow(
|
MenuRow(
|
||||||
MaterialSymbols.Folder,
|
MaterialSymbols.Folder,
|
||||||
@@ -248,7 +266,9 @@ internal fun SessionMenu(
|
|||||||
// Reveal this session's folder — its own hover + tooltip so it
|
// Reveal this session's folder — its own hover + tooltip so it
|
||||||
// reads as a separate action from "open the session".
|
// reads as a separate action from "open the session".
|
||||||
TooltipBox(
|
TooltipBox(
|
||||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
|
TooltipAnchorPosition.Above
|
||||||
|
),
|
||||||
tooltip = { PlainTooltip { Text("Reveal in ${fileManagerName()}") } },
|
tooltip = { PlainTooltip { Text("Reveal in ${fileManagerName()}") } },
|
||||||
state = rememberTooltipState(),
|
state = rememberTooltipState(),
|
||||||
) {
|
) {
|
||||||
@@ -283,8 +303,8 @@ internal fun SessionMenu(
|
|||||||
// MARK: Pack tree (recursive sidebar rendering)
|
// 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. */
|
recursively for sub-packs while PackSection itself stays callback-driven. */
|
||||||
internal class PackOps(
|
internal class PackOps(
|
||||||
val onEnv: (PackState) -> Unit,
|
val onEnv: (PackState) -> Unit,
|
||||||
val onToggle: (PackState) -> Unit,
|
val onToggle: (PackState) -> Unit,
|
||||||
@@ -310,11 +330,18 @@ internal class PackOps(
|
|||||||
val packDropEnd: () -> Unit,
|
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
|
inSiblings is the list this pack belongs to (top-level vPacks, or a parent's
|
||||||
subPacks) — used to position the pack-reorder drop bars by index. */
|
subPacks) — used to position the pack-reorder drop bars by index. */
|
||||||
@Composable
|
@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 vDrag = inOps.drag
|
||||||
val vMyIndex = inSiblings.indexOf(inPack)
|
val vMyIndex = inSiblings.indexOf(inPack)
|
||||||
val vMoving = vDrag.draggingPack && vDrag.engaged && vDrag.dropInto == null
|
val vMoving = vDrag.draggingPack && vDrag.engaged && vDrag.dropInto == null
|
||||||
@@ -322,7 +349,7 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
|
|||||||
val vPad = Modifier.padding(start = (inDepth * 14).dp)
|
val vPad = Modifier.padding(start = (inDepth * 14).dp)
|
||||||
val vBarBefore = vMoving && vDrag.dropParent === inPack.parent && vDrag.dropSibIndex == vMyIndex
|
val vBarBefore = vMoving && vDrag.dropParent === inPack.parent && vDrag.dropSibIndex == vMyIndex
|
||||||
val vBarAfter = vMoving && vDrag.dropParent === inPack.parent &&
|
val vBarAfter = vMoving && vDrag.dropParent === inPack.parent &&
|
||||||
vMyIndex == inSiblings.size - 1 && vDrag.dropSibIndex == inSiblings.size
|
vMyIndex == inSiblings.size - 1 && vDrag.dropSibIndex == inSiblings.size
|
||||||
key(inPack) {
|
key(inPack) {
|
||||||
if (vBarBefore) Box(modifier = vPad) { RowDropBar() }
|
if (vBarBefore) Box(modifier = vPad) { RowDropBar() }
|
||||||
Box(modifier = vPad) {
|
Box(modifier = vPad) {
|
||||||
@@ -354,7 +381,16 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (inPack.expanded) inPack.subPacks.forEach { PackTree(it, inPack.subPacks, inDepth + 1, inOps, inActive, inEnvActive) }
|
if (inPack.expanded) inPack.subPacks.forEach {
|
||||||
|
PackTree(
|
||||||
|
it,
|
||||||
|
inPack.subPacks,
|
||||||
|
inDepth + 1,
|
||||||
|
inOps,
|
||||||
|
inActive,
|
||||||
|
inEnvActive
|
||||||
|
)
|
||||||
|
}
|
||||||
if (vBarAfter) Box(modifier = vPad) { RowDropBar() }
|
if (vBarAfter) Box(modifier = vPad) { RowDropBar() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,11 +398,11 @@ internal fun PackTree(inPack: PackState, inSiblings: List<PackState>, inDepth: I
|
|||||||
// MARK: Pack section (foldable pack in the sidebar)
|
// 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
|
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
|
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
|
main panel follow it); the chevron only folds. The request list drags to
|
||||||
reorder, scoped to this pack. Right-click the header for the pack menu. */
|
reorder, scoped to this pack. Right-click the header for the pack menu. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PackSection(
|
internal fun PackSection(
|
||||||
inPack: PackState,
|
inPack: PackState,
|
||||||
@@ -407,13 +443,14 @@ internal fun PackSection(
|
|||||||
// to reorder / reparent; highlighted when it's the "drop inside" target.
|
// to reorder / reparent; highlighted when it's the "drop inside" target.
|
||||||
val vHeaderDragged = inDrag.dragPack === inPack && vMoving
|
val vHeaderDragged = inDrag.dragPack === inPack && vMoving
|
||||||
val vIntoHi = vMoving && ((inDrag.draggingPack && inDrag.dropInto === inPack) ||
|
val vIntoHi = vMoving && ((inDrag.draggingPack && inDrag.dropInto === inPack) ||
|
||||||
(inDrag.draggingReq && inDrag.dropPack === inPack && !inPack.expanded))
|
(inDrag.draggingReq && inDrag.dropPack === inPack && !inPack.expanded))
|
||||||
var vHeadMod = Modifier.fillMaxWidth()
|
var vHeadMod = Modifier.fillMaxWidth()
|
||||||
.onGloballyPositioned { inDrag.headTop[inPack] = it.y }
|
.onGloballyPositioned { inDrag.headTop[inPack] = it.y }
|
||||||
.onSizeChanged { inDrag.headH[inPack] = it.height }
|
.onSizeChanged { inDrag.headH[inPack] = it.height }
|
||||||
// See RequestTabStrip note: alpha + translation on the SAME graphicsLayer so
|
// See RequestTabStrip note: alpha + translation on the SAME graphicsLayer so
|
||||||
// clip=false and the drag ghost isn't clipped to its original bounds.
|
// 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))
|
vHeadMod = vHeadMod.clip(RoundedCornerShape(6.dp))
|
||||||
.background(
|
.background(
|
||||||
when {
|
when {
|
||||||
@@ -465,9 +502,15 @@ internal fun PackSection(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
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(
|
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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||||
) {
|
) {
|
||||||
@@ -479,8 +522,16 @@ internal fun PackSection(
|
|||||||
// + (new request) and ⋮ (pack menu) — both reveal on hover only, so the
|
// + (new request) and ⋮ (pack menu) — both reveal on hover only, so the
|
||||||
// header width never shifts (alpha, not conditional layout). A linked
|
// header width never shifts (alpha, not conditional layout). A linked
|
||||||
// copy mirrors its source's requests, so it has no "new request".
|
// 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) {
|
Row(
|
||||||
if (!inPack.isLinked) IconBtn(MaterialSymbols.Add, "New request", inSize = 16.dp, inPadding = 4.dp) { inOnNewRequest() }
|
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 {
|
Box {
|
||||||
IconBtn(MaterialSymbols.MoreHoriz, "Pack menu", inSize = 16.dp, inPadding = 4.dp) { vMenu = true }
|
IconBtn(MaterialSymbols.MoreHoriz, "Pack menu", inSize = 16.dp, inPadding = 4.dp) { vMenu = true }
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
@@ -488,16 +539,34 @@ internal fun PackSection(
|
|||||||
onDismissRequest = { vMenu = false },
|
onDismissRequest = { vMenu = false },
|
||||||
modifier = Modifier.widthIn(min = 220.dp),
|
modifier = Modifier.widthIn(min = 220.dp),
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Edit, "Rename pack") }, onClick = { vMenu = false; inOnRenamePack() })
|
DropdownMenuItem(
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate pack") }, onClick = { vMenu = false; inOnDuplicatePack() })
|
text = { MenuRow(MaterialSymbols.Edit, "Rename pack") },
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Add, "New sub-pack") }, onClick = { vMenu = false; inOnNewSubPack() })
|
onClick = { vMenu = false; inOnRenamePack() })
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Share, "Linked copy") }, onClick = { vMenu = false; inOnLinkedCopy() })
|
DropdownMenuItem(
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Save, "Export pack") }, onClick = { vMenu = false; inOnSavePack() })
|
text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate pack") },
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Folder, "Export pack as…") }, onClick = { vMenu = false; inOnSaveAsPack() })
|
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)
|
HorizontalDivider(color = c.border)
|
||||||
PackColorPicker(inPack.color) { inOnSetColor(it) }
|
PackColorPicker(inPack.color) { inOnSetColor(it) }
|
||||||
HorizontalDivider(color = c.border)
|
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() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,7 +594,12 @@ internal fun PackSection(
|
|||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
if (inHeaderless && vReqs.isEmpty())
|
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 ->
|
vReqs.forEach { vRs ->
|
||||||
if (vRs === vBarBefore) RowDropBar()
|
if (vRs === vBarBefore) RowDropBar()
|
||||||
key(vRs) {
|
key(vRs) {
|
||||||
@@ -539,28 +613,30 @@ internal fun PackSection(
|
|||||||
.onSizeChanged { inDrag.rowH[vRs] = it.height }
|
.onSizeChanged { inDrag.rowH[vRs] = it.height }
|
||||||
// translate is draw-only (doesn't shift absoluteY), so the
|
// translate is draw-only (doesn't shift absoluteY), so the
|
||||||
// cursor offset below stays correct while dragging.
|
// 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) {
|
if (!inPack.isLinked) vMod = vMod.pointerInput(vRs) {
|
||||||
// Same graphicsLayer feedback concern as the pack drag —
|
// Same graphicsLayer feedback concern as the pack drag —
|
||||||
// accumulate dragAmount instead of reading change.position.
|
// accumulate dragAmount instead of reading change.position.
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
onDragStart = { offset ->
|
onDragStart = { offset ->
|
||||||
inDrag.clear()
|
inDrag.clear()
|
||||||
inDrag.dragReq = vRs; inDrag.dragReqOwner = inPack
|
inDrag.dragReq = vRs; inDrag.dragReqOwner = inPack
|
||||||
inDrag.pressRel = offset.y.toInt(); inDrag.dy = 0f
|
inDrag.pressRel = offset.y.toInt(); inDrag.dy = 0f
|
||||||
inDrag.dropPack = inPack; inDrag.dropIndex = vReqs.indexOf(vRs)
|
inDrag.dropPack = inPack; inDrag.dropIndex = vReqs.indexOf(vRs)
|
||||||
},
|
},
|
||||||
onDrag = { _, dragAmount ->
|
onDrag = { _, dragAmount ->
|
||||||
val vd = inDrag.dragReq ?: return@detectDragGestures
|
val vd = inDrag.dragReq ?: return@detectDragGestures
|
||||||
inDrag.dy += dragAmount.y
|
inDrag.dy += dragAmount.y
|
||||||
val vRelY = inDrag.pressRel + inDrag.dy.toInt()
|
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 =
|
||||||
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
|
true
|
||||||
},
|
if (inDrag.engaged) inResolveReqDrop((inDrag.rowTop[vd] ?: 0) + vRelY)
|
||||||
onDragEnd = { inOnReqDropEnd() },
|
},
|
||||||
onDragCancel = { inOnReqDropEnd() },
|
onDragEnd = { inOnReqDropEnd() },
|
||||||
)
|
onDragCancel = { inOnReqDropEnd() },
|
||||||
}
|
)
|
||||||
|
}
|
||||||
Box(modifier = vMod) {
|
Box(modifier = vMod) {
|
||||||
RequestRow(
|
RequestRow(
|
||||||
inRs = vRs,
|
inRs = vRs,
|
||||||
@@ -640,10 +716,22 @@ internal fun RequestRow(
|
|||||||
onDismissRequest = { vMenu = false },
|
onDismissRequest = { vMenu = false },
|
||||||
modifier = Modifier.widthIn(min = 168.dp),
|
modifier = Modifier.widthIn(min = 168.dp),
|
||||||
) {
|
) {
|
||||||
if (!inReadOnly) DropdownMenuItem(text = { MenuRow(MaterialSymbols.Edit, "Rename") }, onClick = { vMenu = false; inOnRename() })
|
if (!inReadOnly) DropdownMenuItem(
|
||||||
if (!inReadOnly) DropdownMenuItem(text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate") }, onClick = { vMenu = false; inOnDuplicate() })
|
text = { MenuRow(MaterialSymbols.Edit, "Rename") },
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Terminal, "Copy as cURL") }, onClick = { vMenu = false; inOnCopyCurl() })
|
onClick = { vMenu = false; inOnRename() })
|
||||||
if (!inReadOnly) DropdownMenuItem(text = { MenuRow(MaterialSymbols.Delete, "Delete", methodColor(ReqMethod.DELETE)) }, onClick = { vMenu = false; inOnDelete() })
|
if (!inReadOnly) DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.FileCopy, "Duplicate") },
|
||||||
|
onClick = { vMenu = false; inOnDuplicate() })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { MenuRow(MaterialSymbols.Terminal, "Copy as cURL") },
|
||||||
|
onClick = { vMenu = false; inOnCopyCurl() })
|
||||||
|
if (!inReadOnly) DropdownMenuItem(text = {
|
||||||
|
MenuRow(
|
||||||
|
MaterialSymbols.Delete,
|
||||||
|
"Delete",
|
||||||
|
methodColor(ReqMethod.DELETE)
|
||||||
|
)
|
||||||
|
}, onClick = { vMenu = false; inOnDelete() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: BodyFormat — viewer/highlighter selection
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Format of a body payload as shown in the viewer, and (for a TEXT request body)
|
||||||
|
the type the user picks on the right of the Body tab — it drives both syntax
|
||||||
|
highlighting and the sent Content-Type. RAW disables highlighting and falls back
|
||||||
|
to the selectable BasicTextField; other values pick a tokeniser. */
|
||||||
|
@Serializable
|
||||||
|
enum class BodyFormat { RAW, JSON, XML, YAML, HTML }
|
||||||
|
|
||||||
|
/** Short label for the format / "type" picker (RAW = plain text, no highlighting). */
|
||||||
|
val BodyFormat.label: String
|
||||||
|
get() = when (this) {
|
||||||
|
BodyFormat.RAW -> "RAW"
|
||||||
|
BodyFormat.JSON -> "JSON"
|
||||||
|
BodyFormat.XML -> "XML"
|
||||||
|
BodyFormat.YAML -> "YAML"
|
||||||
|
BodyFormat.HTML -> "HTML"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Content-Type a TEXT body of this format is sent with. */
|
||||||
|
val BodyFormat.contentType: String
|
||||||
|
get() = when (this) {
|
||||||
|
BodyFormat.RAW -> "text/plain"
|
||||||
|
BodyFormat.JSON -> "application/json"
|
||||||
|
BodyFormat.XML -> "application/xml"
|
||||||
|
BodyFormat.YAML -> "application/yaml"
|
||||||
|
BodyFormat.HTML -> "text/html"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick a format from a Content-Type header. JSON / XML / YAML / HTML
|
||||||
|
match their canonical types and common variants; anything else
|
||||||
|
(image types, text/plain, …) falls back to RAW. */
|
||||||
|
fun autoFormatFor(inContentType: String?): BodyFormat {
|
||||||
|
val vCt = inContentType?.substringBefore(';')?.trim()?.lowercase() ?: return BodyFormat.RAW
|
||||||
|
return when {
|
||||||
|
vCt.endsWith("+json") || vCt == "application/json" -> BodyFormat.JSON
|
||||||
|
vCt.endsWith("+xml") || vCt == "application/xml" || vCt == "text/xml" -> BodyFormat.XML
|
||||||
|
vCt == "application/yaml" || vCt == "text/yaml" || vCt == "application/x-yaml" -> BodyFormat.YAML
|
||||||
|
vCt == "text/html" -> BodyFormat.HTML
|
||||||
|
else -> BodyFormat.RAW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Highlight palette
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Per-token colours. Two preset themes: a dark-on-dark and a dark-on-
|
||||||
|
light variant matching VS Code's defaults. Caller picks based on
|
||||||
|
surrounding theme; auto-pick available via SyntaxPalette.forDark(). */
|
||||||
|
data class SyntaxPalette(
|
||||||
|
val key: Color,
|
||||||
|
val string: Color,
|
||||||
|
val number: Color,
|
||||||
|
val keyword: Color,
|
||||||
|
val tag: Color,
|
||||||
|
val comment: Color,
|
||||||
|
val punct: Color,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** Light-on-dark — VS Code Dark+. */
|
||||||
|
val Dark = SyntaxPalette(
|
||||||
|
key = Color(0xFF9CDCFE),
|
||||||
|
string = Color(0xFFCE9178),
|
||||||
|
number = Color(0xFFB5CEA8),
|
||||||
|
keyword = Color(0xFFC586C0),
|
||||||
|
tag = Color(0xFF569CD6),
|
||||||
|
comment = Color(0xFF6A9955),
|
||||||
|
punct = Color(0xFFCCCCCC),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Dark-on-light — VS Code Light+. Higher contrast on white. */
|
||||||
|
val Light = SyntaxPalette(
|
||||||
|
key = Color(0xFF0451A5),
|
||||||
|
string = Color(0xFFA31515),
|
||||||
|
number = Color(0xFF098658),
|
||||||
|
keyword = Color(0xFF0000FF),
|
||||||
|
tag = Color(0xFF800000),
|
||||||
|
comment = Color(0xFF008000),
|
||||||
|
punct = Color(0xFF333333),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Pick a palette by the host theme's background brightness. */
|
||||||
|
fun forDark(inIsDark: Boolean): SyntaxPalette = if (inIsDark) Dark else Light
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Entry point
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Render the given text with per-token colour spans for the requested
|
||||||
|
format. RAW returns an unstyled AnnotatedString. */
|
||||||
|
fun highlight(
|
||||||
|
inText: String,
|
||||||
|
inFormat: BodyFormat,
|
||||||
|
inPalette: SyntaxPalette = SyntaxPalette.Dark,
|
||||||
|
): AnnotatedString = when (inFormat) {
|
||||||
|
BodyFormat.RAW -> AnnotatedString(inText)
|
||||||
|
BodyFormat.JSON -> highlightJson(inText, inPalette)
|
||||||
|
BodyFormat.XML, BodyFormat.HTML -> highlightXml(inText, inPalette)
|
||||||
|
BodyFormat.YAML -> highlightYaml(inText, inPalette)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: JSON tokeniser — single-pass, error-tolerant
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Tokeniser for JSON. Walks the string once and emits style spans for
|
||||||
|
strings, numbers, booleans, null, punctuation. Distinguishes "keys"
|
||||||
|
(strings followed by ':') from string values by peeking after each
|
||||||
|
string. Error-tolerant — malformed JSON still highlights as best
|
||||||
|
effort rather than throwing. */
|
||||||
|
private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
||||||
|
val vN = inText.length
|
||||||
|
var vI = 0
|
||||||
|
while (vI < vN) {
|
||||||
|
val vC = inText[vI]
|
||||||
|
when {
|
||||||
|
vC == '"' -> {
|
||||||
|
// Find closing quote, honouring backslash-escaped chars so an
|
||||||
|
// escaped \" doesn't terminate the string early.
|
||||||
|
val vStart = vI
|
||||||
|
var vJ = vI + 1
|
||||||
|
while (vJ < vN) {
|
||||||
|
if (inText[vJ] == '\\' && vJ + 1 < vN) {
|
||||||
|
vJ += 2; continue
|
||||||
|
}
|
||||||
|
if (inText[vJ] == '"') {
|
||||||
|
vJ++; break
|
||||||
|
}
|
||||||
|
vJ++
|
||||||
|
}
|
||||||
|
// Peek for ':' (skipping whitespace) → it's a key, not a value.
|
||||||
|
var vK = vJ
|
||||||
|
while (vK < vN && inText[vK].isWhitespace()) vK++
|
||||||
|
val vIsKey = vK < vN && inText[vK] == ':'
|
||||||
|
val vColor = if (vIsKey) inP.key else inP.string
|
||||||
|
pushStyle(SpanStyle(color = vColor))
|
||||||
|
append(inText.substring(vStart, vJ))
|
||||||
|
pop()
|
||||||
|
vI = vJ
|
||||||
|
}
|
||||||
|
|
||||||
|
vC.isDigit() || (vC == '-' && vI + 1 < vN && inText[vI + 1].isDigit()) -> {
|
||||||
|
val vStart = vI
|
||||||
|
if (vC == '-') vI++
|
||||||
|
while (vI < vN && (inText[vI].isDigit() || inText[vI] in ".eE+-")) vI++
|
||||||
|
pushStyle(SpanStyle(color = inP.number))
|
||||||
|
append(inText.substring(vStart, vI))
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
vC.isLetter() -> {
|
||||||
|
val vStart = vI
|
||||||
|
while (vI < vN && inText[vI].isLetter()) vI++
|
||||||
|
val vWord = inText.substring(vStart, vI)
|
||||||
|
if (vWord == "true" || vWord == "false" || vWord == "null") {
|
||||||
|
pushStyle(SpanStyle(color = inP.keyword))
|
||||||
|
append(vWord)
|
||||||
|
pop()
|
||||||
|
} else {
|
||||||
|
append(vWord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vC in "{}[],:" -> {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct))
|
||||||
|
append(vC)
|
||||||
|
pop()
|
||||||
|
vI++
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
append(vC); vI++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: XML / HTML tokeniser
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Tokeniser for XML / HTML. Distinguishes tag names, attribute names,
|
||||||
|
attribute values (string), and comments. Inside tags, attr=value pairs
|
||||||
|
are recognised; outside tags text is left in the default colour. */
|
||||||
|
private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
||||||
|
val vN = inText.length
|
||||||
|
var vI = 0
|
||||||
|
while (vI < vN) {
|
||||||
|
when (val vC = inText[vI]) {
|
||||||
|
'<' if inText.startsWith("<!--", vI) -> {
|
||||||
|
val vEnd = inText.indexOf("-->", vI + 4).let { if (it < 0) vN else it + 3 }
|
||||||
|
pushStyle(SpanStyle(color = inP.comment))
|
||||||
|
append(inText.substring(vI, vEnd))
|
||||||
|
pop()
|
||||||
|
vI = vEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
'<' -> {
|
||||||
|
// Tag open: <[/]name attr=val attr2="val2">
|
||||||
|
val vGt = inText.indexOf('>', vI).let { if (it < 0) vN else it + 1 }
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('<'); pop()
|
||||||
|
var vJ = vI + 1
|
||||||
|
if (vJ < vN && inText[vJ] == '/') {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++
|
||||||
|
}
|
||||||
|
// Tag name.
|
||||||
|
val vTagStart = vJ
|
||||||
|
while (vJ < vN && !inText[vJ].isWhitespace() && inText[vJ] != '>' && inText[vJ] != '/') vJ++
|
||||||
|
if (vJ > vTagStart) {
|
||||||
|
pushStyle(SpanStyle(color = inP.tag))
|
||||||
|
append(inText.substring(vTagStart, vJ))
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
// Attributes — name="value" pairs.
|
||||||
|
while (vJ < vGt - 1) {
|
||||||
|
val vC2 = inText[vJ]
|
||||||
|
when {
|
||||||
|
vC2.isWhitespace() -> {
|
||||||
|
append(vC2); vJ++
|
||||||
|
}
|
||||||
|
|
||||||
|
vC2 == '/' -> {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++
|
||||||
|
}
|
||||||
|
|
||||||
|
vC2 == '=' -> {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('='); pop(); vJ++
|
||||||
|
}
|
||||||
|
|
||||||
|
vC2 == '"' || vC2 == '\'' -> {
|
||||||
|
val vEnd = inText.indexOf(vC2, vJ + 1).let { if (it < 0) vGt - 1 else it + 1 }
|
||||||
|
pushStyle(SpanStyle(color = inP.string))
|
||||||
|
append(inText.substring(vJ, vEnd))
|
||||||
|
pop()
|
||||||
|
vJ = vEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val vAttrStart = vJ
|
||||||
|
while (vJ < vGt - 1 && !inText[vJ].isWhitespace() && inText[vJ] != '=' && inText[vJ] != '>' && inText[vJ] != '/') vJ++
|
||||||
|
pushStyle(SpanStyle(color = inP.key))
|
||||||
|
append(inText.substring(vAttrStart, vJ))
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vGt > vI && inText.getOrNull(vGt - 1) == '>') {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('>'); pop()
|
||||||
|
}
|
||||||
|
vI = vGt
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
append(vC); vI++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: YAML tokeniser — line-based
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Tokeniser for YAML. Walks line by line because YAML's grammar is
|
||||||
|
indent-sensitive and most of the interesting colour decisions
|
||||||
|
(key vs value, list item vs scalar) depend on position within a
|
||||||
|
line. Comments (#), keys (text before ':'), quoted strings, numbers
|
||||||
|
and booleans are coloured; everything else stays default. */
|
||||||
|
private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
||||||
|
val vLines = inText.split('\n')
|
||||||
|
for ((vIdx, vLineRaw) in vLines.withIndex()) {
|
||||||
|
val vLine = vLineRaw
|
||||||
|
// Comment: rest of the line after a # (not inside a quoted string).
|
||||||
|
val vCommentAt = findUnquotedHash(vLine)
|
||||||
|
val vCodePart = if (vCommentAt >= 0) vLine.substring(0, vCommentAt) else vLine
|
||||||
|
val vCommentPart = if (vCommentAt >= 0) vLine.substring(vCommentAt) else ""
|
||||||
|
|
||||||
|
// Find a key: indent + (optional "- ") + key + ':' + (space|eol).
|
||||||
|
val vColon = indexOfKeyColon(vCodePart)
|
||||||
|
if (vColon > 0) {
|
||||||
|
val vPrefix = vCodePart.substring(0, vColon)
|
||||||
|
val vRest = vCodePart.substring(vColon + 1)
|
||||||
|
// Lead whitespace + optional list marker, then key.
|
||||||
|
var vK = 0
|
||||||
|
while (vK < vPrefix.length && vPrefix[vK].isWhitespace()) {
|
||||||
|
append(vPrefix[vK]); vK++
|
||||||
|
}
|
||||||
|
if (vK + 1 < vPrefix.length && vPrefix[vK] == '-' && vPrefix[vK + 1].isWhitespace()) {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
|
||||||
|
append(vPrefix[vK + 1])
|
||||||
|
vK += 2
|
||||||
|
}
|
||||||
|
pushStyle(SpanStyle(color = inP.key))
|
||||||
|
append(vPrefix.substring(vK))
|
||||||
|
pop()
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append(':'); pop()
|
||||||
|
appendYamlValue(vRest, inP)
|
||||||
|
} else {
|
||||||
|
appendYamlValue(vCodePart, inP)
|
||||||
|
}
|
||||||
|
if (vCommentPart.isNotEmpty()) {
|
||||||
|
pushStyle(SpanStyle(color = inP.comment))
|
||||||
|
append(vCommentPart)
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
if (vIdx < vLines.size - 1) append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the value portion of a YAML line — applies string / number /
|
||||||
|
keyword / list-marker colours where applicable. */
|
||||||
|
private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPalette) {
|
||||||
|
val vTrimmed = inSeg.trimStart()
|
||||||
|
val vPad = inSeg.substring(0, inSeg.length - vTrimmed.length)
|
||||||
|
append(vPad)
|
||||||
|
when {
|
||||||
|
vTrimmed.isEmpty() -> { /* nothing */
|
||||||
|
}
|
||||||
|
|
||||||
|
vTrimmed.startsWith('-') && (vTrimmed.length == 1 || vTrimmed[1].isWhitespace()) -> {
|
||||||
|
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
|
||||||
|
appendYamlValue(vTrimmed.substring(1), inP)
|
||||||
|
}
|
||||||
|
|
||||||
|
vTrimmed.startsWith('"') || vTrimmed.startsWith('\'') -> {
|
||||||
|
val vQ = vTrimmed[0]
|
||||||
|
val vEnd = vTrimmed.indexOf(vQ, 1).let { if (it < 0) vTrimmed.length else it + 1 }
|
||||||
|
pushStyle(SpanStyle(color = inP.string))
|
||||||
|
append(vTrimmed.substring(0, vEnd))
|
||||||
|
pop()
|
||||||
|
append(vTrimmed.substring(vEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
vTrimmed == "true" || vTrimmed == "false" || vTrimmed == "null" || vTrimmed == "~" -> {
|
||||||
|
pushStyle(SpanStyle(color = inP.keyword)); append(vTrimmed); pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
vTrimmed.first().let { it.isDigit() || it == '-' && vTrimmed.length > 1 && vTrimmed[1].isDigit() } -> {
|
||||||
|
var vJ = 0
|
||||||
|
if (vTrimmed[0] == '-') vJ++
|
||||||
|
while (vJ < vTrimmed.length && (vTrimmed[vJ].isDigit() || vTrimmed[vJ] in ".eE+-")) vJ++
|
||||||
|
pushStyle(SpanStyle(color = inP.number))
|
||||||
|
append(vTrimmed.substring(0, vJ))
|
||||||
|
pop()
|
||||||
|
append(vTrimmed.substring(vJ))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> append(vTrimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the index of the first '#' that isn't inside a quoted string,
|
||||||
|
or -1 if there is none. Used to split a YAML line into code + comment. */
|
||||||
|
private fun findUnquotedHash(inLine: String): Int {
|
||||||
|
var vInSingle = false
|
||||||
|
var vInDouble = false
|
||||||
|
for (vI in inLine.indices) {
|
||||||
|
when (inLine[vI]) {
|
||||||
|
'"' if !vInSingle -> vInDouble = !vInDouble
|
||||||
|
'\'' if !vInDouble -> vInSingle = !vInSingle
|
||||||
|
'#' if !vInSingle && !vInDouble -> return vI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locate the colon that separates a YAML key from its value at the
|
||||||
|
current indent level — the FIRST unquoted ':' that's followed by
|
||||||
|
either whitespace or end-of-line. Returns -1 if the line has no key. */
|
||||||
|
private fun indexOfKeyColon(inLine: String): Int {
|
||||||
|
var vInSingle = false
|
||||||
|
var vInDouble = false
|
||||||
|
for (vI in inLine.indices) {
|
||||||
|
when (inLine[vI]) {
|
||||||
|
'"' if !vInSingle -> vInDouble = !vInDouble
|
||||||
|
'\'' if !vInDouble -> vInSingle = !vInSingle
|
||||||
|
':' if !vInSingle && !vInDouble -> {
|
||||||
|
val vNext = if (vI + 1 < inLine.length) inLine[vI + 1] else ' '
|
||||||
|
if (vNext.isWhitespace() || vI + 1 == inLine.length) return vI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Theme — the app's legacy colour slots, derived from the active palette
|
||||||
|
// ==================
|
||||||
|
// AppColors is the app's own colour vocabulary (used by the custom widgets). It
|
||||||
|
// is now DERIVED from the selected Voltic M3 ColorScheme via appColorsFromScheme
|
||||||
|
// (VolticThemes.kt) and provided through LocalAppColors by App(). The default
|
||||||
|
// below (purple dark) only applies before that provider is installed.
|
||||||
|
|
||||||
|
class AppColors(
|
||||||
|
val bg: Color, val panel: Color, val field: Color, val border: Color,
|
||||||
|
val accent: Color, val text: Color, val dim: Color, val onAccent: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val LocalAppColors = staticCompositionLocalOf { appColorsFromScheme(VolticPalette.Purple.scheme.dark) }
|
||||||
@@ -15,41 +15,54 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.Popup
|
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.MaterialSymbols
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||||
import com.compose.sdl.layout.x
|
|
||||||
import com.compose.sdl.layout.y
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: TLS certificate-chain dialog + cert cards
|
// 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. */
|
Copy PEM action). Fetched on demand by the lock button in the URL bar. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -> Unit) {
|
internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
Dialog(onDismissRequest = inOnDismiss) {
|
Dialog(onDismissRequest = inOnDismiss) {
|
||||||
Surface(color = c.panel, shape = RoundedCornerShape(10.dp), modifier = Modifier.width(540.dp)) {
|
Surface(color = c.panel, shape = RoundedCornerShape(10.dp), modifier = Modifier.width(540.dp)) {
|
||||||
Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.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)
|
MaterialSymbolsOutlined(MaterialSymbols.Lock, tint = c.accent, size = 20.dp)
|
||||||
Text("TLS certificate chain", color = c.text, fontSize = 16.sp)
|
Text("TLS certificate chain", color = c.text, fontSize = 16.sp)
|
||||||
}
|
}
|
||||||
val vCerts = inChain?.certs.orEmpty()
|
val vCerts = inChain?.certs.orEmpty()
|
||||||
when {
|
when {
|
||||||
inChain == null -> Text("Probing…", color = c.dim, fontSize = 13.sp)
|
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 -> {
|
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(
|
Column(
|
||||||
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
|
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
@@ -57,7 +70,10 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
Row(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")
|
val vChainPem = vCerts.mapNotNull { certField(it.fields, "Cert") }.joinToString("\n")
|
||||||
if (vChainPem.isNotEmpty()) CopyButton(vChainPem, "Copy chain")
|
if (vChainPem.isNotEmpty()) CopyButton(vChainPem, "Copy chain")
|
||||||
OutlinedButton(onClick = inOnDismiss) { Text("Close", color = c.text) }
|
OutlinedButton(onClick = inOnDismiss) { Text("Close", color = c.text) }
|
||||||
@@ -68,10 +84,10 @@ internal fun TlsChainDialog(inChain: TlsChain?, inUrl: String, inOnDismiss: () -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One certificate's summary card (subject / issuer / validity). Server-presented
|
/** 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
|
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
|
name-only placeholder) get an accent-coloured border + a label. A self-signed
|
||||||
cert shows "Self-signed" in green instead of repeating its issuer. */
|
cert shows "Self-signed" in green instead of repeating its issuer. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CertCard(inIndex: Int, inCert: ChainCert) {
|
internal fun CertCard(inIndex: Int, inCert: ChainCert) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -88,14 +104,22 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
|
||||||
.background(c.field.copy(alpha = if (inCert.fromServer) 1f else 0.4f), 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),
|
.padding(10.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
) {
|
) {
|
||||||
val vTitle = vSubject?.let { cnOf(it) } ?: if (inIndex == 0) "Leaf certificate" else "Issuer #$inIndex"
|
val vTitle = vSubject?.let { cnOf(it) } ?: if (inIndex == 0) "Leaf certificate" else "Issuer #$inIndex"
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(vTitle, color = c.accent, fontSize = 12.sp, modifier = Modifier.weight(1f))
|
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)
|
if (vPem != null) CopyButton(vPem)
|
||||||
}
|
}
|
||||||
vSubject?.let { CertLine("Subject", it) }
|
vSubject?.let { CertLine("Subject", it) }
|
||||||
@@ -117,10 +141,10 @@ internal fun CertCard(inIndex: Int, inCert: ChainCert) {
|
|||||||
|
|
||||||
internal val kSelfSignedColor = Color(0xFF3FB950L)
|
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
|
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.
|
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. */
|
Uses a non-catching Popup so it never dismisses the dialog or eats clicks. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CopyButton(inText: String, inLabel: String? = null) {
|
internal fun CopyButton(inText: String, inLabel: String? = null) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -132,7 +156,11 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
|
|||||||
var vHeight by remember { mutableStateOf(0) }
|
var vHeight by remember { mutableStateOf(0) }
|
||||||
val vClipboard = LocalClipboard.current
|
val vClipboard = LocalClipboard.current
|
||||||
val vScope = rememberCoroutineScope()
|
val vScope = rememberCoroutineScope()
|
||||||
LaunchedEffect(vCopied) { if (vCopied) { delay(2000); vCopied = false } }
|
LaunchedEffect(vCopied) {
|
||||||
|
if (vCopied) {
|
||||||
|
delay(2000.milliseconds); vCopied = false
|
||||||
|
}
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.onGloballyPositioned { vX = it.x; vY = it.y }
|
.onGloballyPositioned { vX = it.x; vY = it.y }
|
||||||
@@ -141,7 +169,7 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
|
|||||||
.background(if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(7.dp))
|
.background(if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(7.dp))
|
||||||
.hoverable(vHoverSrc)
|
.hoverable(vHoverSrc)
|
||||||
.clickable {
|
.clickable {
|
||||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(inText)) }
|
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(inText)) }
|
||||||
vCopied = true
|
vCopied = true
|
||||||
}
|
}
|
||||||
.padding(horizontal = if (inLabel != null) 10.dp else 5.dp, vertical = 5.dp),
|
.padding(horizontal = if (inLabel != null) 10.dp else 5.dp, vertical = 5.dp),
|
||||||
@@ -154,7 +182,11 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
|
|||||||
tint = if (vCopied) kSelfSignedColor else c.dim,
|
tint = if (vCopied) kSelfSignedColor else c.dim,
|
||||||
size = 15.dp,
|
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).
|
// Icon-only: float a tiny "Copied" bubble (non-catching popup → no dismiss).
|
||||||
if (vCopied && inLabel == null) {
|
if (vCopied && inLabel == null) {
|
||||||
@@ -177,23 +209,23 @@ internal fun CertLine(inLabel: String, inValue: String, inValueColor: Color? = n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Look up a CURLINFO_CERTINFO field by name (case-insensitive). */
|
/** Look up a CURLINFO_CERTINFO field by name (case-insensitive). */
|
||||||
internal fun certField(inFields: List<Pair<String, String>>, inName: String): String? =
|
internal fun certField(inFields: List<Pair<String, String>>, inName: String): String? =
|
||||||
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
|
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
|
||||||
|
|
||||||
/* 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:
|
format the platform backends emit:
|
||||||
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
|
"CN=R3, O=Let's Encrypt, C=US" (Windows CertGetNameStringA)
|
||||||
"/C=US/O=Let's Encrypt/CN=R3" (OpenSSL X509_NAME_oneline)
|
"/C=US/O=Let's Encrypt/CN=R3" (OpenSSL X509_NAME_oneline)
|
||||||
"CN = R3, O = Let's Encrypt, C = US" (OpenSSL X509_NAME_print_ex
|
"CN = R3, O = Let's Encrypt, C = US" (OpenSSL X509_NAME_print_ex
|
||||||
default, with spaces — what
|
default, with spaces — what
|
||||||
libcurl on macOS emits)
|
libcurl on macOS emits)
|
||||||
Null when there's no CN. */
|
Null when there's no CN. */
|
||||||
internal fun cnOf(inDn: String): String? {
|
internal fun cnOf(inDn: String): String? {
|
||||||
val vMatch = Regex("""(?i)\bCN\s*=\s*([^,/]+)""").find(inDn) ?: return null
|
val vMatch = Regex("""(?i)\bCN\s*=\s*([^,/]+)""").find(inDn) ?: return null
|
||||||
return vMatch.groupValues[1].trim().ifBlank { null }
|
return vMatch.groupValues[1].trim().ifBlank { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The host portion of a URL (for the chain dialog header). */
|
/** The host portion of a URL (for the chain dialog header). */
|
||||||
internal fun hostOf(inUrl: String): String =
|
internal fun hostOf(inUrl: String): String =
|
||||||
inUrl.substringAfter("://", inUrl).substringBefore("/").substringBefore("?").ifBlank { inUrl }
|
inUrl.substringAfter("://", inUrl).substringBefore("/").substringBefore("?").ifBlank { inUrl }
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: TLS chain / client-certificate seam
|
||||||
|
// ==================
|
||||||
|
// The real implementations drive the bundled libcurl directly (CurlMtls.kt,
|
||||||
|
// nativeMain) — Ktor's engines expose no client-cert / CERTINFO API. The jvm
|
||||||
|
// parity app has no bundled libcurl, so its actuals report the feature as
|
||||||
|
// native-only instead.
|
||||||
|
|
||||||
|
/** One certificate in the chain: its CURLINFO_CERTINFO-style fields (Subject /
|
||||||
|
Issuer / the PEM under "Cert" / dates …) and whether the server actually
|
||||||
|
presented it. Derived certs — an issuer resolved from the OS store, or a
|
||||||
|
name-only placeholder — have fromServer = false and are drawn dotted. */
|
||||||
|
class ChainCert(val fields: List<Pair<String, String>>, val fromServer: Boolean)
|
||||||
|
|
||||||
|
/** The server's TLS certificate chain. error is set instead when it couldn't be
|
||||||
|
fetched; fields vary by TLS backend. */
|
||||||
|
class TlsChain(val certs: List<ChainCert>, val error: String?)
|
||||||
|
|
||||||
|
/** Fetch the TLS certificate chain the request's server presents. */
|
||||||
|
expect fun inspectTlsChain(inReq: ApiRequest): TlsChain
|
||||||
|
|
||||||
|
/** Send the request with its client certificate attached (mTLS). */
|
||||||
|
expect fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
|
||||||
|
|
||||||
|
/** Remove temp client certs a previous crashed run left behind. Call once at
|
||||||
|
startup; no-op where the OS store isn't used. */
|
||||||
|
expect fun sweepTempClientCerts()
|
||||||
@@ -4,13 +4,13 @@ package apidemo
|
|||||||
// MARK: Variable substitution ({{name}})
|
// 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. */
|
Surrounding whitespace inside the braces is tolerated. */
|
||||||
private val kVarRegex = Regex("""\{\{\s*([A-Za-z0-9_.\-]+)\s*\}\}""")
|
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
|
value. Unknown / disabled names are left untouched so the user can spot the
|
||||||
ones that won't resolve. */
|
ones that won't resolve. */
|
||||||
fun substituteVars(inText: String, inVars: List<KeyVal>): String {
|
fun substituteVars(inText: String, inVars: List<KeyVal>): String {
|
||||||
if (inText.isEmpty()) return inText
|
if (inText.isEmpty()) return inText
|
||||||
val vMap = inVars
|
val vMap = inVars
|
||||||
@@ -20,26 +20,41 @@ fun substituteVars(inText: String, inVars: List<KeyVal>): String {
|
|||||||
return kVarRegex.replace(inText) { vMatch -> vMap[vMatch.groupValues[1]] ?: vMatch.value }
|
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
|
query params, headers and body — returning a fully-resolved request. The
|
||||||
original (template) request is left untouched. */
|
original (template) request is left untouched. */
|
||||||
fun resolveVars(inReq: ApiRequest, inVars: List<KeyVal>): ApiRequest {
|
fun resolveVars(inReq: ApiRequest, inVars: List<KeyVal>): ApiRequest {
|
||||||
val vActive = inVars.any { it.enabled && it.key.isNotBlank() }
|
val vActive = inVars.any { it.enabled && it.key.isNotBlank() }
|
||||||
if (!vActive) return inReq
|
if (!vActive) return inReq
|
||||||
return inReq.copy(
|
return inReq.copy(
|
||||||
url = substituteVars(inReq.url, inVars),
|
url = substituteVars(inReq.url, inVars),
|
||||||
params = inReq.params.map { it.copy(key = substituteVars(it.key, inVars), value = substituteVars(it.value, inVars)) },
|
params = inReq.params.map {
|
||||||
headers = inReq.headers.map { it.copy(key = substituteVars(it.key, inVars), value = substituteVars(it.value, inVars)) },
|
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),
|
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),
|
certPath = substituteVars(inReq.certPath, inVars),
|
||||||
keyPath = substituteVars(inReq.keyPath, inVars),
|
keyPath = substituteVars(inReq.keyPath, inVars),
|
||||||
certPassword = substituteVars(inReq.certPassword, 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. */
|
variable to fill them — surfaced as a warning in the editor. */
|
||||||
fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
|
fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
|
||||||
val vDefined = inVars
|
val vDefined = inVars
|
||||||
.filter { it.enabled && it.key.isNotBlank() }
|
.filter { it.enabled && it.key.isNotBlank() }
|
||||||
@@ -49,7 +64,7 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
|
|||||||
append(inReq.url).append('\n')
|
append(inReq.url).append('\n')
|
||||||
inReq.params.forEach { append(it.key).append('\n').append(it.value).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') }
|
inReq.headers.forEach { append(it.key).append('\n').append(it.value).append('\n') }
|
||||||
if (inReq.method.allowsBody) {
|
if (true) {
|
||||||
when (inReq.bodyType) {
|
when (inReq.bodyType) {
|
||||||
BodyType.FORM -> inReq.form.forEach { append(it.key).append('\n').append(it.value).append('\n') }
|
BodyType.FORM -> inReq.form.forEach { append(it.key).append('\n').append(it.value).append('\n') }
|
||||||
else -> append(inReq.body)
|
else -> append(inReq.body)
|
||||||
@@ -67,10 +82,10 @@ fun unresolvedVars(inReq: ApiRequest, inVars: List<KeyVal>): List<String> {
|
|||||||
// MARK: Copy as cURL
|
// 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
|
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
|
are folded into the URL; a Content-Type header is added for the body type
|
||||||
unless the request already sets one. */
|
unless the request already sets one. */
|
||||||
fun toCurl(inReq: ApiRequest): String {
|
fun toCurl(inReq: ApiRequest): String {
|
||||||
val vSb = StringBuilder()
|
val vSb = StringBuilder()
|
||||||
vSb.append("curl -X ").append(inReq.method.name)
|
vSb.append("curl -X ").append(inReq.method.name)
|
||||||
@@ -91,27 +106,31 @@ fun toCurl(inReq: ApiRequest): String {
|
|||||||
if (inReq.certPassword.isNotEmpty()) vSb.append(" \\\n --pass ").append(shellQuote(inReq.certPassword))
|
if (inReq.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 vContentType = inReq.bodyContentType()
|
||||||
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
|
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
|
||||||
if (vContentType != null && !vHasCt) {
|
if (vContentType != null && !vHasCt) {
|
||||||
vSb.append(" \\\n -H ").append(shellQuote("Content-Type: $vContentType"))
|
vSb.append(" \\\n -H ").append(shellQuote("Content-Type: $vContentType"))
|
||||||
}
|
}
|
||||||
when (inReq.bodyType) {
|
when (inReq.bodyType) {
|
||||||
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ").append(shellQuote(formEncode(inReq.form)))
|
BodyType.FORM -> if (inReq.form.any { it.enabled && it.key.isNotBlank() }) vSb.append(" \\\n --data ")
|
||||||
BodyType.FILE -> if (inReq.body.isNotBlank()) vSb.append(" \\\n --data-binary ").append(shellQuote("@${inReq.body}"))
|
.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))
|
else -> if (inReq.body.isNotEmpty()) vSb.append(" \\\n --data ").append(shellQuote(inReq.body))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return vSb.toString()
|
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 =
|
fun formEncode(inForm: List<KeyVal>): String =
|
||||||
inForm.filter { it.enabled && it.key.isNotBlank() }
|
inForm.filter { it.enabled && it.key.isNotBlank() }
|
||||||
.joinToString("&") { "${urlEncode(it.key)}=${urlEncode(it.value)}" }
|
.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 {
|
internal fun urlWithParams(inReq: ApiRequest): String {
|
||||||
val vTrimmed = inReq.url.trim()
|
val vTrimmed = inReq.url.trim()
|
||||||
val vEnabled = inReq.params.filter { it.enabled && it.key.isNotBlank() }
|
val vEnabled = inReq.params.filter { it.enabled && it.key.isNotBlank() }
|
||||||
@@ -121,10 +140,10 @@ internal fun urlWithParams(inReq: ApiRequest): String {
|
|||||||
return vTrimmed + vSep + vQuery
|
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("'", "'\\''") + "'"
|
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 {
|
private fun urlEncode(inS: String): String = buildString {
|
||||||
for (vByte in inS.encodeToByteArray()) {
|
for (vByte in inS.encodeToByteArray()) {
|
||||||
val vCode = vByte.toInt() and 0xFF
|
val vCode = vByte.toInt() and 0xFF
|
||||||
@@ -30,8 +30,8 @@ import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
|||||||
// MARK: Small UI atoms (shared across panels / dialogs / sidebar)
|
// 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. */
|
width so request names line up. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MethodTag(inMethod: ReqMethod) {
|
internal fun MethodTag(inMethod: ReqMethod) {
|
||||||
val vCol = methodColor(inMethod)
|
val vCol = methodColor(inMethod)
|
||||||
@@ -45,16 +45,16 @@ internal fun MethodTag(inMethod: ReqMethod) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thin horizontal accent line showing where a dragged request row will drop
|
/** Thin horizontal accent line showing where a dragged request row will drop
|
||||||
(the open-tab strip uses the vertical DropBar). */
|
(the open-tab strip uses the vertical DropBar). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RowDropBar() {
|
internal fun RowDropBar() {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
Box(modifier = Modifier.fillMaxWidth().height(2.dp).background(c.accent, RoundedCornerShape(1.dp)))
|
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). */
|
to flag a tab that holds content (e.g. a non-empty request body). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TabBar(inTabs: List<String>, inSelected: Int, inDots: Set<Int> = emptySet(), inOnSelect: (Int) -> Unit) {
|
internal fun TabBar(inTabs: List<String>, inSelected: Int, inDots: Set<Int> = emptySet(), inOnSelect: (Int) -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -90,10 +90,17 @@ internal fun TogglePill(inLabel: String, inSelected: Boolean, inOnClick: () -> U
|
|||||||
) { Text(inLabel, color = if (inSelected) c.onAccent else c.dim, fontSize = 12.sp) }
|
) { 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. */
|
a slim bordered box, much shorter than the 56 dp Material OutlinedTextField. */
|
||||||
@Composable
|
@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
|
val c = LocalAppColors.current
|
||||||
// Box is an ancestor of the field, so it sees Enter the single-line field
|
// Box is an ancestor of the field, so it sees Enter the single-line field
|
||||||
// leaves unconsumed (it propagates up the focus → root chain).
|
// 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))
|
.border(1.dp, c.border, RoundedCornerShape(6.dp))
|
||||||
.padding(horizontal = 10.dp, vertical = 9.dp)
|
.padding(horizontal = 10.dp, vertical = 9.dp)
|
||||||
if (inOnEnter != null) vBoxMod = vBoxMod.onKeyEvent { ev ->
|
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(
|
Box(
|
||||||
modifier = vBoxMod,
|
modifier = vBoxMod,
|
||||||
@@ -111,10 +120,10 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
|
|||||||
if (inValue.isEmpty() && inPlaceholder.isNotEmpty()) Text(inPlaceholder, color = c.dim, fontSize = 13.sp)
|
if (inValue.isEmpty() && inPlaceholder.isNotEmpty()) Text(inPlaceholder, color = c.dim, fontSize = 13.sp)
|
||||||
androidx.compose.runtime.CompositionLocalProvider(
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
|
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
|
||||||
androidx.compose.foundation.text.selection.TextSelectionColors(
|
androidx.compose.foundation.text.selection.TextSelectionColors(
|
||||||
handleColor = c.accent,
|
handleColor = c.accent,
|
||||||
backgroundColor = c.accent.copy(alpha = 0.35f),
|
backgroundColor = c.accent.copy(alpha = 0.35f),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = inValue,
|
value = inValue,
|
||||||
@@ -128,10 +137,17 @@ internal fun ThinField(inValue: String, inOnChange: (String) -> Unit, inModifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small icon-only button (burger / pack / options / save / close). inPadding
|
/** Small icon-only button (burger / pack / options / save / close). inPadding
|
||||||
tightens the tap area for slimmer rows. */
|
tightens the tap area for slimmer rows. */
|
||||||
@Composable
|
@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 c = LocalAppColors.current
|
||||||
val vHoverSrc = remember { MutableInteractionSource() }
|
val vHoverSrc = remember { MutableInteractionSource() }
|
||||||
val vHover by vHoverSrc.collectIsHoveredAsState()
|
val vHover by vHoverSrc.collectIsHoveredAsState()
|
||||||
@@ -152,18 +168,18 @@ internal fun IconBtn(inIcon: Int, inDesc: String, inModifier: Modifier = Modifie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Outlined icon+label action (New request / Add row / pack actions). */
|
/** Outlined icon+label action (New request / Add row / pack actions). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun OutlinedAction(inIcon: Int, inLabel: String, inOnClick: () -> Unit) {
|
internal fun OutlinedAction(inIcon: Int, inLabel: String, inOnClick: () -> Unit) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
OutlinedButton(onClick = inOnClick) { BtnContent(inIcon, inLabel, c.accent) }
|
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
|
Uses the m3 Button so its size + ripple + interaction match every other
|
||||||
Button in the app — previously handrolled Box + background + clickable + padding
|
Button in the app — previously handrolled Box + background + clickable + padding
|
||||||
gave it a different height / horizontal padding, and the Discard button sat
|
gave it a different height / horizontal padding, and the Discard button sat
|
||||||
next to a Button ("Save first…") looking noticeably smaller in dialogs. */
|
next to a Button ("Save first…") looking noticeably smaller in dialogs. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
|
internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
|
||||||
val vRed = methodColor(ReqMethod.DELETE)
|
val vRed = methodColor(ReqMethod.DELETE)
|
||||||
@@ -173,17 +189,7 @@ internal fun DangerButton(inLabel: String, inIcon: Int, inOnClick: () -> Unit) {
|
|||||||
) { BtnContent(inIcon, inLabel, Color.White) }
|
) { BtnContent(inIcon, inLabel, Color.White) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bordered icon+label chip (Copy / Save as / Cancel). */
|
/** Icon + label row used inside buttons. */
|
||||||
@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. */
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BtnContent(inIcon: Int, inLabel: String, inColor: Color, inSize: Dp = 16.dp) {
|
internal fun BtnContent(inIcon: Int, inLabel: String, inColor: Color, inSize: Dp = 16.dp) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -29,7 +29,6 @@ import androidx.compose.ui.input.pointer.isShiftPressed
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
@@ -37,26 +36,21 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.MaterialSymbols
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||||
import com.compose.sdl.removeMemoryResource
|
|
||||||
import com.compose.sdl.res.ResourceKind
|
|
||||||
import com.compose.sdl.res.painterResource
|
|
||||||
import com.compose.sdl.scrollbar.ScrollbarStyle
|
|
||||||
import com.compose.sdl.scrollbar.VerticalScrollbar
|
|
||||||
import com.compose.sdl.scrollbar.rememberScrollbarAdapter
|
|
||||||
import com.compose.sdl.showSaveFileDialog
|
|
||||||
import com.compose.sdl.text.currentTextMeasurer
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Response
|
// 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
|
BODY section. "Request" shows the resolved request that would be sent (the
|
||||||
Preview); "Response" shows the result (image-aware). A copy / save toolbar
|
Preview); "Response" shows the result (image-aware). A copy / save toolbar
|
||||||
acts on whichever view is showing. */
|
acts on whichever view is showing. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -93,7 +87,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
Text("PREVIEW", color = c.accent, fontSize = 13.sp)
|
Text("PREVIEW", color = c.accent, fontSize = 13.sp)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Text("not sent", color = kWarnColor, fontSize = 11.sp)
|
Text("not sent", color = VolticTheme.extended.warning, fontSize = 11.sp)
|
||||||
} else {
|
} else {
|
||||||
// Request tab — adds the coloured method only once the
|
// Request tab — adds the coloured method only once the
|
||||||
// request has actually been sent (sentReq != null).
|
// request has actually been sent (sentReq != null).
|
||||||
@@ -110,8 +104,8 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
// pre-send / loading state.
|
// pre-send / loading state.
|
||||||
val (vRespAccent, vRespColor) = when {
|
val (vRespAccent, vRespColor) = when {
|
||||||
vResp?.error != null -> "FAILED" to statusColor(0)
|
vResp?.error != null -> "FAILED" to statusColor(0)
|
||||||
vResp != null -> vResp.status.toString() to statusColor(vResp.status)
|
vResp != null -> vResp.status.toString() to statusColor(vResp.status)
|
||||||
else -> null to c.dim
|
else -> null to c.dim
|
||||||
}
|
}
|
||||||
ViewerTab(
|
ViewerTab(
|
||||||
inLabel = "Response",
|
inLabel = "Response",
|
||||||
@@ -137,12 +131,14 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
vShowRequest && vReqShown == null -> ViewerEmpty(MaterialSymbols.Send, "Not sent")
|
vShowRequest && vReqShown == null -> ViewerEmpty(MaterialSymbols.Send, "Not sent")
|
||||||
vShowRequest -> {
|
vShowRequest -> {
|
||||||
val vR = vReqShown!!
|
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)
|
// Synthesize wire-level headers (Host, Content-Length, User-Agent)
|
||||||
// the engine adds without telling Ktor — same set httpie shows.
|
// 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 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(
|
HttpFlowView(
|
||||||
inStatusLine = formatRequestLine(vR, inRs.response?.httpVersion ?: "HTTP/1.1", c),
|
inStatusLine = formatRequestLine(vR, inRs.response?.httpVersion ?: "HTTP/1.1", c),
|
||||||
inStatusLineAccentColor = methodColor(vR.method),
|
inStatusLineAccentColor = methodColor(vR.method),
|
||||||
@@ -156,6 +152,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
inSoftWrap = inRs.bodyWrap,
|
inSoftWrap = inRs.bodyWrap,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
vResp == null && !vLoading -> ViewerEmpty(MaterialSymbols.Download, "Not received")
|
vResp == null && !vLoading -> ViewerEmpty(MaterialSymbols.Download, "Not received")
|
||||||
else -> {
|
else -> {
|
||||||
val vHeaders = vResp?.headers ?: emptyList()
|
val vHeaders = vResp?.headers ?: emptyList()
|
||||||
@@ -169,8 +166,8 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
inBody = if (vRespImage) null else vBody,
|
inBody = if (vRespImage) null else vBody,
|
||||||
inIsImage = vRespImage && !vLoading,
|
inIsImage = vRespImage && !vLoading,
|
||||||
inImagePainter = if (vRespImage && !vLoading) {
|
inImagePainter = if (vRespImage && !vLoading) {
|
||||||
val vKind = if (vResp?.contentType?.contains("svg", ignoreCase = true) == true) ResourceKind.Svg else ResourceKind.Raster
|
val vSvg = vResp.contentType?.contains("svg", ignoreCase = true) == true
|
||||||
painterResource(inRs.imageKey!!, vKind)
|
memoryImagePainter(inRs.imageKey!!, vSvg)
|
||||||
} else null,
|
} else null,
|
||||||
inHeadersCollapsed = vHeadersCollapsed,
|
inHeadersCollapsed = vHeadersCollapsed,
|
||||||
inOnToggleCollapse = { vHeadersCollapsed = !vHeadersCollapsed },
|
inOnToggleCollapse = { vHeadersCollapsed = !vHeadersCollapsed },
|
||||||
@@ -196,7 +193,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
) {
|
) {
|
||||||
// Response viewer: format selector for the body. Auto-detected
|
// Response viewer: format selector for the body. Auto-detected
|
||||||
// from Content-Type until the user overrides, then pinned.
|
// from Content-Type until the user overrides, then pinned.
|
||||||
if (vRespHasContent && vResp != null && !vRespImage) {
|
if (vRespHasContent && !vRespImage) {
|
||||||
val vAuto = autoFormatFor(vResp.contentType)
|
val vAuto = autoFormatFor(vResp.contentType)
|
||||||
val vCurrent = inRs.respFormatOverride ?: vAuto
|
val vCurrent = inRs.respFormatOverride ?: vAuto
|
||||||
BodyFormatSelector(
|
BodyFormatSelector(
|
||||||
@@ -210,7 +207,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
// Wrap toggle — soft-wrap long lines (default) vs. no wrap +
|
// Wrap toggle — soft-wrap long lines (default) vs. no wrap +
|
||||||
// horizontal scroll (one source line per row). Shown for any text
|
// horizontal scroll (one source line per row). Shown for any text
|
||||||
// body (request preview or response), hidden for an image.
|
// body (request preview or response), hidden for an image.
|
||||||
val vHasTextBody = (vRespHasContent && vResp != null && !vRespImage) || vReqHasContent
|
val vHasTextBody = (vRespHasContent && !vRespImage) || vReqHasContent
|
||||||
if (vHasTextBody) {
|
if (vHasTextBody) {
|
||||||
val vWrapOn = inRs.bodyWrap
|
val vWrapOn = inRs.bodyWrap
|
||||||
Box(
|
Box(
|
||||||
@@ -234,7 +231,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
}
|
}
|
||||||
vMsg?.let { Text(it, color = c.dim, fontSize = 11.sp) }
|
vMsg?.let { Text(it, color = c.dim, fontSize = 11.sp) }
|
||||||
Spacer(Modifier.weight(1f))
|
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)
|
Text(formatTimingSize(vResp), color = c.dim, fontSize = 11.sp)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
@@ -247,7 +244,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
inResponseBody = vRespBody,
|
inResponseBody = vRespBody,
|
||||||
inOnMessage = { vMsg = it },
|
inOnMessage = { vMsg = it },
|
||||||
inOnClear = {
|
inOnClear = {
|
||||||
inRs.imageKey?.let { removeMemoryResource(it) }
|
inRs.imageKey?.let { removeMemoryImage(it) }
|
||||||
inRs.imageKey = null
|
inRs.imageKey = null
|
||||||
inRs.response = null
|
inRs.response = null
|
||||||
inRs.sentReq = null
|
inRs.sentReq = null
|
||||||
@@ -263,10 +260,10 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
|||||||
// MARK: HttpFlowView — flat httpie-style request/response layout
|
// MARK: HttpFlowView — flat httpie-style request/response layout
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Renders the body of a Request or Response tab in a httpie-flavoured
|
/** Renders the body of a Request or Response tab in a httpie-flavoured
|
||||||
layout: status line (with collapse arrow) + headers table + raw body
|
layout: status line (with collapse arrow) + headers table + raw body
|
||||||
inline, no card / rounded-rect chrome around each section. The
|
inline, no card / rounded-rect chrome around each section. The
|
||||||
headers section can be collapsed via the arrow. */
|
headers section can be collapsed via the arrow. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HttpFlowView(
|
internal fun HttpFlowView(
|
||||||
inStatusLine: AnnotatedString,
|
inStatusLine: AnnotatedString,
|
||||||
@@ -311,7 +308,7 @@ internal fun HttpFlowView(
|
|||||||
if (inBody == null) emptyList()
|
if (inBody == null) emptyList()
|
||||||
else {
|
else {
|
||||||
val vAnn = if (inBodyFormat == BodyFormat.RAW) AnnotatedString(inBody)
|
val vAnn = if (inBodyFormat == BodyFormat.RAW) AnnotatedString(inBody)
|
||||||
else highlight(inBody, inBodyFormat, SyntaxPalette.forDark(vDark))
|
else highlight(inBody, inBodyFormat, SyntaxPalette.forDark(vDark))
|
||||||
buildBodyChunks(inBody, vAnn, kLinesPerChunk)
|
buildBodyChunks(inBody, vAnn, kLinesPerChunk)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,7 +328,12 @@ internal fun HttpFlowView(
|
|||||||
// smoothness win when thousands of line items churn through the viewport.
|
// 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 vBodyStyle = remember(c.text) { TextStyle(color = c.text, fontSize = 12.sp, fontFamily = monoFontFamily) }
|
||||||
val vNumStyle = remember(c.dim) {
|
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.
|
// Body-relative x offset (px) of the block's BasicText inside the outer Box.
|
||||||
@@ -342,8 +344,8 @@ internal fun HttpFlowView(
|
|||||||
// moves off-screen via keyboard. Only stable while we're on the "have body"
|
// moves off-screen via keyboard. Only stable while we're on the "have body"
|
||||||
// branch, but that's the only branch that renders chunks in the first place.
|
// branch, but that's the only branch that renders chunks in the first place.
|
||||||
val vChunkItemOffset = 1 +
|
val vChunkItemOffset = 1 +
|
||||||
(if (!inHeadersCollapsed && inHeaders.isNotEmpty()) 1 else 0) +
|
(if (!inHeadersCollapsed && inHeaders.isNotEmpty()) 1 else 0) +
|
||||||
(if (inBody != null || inIsImage) 1 else 0)
|
(if (inBody != null || inIsImage) 1 else 0)
|
||||||
|
|
||||||
// After a keyboard action that moves the caret, if the caret's chunk is not
|
// After a keyboard action that moves the caret, if the caret's chunk is not
|
||||||
// in the visible items list scroll it in. Instant (no anim) so rapid
|
// in the visible items list scroll it in. Instant (no anim) so rapid
|
||||||
@@ -392,7 +394,7 @@ internal fun HttpFlowView(
|
|||||||
val vMaxScrollPx = with(density) { 24.dp.toPx() }
|
val vMaxScrollPx = with(density) { 24.dp.toPx() }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
androidx.compose.runtime.withFrameNanos { }
|
withFrameNanos { }
|
||||||
val vPos = vDragPos ?: continue
|
val vPos = vDragPos ?: continue
|
||||||
val vVpH = vState.layoutInfo.viewportSize.height.toFloat()
|
val vVpH = vState.layoutInfo.viewportSize.height.toFloat()
|
||||||
val vDelta = when {
|
val vDelta = when {
|
||||||
@@ -463,10 +465,12 @@ internal fun HttpFlowView(
|
|||||||
val vR = wordRangeAt(vBody, vOffset)
|
val vR = wordRangeAt(vBody, vOffset)
|
||||||
BodySel(anchor = vR.first, caret = vR.last)
|
BodySel(anchor = vR.first, caret = vR.last)
|
||||||
}
|
}
|
||||||
|
|
||||||
vPressCount >= 3 -> BodySel(
|
vPressCount >= 3 -> BodySel(
|
||||||
anchor = lineStartAt(vBody, vOffset),
|
anchor = lineStartAt(vBody, vOffset),
|
||||||
caret = lineEndAt(vBody, vOffset),
|
caret = lineEndAt(vBody, vOffset),
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> BodySel(anchor = vOffset, caret = vOffset)
|
else -> BodySel(anchor = vOffset, caret = vOffset)
|
||||||
}
|
}
|
||||||
vChange.consume()
|
vChange.consume()
|
||||||
@@ -504,47 +508,58 @@ internal fun HttpFlowView(
|
|||||||
vMod && ev.key == Key.A -> {
|
vMod && ev.key == Key.A -> {
|
||||||
vSel = BodySel(0, vBody.length); true
|
vSel = BodySel(0, vBody.length); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vMod && ev.key == Key.C -> {
|
vMod && ev.key == Key.C -> {
|
||||||
val vS = vSel
|
val vS = vSel
|
||||||
if (vS != null && !vS.isEmpty) {
|
if (vS != null && !vS.isEmpty) {
|
||||||
val vText = vBody.substring(vS.start, vS.end)
|
val vText = vBody.substring(vS.start, vS.end)
|
||||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(vText)) }
|
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(vText)) }
|
||||||
true
|
true
|
||||||
} else false
|
} else false
|
||||||
}
|
}
|
||||||
|
|
||||||
ev.key == Key.Escape -> {
|
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 -> {
|
vShift && ev.key == Key.DirectionRight -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = (vCur.caret + 1).coerceAtMost(vBody.length))
|
vSel = vCur.copy(caret = (vCur.caret + 1).coerceAtMost(vBody.length))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vShift && ev.key == Key.DirectionLeft -> {
|
vShift && ev.key == Key.DirectionLeft -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = (vCur.caret - 1).coerceAtLeast(0))
|
vSel = vCur.copy(caret = (vCur.caret - 1).coerceAtLeast(0))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vShift && ev.key == Key.DirectionDown -> {
|
vShift && ev.key == Key.DirectionDown -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = moveDown(vBody, vCur.caret))
|
vSel = vCur.copy(caret = moveDown(vBody, vCur.caret))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vShift && ev.key == Key.DirectionUp -> {
|
vShift && ev.key == Key.DirectionUp -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = moveUp(vBody, vCur.caret))
|
vSel = vCur.copy(caret = moveUp(vBody, vCur.caret))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vShift && ev.key == Key.MoveHome -> {
|
vShift && ev.key == Key.MoveHome -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = if (vMod) 0 else lineStartAt(vBody, vCur.caret))
|
vSel = vCur.copy(caret = if (vMod) 0 else lineStartAt(vBody, vCur.caret))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
vShift && ev.key == Key.MoveEnd -> {
|
vShift && ev.key == Key.MoveEnd -> {
|
||||||
val vCur = vSel ?: BodySel(0, 0)
|
val vCur = vSel ?: BodySel(0, 0)
|
||||||
vSel = vCur.copy(caret = if (vMod) vBody.length else lineEndAt(vBody, vCur.caret))
|
vSel = vCur.copy(caret = if (vMod) vBody.length else lineEndAt(vBody, vCur.caret))
|
||||||
vScrollCaretIntoView(); true
|
vScrollCaretIntoView(); true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -637,23 +652,23 @@ internal fun HttpFlowView(
|
|||||||
// MARK: Body selection (editor-style, spans virtualized chunks)
|
// 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
|
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].
|
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
|
When equal, it's a zero-width caret still useful as a starting point for
|
||||||
subsequent Shift+Arrow / Shift+Click extensions.
|
subsequent Shift+Arrow / Shift+Click extensions.
|
||||||
|
|
||||||
Note this lives OUTSIDE the SelectionContainer machinery — that machinery only
|
Note this lives OUTSIDE the SelectionContainer machinery — that machinery only
|
||||||
tracks selectables composed by the LazyColumn (visible chunks), which is why
|
tracks selectables composed by the LazyColumn (visible chunks), which is why
|
||||||
Ctrl+A and drag-select cannot reach off-screen text through it. This state is
|
Ctrl+A and drag-select cannot reach off-screen text through it. This state is
|
||||||
plain integer offsets so it survives scroll and covers the whole body. */
|
plain integer offsets so it survives scroll and covers the whole body. */
|
||||||
private data class BodySel(val anchor: Int, val caret: Int) {
|
private data class BodySel(val anchor: Int, val caret: Int) {
|
||||||
val start get() = minOf(anchor, caret)
|
val start get() = minOf(anchor, caret)
|
||||||
val end get() = maxOf(anchor, caret)
|
val end get() = maxOf(anchor, caret)
|
||||||
val isEmpty get() = anchor == caret
|
val isEmpty get() = anchor == caret
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Character offset of the start of the line containing [inOffset]. */
|
/** Character offset of the start of the line containing [inOffset]. */
|
||||||
private fun lineStartAt(inText: String, inOffset: Int): Int {
|
private fun lineStartAt(inText: String, inOffset: Int): Int {
|
||||||
if (inOffset <= 0) return 0
|
if (inOffset <= 0) return 0
|
||||||
val vClamped = inOffset.coerceAtMost(inText.length)
|
val vClamped = inOffset.coerceAtMost(inText.length)
|
||||||
@@ -661,17 +676,17 @@ private fun lineStartAt(inText: String, inOffset: Int): Int {
|
|||||||
return if (vPrev < 0) 0 else vPrev + 1
|
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. */
|
its '\n', or the length of [inText] if this is the last line. */
|
||||||
private fun lineEndAt(inText: String, inOffset: Int): Int {
|
private fun lineEndAt(inText: String, inOffset: Int): Int {
|
||||||
val vClamped = inOffset.coerceIn(0, inText.length)
|
val vClamped = inOffset.coerceIn(0, inText.length)
|
||||||
val vNext = inText.indexOf('\n', vClamped)
|
val vNext = inText.indexOf('\n', vClamped)
|
||||||
return if (vNext < 0) inText.length else vNext
|
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,
|
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. */
|
the caret snaps to its end. Off-by-one at EOF collapses to the body length. */
|
||||||
private fun moveDown(inText: String, inOffset: Int): Int {
|
private fun moveDown(inText: String, inOffset: Int): Int {
|
||||||
val vLineStart = lineStartAt(inText, inOffset)
|
val vLineStart = lineStartAt(inText, inOffset)
|
||||||
val vCol = inOffset - vLineStart
|
val vCol = inOffset - vLineStart
|
||||||
@@ -682,7 +697,7 @@ private fun moveDown(inText: String, inOffset: Int): Int {
|
|||||||
return (vNextStart + vCol).coerceAtMost(vNextEnd)
|
return (vNextStart + vCol).coerceAtMost(vNextEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Up counterpart to [moveDown]. */
|
/** The Up counterpart to [moveDown]. */
|
||||||
private fun moveUp(inText: String, inOffset: Int): Int {
|
private fun moveUp(inText: String, inOffset: Int): Int {
|
||||||
val vLineStart = lineStartAt(inText, inOffset)
|
val vLineStart = lineStartAt(inText, inOffset)
|
||||||
if (vLineStart == 0) return 0
|
if (vLineStart == 0) return 0
|
||||||
@@ -692,10 +707,10 @@ private fun moveUp(inText: String, inOffset: Int): Int {
|
|||||||
return (vPrevStart + vCol).coerceAtMost(vPrevEnd)
|
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
|
(letters, digits, underscore) containing [inOffset], or an empty range at the
|
||||||
click point when it lands on non-word text (whitespace, punctuation). Matches
|
click point when it lands on non-word text (whitespace, punctuation). Matches
|
||||||
the boundary editors like VS Code / IntelliJ use for double-click select. */
|
the boundary editors like VS Code / IntelliJ use for double-click select. */
|
||||||
private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
|
private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
|
||||||
val vN = inText.length
|
val vN = inText.length
|
||||||
if (vN == 0) return 0..0
|
if (vN == 0) return 0..0
|
||||||
@@ -703,8 +718,8 @@ private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
|
|||||||
fun isWord(c: Char) = c.isLetterOrDigit() || c == '_'
|
fun isWord(c: Char) = c.isLetterOrDigit() || c == '_'
|
||||||
// A click just after the last char of a word should still select that word.
|
// A click just after the last char of a word should still select that word.
|
||||||
val vProbe = if (vClamped < vN && isWord(inText[vClamped])) vClamped
|
val vProbe = if (vClamped < vN && isWord(inText[vClamped])) vClamped
|
||||||
else if (vClamped > 0 && isWord(inText[vClamped - 1])) vClamped - 1
|
else if (vClamped > 0 && isWord(inText[vClamped - 1])) vClamped - 1
|
||||||
else return vClamped..vClamped
|
else return vClamped..vClamped
|
||||||
var vStart = vProbe
|
var vStart = vProbe
|
||||||
while (vStart > 0 && isWord(inText[vStart - 1])) vStart--
|
while (vStart > 0 && isWord(inText[vStart - 1])) vStart--
|
||||||
var vEnd = vProbe
|
var vEnd = vProbe
|
||||||
@@ -712,9 +727,9 @@ private fun wordRangeAt(inText: String, inOffset: Int): IntRange {
|
|||||||
return vStart..vEnd
|
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
|
[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. */
|
count is O(body / kLinesPerChunk) which stays modest even for huge bodies. */
|
||||||
private fun chunkContaining(inChunks: List<BodyChunk>, inOffset: Int): Int? {
|
private fun chunkContaining(inChunks: List<BodyChunk>, inOffset: Int): Int? {
|
||||||
if (inChunks.isEmpty()) return null
|
if (inChunks.isEmpty()) return null
|
||||||
for (i in inChunks.indices) {
|
for (i in inChunks.indices) {
|
||||||
@@ -729,22 +744,22 @@ private fun chunkContaining(inChunks: List<BodyChunk>, inOffset: Int): Int? {
|
|||||||
// measure at the viewport edges, large enough to slash node/selectable count.
|
// measure at the viewport edges, large enough to slash node/selectable count.
|
||||||
private const val kLinesPerChunk = 48
|
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
|
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
|
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
|
count low as blocks scroll through the viewport. The gutter is pinned outside
|
||||||
the shared h-scroll and never joins the selection.
|
the shared h-scroll and never joins the selection.
|
||||||
|
|
||||||
Gutter alignment under soft-wrap: the gutter starts as plain 1:1 numbers, then the
|
Gutter alignment under soft-wrap: the gutter starts as plain 1:1 numbers, then the
|
||||||
body's own onTextLayout tells us how many VISUAL rows each source line wrapped into,
|
body's own onTextLayout tells us how many VISUAL rows each source line wrapped into,
|
||||||
and we pad the gutter with that many blank rows so number N still sits on line N's
|
and we pad the gutter with that many blank rows so number N still sits on line N's
|
||||||
first row. It reuses the body's layout (no extra measurement) and only recomputes on
|
first row. It reuses the body's layout (no extra measurement) and only recomputes on
|
||||||
(re)layout, not per frame. No-wrap mode is 1 row per line, so the plain gutter is exact.
|
(re)layout, not per frame. No-wrap mode is 1 row per line, so the plain gutter is exact.
|
||||||
|
|
||||||
The block's body text carries the current selection as a background SpanStyle
|
The block's body text carries the current selection as a background SpanStyle
|
||||||
intersected with [chunk.startCharOffset, +chunk.body.length). onTextLayout also
|
intersected with [chunk.startCharOffset, +chunk.body.length). onTextLayout also
|
||||||
reports the layout back up to the viewer so hit-testing can convert pointer
|
reports the layout back up to the viewer so hit-testing can convert pointer
|
||||||
positions into character offsets across the whole body. */
|
positions into character offsets across the whole body. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun BodyChunkRow(
|
private fun BodyChunkRow(
|
||||||
inChunk: BodyChunk,
|
inChunk: BodyChunk,
|
||||||
@@ -830,8 +845,9 @@ private fun BodyChunkRow(
|
|||||||
vSb.append(inChunk.firstLine + j)
|
vSb.append(inChunk.firstLine + j)
|
||||||
if (vLen > 0) {
|
if (vLen > 0) {
|
||||||
val vStart = inChunk.lineStartsInBlock[j].coerceIn(0, vMaxOff)
|
val vStart = inChunk.lineStartsInBlock[j].coerceIn(0, vMaxOff)
|
||||||
val vEnd = (if (j + 1 < inChunk.lineCount) inChunk.lineStartsInBlock[j + 1] - 1 else vMaxOff)
|
val vEnd =
|
||||||
.coerceIn(vStart, vMaxOff)
|
(if (j + 1 < inChunk.lineCount) inChunk.lineStartsInBlock[j + 1] - 1 else vMaxOff)
|
||||||
|
.coerceIn(vStart, vMaxOff)
|
||||||
val vRows = layout.getLineForOffset(vEnd) - layout.getLineForOffset(vStart) + 1
|
val vRows = layout.getLineForOffset(vEnd) - layout.getLineForOffset(vStart) + 1
|
||||||
repeat((vRows - 1).coerceAtLeast(0)) { vSb.append('\n') }
|
repeat((vRows - 1).coerceAtLeast(0)) { vSb.append('\n') }
|
||||||
}
|
}
|
||||||
@@ -855,11 +871,11 @@ 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
|
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
|
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),
|
full body (used to map local text offsets to global ones for cross-chunk selection),
|
||||||
and the highlighted block text. */
|
and the highlighted block text. */
|
||||||
private class BodyChunk(
|
private class BodyChunk(
|
||||||
val firstLine: Int,
|
val firstLine: Int,
|
||||||
val lineCount: Int,
|
val lineCount: Int,
|
||||||
@@ -868,9 +884,9 @@ private class BodyChunk(
|
|||||||
val body: AnnotatedString,
|
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
|
subSequence runs once per block HERE (build time, memoised) instead of once per
|
||||||
visible line every frame — the difference between smooth and janky on a big body. */
|
visible line every frame — the difference between smooth and janky on a big body. */
|
||||||
private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerChunk: Int): List<BodyChunk> {
|
private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerChunk: Int): List<BodyChunk> {
|
||||||
val vStarts = lineStartOffsets(inText)
|
val vStarts = lineStartOffsets(inText)
|
||||||
val vN = vStarts.size
|
val vN = vStarts.size
|
||||||
@@ -890,10 +906,10 @@ private fun buildBodyChunks(inText: String, inAnn: AnnotatedString, inLinesPerCh
|
|||||||
return vOut
|
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)
|
the -1 dropping the '\n'; the last line runs to the string end). One cheap O(n)
|
||||||
scan, memoised — replaces the old gutter pre-pass that ran measurer.wrap() on
|
scan, memoised — replaces the old gutter pre-pass that ran measurer.wrap() on
|
||||||
every one of N lines on each width change (the other half of the freeze). */
|
every one of N lines on each width change (the other half of the freeze). */
|
||||||
private fun lineStartOffsets(inText: String): IntArray {
|
private fun lineStartOffsets(inText: String): IntArray {
|
||||||
val vStarts = ArrayList<Int>(64)
|
val vStarts = ArrayList<Int>(64)
|
||||||
vStarts.add(0)
|
vStarts.add(0)
|
||||||
@@ -901,9 +917,9 @@ private fun lineStartOffsets(inText: String): IntArray {
|
|||||||
return vStarts.toIntArray()
|
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.
|
fixed width so values line up. Long keys wrap; long values wrap too.
|
||||||
Keys render in accent colour, values in regular text. */
|
Keys render in accent colour, values in regular text. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HeaderTable(inHeaders: List<Pair<String, String>>, modifier: Modifier = Modifier) {
|
internal fun HeaderTable(inHeaders: List<Pair<String, String>>, modifier: Modifier = Modifier) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -927,8 +943,8 @@ internal fun HeaderTable(inHeaders: List<Pair<String, String>>, modifier: Modifi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Body view with line numbers in the gutter — mimics the code panel
|
/** Body view with line numbers in the gutter — mimics the code panel
|
||||||
in httpie. Numbers are dim; body text uses the regular colour. */
|
in httpie. Numbers are dim; body text uses the regular colour. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BodyView(
|
internal fun BodyView(
|
||||||
inText: String,
|
inText: String,
|
||||||
@@ -974,18 +990,18 @@ internal fun BodyView(
|
|||||||
// also use it for the first frame, before the body reports its width).
|
// also use it for the first frame, before the body reports its width).
|
||||||
val vNumColor = c.dim.copy(alpha = 0.45f)
|
val vNumColor = c.dim.copy(alpha = 0.45f)
|
||||||
val vNumbers = remember(inText, vBodyWidthPx, inSoftWrap, vFontPx) {
|
val vNumbers = remember(inText, vBodyWidthPx, inSoftWrap, vFontPx) {
|
||||||
val vM = if (inSoftWrap && vBodyWidthPx > 0) currentTextMeasurer else null
|
val vWrapRows = inSoftWrap && vBodyWidthPx > 0
|
||||||
buildString {
|
buildString {
|
||||||
for ((vIdx, vSrc) in vLines.withIndex()) {
|
for ((vIdx, vSrc) in vLines.withIndex()) {
|
||||||
if (vIdx > 0) append('\n')
|
if (vIdx > 0) append('\n')
|
||||||
append(vIdx + 1)
|
append(vIdx + 1)
|
||||||
if (vM != null) {
|
if (vWrapRows) {
|
||||||
// Pass the density-scaled font size so this wrap matches the body's
|
// Pass the density-scaled font size so this wrap matches the body's
|
||||||
// actual glyph pixel widths — passing 12 (sp) here counted each
|
// actual glyph pixel widths — passing 12 (sp) here counted each
|
||||||
// char as half its rendered size, so long lines that visually
|
// char as half its rendered size, so long lines that visually
|
||||||
// wrapped into 3 rows in the body were estimated as 1 or 2 rows
|
// wrapped into 3 rows in the body were estimated as 1 or 2 rows
|
||||||
// here and the gutter numbers drifted upward.
|
// here and the gutter numbers drifted upward.
|
||||||
val vRows = vM.wrap(vSrc, vFontPx, vBodyWidthPx, monoFontFamilyName).lines.size.coerceAtLeast(1)
|
val vRows = wrappedRowCount(vSrc, vFontPx, vBodyWidthPx, monoFontFamilyName).coerceAtLeast(1)
|
||||||
repeat(vRows - 1) { append('\n') }
|
repeat(vRows - 1) { append('\n') }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1008,7 +1024,8 @@ internal fun BodyView(
|
|||||||
// anywhere (not just on the text line) focuses it and starts writing.
|
// anywhere (not just on the text line) focuses it and starts writing.
|
||||||
// Read-only (response) keeps wrap-height so it grows with content.
|
// Read-only (response) keeps wrap-height so it grows with content.
|
||||||
val vEditable = inOnChange != null
|
val vEditable = inOnChange != null
|
||||||
Box(modifier = Modifier.weight(1f)
|
Box(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
.onSizeChanged { vBodyWidthPx = it.width }
|
.onSizeChanged { vBodyWidthPx = it.width }
|
||||||
.then(if (vEditable) Modifier.fillMaxHeight() else Modifier)) {
|
.then(if (vEditable) Modifier.fillMaxHeight() else Modifier)) {
|
||||||
if (inText.isEmpty() && inPlaceholder.isNotEmpty()) {
|
if (inText.isEmpty() && inPlaceholder.isNotEmpty()) {
|
||||||
@@ -1031,16 +1048,16 @@ internal fun BodyView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidx.compose.runtime.CompositionLocalProvider(
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
androidx.compose.foundation.text.selection.LocalTextSelectionColors provides
|
LocalTextSelectionColors provides
|
||||||
androidx.compose.foundation.text.selection.TextSelectionColors(
|
androidx.compose.foundation.text.selection.TextSelectionColors(
|
||||||
handleColor = c.accent,
|
handleColor = c.accent,
|
||||||
backgroundColor = c.accent.copy(alpha = 0.35f),
|
backgroundColor = c.accent.copy(alpha = 0.35f),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = inText,
|
value = inText,
|
||||||
onValueChange = inOnChange,
|
onValueChange = inOnChange,
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(
|
textStyle = TextStyle(
|
||||||
color = c.text,
|
color = c.text,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
fontFamily = monoFontFamily,
|
fontFamily = monoFontFamily,
|
||||||
@@ -1079,11 +1096,11 @@ 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 non-null (e.g. method name once sent, status code once received);
|
||||||
when null the tab is just the label, no placeholder dash. Selected
|
when null the tab is just the label, no placeholder dash. Selected
|
||||||
tab gets full-strength labels and a 2dp underline; unselected tabs
|
tab gets full-strength labels and a 2dp underline; unselected tabs
|
||||||
stay dimmed. */
|
stay dimmed. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewerTab(
|
internal fun ViewerTab(
|
||||||
inLabel: String,
|
inLabel: String,
|
||||||
@@ -1103,7 +1120,11 @@ internal fun ViewerTab(
|
|||||||
) {
|
) {
|
||||||
Text(inLabel, color = if (inSelected) c.dim else c.dim.copy(alpha = 0.6f), fontSize = 14.sp)
|
Text(inLabel, color = if (inSelected) c.dim else c.dim.copy(alpha = 0.6f), fontSize = 14.sp)
|
||||||
if (inAccent != null) {
|
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))
|
Spacer(Modifier.height(4.dp))
|
||||||
@@ -1120,10 +1141,10 @@ internal fun ViewerTab(
|
|||||||
// MARK: Helpers — header parsing / formatting
|
// MARK: Helpers — header parsing / formatting
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* "HTTP/1.1 200 OK" status line for a Response, with the protocol
|
/** "HTTP/1.1 200 OK" status line for a Response, with the protocol
|
||||||
token dimmed (it's structural) and the status code + reason in the
|
token dimmed (it's structural) and the status code + reason in the
|
||||||
status colour. Just "HTTP/1.1" pre-response — no placeholder. Triple-
|
status colour. Just "HTTP/1.1" pre-response — no placeholder. Triple-
|
||||||
spaced to match the request-line formatting. */
|
spaced to match the request-line formatting. */
|
||||||
internal fun formatStatusLine(inResp: ApiResponse?, inColors: AppColors): AnnotatedString {
|
internal fun formatStatusLine(inResp: ApiResponse?, inColors: AppColors): AnnotatedString {
|
||||||
if (inResp == null) return buildAnnotatedString {
|
if (inResp == null) return buildAnnotatedString {
|
||||||
pushStyle(SpanStyle(color = inColors.dim))
|
pushStyle(SpanStyle(color = inColors.dim))
|
||||||
@@ -1150,34 +1171,35 @@ 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
|
its own colour: method in its canonical method colour, URL in the
|
||||||
default text colour, protocol token dimmed so the eye lands on 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.
|
request target first. Triple-spaced so the columns read at a glance.
|
||||||
The protocol string comes from the matching response when we have
|
The protocol string comes from the matching response when we have
|
||||||
one (so HTTP/2 servers show "HTTP/2"); fallback "HTTP/1.1" before
|
one (so HTTP/2 servers show "HTTP/2"); fallback "HTTP/1.1" before
|
||||||
send. */
|
send. */
|
||||||
internal fun formatRequestLine(inReq: ApiRequest, inHttpVersion: String, inColors: AppColors): AnnotatedString = buildAnnotatedString {
|
internal fun formatRequestLine(inReq: ApiRequest, inHttpVersion: String, inColors: AppColors): AnnotatedString =
|
||||||
pushStyle(SpanStyle(color = methodColor(inReq.method), fontWeight = FontWeight.Bold))
|
buildAnnotatedString {
|
||||||
append(inReq.method.name)
|
pushStyle(SpanStyle(color = methodColor(inReq.method), fontWeight = FontWeight.Bold))
|
||||||
pop()
|
append(inReq.method.name)
|
||||||
append(" ")
|
pop()
|
||||||
pushStyle(SpanStyle(color = inColors.text))
|
append(" ")
|
||||||
// urlWithParams appends the Query-tab key/values percent-encoded;
|
pushStyle(SpanStyle(color = inColors.text))
|
||||||
// urlPathOnly then strips scheme+host so the request line reads
|
// urlWithParams appends the Query-tab key/values percent-encoded;
|
||||||
// "GET /search?q=foo&n=10 HTTP/1.1" like httpie / curl trace.
|
// urlPathOnly then strips scheme+host so the request line reads
|
||||||
append(urlPathOnly(urlWithParams(inReq)).ifEmpty { "/" })
|
// "GET /search?q=foo&n=10 HTTP/1.1" like httpie / curl trace.
|
||||||
pop()
|
append(urlPathOnly(urlWithParams(inReq)).ifEmpty { "/" })
|
||||||
append(" ")
|
pop()
|
||||||
pushStyle(SpanStyle(color = inColors.dim))
|
append(" ")
|
||||||
append(inHttpVersion)
|
pushStyle(SpanStyle(color = inColors.dim))
|
||||||
pop()
|
append(inHttpVersion)
|
||||||
}
|
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
|
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
|
separate Host: header line, not in the path. Falls back to the input
|
||||||
string when no "://" is present (relative URL). */
|
string when no "://" is present (relative URL). */
|
||||||
internal fun urlPathOnly(inUrl: String): String {
|
internal fun urlPathOnly(inUrl: String): String {
|
||||||
val vIdx = inUrl.indexOf("://")
|
val vIdx = inUrl.indexOf("://")
|
||||||
if (vIdx < 0) return inUrl
|
if (vIdx < 0) return inUrl
|
||||||
@@ -1186,8 +1208,8 @@ internal fun urlPathOnly(inUrl: String): String {
|
|||||||
return if (vSlash < 0) "/" else vAfterScheme.substring(vSlash)
|
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. */
|
header for display when the engine didn't surface one. */
|
||||||
internal fun urlHost(inUrl: String): String? {
|
internal fun urlHost(inUrl: String): String? {
|
||||||
val vIdx = inUrl.indexOf("://")
|
val vIdx = inUrl.indexOf("://")
|
||||||
if (vIdx < 0) return null
|
if (vIdx < 0) return null
|
||||||
@@ -1197,44 +1219,44 @@ internal fun urlHost(inUrl: String): String? {
|
|||||||
return vAuthority.ifEmpty { null }
|
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
|
NSURLSession picks the default. Match what httpie does: identify
|
||||||
ourselves so the wire log isn't missing the field entirely. */
|
ourselves so the wire log isn't missing the field entirely. */
|
||||||
internal const val kUserAgent: String = "compose-apidemo/1.0"
|
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
|
adds at the wire level (Host, Content-Length, User-Agent) so the
|
||||||
Request tab shows the actual on-the-wire header set rather than
|
Request tab shows the actual on-the-wire header set rather than
|
||||||
just the subset Ktor sees. Sorted alphabetically. */
|
just the subset Ktor sees. Sorted alphabetically. */
|
||||||
internal fun synthesizeRequestHeaders(
|
internal fun synthesizeRequestHeaders(
|
||||||
inReq: ApiRequest,
|
inReq: ApiRequest,
|
||||||
inReported: List<Pair<String, String>>,
|
inReported: List<Pair<String, String>>,
|
||||||
): 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 ->
|
urlHost(inReq.url)?.let { vHost ->
|
||||||
vByKey.getOrPut("host") { "Host" to vHost }
|
vByKey.getOrPut("host") { "Host" to vHost }
|
||||||
}
|
}
|
||||||
if (!vByKey.containsKey("user-agent")) {
|
if (!vByKey.containsKey("user-agent")) {
|
||||||
vByKey["user-agent"] = "User-Agent" to kUserAgent
|
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)
|
val vLen = computedBodyLength(inReq)
|
||||||
if (vLen != null) vByKey["content-length"] = "Content-Length" to vLen.toString()
|
if (vLen != null) vByKey["content-length"] = "Content-Length" to vLen.toString()
|
||||||
}
|
}
|
||||||
return vByKey.values.sortedBy { it.first.lowercase() }
|
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
|
raw UTF-8 byte count; FORM serialises and counts; FILE skips
|
||||||
(loading the file just for the count would be wasteful — the engine
|
(loading the file just for the count would be wasteful — the engine
|
||||||
sets the field anyway). */
|
sets the field anyway). */
|
||||||
internal fun computedBodyLength(inReq: ApiRequest): Int? = when (inReq.bodyType) {
|
internal fun computedBodyLength(inReq: ApiRequest): Int? = when (inReq.bodyType) {
|
||||||
BodyType.TEXT -> inReq.body.encodeToByteArray().size
|
BodyType.TEXT -> inReq.body.encodeToByteArray().size
|
||||||
BodyType.FORM -> formEncode(inReq.form).encodeToByteArray().size
|
BodyType.FORM -> formEncode(inReq.form).encodeToByteArray().size
|
||||||
BodyType.FILE, BodyType.NONE -> null
|
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 {
|
internal fun formatTimingSize(inResp: ApiResponse): String {
|
||||||
val vSize = when {
|
val vSize = when {
|
||||||
inResp.sizeBytes < 1024 -> "${inResp.sizeBytes} B"
|
inResp.sizeBytes < 1024 -> "${inResp.sizeBytes} B"
|
||||||
@@ -1248,10 +1270,10 @@ internal fun formatTimingSize(inResp: ApiResponse): String {
|
|||||||
// MARK: ViewerOverflowMenu — 3-dot menu in the bottom-right of the viewer
|
// MARK: ViewerOverflowMenu — 3-dot menu in the bottom-right of the viewer
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Replaces the inline Copy / Save chips with a single MoreHoriz menu.
|
/** Replaces the inline Copy / Save chips with a single MoreHoriz menu.
|
||||||
Copy actions target whichever tab is showing (request or response);
|
Copy actions target whichever tab is showing (request or response);
|
||||||
Clear is global — wipes the response, sentReq, preview state, and any
|
Clear is global — wipes the response, sentReq, preview state, and any
|
||||||
memory-backed image resource for the current request. */
|
memory-backed image resource for the current request. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewerOverflowMenu(
|
internal fun ViewerOverflowMenu(
|
||||||
inIsRequestTab: Boolean,
|
inIsRequestTab: Boolean,
|
||||||
@@ -1309,18 +1331,18 @@ internal fun ViewerOverflowMenu(
|
|||||||
}
|
}
|
||||||
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
|
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
|
||||||
DropdownMenuItem(text = { Text("Copy all") }, onClick = {
|
DropdownMenuItem(text = { Text("Copy all") }, onClick = {
|
||||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(copyAll())) }
|
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(copyAll())) }
|
||||||
inOnMessage("Copied all.")
|
inOnMessage("Copied all.")
|
||||||
vOpen = false
|
vOpen = false
|
||||||
})
|
})
|
||||||
DropdownMenuItem(text = { Text("Copy headers") }, onClick = {
|
DropdownMenuItem(text = { Text("Copy headers") }, onClick = {
|
||||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(vHeadersText)) }
|
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(vHeadersText)) }
|
||||||
inOnMessage("Copied headers.")
|
inOnMessage("Copied headers.")
|
||||||
vOpen = false
|
vOpen = false
|
||||||
})
|
})
|
||||||
if (vCanCopyBody) {
|
if (vCanCopyBody) {
|
||||||
DropdownMenuItem(text = { Text("Copy body") }, onClick = {
|
DropdownMenuItem(text = { Text("Copy body") }, onClick = {
|
||||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(vBodyText)) }
|
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(vBodyText)) }
|
||||||
inOnMessage("Copied body.")
|
inOnMessage("Copied body.")
|
||||||
vOpen = false
|
vOpen = false
|
||||||
})
|
})
|
||||||
@@ -1341,13 +1363,15 @@ internal fun ViewerOverflowMenu(
|
|||||||
vOpen = false
|
vOpen = false
|
||||||
})
|
})
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = c.border)
|
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. */
|
(background gets a light foreground). Standard Rec. 709 weights. */
|
||||||
internal fun isDarkBg(inColor: Color): Boolean {
|
internal fun isDarkBg(inColor: Color): Boolean {
|
||||||
val vY = 0.299f * inColor.red + 0.587f * inColor.green + 0.114f * inColor.blue
|
val vY = 0.299f * inColor.red + 0.587f * inColor.green + 0.114f * inColor.blue
|
||||||
return vY < 0.5f
|
return vY < 0.5f
|
||||||
@@ -1357,9 +1381,9 @@ internal fun isDarkBg(inColor: Color): Boolean {
|
|||||||
// MARK: BodyFormatSelector — small dropdown for RAW / JSON / XML / YAML / HTML
|
// MARK: BodyFormatSelector — small dropdown for RAW / JSON / XML / YAML / HTML
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Format / "type" picker. inBordered renders it as a full dropdown matching
|
/** Format / "type" picker. inBordered renders it as a full dropdown matching
|
||||||
BodyTypeMenu (the Body-tab Text-type picker); otherwise it's the compact inline
|
BodyTypeMenu (the Body-tab Text-type picker); otherwise it's the compact inline
|
||||||
control used by the response viewer. */
|
control used by the response viewer. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BodyFormatSelector(
|
internal fun BodyFormatSelector(
|
||||||
inSelected: BodyFormat,
|
inSelected: BodyFormat,
|
||||||
@@ -1381,23 +1405,28 @@ internal fun BodyFormatSelector(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(if (inBordered) 5.dp else 4.dp),
|
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)
|
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 }) {
|
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
|
||||||
for (vF in BodyFormat.values()) {
|
for (vF in BodyFormat.entries) {
|
||||||
DropdownMenuItem(text = { Text(vF.label, color = if (vF == inSelected) c.accent else c.text) },
|
DropdownMenuItem(
|
||||||
|
text = { Text(vF.label, color = if (vF == inSelected) c.accent else c.text) },
|
||||||
onClick = { inOnChange(vF); vOpen = false })
|
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
|
a real response back (i.e. the OS engine completed the TLS handshake
|
||||||
without an error). The engines we ship — NSURLSession on macOS,
|
without an error). The engines we ship — NSURLSession on macOS,
|
||||||
WinHttp on Windows, libcurl on Linux — all reject an untrusted
|
WinHttp on Windows, libcurl on Linux — all reject an untrusted
|
||||||
certificate by default, so a non-error response is implicit proof
|
certificate by default, so a non-error response is implicit proof
|
||||||
that the OS validated the chain. */
|
that the OS validated the chain. */
|
||||||
internal fun isTlsValidated(inUrl: String, inResp: ApiResponse?): Boolean {
|
internal fun isTlsValidated(inUrl: String, inResp: ApiResponse?): Boolean {
|
||||||
if (!inUrl.startsWith("https://", ignoreCase = true)) return false
|
if (!inUrl.startsWith("https://", ignoreCase = true)) return false
|
||||||
if (inResp == null) return false
|
if (inResp == null) return false
|
||||||
@@ -1405,8 +1434,8 @@ internal fun isTlsValidated(inUrl: String, inResp: ApiResponse?): Boolean {
|
|||||||
return inResp.status > 0
|
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. */
|
table renderer can lay them out as a key/value grid. */
|
||||||
internal fun parseHeaderLines(inText: String): List<Pair<String, String>> {
|
internal fun parseHeaderLines(inText: String): List<Pair<String, String>> {
|
||||||
if (inText.isBlank() || inText == "(no headers)") return emptyList()
|
if (inText.isBlank() || inText == "(no headers)") return emptyList()
|
||||||
return inText.split('\n').mapNotNull { vLine ->
|
return inText.split('\n').mapNotNull { vLine ->
|
||||||
@@ -1415,16 +1444,16 @@ 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. */
|
already-correct ones unchanged. */
|
||||||
internal fun titleCaseHeader(inKey: String): String =
|
internal fun titleCaseHeader(inKey: String): String =
|
||||||
inKey.split('-').joinToString("-") { vWord ->
|
inKey.split('-').joinToString("-") { vWord ->
|
||||||
if (vWord.isEmpty()) vWord
|
if (vWord.isEmpty()) vWord
|
||||||
else vWord[0].uppercaseChar() + vWord.drop(1).lowercase()
|
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. */
|
No Body). Fills its parent unless a sized modifier is supplied. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewerEmpty(inIcon: Int, inText: String, inModifier: Modifier = Modifier.fillMaxSize()) {
|
internal fun ViewerEmpty(inIcon: Int, inText: String, inModifier: Modifier = Modifier.fillMaxSize()) {
|
||||||
val c = LocalAppColors.current
|
val c = LocalAppColors.current
|
||||||
@@ -1436,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. */
|
/** Format an actual header list (key: value per line). */
|
||||||
@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). */
|
|
||||||
internal fun headersText(inHeaders: List<Pair<String, String>>): String =
|
internal fun headersText(inHeaders: List<Pair<String, String>>): String =
|
||||||
inHeaders.joinToString("\n") { (vK, vV) -> "$vK: $vV" }.ifEmpty { "(no headers)" }
|
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;
|
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). */
|
a sent request shows the real headers via headersText(response.requestHeaders). */
|
||||||
internal fun requestHeadersText(inReq: ApiRequest): String {
|
internal fun requestHeadersText(inReq: ApiRequest): String {
|
||||||
val vLines = mutableListOf<String>()
|
val vLines = mutableListOf<String>()
|
||||||
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { vLines.add("${it.key}: ${it.value}") }
|
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { vLines.add("${it.key}: ${it.value}") }
|
||||||
val vCt = inReq.bodyContentType()
|
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")
|
vLines.add("Content-Type: $vCt")
|
||||||
}
|
}
|
||||||
return vLines.joinToString("\n").ifEmpty { "(no headers)" }
|
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) {
|
internal fun requestBodyText(inReq: ApiRequest): String = when (inReq.bodyType) {
|
||||||
BodyType.NONE -> "(no body)"
|
BodyType.NONE -> "(no body)"
|
||||||
BodyType.TEXT -> inReq.body.ifEmpty { "(empty)" }
|
BodyType.TEXT -> inReq.body.ifEmpty { "(empty)" }
|
||||||
BodyType.FORM -> formEncode(inReq.form).ifEmpty { "(empty form)" }
|
BodyType.FORM -> formEncode(inReq.form).ifEmpty { "(empty form)" }
|
||||||
BodyType.FILE -> if (inReq.body.isBlank()) "(no file)" else "(file) ${inReq.body}"
|
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package apidemo.compat
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import io.ktor.client.*
|
||||||
|
import okio.FileSystem
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Platform seams (native = com.compose.sdl / SDL3, jvm = AWT + upstream desktop)
|
||||||
|
// ==================
|
||||||
|
// The port modules are native-only, so shared code can't call com.compose.sdl
|
||||||
|
// directly — every SDL-backed API the app uses goes through these expects.
|
||||||
|
// Signatures mirror the native originals so the native actuals are pure
|
||||||
|
// delegation (same pattern as :demo's demo.shim package).
|
||||||
|
|
||||||
|
/** Per-user app-data dir (created on demand, ends with a separator) or null. */
|
||||||
|
expect fun appDataDir(inOrg: String, inApp: String): String?
|
||||||
|
|
||||||
|
/** Open the OS file manager showing the given path. Fire-and-forget. */
|
||||||
|
expect fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)? = null)
|
||||||
|
|
||||||
|
/** "Explorer" / "Finder" / "Files" — for menu labels. */
|
||||||
|
expect fun fileManagerName(): String
|
||||||
|
|
||||||
|
/** Async save-file dialog; the callback gets the chosen absolute path or null. */
|
||||||
|
expect fun showSaveFileDialog(inDefaultName: String? = null, inOnResult: (String?) -> Unit)
|
||||||
|
|
||||||
|
/** Async open-file dialog; the callback gets the chosen absolute path or null. */
|
||||||
|
expect fun showOpenFileDialog(inOnResult: (String?) -> Unit)
|
||||||
|
|
||||||
|
/** A plain-text clipboard entry (ClipEntry construction differs per stack). */
|
||||||
|
expect fun clipEntryOfText(inText: String): ClipEntry
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// In-memory response images
|
||||||
|
|
||||||
|
/** Register/replace decoded-image source bytes under a key (resp-image://N). */
|
||||||
|
expect fun registerMemoryImage(inKey: String, inBytes: ByteArray)
|
||||||
|
|
||||||
|
/** Drop a registered image's bytes. */
|
||||||
|
expect fun removeMemoryImage(inKey: String)
|
||||||
|
|
||||||
|
/** Painter for a registered image key (raster or SVG). */
|
||||||
|
@Composable
|
||||||
|
expect fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Text layout
|
||||||
|
|
||||||
|
/** Rows the given line occupies when soft-wrapped to inMaxWidthPx at
|
||||||
|
inFontPx (already density-scaled). Used by the body gutter so its line
|
||||||
|
numbers stay aligned with the wrapped body text. */
|
||||||
|
expect fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int
|
||||||
|
|
||||||
|
/** Editor tab width in spaces — how wide a typed '\t' renders. On the native
|
||||||
|
stack this drives the project text pipeline (TextLayoutConfig); the jvm
|
||||||
|
parity app only stores the preference. */
|
||||||
|
expect var editorTabWidth: Int
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Infrastructure
|
||||||
|
|
||||||
|
/** okio's FileSystem.SYSTEM is declared per-platform, not in its common
|
||||||
|
metadata — surface it through a seam. */
|
||||||
|
internal expect val systemFileSystem: FileSystem
|
||||||
|
|
||||||
|
/** The app's Ktor client: Curl on native (bundled libcurl — same TLS stack as
|
||||||
|
the mTLS path), CIO on the jvm parity target. */
|
||||||
|
expect fun createApiHttpClient(): HttpClient
|
||||||
|
|
||||||
|
/** Window-shell integration: an intercepting close handler (return true to
|
||||||
|
allow the close) and an app-wide key-shortcut handler; both cleared when
|
||||||
|
the composition leaves. */
|
||||||
|
@Composable
|
||||||
|
expect fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package apidemo.compat
|
||||||
|
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.positionInWindow
|
||||||
|
|
||||||
|
// COPY of the port's com.compose.sdl.layout LayoutCoordinates sugar so the
|
||||||
|
// same helpers resolve on the jvm parity target (the port modules are
|
||||||
|
// native-only). Guards `isAttached`: a menu/tooltip anchor can be read while
|
||||||
|
// its node is mid-recycle, and positionInWindow() throws on a detached
|
||||||
|
// coordinator.
|
||||||
|
|
||||||
|
/** Logical-point X of this layout in the window. */
|
||||||
|
val LayoutCoordinates.x: Int get() = if (isAttached) positionInWindow().x.toInt() else 0
|
||||||
|
|
||||||
|
/** Logical-point Y of this layout in the window. */
|
||||||
|
val LayoutCoordinates.y: Int get() = if (isAttached) positionInWindow().y.toInt() else 0
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// COPY of the port's Scrollbar (package com.compose.sdl.scrollbar) so the SAME
|
||||||
|
// implementation runs on the jvm parity target — the port modules are
|
||||||
|
// native-only, so apidemo's commonMain cannot reference them directly.
|
||||||
|
// Keep in sync with the original when it changes.
|
||||||
|
package apidemo.compat
|
||||||
|
|
||||||
|
import androidx.compose.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.*
|
||||||
|
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.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Scrollbar (Compose Desktop-style)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** A faithful subset of Compose Desktop's scrollbar (androidx.compose.foundation).
|
||||||
|
You overlay a VerticalScrollbar / HorizontalScrollbar on a scrollable and feed
|
||||||
|
it a ScrollbarAdapter built from the same ScrollState. This project's Box has
|
||||||
|
no BoxScope (so no Modifier.align) — pin the bar with the Box's
|
||||||
|
contentAlignment; the fillMaxSize content fills the rest:
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
|
||||||
|
val state = rememberScrollState()
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(state)) { ... }
|
||||||
|
VerticalScrollbar(
|
||||||
|
adapter = rememberScrollbarAdapter(state),
|
||||||
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Always visible (no auto-hide); the thumb tweens unhoverColor -> hoverColor on
|
||||||
|
hover over hoverDurationMillis, matching upstream. Drag the thumb to scroll;
|
||||||
|
click the track to page. */
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class ScrollbarStyle(
|
||||||
|
val minimalHeight: Dp,
|
||||||
|
val thickness: Dp,
|
||||||
|
val shape: Shape,
|
||||||
|
val hoverDurationMillis: Int,
|
||||||
|
val unhoverColor: Color,
|
||||||
|
val hoverColor: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
|
||||||
|
provide a white-tinted style via LocalScrollbarStyle or the `style` param. */
|
||||||
|
fun defaultScrollbarStyle(): ScrollbarStyle = ScrollbarStyle(
|
||||||
|
minimalHeight = 16.dp,
|
||||||
|
thickness = 8.dp,
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
hoverDurationMillis = 300,
|
||||||
|
unhoverColor = Color.Black.copy(alpha = 0.12f),
|
||||||
|
hoverColor = Color.Black.copy(alpha = 0.50f),
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalScrollbarStyle = compositionLocalOf { defaultScrollbarStyle() }
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: ScrollbarAdapter
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
|
||||||
|
contentSize / viewportSize are along the scroll axis; the thumb is sized by
|
||||||
|
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
|
||||||
|
interface ScrollbarAdapter {
|
||||||
|
val scrollOffset: Double
|
||||||
|
val contentSize: Double
|
||||||
|
val viewportSize: Double
|
||||||
|
suspend fun scrollTo(scrollOffset: Double)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (The port also ships a ScrollState-based adapter; it reads foundation
|
||||||
|
// internals (ScrollState.contentSize/viewportSize) that a copy outside
|
||||||
|
// :foundation cannot access. apidemo only scrollbars a LazyColumn.)
|
||||||
|
|
||||||
|
/** Adapter for a virtualized LazyColumn / LazyRow. A lazy list only knows the
|
||||||
|
size of its VISIBLE items, so exact pixel geometry of off-screen content is
|
||||||
|
unknowable — we estimate it from the average visible item size × total item
|
||||||
|
count (the same approach as Compose Desktop). Accurate enough for a thumb
|
||||||
|
whose length/position track the scroll position; the estimate self-corrects
|
||||||
|
as items of different sizes scroll through the viewport. */
|
||||||
|
@Composable
|
||||||
|
fun rememberScrollbarAdapter(scrollState: LazyListState): ScrollbarAdapter =
|
||||||
|
remember(scrollState) { LazyListScrollbarAdapter(scrollState) }
|
||||||
|
|
||||||
|
private class LazyListScrollbarAdapter(private val state: LazyListState) : ScrollbarAdapter {
|
||||||
|
// Mean main-axis size of the currently-visible items, in px (>= 1 to avoid /0).
|
||||||
|
private fun averageItemSize(): Double {
|
||||||
|
val vVisible = state.layoutInfo.visibleItemsInfo
|
||||||
|
if (vVisible.isEmpty()) return 0.0
|
||||||
|
val vSpan = (vVisible.last().offset + vVisible.last().size) - vVisible.first().offset
|
||||||
|
return (vSpan.toDouble() / vVisible.size).coerceAtLeast(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val scrollOffset: Double
|
||||||
|
get() = averageItemSize() * state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset
|
||||||
|
|
||||||
|
override val contentSize: Double
|
||||||
|
get() = averageItemSize() * state.layoutInfo.totalItemsCount
|
||||||
|
|
||||||
|
override val viewportSize: Double
|
||||||
|
get() = (state.layoutInfo.viewportEndOffset - state.layoutInfo.viewportStartOffset).toDouble()
|
||||||
|
|
||||||
|
override suspend fun scrollTo(scrollOffset: Double) {
|
||||||
|
val vDistance = scrollOffset - this.scrollOffset
|
||||||
|
// Within a viewport, scroll by EXACT pixels so a drag stays smooth. A lazy
|
||||||
|
// list's pixel geometry is only ESTIMATED (average visible item size), and
|
||||||
|
// that estimate drifts as differently-sized items scroll through the
|
||||||
|
// viewport — so index-snapping every frame makes the thumb jump. scrollBy
|
||||||
|
// sidesteps the estimate entirely. This is what Compose Desktop's own
|
||||||
|
// LazyListScrollbarAdapter does, and for the same reason.
|
||||||
|
if (abs(vDistance) <= viewportSize) {
|
||||||
|
state.scrollBy(vDistance.toFloat())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Far jump (track-click paging past a viewport): no exact geometry to lean
|
||||||
|
// on, so estimate an index/offset from the average and snap to it.
|
||||||
|
val vAvg = averageItemSize()
|
||||||
|
if (vAvg <= 0.0) return
|
||||||
|
val vTarget = scrollOffset.coerceAtLeast(0.0)
|
||||||
|
val vTotal = state.layoutInfo.totalItemsCount
|
||||||
|
val vIndex = (vTarget / vAvg).toInt().coerceIn(0, (vTotal - 1).coerceAtLeast(0))
|
||||||
|
val vItemOffset = (vTarget - vIndex * vAvg).toInt().coerceAtLeast(0)
|
||||||
|
state.scrollToItem(vIndex, vItemOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: VerticalScrollbar / HorizontalScrollbar
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerticalScrollbar(
|
||||||
|
adapter: ScrollbarAdapter,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
reverseLayout: Boolean = false,
|
||||||
|
style: ScrollbarStyle = LocalScrollbarStyle.current,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = true)
|
||||||
|
|
||||||
|
/** Shared impl. The root Box is the track (fixed thickness on the cross axis,
|
||||||
|
filling the main axis from the caller's modifier); a child Box is the thumb,
|
||||||
|
positioned with an offset. Drag + track-click are handled on the track in
|
||||||
|
track-relative coordinates (a stable origin), so the grab point stays under
|
||||||
|
the pointer with no feedback from the thumb moving. interactionSource is
|
||||||
|
accepted for API parity; hover here is driven by Modifier.hoverable. */
|
||||||
|
@Composable
|
||||||
|
private fun Scrollbar(
|
||||||
|
adapter: ScrollbarAdapter,
|
||||||
|
modifier: Modifier,
|
||||||
|
reverseLayout: Boolean,
|
||||||
|
style: ScrollbarStyle,
|
||||||
|
@Suppress("UNUSED_PARAMETER") interactionSource: MutableInteractionSource,
|
||||||
|
isVertical: Boolean,
|
||||||
|
) {
|
||||||
|
val vScope = rememberCoroutineScope()
|
||||||
|
var vTrackPx by remember { mutableStateOf(0) }
|
||||||
|
val vHoverSource = remember { MutableInteractionSource() }
|
||||||
|
val vHovered by vHoverSource.collectIsHoveredAsState()
|
||||||
|
var vDraggingThumb by remember { mutableStateOf(false) }
|
||||||
|
// onSizeChanged reports PHYSICAL pixels (Option-B density flow), so every
|
||||||
|
// int/px carried through here — vTrackPx, vThumbLen, vThumbPos — is in
|
||||||
|
// physical pixels. Passing them into `.dp` doubles again through
|
||||||
|
// density.toPx() on Retina, which visibly bloated the thumb (filled the
|
||||||
|
// whole track) and made drag/track-click positions land 2× too far down.
|
||||||
|
// Route pixel-sized modifiers through the current density so `.dp` and the
|
||||||
|
// pixel value stay in the same coord space.
|
||||||
|
val vDensity = LocalDensity.current
|
||||||
|
|
||||||
|
val vContent = adapter.contentSize
|
||||||
|
val vViewport = adapter.viewportSize
|
||||||
|
val vOffset = adapter.scrollOffset
|
||||||
|
val vMaxScroll = vContent - vViewport
|
||||||
|
val vScrollable = vMaxScroll > 0.5 && vViewport > 0.0
|
||||||
|
|
||||||
|
val vColor by animateColorAsState(
|
||||||
|
targetValue = if (vHovered || vDraggingThumb) style.hoverColor else style.unhoverColor,
|
||||||
|
animationSpec = tween(style.hoverDurationMillis),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vRoot = (if (isVertical) modifier.width(style.thickness) else modifier.height(style.thickness))
|
||||||
|
.onSizeChanged { vTrackPx = if (isVertical) it.height else it.width }
|
||||||
|
.hoverable(vHoverSource)
|
||||||
|
|
||||||
|
Box(modifier = vRoot) {
|
||||||
|
if (!vScrollable || vTrackPx <= 0) return@Box
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Thumb metrics (px)
|
||||||
|
val vMinLen = style.minimalHeight.value.toInt()
|
||||||
|
val vThumbLen = (vTrackPx * (vViewport / vContent)).roundToInt()
|
||||||
|
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
|
||||||
|
val vTravel = vTrackPx - vThumbLen
|
||||||
|
val vRawPos = if (vTravel <= 0) 0 else (vTravel * (vOffset / vMaxScroll)).roundToInt()
|
||||||
|
val vThumbPos = (if (reverseLayout) vTravel - vRawPos else vRawPos).coerceIn(0, vTravel)
|
||||||
|
|
||||||
|
val vThumbLenDp = with(vDensity) { vThumbLen.toDp() }
|
||||||
|
val vThumbMod = (
|
||||||
|
if (isVertical) Modifier.offset { IntOffset(0, vThumbPos) }.width(style.thickness).height(vThumbLenDp)
|
||||||
|
else Modifier.offset { IntOffset(vThumbPos, 0) }.height(style.thickness).width(vThumbLenDp)
|
||||||
|
)
|
||||||
|
.clip(style.shape)
|
||||||
|
.background(vColor)
|
||||||
|
|
||||||
|
Box(modifier = vThumbMod)
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Drag the thumb / page on track click — handled on the track so the
|
||||||
|
// coordinates have a stable (non-moving) origin. The press is CONSUMED:
|
||||||
|
// a scrollbar owns the pointer input it handles, so content sharing this
|
||||||
|
// region (the scrollable beneath, or a parent that also reads pointer
|
||||||
|
// input) never reacts to the same press. Geometry is read LIVE from the
|
||||||
|
// adapter at press time — not from the captured render metrics above,
|
||||||
|
// which go stale between pointerInput relaunches — so a grab after a
|
||||||
|
// wheel-scroll still hit-tests against the thumb's current position.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchTrackSize(isVertical, with(vDensity) { vTrackPx.toDp() }, style.thickness)
|
||||||
|
.pointerInput(isVertical, adapter, vTrackPx, reverseLayout) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val vDown = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
vDown.consume()
|
||||||
|
|
||||||
|
// Live geometry — stable for the drag's duration (content /
|
||||||
|
// viewport / track sizes don't change mid-drag).
|
||||||
|
val vContentPx = adapter.contentSize
|
||||||
|
val vViewportPx = adapter.viewportSize
|
||||||
|
val vMaxScrollPx = vContentPx - vViewportPx
|
||||||
|
if (vMaxScrollPx <= 0.0 || vTrackPx <= 0) return@awaitEachGesture
|
||||||
|
val vMinLen = style.minimalHeight.value.toInt()
|
||||||
|
val vLen = (vTrackPx * (vViewportPx / vContentPx)).roundToInt()
|
||||||
|
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
|
||||||
|
val vTravelPx = vTrackPx - vLen
|
||||||
|
val vRaw = if (vTravelPx <= 0) 0
|
||||||
|
else (vTravelPx * (adapter.scrollOffset / vMaxScrollPx)).roundToInt()
|
||||||
|
val vPos = (if (reverseLayout) vTravelPx - vRaw else vRaw).coerceIn(0, vTravelPx)
|
||||||
|
|
||||||
|
// Map a track-relative thumb-top to a scroll offset. The
|
||||||
|
// target uses the LIVE max scroll (read fresh each move),
|
||||||
|
// not the press-time value — the adapter compares it against
|
||||||
|
// its live scrollOffset, and mixing a stale scale with a live
|
||||||
|
// one makes the delta run away (grab at the bottom, drag up,
|
||||||
|
// and the list overshoots to the middle).
|
||||||
|
fun scrollToTrackPos(inThumbTop: Int) {
|
||||||
|
if (vTravelPx <= 0) return
|
||||||
|
val vLiveMax = adapter.contentSize - adapter.viewportSize
|
||||||
|
if (vLiveMax <= 0.0) return
|
||||||
|
val vClamped = inThumbTop.coerceIn(0, vTravelPx)
|
||||||
|
val vFrac = (if (reverseLayout) vTravelPx - vClamped else vClamped).toDouble() / vTravelPx
|
||||||
|
vScope.launch { adapter.scrollTo(vFrac * vLiveMax) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val vPress = (if (isVertical) vDown.position.y else vDown.position.x).toInt()
|
||||||
|
val vGrab: Int
|
||||||
|
if (vPress in vPos..(vPos + vLen)) {
|
||||||
|
vDraggingThumb = true
|
||||||
|
vGrab = vPress - vPos
|
||||||
|
} else {
|
||||||
|
// Page toward the click by one viewport.
|
||||||
|
vDraggingThumb = false
|
||||||
|
vGrab = 0
|
||||||
|
val vDir = if (vPress < vPos) -1.0 else 1.0
|
||||||
|
vScope.launch { adapter.scrollTo(adapter.scrollOffset + vDir * vViewportPx) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow the pressed pointer to release, consuming every
|
||||||
|
// change so the interaction can't leak. Only a thumb grab
|
||||||
|
// drags-to-scroll; a track press just consumes.
|
||||||
|
do {
|
||||||
|
val vEvent = awaitPointerEvent()
|
||||||
|
val vChange = vEvent.changes.firstOrNull { it.id == vDown.id } ?: break
|
||||||
|
if (vDraggingThumb) {
|
||||||
|
val vCur = (if (isVertical) vChange.position.y else vChange.position.x).toInt()
|
||||||
|
scrollToTrackPos(vCur - vGrab)
|
||||||
|
}
|
||||||
|
vChange.consume()
|
||||||
|
} while (vEvent.changes.any { it.id == vDown.id && it.pressed })
|
||||||
|
|
||||||
|
vDraggingThumb = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
|
||||||
|
comes from the caller pre-converted via LocalDensity (Option-B: layout
|
||||||
|
coords are physical px, so px-to-dp goes through the current density). */
|
||||||
|
private fun Modifier.matchTrackSize(inVertical: Boolean, inTrackDp: Dp, inThickness: Dp): Modifier =
|
||||||
|
if (inVertical) this.width(inThickness).height(inTrackDp)
|
||||||
|
else this.height(inThickness).width(inTrackDp)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// COPY of the port's SplitPane (package com.compose.sdl.widgets) so the SAME
|
||||||
|
// implementation runs on the jvm parity target — the port modules are
|
||||||
|
// native-only, so apidemo's commonMain cannot reference them directly.
|
||||||
|
// Keep in sync with the original when it changes.
|
||||||
|
package apidemo.compat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.hoverable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: SplitPane
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Two-pane resizable split. `initialFirstSize` seeds the first pane's width
|
||||||
|
* (horizontal split) or height (vertical split); after first layout it tracks
|
||||||
|
* the user's drag. `minFirstSize` / `minSecondSize` clamp so neither pane can
|
||||||
|
* shrink past its minimum.
|
||||||
|
*
|
||||||
|
* Internal sizing is in **pixels** to match this port's pixel-space layout
|
||||||
|
* tree (Option-B density flow). `Modifier.width((px / density).dp)` converts
|
||||||
|
* back at the modifier boundary. */
|
||||||
|
@Composable
|
||||||
|
fun HorizontalSplitPane(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
initialFirstSize: Dp = 200.dp,
|
||||||
|
minFirstSize: Dp = 50.dp,
|
||||||
|
minSecondSize: Dp = 50.dp,
|
||||||
|
dividerColor: Color = Color(0x33FFFFFFL),
|
||||||
|
dividerHoverColor: Color = Color(0x66FFFFFFL),
|
||||||
|
first: @Composable () -> Unit,
|
||||||
|
second: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val vDensity = LocalDensity.current
|
||||||
|
val vDividerPx = with(vDensity) { SplitPaneDefaults.DividerThickness.toPx().toInt() }
|
||||||
|
val vMinFirstPx = with(vDensity) { minFirstSize.toPx().toInt() }
|
||||||
|
val vMinSecondPx = with(vDensity) { minSecondSize.toPx().toInt() }
|
||||||
|
val vInitialFirstPx = with(vDensity) { initialFirstSize.toPx().toInt() }
|
||||||
|
|
||||||
|
var vTotalWidthPx by remember { mutableStateOf(0) }
|
||||||
|
var vFirstWidthPx by remember { mutableStateOf(vInitialFirstPx) }
|
||||||
|
val vDividerSource = remember { MutableInteractionSource() }
|
||||||
|
val vDividerHover by vDividerSource.collectIsHoveredAsState()
|
||||||
|
var vDragging by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onSizeChanged { vTotalWidthPx = it.width },
|
||||||
|
) {
|
||||||
|
val vClampedFirstPx = if (vTotalWidthPx > 0)
|
||||||
|
vFirstWidthPx.coerceIn(vMinFirstPx, (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx))
|
||||||
|
else vFirstWidthPx
|
||||||
|
val vSecondWidthPx = (vTotalWidthPx - vClampedFirstPx - vDividerPx).coerceAtLeast(0)
|
||||||
|
|
||||||
|
Box(modifier = Modifier.width(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxHeight()) { first() }
|
||||||
|
|
||||||
|
// Solid divider that fills its own slot — what you see is exactly what
|
||||||
|
// you can grab. Hover or an in-progress drag just changes the colour (no
|
||||||
|
// size change → no layout shift, and no flicker when the pointer briefly
|
||||||
|
// leaves the slot mid-drag).
|
||||||
|
val vActive = vDividerHover || vDragging
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(SplitPaneDefaults.DividerThickness)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(if (vActive) dividerHoverColor else dividerColor)
|
||||||
|
.hoverable(vDividerSource)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { vDragging = true },
|
||||||
|
onDragEnd = { vDragging = false },
|
||||||
|
onDragCancel = { vDragging = false },
|
||||||
|
onDrag = { _, delta ->
|
||||||
|
val vMax = (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx)
|
||||||
|
vFirstWidthPx = (vFirstWidthPx + delta.x.toInt()).coerceIn(vMinFirstPx, vMax)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(modifier = Modifier.width(with(vDensity) { vSecondWidthPx.toDp() }).fillMaxHeight()) { second() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object SplitPaneDefaults {
|
||||||
|
val DividerThickness: Dp = 4.dp // divider width = hit/drag area (solid; what you see is what you grab)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.platform.Font
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Monospace body font — JVM actuals (classpath font/ staged by
|
||||||
|
// jvmProcessResources)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
actual val monoFontFamily: FontFamily? by lazy {
|
||||||
|
val vBytes = object {}.javaClass.getResourceAsStream("/font/NotoSansMono.ttf")?.use { it.readBytes() }
|
||||||
|
if (vBytes == null) {
|
||||||
|
println("apidemo: NotoSansMono.ttf not on the classpath — body uses the default font")
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
FontFamily(Font(identity = kMonoFamily, data = vBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual val monoFontFamilyName: String? by lazy {
|
||||||
|
if (monoFontFamily != null) kMonoFamily else null
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package apidemo
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: TLS chain / client-certificate — JVM actuals (native-only feature)
|
||||||
|
// ==================
|
||||||
|
// The native builds drive the bundled libcurl (CURLOPT_SSLCERT / CERTINFO);
|
||||||
|
// there is no libcurl on the jvm parity stack, so both entry points report
|
||||||
|
// the limitation instead of half-implementing it over JSSE.
|
||||||
|
|
||||||
|
private const val kNativeOnly =
|
||||||
|
"Client-certificate (mTLS) features need the native build — they drive the bundled libcurl directly."
|
||||||
|
|
||||||
|
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain = TlsChain(emptyList(), kNativeOnly)
|
||||||
|
|
||||||
|
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse = ApiResponse(
|
||||||
|
status = 0,
|
||||||
|
statusText = "",
|
||||||
|
timeMs = 0,
|
||||||
|
sizeBytes = 0,
|
||||||
|
headers = emptyList(),
|
||||||
|
body = "",
|
||||||
|
error = kNativeOnly,
|
||||||
|
)
|
||||||
|
|
||||||
|
actual fun sweepTempClientCerts() {}
|
||||||
@@ -0,0 +1,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,20 +3,20 @@ package apidemo
|
|||||||
// Linux: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
|
// Linux: Ktor bundles an OpenSSL-backed libcurl, which reads PEM / DER / PKCS#12
|
||||||
// certificate and key files directly — no certificate-store dance needed.
|
// certificate and key files directly — no certificate-store dance needed.
|
||||||
|
|
||||||
/* Point libcurl straight at the certificate / key files. */
|
/** Point libcurl straight at the certificate / key files. */
|
||||||
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
|
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert =
|
||||||
PreparedCert(
|
PreparedCert(
|
||||||
sslCert = inReq.certPath,
|
sslCert = inReq.certPath,
|
||||||
sslCertType = inReq.certFormat.curlName,
|
sslCertType = inReq.certFormat.curlName,
|
||||||
sslKey = inReq.keyPath.ifBlank { null },
|
sslKey = inReq.keyPath.ifBlank { null },
|
||||||
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
sslKeyType = if (inReq.keyPath.isNotBlank()) inReq.keyFormat.curlName else null,
|
||||||
keyPassword = inReq.certPassword.ifEmpty { null },
|
keyPassword = inReq.certPassword.ifEmpty { null },
|
||||||
cleanup = {},
|
cleanup = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
/* No temporary certificate store off Windows. */
|
/** No temporary certificate store off Windows. */
|
||||||
actual fun sweepTempClientCerts() {}
|
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> =
|
actual fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert> =
|
||||||
serverChainWithIssuerName(inServerCerts)
|
serverChainWithIssuerName(inServerCerts)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package apidemo
|
|||||||
import kotlinx.cinterop.*
|
import kotlinx.cinterop.*
|
||||||
import platform.windows.*
|
import platform.windows.*
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Windows client-certificate store import (Schannel mTLS)
|
// MARK: Windows client-certificate store import (Schannel mTLS)
|
||||||
@@ -32,464 +31,529 @@ private const val kCryptUserKeyset = 0x00001000u // CRYPT_USER_KEYSET (no
|
|||||||
|
|
||||||
private val kEncoding: UInt = X509_ASN_ENCODING.toUInt() or PKCS_7_ASN_ENCODING.toUInt()
|
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. */
|
store reference plus a cleanup that removes it again. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert
|
actual fun prepareClientCert(inReq: ApiRequest): PreparedCert {
|
||||||
{
|
val vCertBytes = readFileBytes(inReq.certPath)
|
||||||
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)
|
||||||
CertFormat.PKCS12 -> importPkcs12(vCertBytes, inReq.certPassword)
|
}
|
||||||
else -> importPemDerRsa(vCertBytes, inReq)
|
return PreparedCert(
|
||||||
}
|
sslCert = "$kStorePath\\$vThumb",
|
||||||
return PreparedCert(
|
sslCertType = null, // store reference — not a file type
|
||||||
sslCert = "$kStorePath\\$vThumb",
|
sslKey = null,
|
||||||
sslCertType = null, // store reference — not a file type
|
sslKeyType = null,
|
||||||
sslKey = null,
|
keyPassword = null,
|
||||||
sslKeyType = null,
|
cleanup = { deleteTempCert(vThumb, vContainer) },
|
||||||
keyPassword = null,
|
)
|
||||||
cleanup = { deleteTempCert(vThumb, vContainer) },
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// PEM / DER (RSA) → store
|
// 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
|
container, link the two, tag and add to CurrentUser\MY. Returns
|
||||||
(thumbprintHex, containerName). */
|
(thumbprintHex, containerName). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@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 vCertDer = derFromMaybePem(inCertBytes, "CERTIFICATE")
|
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
|
||||||
val vKeyBytes = if (inReq.keyPath.isNotBlank()) readFileBytes(inReq.keyPath) else inCertBytes
|
val vKeyDer = privateKeyDer(vKeyBytes)
|
||||||
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).")
|
||||||
?: 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 {
|
||||||
val vBlob = try { rsaKeyDerToBlob(vKeyDer) }
|
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") }
|
} 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 ->
|
val vCert = vCertDer.usePinned { vPin ->
|
||||||
CertCreateCertificateContext(kEncoding, vPin.addressOf(0).reinterpret(), vCertDer.size.convert())
|
CertCreateCertificateContext(kEncoding, vPin.addressOf(0).reinterpret(), vCertDer.size.convert())
|
||||||
} ?: winError("CertCreateCertificateContext")
|
} ?: winError("CertCreateCertificateContext")
|
||||||
|
|
||||||
try
|
try {
|
||||||
{
|
val vThumb = thumbprintHex(vCert)
|
||||||
val vThumb = thumbprintHex(vCert)
|
val vContainer = kTagPrefix + vThumb
|
||||||
val vContainer = kTagPrefix + vThumb
|
importRsaIntoContainer(vContainer, vBlob)
|
||||||
importRsaIntoContainer(vContainer, vBlob)
|
try {
|
||||||
try
|
linkKeyToCert(vCert, vContainer)
|
||||||
{
|
tagFriendlyName(vCert, kTagPrefix + vThumb)
|
||||||
linkKeyToCert(vCert, vContainer)
|
addToStore(vCert)
|
||||||
tagFriendlyName(vCert, kTagPrefix + vThumb)
|
return vThumb to vContainer
|
||||||
addToStore(vCert)
|
} catch (e: Throwable) {
|
||||||
return vThumb to vContainer
|
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. */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun importRsaIntoContainer(inContainer: String, inBlob: ByteArray) = memScoped {
|
|
||||||
deleteContainer(inContainer) // drop a stale one if it somehow exists
|
|
||||||
val vProv = alloc<ULongVar>()
|
|
||||||
if (CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_NEWKEYSET.toUInt()) == 0)
|
|
||||||
winError("CryptAcquireContext(NEWKEYSET)")
|
|
||||||
try
|
|
||||||
{
|
|
||||||
val vKey = alloc<ULongVar>()
|
|
||||||
val vOk = inBlob.usePinned { vPin ->
|
|
||||||
CryptImportKey(vProv.value, vPin.addressOf(0).reinterpret(), inBlob.size.convert(), 0uL, CRYPT_EXPORTABLE.toUInt(), vKey.ptr)
|
|
||||||
}
|
|
||||||
if (vOk == 0) winError("CryptImportKey")
|
|
||||||
CryptDestroyKey(vKey.value)
|
|
||||||
}
|
|
||||||
finally { CryptReleaseContext(vProv.value, 0u) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Attach the named container to the cert so Schannel can find its key. */
|
/** Create a fresh named key container and import the RSA blob into it. */
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun importRsaIntoContainer(inContainer: String, inBlob: ByteArray) = memScoped {
|
||||||
|
deleteContainer(inContainer) // drop a stale one if it somehow exists
|
||||||
|
val vProv = alloc<ULongVar>()
|
||||||
|
if (CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_NEWKEYSET.toUInt()) == 0)
|
||||||
|
winError("CryptAcquireContext(NEWKEYSET)")
|
||||||
|
try {
|
||||||
|
val vKey = alloc<ULongVar>()
|
||||||
|
val vOk = inBlob.usePinned { vPin ->
|
||||||
|
CryptImportKey(
|
||||||
|
vProv.value,
|
||||||
|
vPin.addressOf(0).reinterpret(),
|
||||||
|
inBlob.size.convert(),
|
||||||
|
0uL,
|
||||||
|
CRYPT_EXPORTABLE.toUInt(),
|
||||||
|
vKey.ptr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (vOk == 0) winError("CryptImportKey")
|
||||||
|
CryptDestroyKey(vKey.value)
|
||||||
|
} finally {
|
||||||
|
CryptReleaseContext(vProv.value, 0u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach the named container to the cert so Schannel can find its key. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun linkKeyToCert(inCert: CPointer<CERT_CONTEXT>, inContainer: String) = memScoped {
|
private fun linkKeyToCert(inCert: CPointer<CERT_CONTEXT>, inContainer: String) = memScoped {
|
||||||
val vInfo = alloc<CRYPT_KEY_PROV_INFO>()
|
val vInfo = alloc<CRYPT_KEY_PROV_INFO>()
|
||||||
vInfo.pwszContainerName = inContainer.wcstr.ptr
|
vInfo.pwszContainerName = inContainer.wcstr.ptr
|
||||||
vInfo.pwszProvName = kProvName.wcstr.ptr
|
vInfo.pwszProvName = kProvName.wcstr.ptr
|
||||||
vInfo.dwProvType = PROV_RSA_FULL.toUInt()
|
vInfo.dwProvType = PROV_RSA_FULL.toUInt()
|
||||||
vInfo.dwFlags = 0u
|
vInfo.dwFlags = 0u
|
||||||
vInfo.cProvParam = 0u
|
vInfo.cProvParam = 0u
|
||||||
vInfo.rgProvParam = null
|
vInfo.rgProvParam = null
|
||||||
vInfo.dwKeySpec = AT_KEYEXCHANGE.toUInt()
|
vInfo.dwKeySpec = AT_KEYEXCHANGE.toUInt()
|
||||||
if (CertSetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), 0u, vInfo.ptr) == 0)
|
if (CertSetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), 0u, vInfo.ptr) == 0)
|
||||||
winError("CertSetCertificateContextProperty(KEY_PROV_INFO)")
|
winError("CertSetCertificateContextProperty(KEY_PROV_INFO)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// RSA key ASN.1 → CryptoAPI PRIVATEKEYBLOB
|
// RSA key ASN.1 → CryptoAPI PRIVATEKEYBLOB
|
||||||
// ============
|
// ============
|
||||||
|
|
||||||
/* Minimal DER reader for the RSA key structures. */
|
/** Minimal DER reader for the RSA key structures. */
|
||||||
private class Der(val b: ByteArray)
|
private class Der(val b: ByteArray) {
|
||||||
{
|
var p = 0
|
||||||
var p = 0
|
fun tag(): Int = b[p++].toInt() and 0xFF
|
||||||
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
|
||||||
val vFirst = b[p++].toInt() and 0xFF
|
var vLen = 0
|
||||||
if (vFirst and 0x80 == 0) return vFirst
|
repeat(vFirst and 0x7F) { vLen = (vLen shl 8) or (b[p++].toInt() and 0xFF) }
|
||||||
var vLen = 0
|
return vLen
|
||||||
repeat(vFirst and 0x7F) { vLen = (vLen shl 8) or (b[p++].toInt() and 0xFF) }
|
}
|
||||||
return vLen
|
|
||||||
}
|
|
||||||
fun sequence() { require(tag() == 0x30) { "SEQUENCE expected" }; len() }
|
|
||||||
fun peek(): Int = b[p].toInt() and 0xFF
|
|
||||||
fun skip() { tag(); val vL = len(); p += vL }
|
|
||||||
fun octetString(): ByteArray { require(tag() == 0x04) { "OCTET STRING expected" }; val vL = len(); return b.copyOfRange(p, p + vL).also { p += vL } }
|
|
||||||
// An INTEGER's magnitude (big-endian, leading zero stripped).
|
|
||||||
fun integer(): ByteArray
|
|
||||||
{
|
|
||||||
require(tag() == 0x02) { "INTEGER expected" }
|
|
||||||
val vL = len(); val vV = b.copyOfRange(p, p + vL); p += vL
|
|
||||||
var vS = 0; while (vS < vV.size - 1 && vV[vS].toInt() == 0) vS++
|
|
||||||
return vV.copyOfRange(vS, vV.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Turn a PKCS#1 or PKCS#8 RSA private-key DER into a CryptoAPI PRIVATEKEYBLOB.
|
fun sequence() {
|
||||||
Throws if it isn't an RSA key we can lay out. */
|
require(tag() == 0x30) { "SEQUENCE expected" }; len()
|
||||||
private fun rsaKeyDerToBlob(inDer: ByteArray): ByteArray
|
}
|
||||||
{
|
|
||||||
val vR = Der(inDer)
|
|
||||||
vR.sequence(); vR.integer() // outer version
|
|
||||||
return if (vR.peek() == 0x30)
|
|
||||||
{
|
|
||||||
// PKCS#8 PrivateKeyInfo: skip AlgorithmIdentifier, parse the inner key.
|
|
||||||
vR.skip()
|
|
||||||
parsePkcs1(vR.octetString())
|
|
||||||
}
|
|
||||||
else
|
|
||||||
// PKCS#1: n, e, d, p, q, dp, dq, iqmp follow the version we just read.
|
|
||||||
buildBlob(vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer(), vR.integer())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parsePkcs1(inDer: ByteArray): ByteArray
|
fun peek(): Int = b[p].toInt() and 0xFF
|
||||||
{
|
fun skip() {
|
||||||
val vR = Der(inDer)
|
tag()
|
||||||
vR.sequence(); vR.integer() // version
|
val vL = len(); p += vL
|
||||||
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. */
|
fun octetString(): ByteArray {
|
||||||
|
require(tag() == 0x04) { "OCTET STRING expected" }
|
||||||
|
val vL = len(); return b.copyOfRange(p, p + vL).also { p += vL }
|
||||||
|
}
|
||||||
|
|
||||||
|
// An INTEGER's magnitude (big-endian, leading zero stripped).
|
||||||
|
fun integer(): ByteArray {
|
||||||
|
require(tag() == 0x02) { "INTEGER expected" }
|
||||||
|
val vL = len()
|
||||||
|
val vV = b.copyOfRange(p, p + vL); p += vL
|
||||||
|
var vS = 0; while (vS < vV.size - 1 && vV[vS].toInt() == 0) vS++
|
||||||
|
return vV.copyOfRange(vS, vV.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn a PKCS#1 or PKCS#8 RSA private-key DER into a CryptoAPI PRIVATEKEYBLOB.
|
||||||
|
Throws if it isn't an RSA key we can lay out. */
|
||||||
|
private fun rsaKeyDerToBlob(inDer: ByteArray): ByteArray {
|
||||||
|
val vR = Der(inDer)
|
||||||
|
vR.sequence(); vR.integer() // outer version
|
||||||
|
return if (vR.peek() == 0x30) {
|
||||||
|
// PKCS#8 PrivateKeyInfo: skip AlgorithmIdentifier, parse the inner key.
|
||||||
|
vR.skip()
|
||||||
|
parsePkcs1(vR.octetString())
|
||||||
|
} else
|
||||||
|
// PKCS#1: n, e, d, p, q, dp, dq, iqmp follow the version we just read.
|
||||||
|
buildBlob(
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parsePkcs1(inDer: ByteArray): ByteArray {
|
||||||
|
val vR = Der(inDer)
|
||||||
|
vR.sequence(); vR.integer() // version
|
||||||
|
return buildBlob(
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer(),
|
||||||
|
vR.integer()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lay out the MS PRIVATEKEYBLOB (all little-endian) from the RSA components. */
|
||||||
private fun buildBlob(
|
private fun buildBlob(
|
||||||
inN: ByteArray, inE: ByteArray, inD: ByteArray,
|
inN: ByteArray, inE: ByteArray, inD: ByteArray,
|
||||||
inP: ByteArray, inQ: ByteArray, inDp: ByteArray, inDq: ByteArray, inIq: ByteArray,
|
inP: ByteArray, inQ: ByteArray, inDp: ByteArray, inDq: ByteArray, inIq: ByteArray,
|
||||||
): ByteArray
|
): ByteArray {
|
||||||
{
|
val vMod = inN.size // modulus length in bytes
|
||||||
val vMod = inN.size // modulus length in bytes
|
val vHalf = vMod / 2
|
||||||
val vHalf = vMod / 2
|
val vOut = ArrayList<Byte>(20 + vMod * 2 + vHalf * 5)
|
||||||
val vOut = ArrayList<Byte>(20 + vMod * 2 + vHalf * 5)
|
vOut.add(PRIVATEKEYBLOB.toByte()); vOut.add(CUR_BLOB_VERSION.toByte()); vOut.add(0); vOut.add(0) // BLOBHEADER
|
||||||
vOut.add(PRIVATEKEYBLOB.toByte()); vOut.add(CUR_BLOB_VERSION.toByte()); vOut.add(0); vOut.add(0) // BLOBHEADER
|
vOut.addAll(le32(CALG_RSA_KEYX.toUInt()).toList())
|
||||||
vOut.addAll(le32(CALG_RSA_KEYX.toUInt()).toList())
|
vOut.addAll(le32(0x32415352u).toList()) // "RSA2"
|
||||||
vOut.addAll(le32(0x32415352u).toList()) // "RSA2"
|
vOut.addAll(le32((vMod * 8).toUInt()).toList()) // bitlen
|
||||||
vOut.addAll(le32((vMod * 8).toUInt()).toList()) // bitlen
|
vOut.addAll(le32(beToUInt(inE)).toList()) // pubexp
|
||||||
vOut.addAll(le32(beToUInt(inE)).toList()) // pubexp
|
vOut.addAll(leField(inN, vMod).toList())
|
||||||
vOut.addAll(leField(inN, vMod).toList())
|
vOut.addAll(leField(inP, vHalf).toList())
|
||||||
vOut.addAll(leField(inP, vHalf).toList())
|
vOut.addAll(leField(inQ, vHalf).toList())
|
||||||
vOut.addAll(leField(inQ, vHalf).toList())
|
vOut.addAll(leField(inDp, vHalf).toList())
|
||||||
vOut.addAll(leField(inDp, vHalf).toList())
|
vOut.addAll(leField(inDq, vHalf).toList())
|
||||||
vOut.addAll(leField(inDq, vHalf).toList())
|
vOut.addAll(leField(inIq, vHalf).toList())
|
||||||
vOut.addAll(leField(inIq, vHalf).toList())
|
vOut.addAll(leField(inD, vMod).toList())
|
||||||
vOut.addAll(leField(inD, vMod).toList())
|
return vOut.toByteArray()
|
||||||
return vOut.toByteArray()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
|
/** A big-endian magnitude reversed to little-endian and zero-padded to inSize. */
|
||||||
private fun leField(inBe: ByteArray, inSize: Int): ByteArray
|
private fun leField(inBe: ByteArray, inSize: Int): ByteArray {
|
||||||
{
|
val vOut = ByteArray(inSize)
|
||||||
val vOut = ByteArray(inSize)
|
for (vI in inBe.indices) if (vI < inSize) vOut[vI] = inBe[inBe.size - 1 - vI]
|
||||||
for (vI in inBe.indices) if (vI < inSize) vOut[vI] = inBe[inBe.size - 1 - vI]
|
return vOut
|
||||||
return vOut
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun le32(inV: UInt): ByteArray =
|
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
|
||||||
var vV = 0u
|
for (vB in inBe) vV = (vV shl 8) or (vB.toInt() and 0xFF).toUInt()
|
||||||
for (vB in inBe) vV = (vV shl 8) or (vB.toInt() and 0xFF).toUInt()
|
return vV
|
||||||
return vV
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// PKCS#12 → store
|
// 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. */
|
container PFXImportCertStore created) into CurrentUser\MY. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun importPkcs12(inBytes: ByteArray, inPassword: String): Pair<String, String?> = memScoped {
|
private fun importPkcs12(inBytes: ByteArray, inPassword: String): Pair<String, String?> = memScoped {
|
||||||
inBytes.usePinned { vPin ->
|
inBytes.usePinned { vPin ->
|
||||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||||
vBlob.cbData = inBytes.size.convert()
|
vBlob.cbData = inBytes.size.convert()
|
||||||
vBlob.pbData = vPin.addressOf(0).reinterpret()
|
vBlob.pbData = vPin.addressOf(0).reinterpret()
|
||||||
val vMem = PFXImportCertStore(vBlob.ptr, inPassword, kCryptUserKeyset or CRYPT_EXPORTABLE.toUInt())
|
val vMem = PFXImportCertStore(vBlob.ptr, inPassword, kCryptUserKeyset or CRYPT_EXPORTABLE.toUInt())
|
||||||
?: winError("PFXImportCertStore (wrong password, or not a PKCS#12 file?)")
|
?: winError("PFXImportCertStore (wrong password, or not a PKCS#12 file?)")
|
||||||
try
|
try {
|
||||||
{
|
val vSrc =
|
||||||
val vSrc = CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
|
CertEnumCertificatesInStore(vMem, null) ?: throw RuntimeException("PKCS#12 contained no certificate.")
|
||||||
val vThumb = thumbprintHex(vSrc)
|
val vThumb = thumbprintHex(vSrc)
|
||||||
val vContainer = containerNameOf(vSrc)
|
val vContainer = containerNameOf(vSrc)
|
||||||
tagFriendlyName(vSrc, kTagPrefix + vThumb)
|
tagFriendlyName(vSrc, kTagPrefix + vThumb)
|
||||||
addToStore(vSrc)
|
addToStore(vSrc)
|
||||||
vThumb to vContainer
|
vThumb to vContainer
|
||||||
}
|
} finally {
|
||||||
finally { CertCloseStore(vMem, 0u) }
|
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. */
|
CERT_KEY_PROV_INFO), so cleanup can delete it. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun containerNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
|
private fun containerNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
|
||||||
val vLen = alloc<UIntVar>()
|
val vLen = alloc<UIntVar>()
|
||||||
if (CertGetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), null, vLen.ptr) == 0) return@memScoped null
|
if (CertGetCertificateContextProperty(
|
||||||
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
inCert,
|
||||||
if (CertGetCertificateContextProperty(inCert, CERT_KEY_PROV_INFO_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
|
CERT_KEY_PROV_INFO_PROP_ID.toUInt(),
|
||||||
vBuf.reinterpret<CRYPT_KEY_PROV_INFO>().pointed.pwszContainerName?.toKStringFromUtf16()
|
null,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
||||||
|
if (CertGetCertificateContextProperty(
|
||||||
|
inCert,
|
||||||
|
CERT_KEY_PROV_INFO_PROP_ID.toUInt(),
|
||||||
|
vBuf,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
vBuf.reinterpret<CRYPT_KEY_PROV_INFO>().pointed.pwszContainerName?.toKStringFromUtf16()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Shared store helpers
|
// 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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun tagFriendlyName(inCert: CPointer<CERT_CONTEXT>, inName: String) = memScoped {
|
private fun tagFriendlyName(inCert: CPointer<CERT_CONTEXT>, inName: String) = memScoped {
|
||||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||||
vBlob.cbData = ((inName.length + 1) * 2).convert()
|
vBlob.cbData = ((inName.length + 1) * 2).convert()
|
||||||
vBlob.pbData = inName.wcstr.ptr.reinterpret()
|
vBlob.pbData = inName.wcstr.ptr.reinterpret()
|
||||||
CertSetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), 0u, vBlob.ptr)
|
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun addToStore(inCert: CPointer<CERT_CONTEXT>)
|
private fun addToStore(inCert: CPointer<CERT_CONTEXT>) {
|
||||||
{
|
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
|
||||||
val vStore = openMyStore() ?: winError("CertOpenSystemStore(MY)")
|
try {
|
||||||
try
|
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
|
||||||
{
|
winError("CertAddCertificateContextToStore")
|
||||||
if (CertAddCertificateContextToStore(vStore, inCert, CERT_STORE_ADD_REPLACE_EXISTING.toUInt(), null) == 0)
|
} finally {
|
||||||
winError("CertAddCertificateContextToStore")
|
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun openMyStore() = CertOpenSystemStoreW(0.convert(), kStoreName)
|
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun thumbprintHex(inCert: CPointer<CERT_CONTEXT>): String = memScoped {
|
private fun thumbprintHex(inCert: CPointer<CERT_CONTEXT>): String = memScoped {
|
||||||
val vLen = alloc<UIntVar>().apply { value = 20u }
|
val vLen = alloc<UIntVar>().apply { value = 20u }
|
||||||
val vBuf = allocArray<UByteVar>(20)
|
val vBuf = allocArray<UByteVar>(20)
|
||||||
if (CertGetCertificateContextProperty(inCert, CERT_SHA1_HASH_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0)
|
if (CertGetCertificateContextProperty(inCert, CERT_SHA1_HASH_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0)
|
||||||
winError("CertGetCertificateContextProperty(SHA1)")
|
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. */
|
key container. Called per-request in finally; ignores what's already gone. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun deleteTempCert(inThumbHex: String, inContainer: String?)
|
private fun deleteTempCert(inThumbHex: String, inContainer: String?) {
|
||||||
{
|
val vStore = openMyStore()
|
||||||
val vStore = openMyStore()
|
if (vStore != null) memScoped {
|
||||||
if (vStore != null) memScoped {
|
val vHash = hexToBytes(inThumbHex)
|
||||||
val vHash = hexToBytes(inThumbHex)
|
val vBuf = allocArray<UByteVar>(vHash.size)
|
||||||
val vBuf = allocArray<UByteVar>(vHash.size)
|
for (vI in vHash.indices) vBuf[vI] = vHash[vI].toUByte()
|
||||||
for (vI in vHash.indices) vBuf[vI] = vHash[vI].toUByte()
|
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
||||||
val vBlob = alloc<CRYPT_INTEGER_BLOB>()
|
vBlob.cbData = vHash.size.convert()
|
||||||
vBlob.cbData = vHash.size.convert()
|
vBlob.pbData = vBuf
|
||||||
vBlob.pbData = vBuf
|
val vFound = CertFindCertificateInStore(vStore, kEncoding, 0u, CERT_FIND_SHA1_HASH.toUInt(), vBlob.ptr, null)
|
||||||
val vFound = CertFindCertificateInStore(vStore, kEncoding, 0u, CERT_FIND_SHA1_HASH.toUInt(), vBlob.ptr, null)
|
if (vFound != null) CertDeleteCertificateFromStore(vFound) // also frees the context
|
||||||
if (vFound != null) CertDeleteCertificateFromStore(vFound) // also frees the context
|
CertCloseStore(vStore, 0u)
|
||||||
CertCloseStore(vStore, 0u)
|
}
|
||||||
}
|
if (inContainer != null) deleteContainer(inContainer)
|
||||||
if (inContainer != null) deleteContainer(inContainer)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Delete a named key container (best-effort). */
|
/** Delete a named key container (best-effort). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun deleteContainer(inContainer: String) = memScoped {
|
private fun deleteContainer(inContainer: String) = memScoped {
|
||||||
val vProv = alloc<ULongVar>()
|
val vProv = alloc<ULongVar>()
|
||||||
CryptAcquireContextW(vProv.ptr, inContainer, kProvName, PROV_RSA_FULL.toUInt(), CRYPT_DELETEKEYSET.toUInt())
|
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. */
|
clearing crash leftovers at startup. The user's own certs are never matched. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
actual fun sweepTempClientCerts()
|
actual fun sweepTempClientCerts() {
|
||||||
{
|
val vStore = openMyStore() ?: return
|
||||||
val vStore = openMyStore() ?: return
|
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
|
||||||
val vDoomed = mutableListOf<Pair<String, String?>>() // thumbprint, container
|
var vCert = CertEnumCertificatesInStore(vStore, null)
|
||||||
var vCert = CertEnumCertificatesInStore(vStore, null)
|
while (vCert != null) {
|
||||||
while (vCert != null)
|
val vFriendly = friendlyNameOf(vCert)
|
||||||
{
|
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
|
||||||
val vFriendly = friendlyNameOf(vCert)
|
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
|
||||||
if (vFriendly != null && vFriendly.startsWith(kTagPrefix))
|
vCert = CertEnumCertificatesInStore(vStore, vCert)
|
||||||
vDoomed.add(thumbprintHex(vCert) to containerNameOf(vCert))
|
}
|
||||||
vCert = CertEnumCertificatesInStore(vStore, vCert)
|
CertCloseStore(vStore, 0u)
|
||||||
}
|
vDoomed.forEach { deleteTempCert(it.first, it.second) }
|
||||||
CertCloseStore(vStore, 0u)
|
}
|
||||||
vDoomed.forEach { deleteTempCert(it.first, it.second) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A cert's friendly name, or null. */
|
/** A cert's friendly name, or null. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun friendlyNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
|
private fun friendlyNameOf(inCert: CPointer<CERT_CONTEXT>): String? = memScoped {
|
||||||
val vLen = alloc<UIntVar>()
|
val vLen = alloc<UIntVar>()
|
||||||
if (CertGetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), null, vLen.ptr) == 0) return@memScoped null
|
if (CertGetCertificateContextProperty(
|
||||||
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
inCert,
|
||||||
if (CertGetCertificateContextProperty(inCert, CERT_FRIENDLY_NAME_PROP_ID.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
|
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
|
||||||
vBuf.reinterpret<UShortVar>().toKStringFromUtf16()
|
null,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
||||||
|
if (CertGetCertificateContextProperty(
|
||||||
|
inCert,
|
||||||
|
CERT_FRIENDLY_NAME_PROP_ID.toUInt(),
|
||||||
|
vBuf,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
vBuf.reinterpret<UShortVar>().toKStringFromUtf16()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Chain extension via the OS certificate store
|
// 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
|
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). */
|
a name-only issuer if the OS can't (or the leaf can't be parsed). */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@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") }
|
||||||
val vLeafPem = inServerCerts.firstOrNull()?.let { certFieldOf(it, "Cert") }
|
?: return serverChainWithIssuerName(inServerCerts)
|
||||||
?: return serverChainWithIssuerName(inServerCerts)
|
val vOs = runCatching { osChain(vLeafPem, inServerCerts.size) }.getOrNull()
|
||||||
val vOs = runCatching { osChain(vLeafPem, inServerCerts.size) }.getOrNull()
|
return if (!vOs.isNullOrEmpty()) vOs else serverChainWithIssuerName(inServerCerts)
|
||||||
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun osChain(inLeafPem: String, inServerCount: Int): List<ChainCert> = memScoped {
|
private fun osChain(inLeafPem: String, inServerCount: Int): List<ChainCert> = memScoped {
|
||||||
val vDer = derFromMaybePem(inLeafPem.encodeToByteArray(), "CERTIFICATE")
|
val vDer = derFromMaybePem(inLeafPem.encodeToByteArray(), "CERTIFICATE")
|
||||||
val vLeaf = vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
|
val vLeaf =
|
||||||
?: return@memScoped emptyList()
|
vDer.usePinned { CertCreateCertificateContext(kEncoding, it.addressOf(0).reinterpret(), vDer.size.convert()) }
|
||||||
try
|
?: return@memScoped emptyList()
|
||||||
{
|
try {
|
||||||
val vPara = alloc<CERT_CHAIN_PARA>()
|
val vPara = alloc<CERT_CHAIN_PARA>()
|
||||||
vPara.cbSize = sizeOf<CERT_CHAIN_PARA>().convert()
|
vPara.cbSize = sizeOf<CERT_CHAIN_PARA>().convert()
|
||||||
val vChainPtr = alloc<CPointerVar<CERT_CHAIN_CONTEXT>>()
|
val vChainPtr = alloc<CPointerVar<CERT_CHAIN_CONTEXT>>()
|
||||||
if (CertGetCertificateChain(null, vLeaf, null, null, vPara.ptr, 0u, null, vChainPtr.ptr) == 0)
|
if (CertGetCertificateChain(null, vLeaf, null, null, vPara.ptr, 0u, null, vChainPtr.ptr) == 0)
|
||||||
return@memScoped emptyList()
|
return@memScoped emptyList()
|
||||||
val vChain = vChainPtr.value ?: return@memScoped emptyList()
|
val vChain = vChainPtr.value ?: return@memScoped emptyList()
|
||||||
try
|
try {
|
||||||
{
|
val vSimple = vChain.pointed.rgpChain?.get(0)?.pointed ?: return@memScoped emptyList()
|
||||||
val vSimple = vChain.pointed.rgpChain?.get(0)?.pointed ?: return@memScoped emptyList()
|
val vElems = vSimple.rgpElement ?: return@memScoped emptyList()
|
||||||
val vElems = vSimple.rgpElement ?: return@memScoped emptyList()
|
val vResult = ArrayList<ChainCert>()
|
||||||
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))
|
||||||
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 the chain didn't reach a self-signed root, end with the name only.
|
if (vLast != null) {
|
||||||
val vLast = vResult.lastOrNull()
|
val vSub = certFieldOf(vLast.fields, "Subject")
|
||||||
if (vLast != null)
|
val vIss = certFieldOf(vLast.fields, "Issuer")
|
||||||
{
|
if (!vIss.isNullOrBlank() && vIss != vSub) vResult.add(ChainCert(listOf("Subject" to vIss), false))
|
||||||
val vSub = certFieldOf(vLast.fields, "Subject")
|
}
|
||||||
val vIss = certFieldOf(vLast.fields, "Issuer")
|
vResult
|
||||||
if (!vIss.isNullOrBlank() && vIss != vSub) vResult.add(ChainCert(listOf("Subject" to vIss), false))
|
} finally {
|
||||||
}
|
CertFreeCertificateChain(vChain)
|
||||||
vResult
|
}
|
||||||
}
|
} finally {
|
||||||
finally { CertFreeCertificateChain(vChain) }
|
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)
|
@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>>()
|
||||||
val vFields = ArrayList<Pair<String, String>>()
|
certNameString(inCtx, 0u)?.let { vFields.add("Subject" to it) }
|
||||||
certNameString(inCtx, 0u)?.let { vFields.add("Subject" to it) }
|
certNameString(inCtx, CERT_NAME_ISSUER_FLAG.toUInt())?.let { vFields.add("Issuer" to it) }
|
||||||
certNameString(inCtx, CERT_NAME_ISSUER_FLAG.toUInt())?.let { vFields.add("Issuer" to it) }
|
certPem(inCtx)?.let { vFields.add("Cert" to it) }
|
||||||
certPem(inCtx)?.let { vFields.add("Cert" to it) }
|
return vFields
|
||||||
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun certNameString(inCtx: CPointer<CERT_CONTEXT>, inFlags: UInt): String? = memScoped {
|
private fun certNameString(inCtx: CPointer<CERT_CONTEXT>, inFlags: UInt): String? = memScoped {
|
||||||
val vType = alloc<UIntVar>().apply { value = CERT_X500_NAME_STR.toUInt() }
|
val vType = alloc<UIntVar>().apply { value = CERT_X500_NAME_STR.toUInt() }
|
||||||
val vLen = CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, null, 0u)
|
val vLen = CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, null, 0u)
|
||||||
if (vLen <= 1u) return@memScoped null
|
if (vLen <= 1u) return@memScoped null
|
||||||
val vBuf = allocArray<ByteVar>(vLen.toInt())
|
val vBuf = allocArray<ByteVar>(vLen.toInt())
|
||||||
CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, vBuf, vLen)
|
CertGetNameStringA(inCtx, CERT_NAME_RDN_TYPE.toUInt(), inFlags, vType.ptr, vBuf, vLen)
|
||||||
vBuf.toKString()
|
vBuf.toKString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The PEM (base64-with-header) of a cert context. */
|
/** The PEM (base64-with-header) of a cert context. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun certPem(inCtx: CPointer<CERT_CONTEXT>): String? = memScoped {
|
private fun certPem(inCtx: CPointer<CERT_CONTEXT>): String? = memScoped {
|
||||||
val vData = inCtx.pointed.pbCertEncoded ?: return@memScoped null
|
val vData = inCtx.pointed.pbCertEncoded ?: return@memScoped null
|
||||||
val vSize = inCtx.pointed.cbCertEncoded
|
val vSize = inCtx.pointed.cbCertEncoded
|
||||||
val vLen = alloc<UIntVar>()
|
val vLen = alloc<UIntVar>()
|
||||||
if (CryptBinaryToStringA(vData, vSize, CRYPT_STRING_BASE64HEADER.toUInt(), null, vLen.ptr) == 0) return@memScoped null
|
if (CryptBinaryToStringA(
|
||||||
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
vData,
|
||||||
if (CryptBinaryToStringA(vData, vSize, CRYPT_STRING_BASE64HEADER.toUInt(), vBuf, vLen.ptr) == 0) return@memScoped null
|
vSize,
|
||||||
vBuf.toKString()
|
CRYPT_STRING_BASE64HEADER.toUInt(),
|
||||||
|
null,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
val vBuf = allocArray<ByteVar>(vLen.value.toInt())
|
||||||
|
if (CryptBinaryToStringA(
|
||||||
|
vData,
|
||||||
|
vSize,
|
||||||
|
CRYPT_STRING_BASE64HEADER.toUInt(),
|
||||||
|
vBuf,
|
||||||
|
vLen.ptr
|
||||||
|
) == 0
|
||||||
|
) return@memScoped null
|
||||||
|
vBuf.toKString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Small helpers
|
// Small helpers
|
||||||
// ============
|
// ============
|
||||||
|
|
||||||
/* Decode a PEM block (by label) to DER, or pass bytes through if already DER. */
|
/** 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 {
|
||||||
private fun derFromMaybePem(inBytes: ByteArray, inLabel: String): ByteArray
|
val vText = inBytes.decodeToString()
|
||||||
{
|
if (!vText.contains("-----BEGIN")) return inBytes
|
||||||
val vText = inBytes.decodeToString()
|
val vBegin = vText.indexOf("-----BEGIN $inLabel-----")
|
||||||
if (!vText.contains("-----BEGIN")) return inBytes
|
val vEnd = vText.indexOf("-----END $inLabel-----")
|
||||||
val vBegin = vText.indexOf("-----BEGIN $inLabel-----")
|
val vBlock = if (vBegin >= 0 && vEnd >= 0) vText.substring(vBegin, vEnd) else vText
|
||||||
val vEnd = vText.indexOf("-----END $inLabel-----")
|
return Base64.decode(pemBody(vBlock))
|
||||||
val vBlock = if (vBegin >= 0 && vEnd >= 0) vText.substring(vBegin, vEnd) else vText
|
}
|
||||||
return Base64.Default.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. */
|
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 vText = inBytes.decodeToString()
|
val vMatch = Regex("-----BEGIN [A-Z 0-9]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z 0-9]*PRIVATE KEY-----").find(vText)
|
||||||
if (!vText.contains("-----BEGIN")) return inBytes // already DER
|
?: return null
|
||||||
val vMatch = Regex("-----BEGIN [A-Z 0-9]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z 0-9]*PRIVATE KEY-----").find(vText)
|
return Base64.decode(pemBody(vMatch.value))
|
||||||
?: return null
|
}
|
||||||
return Base64.Default.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 =
|
private fun pemBody(inPem: String): String =
|
||||||
inPem.lineSequence().filterNot { it.startsWith("-----") }.joinToString("") { it.trim() }
|
inPem.lineSequence().filterNot { it.startsWith("-----") }.joinToString("") { it.trim() }
|
||||||
|
|
||||||
private fun hexToBytes(inHex: String): ByteArray =
|
private fun hexToBytes(inHex: String): ByteArray =
|
||||||
ByteArray(inHex.length / 2) { inHex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
ByteArray(inHex.length / 2) { inHex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||||
|
|
||||||
/* Read a null-terminated UTF-16 (wide) string. */
|
/** Read a null-terminated UTF-16 (wide) string. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun CPointer<UShortVar>.toKStringFromUtf16(): String
|
private fun CPointer<UShortVar>.toKStringFromUtf16(): String {
|
||||||
{
|
val vSb = StringBuilder()
|
||||||
val vSb = StringBuilder()
|
var vI = 0
|
||||||
var vI = 0
|
while (true) {
|
||||||
while (true) { val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++ }
|
val vC = this[vI].toInt(); if (vC == 0) break; vSb.append(vC.toChar()); vI++
|
||||||
return vSb.toString()
|
}
|
||||||
}
|
return vSb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun winError(inWhat: String): Nothing =
|
private fun winError(inWhat: String): Nothing =
|
||||||
throw RuntimeException("$inWhat failed (Windows error 0x${GetLastError().toString(16)})")
|
throw RuntimeException("$inWhat failed (Windows error 0x${GetLastError().toString(16)})")
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
package apidemo
|
|
||||||
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Minimal X.509 / DER parser
|
|
||||||
// ==================
|
|
||||||
// Some TLS backends (notably Schannel on Windows) expose only Subject / Issuer /
|
|
||||||
// the PEM through CURLINFO_CERTINFO — not the parsed fields (validity, serial,
|
|
||||||
// SAN, algorithms). Since every backend gives us the PEM, we parse it ourselves
|
|
||||||
// to fill in the detail. Just enough ASN.1 to walk a Certificate; anything odd
|
|
||||||
// is swallowed and the field is simply omitted.
|
|
||||||
|
|
||||||
/* Parsed certificate detail, all nullable/empty when not found or unparseable. */
|
|
||||||
class CertDetails(
|
|
||||||
val version: Int?, // X.509 version (1/2/3)
|
|
||||||
val serial: String?, // colon-separated hex
|
|
||||||
val sigAlg: String?, // signature algorithm (friendly name or OID)
|
|
||||||
val keyAlg: String?, // public-key algorithm (friendly name or OID)
|
|
||||||
val notBefore: String?, // "YYYY-MM-DD HH:MM:SS UTC"
|
|
||||||
val notAfter: String?,
|
|
||||||
val sans: List<String>, // dNSName SANs
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// DER reader: each next() returns [tag, contentStart, contentEnd] and advances.
|
|
||||||
private class DerReader(val b: ByteArray, var pos: Int, val end: Int)
|
|
||||||
{
|
|
||||||
fun hasMore() = pos < end
|
|
||||||
fun next(): IntArray
|
|
||||||
{
|
|
||||||
val vTag = b[pos++].toInt() and 0xFF
|
|
||||||
var vLen = b[pos++].toInt() and 0xFF
|
|
||||||
if (vLen and 0x80 != 0)
|
|
||||||
{
|
|
||||||
val vN = vLen and 0x7F
|
|
||||||
vLen = 0
|
|
||||||
repeat(vN) { vLen = (vLen shl 8) or (b[pos++].toInt() and 0xFF) }
|
|
||||||
}
|
|
||||||
val vStart = pos
|
|
||||||
pos += vLen
|
|
||||||
return intArrayOf(vTag, vStart, vStart + vLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Parse the PEM into the fields the chain dialog shows. Returns null on any
|
|
||||||
structural surprise (the UI just falls back to whatever the backend gave). */
|
|
||||||
fun parseCertDetails(inPem: String): CertDetails?
|
|
||||||
{
|
|
||||||
val vDer = pemToDer(inPem) ?: return null
|
|
||||||
return try
|
|
||||||
{
|
|
||||||
val vCert = DerReader(vDer, 0, vDer.size).next() // Certificate SEQUENCE
|
|
||||||
val vCertInner = DerReader(vDer, vCert[1], vCert[2])
|
|
||||||
val vTbs = vCertInner.next() // tbsCertificate
|
|
||||||
val vSigAlg = firstOidName(vDer, vCertInner.next()) // signatureAlgorithm
|
|
||||||
|
|
||||||
val vT = DerReader(vDer, vTbs[1], vTbs[2])
|
|
||||||
var vE = vT.next()
|
|
||||||
var vVersion: Int? = null
|
|
||||||
if (vE[0] == 0xA0) // [0] EXPLICIT version
|
|
||||||
{
|
|
||||||
val vVi = DerReader(vDer, vE[1], vE[2]).next()
|
|
||||||
if (vVi[0] == 0x02 && vVi[2] > vVi[1]) vVersion = (vDer[vVi[1]].toInt() and 0xFF) + 1
|
|
||||||
vE = vT.next()
|
|
||||||
}
|
|
||||||
val vSerial = if (vE[0] == 0x02) bytesToHex(vDer, vE[1], vE[2]) else null
|
|
||||||
vT.next() // signature
|
|
||||||
vT.next() // issuer
|
|
||||||
val vValidity = vT.next() // validity SEQUENCE
|
|
||||||
val vVr = DerReader(vDer, vValidity[1], vValidity[2])
|
|
||||||
val vNotBefore = parseAsn1Time(vDer, vVr.next())
|
|
||||||
val vNotAfter = parseAsn1Time(vDer, vVr.next())
|
|
||||||
vT.next() // subject
|
|
||||||
val vSpki = vT.next() // subjectPublicKeyInfo SEQUENCE
|
|
||||||
// SPKI = SEQUENCE { algorithm AlgorithmIdentifier SEQUENCE { OID … }, key BIT STRING }
|
|
||||||
val vKeyAlg = firstOidName(vDer, DerReader(vDer, vSpki[1], vSpki[2]).next())
|
|
||||||
val vSans = ArrayList<String>()
|
|
||||||
while (vT.hasMore())
|
|
||||||
{
|
|
||||||
val vX = vT.next()
|
|
||||||
if (vX[0] == 0xA3) parseSans(vDer, vX, vSans) // extensions [3]
|
|
||||||
}
|
|
||||||
CertDetails(vVersion, vSerial, vSigAlg, vKeyAlg, vNotBefore, vNotAfter, vSans)
|
|
||||||
}
|
|
||||||
catch (e: Throwable) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Helpers
|
|
||||||
|
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
|
||||||
private fun pemToDer(inPem: String): ByteArray?
|
|
||||||
{
|
|
||||||
val vBody = inPem.lineSequence().filterNot { it.contains("-----") }.joinToString("") { it.trim() }
|
|
||||||
if (vBody.isBlank()) return null
|
|
||||||
return try { Base64.decode(vBody) } catch (e: Throwable) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The friendly name (or dotted OID) of the first child OID of a SEQUENCE — used
|
|
||||||
for signatureAlgorithm and subjectPublicKeyInfo's AlgorithmIdentifier. */
|
|
||||||
private fun firstOidName(inB: ByteArray, inSeq: IntArray): String?
|
|
||||||
{
|
|
||||||
val vR = DerReader(inB, inSeq[1], inSeq[2])
|
|
||||||
val vOid = vR.next()
|
|
||||||
if (vOid[0] != 0x06) return null
|
|
||||||
return oidName(oidToString(inB, vOid[1], vOid[2]))
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pull dNSName entries out of the SubjectAltName extension under [3]. */
|
|
||||||
private fun parseSans(inB: ByteArray, inExt3: IntArray, inOut: MutableList<String>)
|
|
||||||
{
|
|
||||||
val vSeq = DerReader(inB, inExt3[1], inExt3[2]).next() // SEQUENCE OF Extension
|
|
||||||
val vEs = DerReader(inB, vSeq[1], vSeq[2])
|
|
||||||
while (vEs.hasMore())
|
|
||||||
{
|
|
||||||
val vExtn = vEs.next()
|
|
||||||
val vEr = DerReader(inB, vExtn[1], vExtn[2])
|
|
||||||
val vOid = vEr.next() // extnID
|
|
||||||
// SAN OID 2.5.29.17 == bytes 55 1D 11
|
|
||||||
val vIsSan = vOid[2] - vOid[1] == 3 &&
|
|
||||||
(inB[vOid[1]].toInt() and 0xFF) == 0x55 &&
|
|
||||||
(inB[vOid[1] + 1].toInt() and 0xFF) == 0x1D &&
|
|
||||||
(inB[vOid[1] + 2].toInt() and 0xFF) == 0x11
|
|
||||||
var vVal = vEr.next()
|
|
||||||
if (vVal[0] == 0x01) vVal = vEr.next() // skip critical BOOLEAN
|
|
||||||
if (vIsSan && vVal[0] == 0x04) // OCTET STRING wrapping GeneralNames
|
|
||||||
{
|
|
||||||
val vGnSeq = DerReader(inB, vVal[1], vVal[2]).next()
|
|
||||||
val vGn = DerReader(inB, vGnSeq[1], vGnSeq[2])
|
|
||||||
while (vGn.hasMore())
|
|
||||||
{
|
|
||||||
val vName = vGn.next()
|
|
||||||
if (vName[0] == 0x82) inOut.add(inB.copyOfRange(vName[1], vName[2]).decodeToString()) // [2] dNSName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ASN.1 UTCTime (YYMMDD…) / GeneralizedTime (YYYYMMDD…) → readable UTC string. */
|
|
||||||
private fun parseAsn1Time(inB: ByteArray, inTlv: IntArray): String?
|
|
||||||
{
|
|
||||||
val vS = inB.copyOfRange(inTlv[1], inTlv[2]).decodeToString()
|
|
||||||
return try
|
|
||||||
{
|
|
||||||
val vYear: String
|
|
||||||
val vRest: String
|
|
||||||
if (inTlv[0] == 0x18) // GeneralizedTime
|
|
||||||
{ vYear = vS.substring(0, 4); vRest = vS.substring(4) }
|
|
||||||
else // UTCTime — RFC 5280 pivot at 50
|
|
||||||
{
|
|
||||||
val vYy = vS.substring(0, 2).toInt()
|
|
||||||
vYear = (if (vYy >= 50) "19" else "20") + vS.substring(0, 2)
|
|
||||||
vRest = vS.substring(2)
|
|
||||||
}
|
|
||||||
val vMo = vRest.substring(0, 2); val vDa = vRest.substring(2, 4)
|
|
||||||
val vHh = vRest.substring(4, 6); val vMi = vRest.substring(6, 8)
|
|
||||||
val vSs = if (vRest.length >= 10 && vRest[8].isDigit()) vRest.substring(8, 10) else "00"
|
|
||||||
"$vYear-$vMo-$vDa $vHh:$vMi:$vSs UTC"
|
|
||||||
}
|
|
||||||
catch (e: Throwable) { vS }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Colon-separated hex of a byte range (serial number rendering). */
|
|
||||||
private fun bytesToHex(inB: ByteArray, inFrom: Int, inTo: Int): String =
|
|
||||||
(inFrom until inTo).joinToString(":") { (inB[it].toInt() and 0xFF).toString(16).padStart(2, '0') }
|
|
||||||
|
|
||||||
/* Decode an OID's content bytes to dotted-decimal. */
|
|
||||||
private fun oidToString(inB: ByteArray, inFrom: Int, inTo: Int): String
|
|
||||||
{
|
|
||||||
if (inTo <= inFrom) return ""
|
|
||||||
val vSb = StringBuilder()
|
|
||||||
val vFirst = inB[inFrom].toInt() and 0xFF
|
|
||||||
vSb.append(vFirst / 40).append('.').append(vFirst % 40)
|
|
||||||
var vAcc = 0L
|
|
||||||
for (vI in (inFrom + 1) until inTo)
|
|
||||||
{
|
|
||||||
val vByte = inB[vI].toInt() and 0xFF
|
|
||||||
vAcc = (vAcc shl 7) or (vByte and 0x7F).toLong()
|
|
||||||
if (vByte and 0x80 == 0) { vSb.append('.').append(vAcc); vAcc = 0 }
|
|
||||||
}
|
|
||||||
return vSb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Friendly name for the common signature / public-key OIDs, else the OID. */
|
|
||||||
private fun oidName(inOid: String): String = when (inOid)
|
|
||||||
{
|
|
||||||
"1.2.840.113549.1.1.1" -> "RSA"
|
|
||||||
"1.2.840.113549.1.1.5" -> "SHA1withRSA"
|
|
||||||
"1.2.840.113549.1.1.11" -> "SHA256withRSA"
|
|
||||||
"1.2.840.113549.1.1.12" -> "SHA384withRSA"
|
|
||||||
"1.2.840.113549.1.1.13" -> "SHA512withRSA"
|
|
||||||
"1.2.840.113549.1.1.10" -> "RSASSA-PSS"
|
|
||||||
"1.2.840.10045.2.1" -> "EC"
|
|
||||||
"1.2.840.10045.4.3.2" -> "ECDSAwithSHA256"
|
|
||||||
"1.2.840.10045.4.3.3" -> "ECDSAwithSHA384"
|
|
||||||
"1.2.840.10045.4.3.4" -> "ECDSAwithSHA512"
|
|
||||||
"1.3.101.112" -> "Ed25519"
|
|
||||||
"1.3.101.113" -> "Ed448"
|
|
||||||
else -> inOid
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package apidemo
|
package apidemo
|
||||||
|
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.*
|
||||||
import kotlinx.cinterop.*
|
import kotlinx.cinterop.*
|
||||||
import libcurl.*
|
import libcurl.*
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
@@ -21,321 +21,349 @@ import kotlin.time.TimeSource
|
|||||||
// from HttpRunner.run(); cancellation is best-effort (an in-flight perform can't
|
// from HttpRunner.run(); cancellation is best-effort (an in-flight perform can't
|
||||||
// be interrupted — the result is simply discarded if the caller cancelled).
|
// be interrupted — the result is simply discarded if the caller cancelled).
|
||||||
|
|
||||||
/* A client certificate readied for libcurl: the CURLOPT_SSLCERT value plus the
|
/** A client certificate readied for libcurl: the CURLOPT_SSLCERT value plus the
|
||||||
other SSL options to set, and a cleanup to run once the request is done.
|
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
|
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
|
the cert + key into CurrentUser\MY, references it by SHA-1 thumbprint, and the
|
||||||
cleanup removes it again so the store never accumulates entries. */
|
cleanup removes it again so the store never accumulates entries. */
|
||||||
class PreparedCert(
|
class PreparedCert(
|
||||||
val sslCert: String, // CURLOPT_SSLCERT (file path, or "CurrentUser\\MY\\<thumbprint>")
|
val sslCert: String, // CURLOPT_SSLCERT (file path, or "CurrentUser\\MY\\<thumbprint>")
|
||||||
val sslCertType: String?, // CURLOPT_SSLCERTTYPE (null for a store reference)
|
val sslCertType: String?, // CURLOPT_SSLCERTTYPE (null for a store reference)
|
||||||
val sslKey: String?, // CURLOPT_SSLKEY (null when the cert carries its key)
|
val sslKey: String?, // CURLOPT_SSLKEY (null when the cert carries its key)
|
||||||
val sslKeyType: String?, // CURLOPT_SSLKEYTYPE
|
val sslKeyType: String?, // CURLOPT_SSLKEYTYPE
|
||||||
val keyPassword: String?, // CURLOPT_KEYPASSWD
|
val keyPassword: String?, // CURLOPT_KEYPASSWD
|
||||||
val cleanup: () -> Unit,
|
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. */
|
Throws with a user-facing message if the cert/key can't be loaded. */
|
||||||
expect fun prepareClientCert(inReq: ApiRequest): PreparedCert
|
expect fun prepareClientCert(inReq: ApiRequest): PreparedCert
|
||||||
|
|
||||||
/* Remove any temporary client certs (and their key containers) a previous run
|
// ChainCert / TlsChain and the sweepTempClientCerts / inspectTlsChain /
|
||||||
left behind in the Windows store after a crash — identified by our prefix, so
|
// curlSendWithClientCert expects live in commonMain (TlsCommon.kt) — the UI
|
||||||
the user's own certificates are never touched. No-op off Windows. Call once
|
// consumes them from shared code; the per-OS ClientCert*.kt actuals below the
|
||||||
at startup. */
|
// native tree actualize sweepTempClientCerts directly.
|
||||||
expect fun sweepTempClientCerts()
|
|
||||||
|
|
||||||
/* One certificate in the chain: its CURLINFO_CERTINFO-style fields (Subject /
|
/** Continue a server-presented chain with its issuer(s). Where possible the
|
||||||
Issuer / the PEM under "Cert" / dates …) and whether the server actually
|
issuer is resolved from the OS certificate store with full info; otherwise the
|
||||||
presented it. Derived certs — an issuer resolved from the OS store, or a
|
chain ends with a name-only placeholder. Every derived (non-server) cert is
|
||||||
name-only placeholder — have fromServer = false and are drawn dotted. */
|
flagged fromServer = false. Platform-specific (OS store on Windows). */
|
||||||
class ChainCert(val fields: List<Pair<String, String>>, val fromServer: Boolean)
|
|
||||||
|
|
||||||
/* The server's TLS certificate chain. error is set instead when it couldn't be
|
|
||||||
fetched; fields vary by TLS backend. */
|
|
||||||
class TlsChain(val certs: List<ChainCert>, val error: String?)
|
|
||||||
|
|
||||||
/* Continue a server-presented chain with its issuer(s). Where possible the
|
|
||||||
issuer is resolved from the OS certificate store with full info; otherwise the
|
|
||||||
chain ends with a name-only placeholder. Every derived (non-server) cert is
|
|
||||||
flagged fromServer = false. Platform-specific (OS store on Windows). */
|
|
||||||
expect fun extendChain(inServerCerts: List<List<Pair<String, String>>>): List<ChainCert>
|
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? =
|
internal fun certFieldOf(inFields: List<Pair<String, String>>, inName: String): String? =
|
||||||
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
|
inFields.firstOrNull { it.first.equals(inName, ignoreCase = true) }?.second
|
||||||
|
|
||||||
/* Server certs as ChainCerts; if the last one isn't self-signed, append a
|
/** 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
|
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. */
|
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 vOut = inServer.map { ChainCert(it, true) }.toMutableList()
|
val vLast = inServer.lastOrNull()
|
||||||
val vLast = inServer.lastOrNull()
|
if (vLast != null) {
|
||||||
if (vLast != null)
|
val vSubject = certFieldOf(vLast, "Subject")
|
||||||
{
|
val vIssuer = certFieldOf(vLast, "Issuer")
|
||||||
val vSubject = certFieldOf(vLast, "Subject")
|
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
|
||||||
val vIssuer = certFieldOf(vLast, "Issuer")
|
vOut.add(ChainCert(listOf("Subject" to vIssuer), false))
|
||||||
if (!vIssuer.isNullOrBlank() && vIssuer != vSubject)
|
}
|
||||||
vOut.add(ChainCert(listOf("Subject" to vIssuer), false))
|
return vOut
|
||||||
}
|
|
||||||
return vOut
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Per-transfer accumulators handed to the C callbacks through a StableRef. */
|
|
||||||
private class CurlSink
|
|
||||||
{
|
|
||||||
val body = Buffer() // response body bytes (already decompressed by curl)
|
|
||||||
val headerRaw = StringBuilder() // raw response header lines, all responses incl. redirects
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Body write callback — appends each chunk to the sink's buffer. */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun onCurlBody(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t
|
|
||||||
{
|
|
||||||
val vSink = inUserdata.asStableRef<CurlSink>().get()
|
|
||||||
val vLen = (inSize * inCount).toLong()
|
|
||||||
if (vLen > 0) vSink.body.write(inBuffer.readBytes(vLen.toInt()))
|
|
||||||
return vLen.convert()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header callback — one parsed header line per call; accumulated for parsing. */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun onCurlHeader(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t
|
|
||||||
{
|
|
||||||
val vSink = inUserdata.asStableRef<CurlSink>().get()
|
|
||||||
val vLen = (inSize * inCount).toLong()
|
|
||||||
if (vLen > 0) vSink.headerRaw.append(inBuffer.readBytes(vLen.toInt()).decodeToString())
|
|
||||||
return vLen.convert()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sink callback that discards everything (used by the TLS-chain probe so the
|
|
||||||
response body/headers don't spill to stdout). */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun onCurlDiscard(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount: size_t, inUserdata: COpaquePointer): size_t =
|
|
||||||
(inSize * inCount)
|
|
||||||
|
|
||||||
/* Open a TLS connection to the request's URL (handshake only — no body) and
|
|
||||||
return the server's certificate chain via CURLINFO_CERTINFO. Reuses the
|
|
||||||
request's client certificate if it has one (so mTLS endpoints work too).
|
|
||||||
inReq is already var-resolved. */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
fun inspectTlsChain(inReq: ApiRequest): TlsChain
|
|
||||||
{
|
|
||||||
val vCert = try { if (inReq.hasClientCert) prepareClientCert(inReq) else null }
|
|
||||||
catch (e: Throwable) { return TlsChain(emptyList(), e.message ?: "Client certificate error") }
|
|
||||||
val vCurl = curl_easy_init() ?: run { vCert?.cleanup(); return TlsChain(emptyList(), "Could not initialize libcurl") }
|
|
||||||
try
|
|
||||||
{
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L) // just the handshake + headers
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_CERTINFO, 1L)
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 0L) // inspect the host as typed
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlDiscard))
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlDiscard))
|
|
||||||
vCert?.let { vC ->
|
|
||||||
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vC.sslCert)
|
|
||||||
vC.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
|
|
||||||
vC.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
|
|
||||||
vC.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
|
|
||||||
vC.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
|
|
||||||
}
|
|
||||||
val vCode = curl_easy_perform(vCurl)
|
|
||||||
if (vCode != CURLE_OK)
|
|
||||||
return TlsChain(emptyList(), curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode")
|
|
||||||
val vRaw = readCertInfo(vCurl)
|
|
||||||
if (vRaw.isEmpty()) return TlsChain(emptyList(), "No certificate chain reported (not an HTTPS URL, or the TLS backend didn't expose it).")
|
|
||||||
return TlsChain(extendChain(vRaw), null)
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
curl_easy_cleanup(vCurl)
|
|
||||||
vCert?.cleanup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Read CURLINFO_CERTINFO off a performed handle into per-cert field lists. */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>>> = memScoped {
|
|
||||||
val vPtr = alloc<COpaquePointerVar>()
|
|
||||||
if (curl_easy_getinfo(inCurl, CURLINFO_CERTINFO, vPtr.ptr) != CURLE_OK) return@memScoped emptyList()
|
|
||||||
val vInfo = vPtr.value?.reinterpret<curl_certinfo>()?.pointed ?: return@memScoped emptyList()
|
|
||||||
val vArr = vInfo.certinfo ?: return@memScoped emptyList()
|
|
||||||
val vResult = ArrayList<List<Pair<String, String>>>()
|
|
||||||
for (vI in 0 until vInfo.num_of_certs)
|
|
||||||
{
|
|
||||||
val vFields = ArrayList<Pair<String, String>>()
|
|
||||||
var vNode = vArr[vI]
|
|
||||||
while (vNode != null)
|
|
||||||
{
|
|
||||||
val vData = vNode.pointed.data?.toKString()
|
|
||||||
if (vData != null)
|
|
||||||
{
|
|
||||||
val vColon = vData.indexOf(':')
|
|
||||||
if (vColon > 0) vFields.add(vData.substring(0, vColon) to vData.substring(vColon + 1))
|
|
||||||
else vFields.add("" to vData)
|
|
||||||
}
|
|
||||||
vNode = vNode.pointed.next
|
|
||||||
}
|
|
||||||
vResult.add(vFields)
|
|
||||||
}
|
|
||||||
vResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Send a client-certificate request through libcurl and adapt the result into
|
/** Per-transfer accumulators handed to the C callbacks through a StableRef. */
|
||||||
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
|
private class CurlSink {
|
||||||
|
val body = Buffer() // response body bytes (already decompressed by curl)
|
||||||
|
val headerRaw = StringBuilder() // raw response header lines, all responses incl. redirects
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body write callback — appends each chunk to the sink's buffer. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
|
private fun onCurlBody(
|
||||||
{
|
inBuffer: CPointer<ByteVar>,
|
||||||
val vMark = TimeSource.Monotonic.markNow()
|
inSize: size_t,
|
||||||
val vCert = try { prepareClientCert(inReq) }
|
inCount: size_t,
|
||||||
catch (e: Throwable) { return errorResponse(e.message ?: "Client certificate error", vMark.elapsedNow().inWholeMilliseconds) }
|
inUserdata: COpaquePointer
|
||||||
val vCurl = curl_easy_init()
|
): size_t {
|
||||||
?: run { vCert.cleanup(); return errorResponse("Could not initialize libcurl", vMark.elapsedNow().inWholeMilliseconds) }
|
val vSink = inUserdata.asStableRef<CurlSink>().get()
|
||||||
|
val vLen = (inSize * inCount).toLong()
|
||||||
|
if (vLen > 0) vSink.body.write(inBuffer.readBytes(vLen.toInt()))
|
||||||
|
return vLen.convert()
|
||||||
|
}
|
||||||
|
|
||||||
val vSink = CurlSink()
|
/** Header callback — one parsed header line per call; accumulated for parsing. */
|
||||||
val vSinkRef = StableRef.create(vSink)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
var vHeaders: CPointer<curl_slist>? = null
|
private fun onCurlHeader(
|
||||||
|
inBuffer: CPointer<ByteVar>,
|
||||||
|
inSize: size_t,
|
||||||
|
inCount: size_t,
|
||||||
|
inUserdata: COpaquePointer
|
||||||
|
): size_t {
|
||||||
|
val vSink = inUserdata.asStableRef<CurlSink>().get()
|
||||||
|
val vLen = (inSize * inCount).toLong()
|
||||||
|
if (vLen > 0) vSink.headerRaw.append(inBuffer.readBytes(vLen.toInt()).decodeToString())
|
||||||
|
return vLen.convert()
|
||||||
|
}
|
||||||
|
|
||||||
try
|
/** Sink callback that discards everything (used by the TLS-chain probe so the
|
||||||
{
|
response body/headers don't spill to stdout). */
|
||||||
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 1L)
|
private fun onCurlDiscard(
|
||||||
curl_easy_setopt(vCurl, CURLOPT_ACCEPT_ENCODING, "") // advertise + auto-decompress
|
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
|
||||||
// Method + body
|
return the server's certificate chain via CURLINFO_CERTINFO. Reuses the
|
||||||
val vBody = if (inReq.method == ReqMethod.HEAD) null else requestBodyBytes(inReq)
|
request's client certificate if it has one (so mTLS endpoints work too).
|
||||||
if (inReq.method == ReqMethod.HEAD)
|
inReq is already var-resolved. */
|
||||||
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
else
|
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain {
|
||||||
curl_easy_setopt(vCurl, CURLOPT_CUSTOMREQUEST, inReq.method.name)
|
val vCert = try {
|
||||||
if (vBody != null && vBody.bytes.isNotEmpty())
|
if (inReq.hasClientCert) prepareClientCert(inReq) else null
|
||||||
{
|
} catch (e: Throwable) {
|
||||||
curl_easy_setopt(vCurl, CURLOPT_POSTFIELDSIZE_LARGE, vBody.bytes.size.toLong())
|
return TlsChain(emptyList(), e.message ?: "Client certificate error")
|
||||||
vBody.bytes.usePinned { curl_easy_setopt(vCurl, CURLOPT_COPYPOSTFIELDS, it.addressOf(0)) }
|
}
|
||||||
}
|
val vCurl =
|
||||||
|
curl_easy_init() ?: run { vCert?.cleanup(); return TlsChain(emptyList(), "Could not initialize libcurl") }
|
||||||
|
try {
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L) // just the handshake + headers
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_CERTINFO, 1L)
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 0L) // inspect the host as typed
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlDiscard))
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlDiscard))
|
||||||
|
vCert?.let { vC ->
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vC.sslCert)
|
||||||
|
vC.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
|
||||||
|
vC.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
|
||||||
|
vC.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
|
||||||
|
vC.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
|
||||||
|
}
|
||||||
|
val vCode = curl_easy_perform(vCurl)
|
||||||
|
if (vCode != CURLE_OK)
|
||||||
|
return TlsChain(emptyList(), curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode")
|
||||||
|
val vRaw = readCertInfo(vCurl)
|
||||||
|
if (vRaw.isEmpty()) return TlsChain(
|
||||||
|
emptyList(),
|
||||||
|
"No certificate chain reported (not an HTTPS URL, or the TLS backend didn't expose it)."
|
||||||
|
)
|
||||||
|
return TlsChain(extendChain(vRaw), null)
|
||||||
|
} finally {
|
||||||
|
curl_easy_cleanup(vCurl)
|
||||||
|
vCert?.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============
|
/** Read CURLINFO_CERTINFO off a performed handle into per-cert field lists. */
|
||||||
// Headers (+ Content-Type for the body type if the user didn't set one)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
inReq.headers
|
private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>>> = memScoped {
|
||||||
.filter { it.enabled && it.key.isNotBlank() }
|
val vPtr = alloc<COpaquePointerVar>()
|
||||||
.forEach { vHeaders = curl_slist_append(vHeaders, "${it.key}: ${it.value}") }
|
if (curl_easy_getinfo(inCurl, CURLINFO_CERTINFO, vPtr.ptr) != CURLE_OK) return@memScoped emptyList()
|
||||||
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
|
val vInfo = vPtr.value?.reinterpret<curl_certinfo>()?.pointed ?: return@memScoped emptyList()
|
||||||
if (vBody?.contentType != null && !vHasCt)
|
val vArr = vInfo.certinfo ?: return@memScoped emptyList()
|
||||||
vHeaders = curl_slist_append(vHeaders, "Content-Type: ${vBody.contentType}")
|
val vResult = ArrayList<List<Pair<String, String>>>()
|
||||||
vHeaders = curl_slist_append(vHeaders, "Expect:") // disable 100-continue, as Ktor's engine does
|
for (vI in 0 until vInfo.num_of_certs) {
|
||||||
vHeaders?.let { curl_easy_setopt(vCurl, CURLOPT_HTTPHEADER, it) }
|
val vFields = ArrayList<Pair<String, String>>()
|
||||||
|
var vNode = vArr[vI]
|
||||||
|
while (vNode != null) {
|
||||||
|
val vData = vNode.pointed.data?.toKString()
|
||||||
|
if (vData != null) {
|
||||||
|
val vColon = vData.indexOf(':')
|
||||||
|
if (vColon > 0) vFields.add(vData.substring(0, vColon) to vData.substring(vColon + 1))
|
||||||
|
else vFields.add("" to vData)
|
||||||
|
}
|
||||||
|
vNode = vNode.pointed.next
|
||||||
|
}
|
||||||
|
vResult.add(vFields)
|
||||||
|
}
|
||||||
|
vResult
|
||||||
|
}
|
||||||
|
|
||||||
// ============
|
/** Send a client-certificate request through libcurl and adapt the result into
|
||||||
// Response capture callbacks
|
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
|
||||||
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlBody))
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
curl_easy_setopt(vCurl, CURLOPT_WRITEDATA, vSinkRef.asCPointer())
|
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse {
|
||||||
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlHeader))
|
val vMark = TimeSource.Monotonic.markNow()
|
||||||
curl_easy_setopt(vCurl, CURLOPT_HEADERDATA, vSinkRef.asCPointer())
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ============
|
val vSink = CurlSink()
|
||||||
// Client certificate (prepared per-platform: cert/key files on the
|
val vSinkRef = StableRef.create(vSink)
|
||||||
// OpenSSL backends, a CurrentUser\MY thumbprint on Windows/Schannel).
|
var vHeaders: CPointer<curl_slist>? = null
|
||||||
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vCert.sslCert)
|
|
||||||
vCert.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
|
|
||||||
vCert.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
|
|
||||||
vCert.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
|
|
||||||
vCert.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
|
|
||||||
|
|
||||||
// ============
|
try {
|
||||||
// Perform
|
curl_easy_setopt(vCurl, CURLOPT_URL, urlWithParams(inReq))
|
||||||
val vCode = curl_easy_perform(vCurl)
|
curl_easy_setopt(vCurl, CURLOPT_FOLLOWLOCATION, 1L)
|
||||||
val vMs = vMark.elapsedNow().inWholeMilliseconds
|
curl_easy_setopt(vCurl, CURLOPT_ACCEPT_ENCODING, "") // advertise + auto-decompress
|
||||||
if (vCode != CURLE_OK)
|
|
||||||
return errorResponse(curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode", vMs)
|
|
||||||
|
|
||||||
val vBytes = vSink.body.readByteArray()
|
// ============
|
||||||
val vStatus = memScoped { val p = alloc<LongVar>(); curl_easy_getinfo(vCurl, CURLINFO_RESPONSE_CODE, p.ptr); p.value.toInt() }
|
// Method + body
|
||||||
val vCt = memScoped { val p = alloc<CPointerVar<ByteVar>>(); curl_easy_getinfo(vCurl, CURLINFO_CONTENT_TYPE, p.ptr); p.value?.toKString() }
|
val vBody = if (inReq.method == ReqMethod.HEAD) null else requestBodyBytes(inReq)
|
||||||
val vVer = memScoped { val p = alloc<LongVar>(); curl_easy_getinfo(vCurl, CURLINFO_HTTP_VERSION, p.ptr); httpVersionName(p.value) }
|
if (inReq.method == ReqMethod.HEAD)
|
||||||
val vIsImage = vCt?.startsWith("image/", ignoreCase = true) == true
|
curl_easy_setopt(vCurl, CURLOPT_NOBODY, 1L)
|
||||||
val vBinary = !vIsImage && isBinaryBody(vCt, vBytes)
|
else
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_CUSTOMREQUEST, inReq.method.name)
|
||||||
|
if (vBody != null && vBody.bytes.isNotEmpty()) {
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_POSTFIELDSIZE_LARGE, vBody.bytes.size.toLong())
|
||||||
|
vBody.bytes.usePinned { curl_easy_setopt(vCurl, CURLOPT_COPYPOSTFIELDS, it.addressOf(0)) }
|
||||||
|
}
|
||||||
|
|
||||||
// Headers we explicitly put on the wire (curl also adds Host / Accept /
|
// ============
|
||||||
// User-Agent / Content-Length, which it doesn't expose without a debug hook).
|
// Headers (+ Content-Type for the body type if the user didn't set one)
|
||||||
val vSent = buildList {
|
inReq.headers
|
||||||
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { add(it.key to it.value) }
|
.filter { it.enabled && it.key.isNotBlank() }
|
||||||
if (vBody?.contentType != null && !vHasCt) add("Content-Type" to vBody.contentType)
|
.forEach { vHeaders = curl_slist_append(vHeaders, "${it.key}: ${it.value}") }
|
||||||
}.sortedBy { it.first.lowercase() }
|
val vHasCt = inReq.headers.any { it.enabled && it.key.equals("content-type", ignoreCase = true) }
|
||||||
|
if (vBody?.contentType != null && !vHasCt)
|
||||||
|
vHeaders = curl_slist_append(vHeaders, "Content-Type: ${vBody.contentType}")
|
||||||
|
vHeaders = curl_slist_append(vHeaders, "Expect:") // disable 100-continue, as Ktor's engine does
|
||||||
|
vHeaders?.let { curl_easy_setopt(vCurl, CURLOPT_HTTPHEADER, it) }
|
||||||
|
|
||||||
return ApiResponse(
|
// ============
|
||||||
ok = true,
|
// Response capture callbacks
|
||||||
status = vStatus,
|
curl_easy_setopt(vCurl, CURLOPT_WRITEFUNCTION, staticCFunction(::onCurlBody))
|
||||||
statusText = runCatching { HttpStatusCode.fromValue(vStatus).description }.getOrDefault(""),
|
curl_easy_setopt(vCurl, CURLOPT_WRITEDATA, vSinkRef.asCPointer())
|
||||||
timeMs = vMs,
|
curl_easy_setopt(vCurl, CURLOPT_HEADERFUNCTION, staticCFunction(::onCurlHeader))
|
||||||
sizeBytes = vBytes.size.toLong(),
|
curl_easy_setopt(vCurl, CURLOPT_HEADERDATA, vSinkRef.asCPointer())
|
||||||
headers = parseRawHeaders(vSink.headerRaw.toString()),
|
|
||||||
body = when {
|
|
||||||
vIsImage -> ""
|
|
||||||
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes — not shown; use Save as…)"
|
|
||||||
else -> vBytes.decodeToString()
|
|
||||||
},
|
|
||||||
bytes = vBytes,
|
|
||||||
contentType = vCt,
|
|
||||||
requestHeaders = vSent,
|
|
||||||
httpVersion = vVer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
vHeaders?.let { curl_slist_free_all(it) }
|
|
||||||
curl_easy_cleanup(vCurl)
|
|
||||||
vSinkRef.dispose()
|
|
||||||
vCert.cleanup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A failed-request ApiResponse with a message (mirrors HttpRunner's catch). */
|
// ============
|
||||||
|
// Client certificate (prepared per-platform: cert/key files on the
|
||||||
|
// OpenSSL backends, a CurrentUser\MY thumbprint on Windows/Schannel).
|
||||||
|
curl_easy_setopt(vCurl, CURLOPT_SSLCERT, vCert.sslCert)
|
||||||
|
vCert.sslCertType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLCERTTYPE, it) }
|
||||||
|
vCert.sslKey?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEY, it) }
|
||||||
|
vCert.sslKeyType?.let { curl_easy_setopt(vCurl, CURLOPT_SSLKEYTYPE, it) }
|
||||||
|
vCert.keyPassword?.let { curl_easy_setopt(vCurl, CURLOPT_KEYPASSWD, it) }
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Perform
|
||||||
|
val vCode = curl_easy_perform(vCurl)
|
||||||
|
val vMs = vMark.elapsedNow().inWholeMilliseconds
|
||||||
|
if (vCode != CURLE_OK)
|
||||||
|
return errorResponse(curl_easy_strerror(vCode)?.toKString() ?: "libcurl error $vCode", vMs)
|
||||||
|
|
||||||
|
val vBytes = vSink.body.readByteArray()
|
||||||
|
val vStatus = memScoped {
|
||||||
|
val p = alloc<LongVar>(); curl_easy_getinfo(
|
||||||
|
vCurl,
|
||||||
|
CURLINFO_RESPONSE_CODE,
|
||||||
|
p.ptr
|
||||||
|
); p.value.toInt()
|
||||||
|
}
|
||||||
|
val vCt = memScoped {
|
||||||
|
val p = alloc<CPointerVar<ByteVar>>(); curl_easy_getinfo(
|
||||||
|
vCurl,
|
||||||
|
CURLINFO_CONTENT_TYPE,
|
||||||
|
p.ptr
|
||||||
|
); p.value?.toKString()
|
||||||
|
}
|
||||||
|
val vVer = memScoped {
|
||||||
|
val p = alloc<LongVar>(); curl_easy_getinfo(
|
||||||
|
vCurl,
|
||||||
|
CURLINFO_HTTP_VERSION,
|
||||||
|
p.ptr
|
||||||
|
); httpVersionName(p.value)
|
||||||
|
}
|
||||||
|
val vIsImage = vCt?.startsWith("image/", ignoreCase = true) == true
|
||||||
|
val vBinary = !vIsImage && isBinaryBody(vCt, vBytes)
|
||||||
|
|
||||||
|
// Headers we explicitly put on the wire (curl also adds Host / Accept /
|
||||||
|
// User-Agent / Content-Length, which it doesn't expose without a debug hook).
|
||||||
|
val vSent = buildList {
|
||||||
|
inReq.headers.filter { it.enabled && it.key.isNotBlank() }.forEach { add(it.key to it.value) }
|
||||||
|
if (vBody?.contentType != null && !vHasCt) add("Content-Type" to vBody.contentType)
|
||||||
|
}.sortedBy { it.first.lowercase() }
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
status = vStatus,
|
||||||
|
statusText = runCatching { HttpStatusCode.fromValue(vStatus).description }.getOrDefault(""),
|
||||||
|
timeMs = vMs,
|
||||||
|
sizeBytes = vBytes.size.toLong(),
|
||||||
|
headers = parseRawHeaders(vSink.headerRaw.toString()),
|
||||||
|
body = when {
|
||||||
|
vIsImage -> ""
|
||||||
|
vBinary -> "(${vCt ?: "binary"} · ${vBytes.size} bytes — not shown; use Save as…)"
|
||||||
|
else -> vBytes.decodeToString()
|
||||||
|
},
|
||||||
|
bytes = vBytes,
|
||||||
|
contentType = vCt,
|
||||||
|
requestHeaders = vSent,
|
||||||
|
httpVersion = vVer,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
vHeaders?.let { curl_slist_free_all(it) }
|
||||||
|
curl_easy_cleanup(vCurl)
|
||||||
|
vSinkRef.dispose()
|
||||||
|
vCert.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A failed-request ApiResponse with a message (mirrors HttpRunner's catch). */
|
||||||
private fun errorResponse(inMessage: String, inMs: Long): ApiResponse =
|
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(). */
|
should send (null when there's no body). Mirrors HttpRunner.run(). */
|
||||||
private class CurlBody(val contentType: String?, val bytes: ByteArray)
|
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
|
||||||
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.TEXT -> CurlBody(inReq.bodyContentType(), inReq.body.encodeToByteArray())
|
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(
|
||||||
BodyType.FORM -> CurlBody(inReq.bodyContentType(), formEncode(inReq.form).encodeToByteArray())
|
inReq.bodyContentType(),
|
||||||
BodyType.FILE -> if (inReq.body.isBlank()) null else CurlBody(inReq.bodyContentType(), readFileBytes(inReq.body))
|
readFileBytes(inReq.body)
|
||||||
BodyType.NONE -> null
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Parse libcurl's accumulated raw header text into ordered key/value pairs,
|
BodyType.NONE -> null
|
||||||
keeping only the final response's block (a new "HTTP/" status line — emitted
|
}
|
||||||
per redirect hop — resets what we've gathered). */
|
}
|
||||||
private fun parseRawHeaders(inRaw: String): List<Pair<String, String>>
|
|
||||||
{
|
|
||||||
var vCurrent = mutableListOf<Pair<String, String>>()
|
|
||||||
for (vRawLine in inRaw.split("\r\n", "\n"))
|
|
||||||
{
|
|
||||||
val vLine = vRawLine.trimEnd()
|
|
||||||
if (vLine.isEmpty()) continue
|
|
||||||
if (vLine.startsWith("HTTP/", ignoreCase = true)) { vCurrent = mutableListOf(); continue }
|
|
||||||
val vColon = vLine.indexOf(':')
|
|
||||||
if (vColon <= 0) continue
|
|
||||||
vCurrent.add(vLine.substring(0, vColon).trim() to vLine.substring(vColon + 1).trim())
|
|
||||||
}
|
|
||||||
return vCurrent.sortedBy { it.first.lowercase() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
|
/** Parse libcurl's accumulated raw header text into ordered key/value pairs,
|
||||||
|
keeping only the final response's block (a new "HTTP/" status line — emitted
|
||||||
|
per redirect hop — resets what we've gathered). */
|
||||||
|
private fun parseRawHeaders(inRaw: String): List<Pair<String, String>> {
|
||||||
|
var vCurrent = mutableListOf<Pair<String, String>>()
|
||||||
|
for (vRawLine in inRaw.split("\r\n", "\n")) {
|
||||||
|
val vLine = vRawLine.trimEnd()
|
||||||
|
if (vLine.isEmpty()) continue
|
||||||
|
if (vLine.startsWith("HTTP/", ignoreCase = true)) {
|
||||||
|
vCurrent = mutableListOf(); continue
|
||||||
|
}
|
||||||
|
val vColon = vLine.indexOf(':')
|
||||||
|
if (vColon <= 0) continue
|
||||||
|
vCurrent.add(vLine.substring(0, vColon).trim() to vLine.substring(vColon + 1).trim())
|
||||||
|
}
|
||||||
|
return vCurrent.sortedBy { it.first.lowercase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** libcurl's CURLINFO_HTTP_VERSION code → a human protocol string. */
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@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_0.toLong() -> "HTTP/1.0"
|
CURL_HTTP_VERSION_1_1.toLong() -> "HTTP/1.1"
|
||||||
CURL_HTTP_VERSION_1_1.toLong() -> "HTTP/1.1"
|
CURL_HTTP_VERSION_2_0.toLong() -> "HTTP/2"
|
||||||
CURL_HTTP_VERSION_2_0.toLong() -> "HTTP/2"
|
CURL_HTTP_VERSION_3.toLong() -> "HTTP/3"
|
||||||
CURL_HTTP_VERSION_3.toLong() -> "HTTP/3"
|
else -> "HTTP/1.1"
|
||||||
else -> "HTTP/1.1"
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,17 +6,10 @@ import com.compose.sdl.loadComposeResourceBytes
|
|||||||
import com.compose.sdl.text.namedFontFamily
|
import com.compose.sdl.text.namedFontFamily
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Monospace body font
|
// MARK: Monospace body font — native actuals (data.kres + IconFont)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
// Family name the body editor passes for monospace text. Resolves to the bundled
|
actual val monoFontFamilyName: String? by lazy {
|
||||||
// Noto Sans Mono once registered; null means the font isn't in data.kres, in
|
|
||||||
// which case the body falls back to the default (proportional Noto Sans).
|
|
||||||
const val kMonoFamily = "noto-mono"
|
|
||||||
|
|
||||||
// The raw project family name string — used by the SyntaxHighlight tokeniser's
|
|
||||||
// wrap() call which measures via NativeTextMeasurer (accepts a name string).
|
|
||||||
val monoFontFamilyName: String? by lazy {
|
|
||||||
val vBytes = loadComposeResourceBytes("font/NotoSansMono.ttf")
|
val vBytes = loadComposeResourceBytes("font/NotoSansMono.ttf")
|
||||||
if (vBytes == null) {
|
if (vBytes == null) {
|
||||||
println("apidemo: NotoSansMono.ttf not bundled — body uses the default font")
|
println("apidemo: NotoSansMono.ttf not bundled — body uses the default font")
|
||||||
@@ -27,9 +20,6 @@ val monoFontFamilyName: String? by lazy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The material3 Text / BasicTextField-shaped FontFamily wrapper. Wraps the
|
actual val monoFontFamily: FontFamily? by lazy {
|
||||||
// registered project family in a `NamedFont`-backed FontFamily so the m3 Text
|
|
||||||
// signature (which takes `FontFamily?` proper) can be given the mono family.
|
|
||||||
val monoFontFamily: FontFamily? by lazy {
|
|
||||||
monoFontFamilyName?.let { namedFontFamily(it) }
|
monoFontFamilyName?.let { namedFontFamily(it) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
package apidemo
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Pack model (serialized to / from .json)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* A "pack" is a savable collection of requests plus its environment variables
|
|
||||||
— the export/import unit. Variables are referenced as {{name}} in any URL,
|
|
||||||
query value, header or body and substituted just before the request is sent
|
|
||||||
(see resolveVars in Tools.kt). Packs may be empty; the rich starter set lives
|
|
||||||
in defaultSession(). */
|
|
||||||
@Serializable
|
|
||||||
data class Pack(
|
|
||||||
val name: String = "My Pack",
|
|
||||||
val requests: List<ApiRequest> = emptyList(),
|
|
||||||
val variables: List<KeyVal> = emptyList(),
|
|
||||||
val color: Int = 0, // 1-based index into the pack-colour palette; 0 = none
|
|
||||||
val headers: List<KeyVal> = emptyList(), // pack-level headers, inherited by requests
|
|
||||||
val params: List<KeyVal> = emptyList(), // pack-level query params, inherited by requests
|
|
||||||
val cert: CertConfig? = null, // pack-level client cert, inherited by requests
|
|
||||||
val id: String = "", // stable id (used by linked-copy packs)
|
|
||||||
val linkedTo: String? = null, // id of the source pack when this is a linked copy
|
|
||||||
val isRoot: Boolean = false, // the hidden session-root "pack" holding loose requests
|
|
||||||
val subPacks: List<Pack> = emptyList(), // nested packs (a pack tree)
|
|
||||||
)
|
|
||||||
|
|
||||||
/* A client-certificate (mutual TLS) configuration. Lives on a request today and
|
|
||||||
— with the pack tree — on the session / packs too (inherited downward). Maps
|
|
||||||
to libcurl's CURLOPT_SSLCERT / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. */
|
|
||||||
@Serializable
|
|
||||||
data class CertConfig(
|
|
||||||
val certPath: String = "",
|
|
||||||
val certFormat: CertFormat = CertFormat.PEM,
|
|
||||||
val keyPath: String = "",
|
|
||||||
val keyFormat: CertFormat = CertFormat.PEM,
|
|
||||||
val certPassword: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
/* The starter SESSION loaded on first launch (and on demand from the session
|
|
||||||
menu) — a guided tour of every feature against httpbin.org: the inheritance
|
|
||||||
ladder (session → pack → sub-pack → request, for variables / query params /
|
|
||||||
headers / client cert, innermost wins), loose root requests, a nested sub-pack,
|
|
||||||
per-request overrides, a client-cert pack, and a linked-copy pack. Each /get or
|
|
||||||
/anything endpoint echoes the args + headers it received, so the merged,
|
|
||||||
inherited result is visible in the response. Loaded as an unsaved session. */
|
|
||||||
fun defaultSession(): Session = Session(
|
|
||||||
activePack = 0,
|
|
||||||
// ── Session level — the base of every inheritance ladder ──
|
|
||||||
globalEnv = listOf(
|
|
||||||
KeyVal("baseUrl", "https://httpbin.org"),
|
|
||||||
KeyVal("token", "session-token-abc"),
|
|
||||||
KeyVal("user", "session-user"),
|
|
||||||
KeyVal("password", "passwd"),
|
|
||||||
KeyVal("apiVer", "v1"), // overridden by Methods (v2) then Nested (v3)
|
|
||||||
),
|
|
||||||
globalHeaders = listOf(KeyVal("X-Client", "compose-apidemo")), // every request sends it (echoed by /headers)
|
|
||||||
globalParams = listOf(KeyVal("trace", "session")), // every request gets ?trace=session (echoed in /get args)
|
|
||||||
// No session-level cert: it would route every request through mTLS and fail
|
|
||||||
// without a real cert file. Client certs are shown on the "Secure (mTLS)" pack.
|
|
||||||
// ── Loose requests at the session root (in no pack — inherit session only) ──
|
|
||||||
root = Pack(isRoot = true, name = "", requests = listOf(
|
|
||||||
ApiRequest(name = "Ping (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
|
|
||||||
ApiRequest(name = "Headers echo (loose)", method = ReqMethod.GET, url = "{{baseUrl}}/headers"),
|
|
||||||
)),
|
|
||||||
packs = listOf(
|
|
||||||
// ── Methods — pack header/param/var inherited by its requests, plus a
|
|
||||||
// nested sub-pack and per-request overrides of each kind. ──
|
|
||||||
SavedPack(pack = Pack(name = "Methods", id = "pk-methods", color = 1,
|
|
||||||
headers = listOf(KeyVal("Accept", "application/json")), // pack header, inherited by its requests
|
|
||||||
params = listOf(KeyVal("source", "methods")), // pack query param, inherited (echoed in /get args)
|
|
||||||
variables = listOf(KeyVal("apiVer", "v2")), // overrides the session's apiVer for this pack
|
|
||||||
requests = listOf(
|
|
||||||
ApiRequest(name = "GET — echoes inherited", method = ReqMethod.GET, url = "{{baseUrl}}/get",
|
|
||||||
params = listOf(KeyVal("q", "compose"))), // own q + inherited trace=session + source=methods
|
|
||||||
ApiRequest(name = "GET — overrides query param", method = ReqMethod.GET, url = "{{baseUrl}}/get",
|
|
||||||
params = listOf(KeyVal("trace", "request"))), // request's trace wins over the session's
|
|
||||||
ApiRequest(name = "POST — overrides header", method = ReqMethod.POST, url = "{{baseUrl}}/post",
|
|
||||||
headers = listOf(KeyVal("Accept", "text/plain")), // request's Accept wins over the pack's
|
|
||||||
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{\n \"user\": \"{{user}}\",\n \"api\": \"{{apiVer}}\"\n}"),
|
|
||||||
ApiRequest(name = "POST — overrides {{user}} var", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
|
|
||||||
variables = listOf(KeyVal("user", "request-user")), // request var beats session/pack — see body echo
|
|
||||||
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"who\": \"{{user}}\" }"),
|
|
||||||
ApiRequest(name = "PUT — replace", method = ReqMethod.PUT, url = "{{baseUrl}}/put",
|
|
||||||
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"id\": 1, \"name\": \"updated\" }"),
|
|
||||||
ApiRequest(name = "DELETE", method = ReqMethod.DELETE, url = "{{baseUrl}}/delete"),
|
|
||||||
ApiRequest(name = "Anything echo", method = ReqMethod.POST, url = "{{baseUrl}}/anything",
|
|
||||||
bodyType = BodyType.TEXT, bodyFormat = BodyFormat.JSON, body = "{ \"hello\": \"world\" }"),
|
|
||||||
),
|
|
||||||
// Nested sub-pack — inherits Session → Methods → here (apiVer becomes v3,
|
|
||||||
// plus its own X-Nested header; trace / source / Accept still flow down).
|
|
||||||
subPacks = listOf(
|
|
||||||
Pack(name = "Nested", color = 4,
|
|
||||||
variables = listOf(KeyVal("apiVer", "v3")),
|
|
||||||
headers = listOf(KeyVal("X-Nested", "true")),
|
|
||||||
requests = listOf(
|
|
||||||
ApiRequest(name = "GET — deep inheritance", method = ReqMethod.GET, url = "{{baseUrl}}/anything/{{apiVer}}"),
|
|
||||||
)),
|
|
||||||
))),
|
|
||||||
// ── Auth & status — uses the inherited {{token}} / {{user}} / {{password}} ──
|
|
||||||
SavedPack(pack = Pack(name = "Auth & status", color = 2, requests = listOf(
|
|
||||||
ApiRequest(name = "Bearer auth", method = ReqMethod.GET, url = "{{baseUrl}}/bearer",
|
|
||||||
headers = listOf(KeyVal("Authorization", "Bearer {{token}}"))),
|
|
||||||
ApiRequest(name = "Basic auth", method = ReqMethod.GET, url = "{{baseUrl}}/basic-auth/{{user}}/{{password}}"),
|
|
||||||
ApiRequest(name = "Status 200", method = ReqMethod.GET, url = "{{baseUrl}}/status/200"),
|
|
||||||
ApiRequest(name = "Status 500", method = ReqMethod.GET, url = "{{baseUrl}}/status/500"),
|
|
||||||
ApiRequest(name = "Redirect x3", method = ReqMethod.GET, url = "{{baseUrl}}/redirect/3"),
|
|
||||||
ApiRequest(name = "Delay 3s (try Cancel)", method = ReqMethod.GET, url = "{{baseUrl}}/delay/3"),
|
|
||||||
))),
|
|
||||||
// ── Formats — response viewer (JSON / XML / HTML / image) ──
|
|
||||||
SavedPack(pack = Pack(name = "Formats", color = 3, requests = listOf(
|
|
||||||
ApiRequest(name = "JSON", method = ReqMethod.GET, url = "{{baseUrl}}/json"),
|
|
||||||
ApiRequest(name = "XML", method = ReqMethod.GET, url = "{{baseUrl}}/xml"),
|
|
||||||
ApiRequest(name = "HTML", method = ReqMethod.GET, url = "{{baseUrl}}/html"),
|
|
||||||
ApiRequest(name = "UTF-8 text", method = ReqMethod.GET, url = "{{baseUrl}}/encoding/utf8"),
|
|
||||||
ApiRequest(name = "PNG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/png"),
|
|
||||||
ApiRequest(name = "SVG image", method = ReqMethod.GET, url = "{{baseUrl}}/image/svg"),
|
|
||||||
))),
|
|
||||||
// ── Secure (mTLS) — a PACK-LEVEL client cert, inherited by its requests.
|
|
||||||
// Open a request's Cert tab to see the inherited cert (source pill +
|
|
||||||
// Override). Set real cert paths to actually send; the example paths
|
|
||||||
// won't load. The second request overrides with its own cert. ──
|
|
||||||
SavedPack(pack = Pack(name = "Secure (mTLS)", color = 5,
|
|
||||||
cert = CertConfig(certPath = "/path/to/client.p12", certFormat = CertFormat.PKCS12, certPassword = "changeit"),
|
|
||||||
requests = listOf(
|
|
||||||
ApiRequest(name = "Inherits the pack cert", method = ReqMethod.GET, url = "{{baseUrl}}/get"),
|
|
||||||
ApiRequest(name = "Overrides with own cert", method = ReqMethod.GET, url = "{{baseUrl}}/get",
|
|
||||||
certPath = "/path/to/other-client.pem", certFormat = CertFormat.PEM, keyPath = "/path/to/other-key.pem"),
|
|
||||||
))),
|
|
||||||
// ── Linked copy of "Methods": a read-only mirror of its requests, but with
|
|
||||||
// its own baseUrl var → the same calls run against the httpbingo sibling.
|
|
||||||
// Showcases linked packs + per-pack variable override. ──
|
|
||||||
SavedPack(pack = Pack(name = "Methods · httpbingo", color = 6, requests = emptyList(),
|
|
||||||
variables = listOf(KeyVal("baseUrl", "https://httpbingo.org")), linkedTo = "pk-methods")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApiRequest(
|
|
||||||
val name: String = "New request",
|
|
||||||
val method: ReqMethod = ReqMethod.GET,
|
|
||||||
val url: String = "https://",
|
|
||||||
val params: List<KeyVal> = emptyList(),
|
|
||||||
val variables: List<KeyVal> = emptyList(), // request-level vars ({{name}}); override inherited ones
|
|
||||||
val headers: List<KeyVal> = emptyList(),
|
|
||||||
val bodyType: BodyType = BodyType.NONE,
|
|
||||||
val body: String = "", // TEXT content, or the file path when bodyType == FILE
|
|
||||||
val bodyFormat: BodyFormat = BodyFormat.RAW, // for a TEXT body: syntax-highlight type + sent Content-Type
|
|
||||||
val form: List<KeyVal> = emptyList(), // key/value fields when bodyType == FORM
|
|
||||||
// Client certificate (mutual TLS). When certPath is set, the request is sent
|
|
||||||
// through the libcurl path (CurlMtls.kt) with these mapped to CURLOPT_SSLCERT
|
|
||||||
// / SSLCERTTYPE / SSLKEY / SSLKEYTYPE / KEYPASSWD. A PKCS#12 file bundles its
|
|
||||||
// own key, so keyPath is left blank for it.
|
|
||||||
val certPath: String = "",
|
|
||||||
val certFormat: CertFormat = CertFormat.PEM,
|
|
||||||
val keyPath: String = "",
|
|
||||||
val keyFormat: CertFormat = CertFormat.PEM,
|
|
||||||
val certPassword: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
/* Whether this request carries a client certificate (and so is sent via the
|
|
||||||
libcurl mTLS path rather than the default engine). */
|
|
||||||
val ApiRequest.hasClientCert: Boolean
|
|
||||||
get() = certPath.isNotBlank()
|
|
||||||
|
|
||||||
/* This request's cert fields as a CertConfig (certPath may be blank = none). */
|
|
||||||
fun ApiRequest.certConfig(): CertConfig = CertConfig(certPath, certFormat, keyPath, keyFormat, certPassword)
|
|
||||||
|
|
||||||
/* This request with its cert fields replaced by inCert's. */
|
|
||||||
fun ApiRequest.withCert(inCert: CertConfig): ApiRequest =
|
|
||||||
copy(certPath = inCert.certPath, certFormat = inCert.certFormat,
|
|
||||||
keyPath = inCert.keyPath, keyFormat = inCert.keyFormat, certPassword = inCert.certPassword)
|
|
||||||
|
|
||||||
/* True when this config actually selects a certificate. */
|
|
||||||
val CertConfig.isSet: Boolean
|
|
||||||
get() = certPath.isNotBlank()
|
|
||||||
|
|
||||||
/* Certificate / private-key encodings, mapped to libcurl's CURLOPT_SSLCERTTYPE
|
|
||||||
/ CURLOPT_SSLKEYTYPE string values and to `curl --cert-type` / `--key-type`.
|
|
||||||
Runtime support depends on the TLS backend: OpenSSL (macOS/Linux) handles all
|
|
||||||
three; Schannel (Windows) effectively supports P12. */
|
|
||||||
@Serializable
|
|
||||||
enum class CertFormat(val curlName: String, val label: String) {
|
|
||||||
PEM("PEM", "PEM"),
|
|
||||||
DER("DER", "DER"),
|
|
||||||
PKCS12("P12", "PKCS#12"),
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A toggleable key/value row, used for both query params and headers. */
|
|
||||||
@Serializable
|
|
||||||
data class KeyVal(val key: String = "", val value: String = "", val enabled: Boolean = true)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class ReqMethod { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS }
|
|
||||||
|
|
||||||
/* HTTP doesn't forbid a body on GET / HEAD / OPTIONS — RFC 7230 §3.3
|
|
||||||
leaves it as "SHOULD NOT" / server discretion. Many APIs use a body
|
|
||||||
on GET (Elasticsearch search, GraphQL queries) so we don't gate it
|
|
||||||
here; the UI just sets bodyType=NONE by default for those methods. */
|
|
||||||
val ReqMethod.allowsBody: Boolean
|
|
||||||
get() = true
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class BodyType { NONE, TEXT, FORM, FILE }
|
|
||||||
|
|
||||||
/* Title-case label for the body-type picker. */
|
|
||||||
val BodyType.label: String
|
|
||||||
get() = when (this) {
|
|
||||||
BodyType.NONE -> "None"
|
|
||||||
BodyType.TEXT -> "Text"
|
|
||||||
BodyType.FORM -> "Form"
|
|
||||||
BodyType.FILE -> "File"
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The Content-Type the request body implies (null when there's no body). For a
|
|
||||||
TEXT body it comes from the chosen format (Text→text/plain, JSON→application/
|
|
||||||
json, …); FORM / FILE are fixed. Sent unless the request already sets one. */
|
|
||||||
fun ApiRequest.bodyContentType(): String? = when (bodyType) {
|
|
||||||
BodyType.NONE -> null
|
|
||||||
BodyType.TEXT -> bodyFormat.contentType
|
|
||||||
BodyType.FORM -> "application/x-www-form-urlencoded"
|
|
||||||
BodyType.FILE -> "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Response (runtime only — not part of a pack)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
class ApiResponse(
|
|
||||||
val ok: Boolean,
|
|
||||||
val status: Int,
|
|
||||||
val statusText: String,
|
|
||||||
val timeMs: Long,
|
|
||||||
val sizeBytes: Long,
|
|
||||||
val headers: List<Pair<String, String>>,
|
|
||||||
val body: String,
|
|
||||||
val bytes: ByteArray = ByteArray(0),
|
|
||||||
val contentType: String? = null,
|
|
||||||
val requestHeaders: List<Pair<String, String>> = emptyList(), // headers actually sent
|
|
||||||
val httpVersion: String = "HTTP/1.1", // from Ktor's response.version, e.g. "HTTP/1.1" or "HTTP/2"
|
|
||||||
val error: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/* True when the response carries an image payload (rendered, not shown as text). */
|
|
||||||
val ApiResponse.isImage: Boolean
|
|
||||||
get() = contentType?.startsWith("image/", ignoreCase = true) == true
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
|
||||||
|
|
||||||
package apidemo
|
|
||||||
|
|
||||||
import androidx.compose.foundation.*
|
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.PointerButton
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventType
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import com.compose.sdl.icons.MaterialSymbols
|
|
||||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
|
||||||
import com.compose.sdl.layout.x
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Request tab strip (open requests; drag a tab to reorder)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Strip of open requests above the editor. Each tab is clickable to select
|
|
||||||
and drag-reorderable. While dragging, the grabbed tab lifts (zIndex),
|
|
||||||
fades, and follows the cursor via a graphicsLayer translation — its
|
|
||||||
neighbours don't shuffle; the reorder commits on drop, to the slot whose
|
|
||||||
neighbours' centres the cursor passed. key(tab) keeps each tab's
|
|
||||||
LayoutNode stable so the drag session survives the reorder. */
|
|
||||||
@Composable
|
|
||||||
internal fun RequestTabStrip(
|
|
||||||
inTabs: List<StripTab>,
|
|
||||||
inActiveKey: Any?,
|
|
||||||
inOnSelect: (StripTab) -> Unit,
|
|
||||||
inOnClose: (StripTab) -> Unit,
|
|
||||||
inOnCloseOthers: (StripTab) -> Unit,
|
|
||||||
inOnCloseAll: () -> Unit,
|
|
||||||
inOnReorder: (Int, Int) -> Unit,
|
|
||||||
) {
|
|
||||||
val c = LocalAppColors.current
|
|
||||||
val vScroll = rememberScrollState() // strip's horizontal scroll
|
|
||||||
val vLeft = remember { mutableStateMapOf<Any, Int>() } // window-x of each tab's left edge, by tab key
|
|
||||||
val vWidth = remember { mutableStateMapOf<Any, Int>() } // measured tab width, by tab key
|
|
||||||
var vDragKey by remember { mutableStateOf<Any?>(null) }
|
|
||||||
var vPressRelX by remember { mutableStateOf(0) }
|
|
||||||
var vDragDx by remember { mutableStateOf(0f) }
|
|
||||||
var vDragTarget by remember { mutableStateOf(-1) }
|
|
||||||
|
|
||||||
// One shared context menu, anchored to the tab that opened it (m3 DropdownMenu
|
|
||||||
// is parent-anchored, not cursor-anchored — the old x/y capture is gone).
|
|
||||||
var vMenu by remember { mutableStateOf(false) }
|
|
||||||
var vMenuTab by remember { mutableStateOf<StripTab?>(null) }
|
|
||||||
|
|
||||||
var vListOpen by remember { mutableStateOf(false) } // overflow list
|
|
||||||
|
|
||||||
val vShowBar = vDragKey != null && vDragDx != 0f
|
|
||||||
val vOthers = if (vShowBar) inTabs.filter { it.tabKey != vDragKey } else emptyList()
|
|
||||||
val vBarBeforeKey = if (vShowBar) vOthers.getOrNull(vDragTarget)?.tabKey else null
|
|
||||||
val vBarAtEnd = vShowBar && vDragTarget >= vOthers.size
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().background(c.panel),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
.horizontalScroll(vScroll).padding(horizontal = 6.dp, vertical = 5.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
inTabs.forEach { vTab ->
|
|
||||||
val vKey = vTab.tabKey
|
|
||||||
if (vKey == vBarBeforeKey) DropBar()
|
|
||||||
key(vKey) {
|
|
||||||
val vSel = vKey == inActiveKey
|
|
||||||
val vDragged = vKey == vDragKey
|
|
||||||
|
|
||||||
var vMod = Modifier
|
|
||||||
.onGloballyPositioned { vLeft[vKey] = it.x }
|
|
||||||
.onSizeChanged { vWidth[vKey] = it.width }
|
|
||||||
// NOTE: `alpha` and `translationX` MUST be on the same graphicsLayer.
|
|
||||||
// `Modifier.alpha` expands to `graphicsLayer(alpha, clip = true)` — clip is
|
|
||||||
// applied in the ALPHA layer's local coord space (before any child layer's
|
|
||||||
// translation), so `.alpha().graphicsLayer(translationX = X)` clips the drag
|
|
||||||
// ghost inside its original bounds and the ghost disappears as it slides. One
|
|
||||||
// layer with both properties = clip stays false, alpha modulates via layerPaint.
|
|
||||||
if (vDragged) vMod = vMod.zIndex(1f).graphicsLayer(alpha = 0.65f, translationX = vDragDx, translationY = 0f)
|
|
||||||
vMod = vMod
|
|
||||||
.clip(RoundedCornerShape(7.dp))
|
|
||||||
.background(if (vSel) c.accent.copy(alpha = 0.20f) else c.field, RoundedCornerShape(7.dp))
|
|
||||||
.border(1.dp, if (vSel || vDragged) c.accent else c.border, RoundedCornerShape(7.dp))
|
|
||||||
.pointerInput(vKey) {
|
|
||||||
// Accumulate the per-frame delta rather than reading change.position.
|
|
||||||
// The tab wears a graphicsLayer(translationX = vDragDx) while dragging,
|
|
||||||
// which transforms the modifier's local pointer frame — so position.x
|
|
||||||
// would compensate for the translation each frame and the tab would
|
|
||||||
// trail the mouse by roughly half its offset. dragAmount is the
|
|
||||||
// root-frame delta and doesn't feed back through the layer transform.
|
|
||||||
detectDragGestures(
|
|
||||||
onDragStart = { offset ->
|
|
||||||
vDragKey = vKey
|
|
||||||
vPressRelX = offset.x.toInt()
|
|
||||||
vDragDx = 0f
|
|
||||||
vDragTarget = inTabs.indexOfFirst { it.tabKey == vKey }
|
|
||||||
},
|
|
||||||
onDrag = { _, dragAmount ->
|
|
||||||
val vd = vDragKey ?: return@detectDragGestures
|
|
||||||
vDragDx += dragAmount.x
|
|
||||||
val vRelX = vPressRelX + vDragDx.toInt()
|
|
||||||
val vCursorX = (vLeft[vd] ?: 0) + vRelX
|
|
||||||
var vCount = 0
|
|
||||||
inTabs.forEach { vT ->
|
|
||||||
if (vT.tabKey != vd) {
|
|
||||||
val vCenter = (vLeft[vT.tabKey] ?: 0) + (vWidth[vT.tabKey] ?: 0) / 2
|
|
||||||
if (vCursorX > vCenter) vCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vDragTarget = vCount
|
|
||||||
},
|
|
||||||
onDragEnd = {
|
|
||||||
val vd = vDragKey
|
|
||||||
if (vd != null) {
|
|
||||||
val vFrom = inTabs.indexOfFirst { it.tabKey == vd }
|
|
||||||
if (vFrom >= 0 && vDragTarget >= 0 && vDragTarget != vFrom) inOnReorder(vFrom, vDragTarget)
|
|
||||||
}
|
|
||||||
vDragKey = null; vDragDx = 0f; vDragTarget = -1
|
|
||||||
},
|
|
||||||
onDragCancel = { vDragKey = null; vDragDx = 0f; vDragTarget = -1 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Middle-click closes the tab; right-click opens the tab menu. Compose
|
|
||||||
// has no first-class match for either — inline pointerInput checks the
|
|
||||||
// pressed PointerButton at each Press.
|
|
||||||
.pointerInput(vTab) {
|
|
||||||
awaitPointerEventScope {
|
|
||||||
while (true) {
|
|
||||||
val vEv = awaitPointerEvent()
|
|
||||||
if (vEv.type != PointerEventType.Press) continue
|
|
||||||
when (vEv.button) {
|
|
||||||
PointerButton.Tertiary -> {
|
|
||||||
vEv.changes.firstOrNull()?.consume()
|
|
||||||
inOnClose(vTab)
|
|
||||||
}
|
|
||||||
PointerButton.Secondary -> {
|
|
||||||
vEv.changes.firstOrNull()?.consume()
|
|
||||||
vMenuTab = vTab; vMenu = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clickable { inOnSelect(vTab) }
|
|
||||||
.padding(start = 9.dp, top = 6.dp, bottom = 6.dp, end = 3.dp)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = vMod,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
val vRs = vTab.req
|
|
||||||
if (vRs == null) {
|
|
||||||
// Session / pack settings tab.
|
|
||||||
MaterialSymbolsOutlined(MaterialSymbols.Tune, tint = if (vSel) c.accent else c.dim, size = 13.dp)
|
|
||||||
Text(if (vTab.isSession) "Session" else (vTab.pack?.name ?: ""), color = if (vSel) c.text else c.dim, fontSize = 13.sp)
|
|
||||||
} else {
|
|
||||||
Box(modifier = Modifier.size(7.dp).background(methodColor(vRs.req.method), RoundedCornerShape(4.dp)))
|
|
||||||
Text(vRs.req.name, color = if (vSel) c.text else c.dim, fontSize = 13.sp)
|
|
||||||
if (vRs.loading) CircularProgressIndicator(modifier = Modifier.size(12.dp), color = c.accent, strokeWidth = 1.5.dp)
|
|
||||||
}
|
|
||||||
IconBtn(MaterialSymbols.Close, "Close", inSize = 13.dp) { inOnClose(vTab) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (vBarAtEnd) DropBar()
|
|
||||||
|
|
||||||
// Shared right-click menu, opened at the cursor over the clicked tab.
|
|
||||||
val vMTab = vMenuTab
|
|
||||||
if (vMenu && vMTab != null) {
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = true,
|
|
||||||
onDismissRequest = { vMenu = false },
|
|
||||||
modifier = Modifier.widthIn(min = 188.dp),
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Close, "Close tab") }, onClick = { vMenu = false; inOnClose(vMTab) })
|
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Clear, "Close other tabs") }, onClick = { vMenu = false; inOnCloseOthers(vMTab) })
|
|
||||||
DropdownMenuItem(text = { MenuRow(MaterialSymbols.Clear, "Close all tabs") }, onClick = { vMenu = false; inOnCloseAll() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overflow → chevron opens a scrollable list of every open tab.
|
|
||||||
if (vScroll.maxValue > 0) {
|
|
||||||
Box(modifier = Modifier.padding(end = 4.dp)) {
|
|
||||||
IconBtn(MaterialSymbols.ExpandMore, "All tabs") { vListOpen = true }
|
|
||||||
DropdownMenu(expanded = vListOpen, onDismissRequest = { vListOpen = false }, modifier = Modifier.widthIn(min = 240.dp)) {
|
|
||||||
Column(modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState())) {
|
|
||||||
inTabs.forEach { vTab ->
|
|
||||||
DropdownMenuItem(text = {
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
val vSel = vTab.tabKey == inActiveKey
|
|
||||||
val vRs = vTab.req
|
|
||||||
if (vRs == null) {
|
|
||||||
MaterialSymbolsOutlined(
|
|
||||||
MaterialSymbols.Tune,
|
|
||||||
tint = if (vSel) c.accent else c.dim,
|
|
||||||
size = 13.dp
|
|
||||||
)
|
|
||||||
Text(if (vTab.isSession) "Session" else "${vTab.pack?.name} · var", color = if (vSel) c.accent else c.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
|
|
||||||
} else {
|
|
||||||
Box(modifier = Modifier.size(7.dp).background(methodColor(vRs.req.method), RoundedCornerShape(4.dp)))
|
|
||||||
Text(vRs.req.name, color = if (vSel) c.accent else c.text, fontSize = 13.sp, modifier = Modifier.weight(1f))
|
|
||||||
if (vRs.loading) CircularProgressIndicator(modifier = Modifier.size(12.dp), color = c.accent, strokeWidth = 1.5.dp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, onClick = { vListOpen = false; inOnSelect(vTab) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Thin accent bar marking where a dragged tab will be inserted. Rendered in-flow
|
|
||||||
between tabs so the Row centres it vertically and reserves a little space. */
|
|
||||||
@Composable
|
|
||||||
internal fun DropBar() {
|
|
||||||
val c = LocalAppColors.current
|
|
||||||
Box(modifier = Modifier.width(3.dp).height(24.dp).clip(RoundedCornerShape(2.dp)).background(c.accent, RoundedCornerShape(2.dp)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
package apidemo
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: BodyFormat — viewer/highlighter selection
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Format of a body payload as shown in the viewer, and (for a TEXT request body)
|
|
||||||
the type the user picks on the right of the Body tab — it drives both syntax
|
|
||||||
highlighting and the sent Content-Type. RAW disables highlighting and falls back
|
|
||||||
to the selectable BasicTextField; other values pick a tokeniser. */
|
|
||||||
@Serializable
|
|
||||||
enum class BodyFormat { RAW, JSON, XML, YAML, HTML }
|
|
||||||
|
|
||||||
/* Short label for the format / "type" picker (RAW = plain text, no highlighting). */
|
|
||||||
val BodyFormat.label: String
|
|
||||||
get() = when (this) {
|
|
||||||
BodyFormat.RAW -> "RAW"
|
|
||||||
BodyFormat.JSON -> "JSON"
|
|
||||||
BodyFormat.XML -> "XML"
|
|
||||||
BodyFormat.YAML -> "YAML"
|
|
||||||
BodyFormat.HTML -> "HTML"
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The Content-Type a TEXT body of this format is sent with. */
|
|
||||||
val BodyFormat.contentType: String
|
|
||||||
get() = when (this) {
|
|
||||||
BodyFormat.RAW -> "text/plain"
|
|
||||||
BodyFormat.JSON -> "application/json"
|
|
||||||
BodyFormat.XML -> "application/xml"
|
|
||||||
BodyFormat.YAML -> "application/yaml"
|
|
||||||
BodyFormat.HTML -> "text/html"
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pick a format from a Content-Type header. JSON / XML / YAML / HTML
|
|
||||||
match their canonical types and common variants; anything else
|
|
||||||
(image types, text/plain, …) falls back to RAW. */
|
|
||||||
fun autoFormatFor(inContentType: String?): BodyFormat {
|
|
||||||
val vCt = inContentType?.substringBefore(';')?.trim()?.lowercase() ?: return BodyFormat.RAW
|
|
||||||
return when {
|
|
||||||
vCt.endsWith("+json") || vCt == "application/json" -> BodyFormat.JSON
|
|
||||||
vCt.endsWith("+xml") || vCt == "application/xml" || vCt == "text/xml" -> BodyFormat.XML
|
|
||||||
vCt == "application/yaml" || vCt == "text/yaml" || vCt == "application/x-yaml" -> BodyFormat.YAML
|
|
||||||
vCt == "text/html" -> BodyFormat.HTML
|
|
||||||
else -> BodyFormat.RAW
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Highlight palette
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Per-token colours. Two preset themes: a dark-on-dark and a dark-on-
|
|
||||||
light variant matching VS Code's defaults. Caller picks based on
|
|
||||||
surrounding theme; auto-pick available via SyntaxPalette.forDark(). */
|
|
||||||
data class SyntaxPalette(
|
|
||||||
val key: Color,
|
|
||||||
val string: Color,
|
|
||||||
val number: Color,
|
|
||||||
val keyword: Color,
|
|
||||||
val tag: Color,
|
|
||||||
val comment: Color,
|
|
||||||
val punct: Color,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
/* Light-on-dark — VS Code Dark+. */
|
|
||||||
val Dark = SyntaxPalette(
|
|
||||||
key = Color(0xFF9CDCFE),
|
|
||||||
string = Color(0xFFCE9178),
|
|
||||||
number = Color(0xFFB5CEA8),
|
|
||||||
keyword = Color(0xFFC586C0),
|
|
||||||
tag = Color(0xFF569CD6),
|
|
||||||
comment = Color(0xFF6A9955),
|
|
||||||
punct = Color(0xFFCCCCCC),
|
|
||||||
)
|
|
||||||
/* Dark-on-light — VS Code Light+. Higher contrast on white. */
|
|
||||||
val Light = SyntaxPalette(
|
|
||||||
key = Color(0xFF0451A5),
|
|
||||||
string = Color(0xFFA31515),
|
|
||||||
number = Color(0xFF098658),
|
|
||||||
keyword = Color(0xFF0000FF),
|
|
||||||
tag = Color(0xFF800000),
|
|
||||||
comment = Color(0xFF008000),
|
|
||||||
punct = Color(0xFF333333),
|
|
||||||
)
|
|
||||||
|
|
||||||
/* Pick a palette by the host theme's background brightness. */
|
|
||||||
fun forDark(inIsDark: Boolean): SyntaxPalette = if (inIsDark) Dark else Light
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Entry point
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Render the given text with per-token colour spans for the requested
|
|
||||||
format. RAW returns an unstyled AnnotatedString. */
|
|
||||||
fun highlight(
|
|
||||||
inText: String,
|
|
||||||
inFormat: BodyFormat,
|
|
||||||
inPalette: SyntaxPalette = SyntaxPalette.Dark,
|
|
||||||
): AnnotatedString = when (inFormat) {
|
|
||||||
BodyFormat.RAW -> AnnotatedString(inText)
|
|
||||||
BodyFormat.JSON -> highlightJson(inText, inPalette)
|
|
||||||
BodyFormat.XML, BodyFormat.HTML -> highlightXml(inText, inPalette)
|
|
||||||
BodyFormat.YAML -> highlightYaml(inText, inPalette)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: JSON tokeniser — single-pass, error-tolerant
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Tokeniser for JSON. Walks the string once and emits style spans for
|
|
||||||
strings, numbers, booleans, null, punctuation. Distinguishes "keys"
|
|
||||||
(strings followed by ':') from string values by peeking after each
|
|
||||||
string. Error-tolerant — malformed JSON still highlights as best
|
|
||||||
effort rather than throwing. */
|
|
||||||
private fun highlightJson(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
|
||||||
val vN = inText.length
|
|
||||||
var vI = 0
|
|
||||||
while (vI < vN) {
|
|
||||||
val vC = inText[vI]
|
|
||||||
when {
|
|
||||||
vC == '"' -> {
|
|
||||||
// Find closing quote, honouring backslash-escaped chars so an
|
|
||||||
// escaped \" doesn't terminate the string early.
|
|
||||||
val vStart = vI
|
|
||||||
var vJ = vI + 1
|
|
||||||
while (vJ < vN) {
|
|
||||||
if (inText[vJ] == '\\' && vJ + 1 < vN) { vJ += 2; continue }
|
|
||||||
if (inText[vJ] == '"') { vJ++; break }
|
|
||||||
vJ++
|
|
||||||
}
|
|
||||||
// Peek for ':' (skipping whitespace) → it's a key, not a value.
|
|
||||||
var vK = vJ
|
|
||||||
while (vK < vN && inText[vK].isWhitespace()) vK++
|
|
||||||
val vIsKey = vK < vN && inText[vK] == ':'
|
|
||||||
val vColor = if (vIsKey) inP.key else inP.string
|
|
||||||
pushStyle(SpanStyle(color = vColor))
|
|
||||||
append(inText.substring(vStart, vJ))
|
|
||||||
pop()
|
|
||||||
vI = vJ
|
|
||||||
}
|
|
||||||
vC.isDigit() || (vC == '-' && vI + 1 < vN && inText[vI + 1].isDigit()) -> {
|
|
||||||
val vStart = vI
|
|
||||||
if (vC == '-') vI++
|
|
||||||
while (vI < vN && (inText[vI].isDigit() || inText[vI] in ".eE+-")) vI++
|
|
||||||
pushStyle(SpanStyle(color = inP.number))
|
|
||||||
append(inText.substring(vStart, vI))
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
vC.isLetter() -> {
|
|
||||||
val vStart = vI
|
|
||||||
while (vI < vN && inText[vI].isLetter()) vI++
|
|
||||||
val vWord = inText.substring(vStart, vI)
|
|
||||||
if (vWord == "true" || vWord == "false" || vWord == "null") {
|
|
||||||
pushStyle(SpanStyle(color = inP.keyword))
|
|
||||||
append(vWord)
|
|
||||||
pop()
|
|
||||||
} else {
|
|
||||||
append(vWord)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vC in "{}[],:" -> {
|
|
||||||
pushStyle(SpanStyle(color = inP.punct))
|
|
||||||
append(vC)
|
|
||||||
pop()
|
|
||||||
vI++
|
|
||||||
}
|
|
||||||
else -> { append(vC); vI++ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: XML / HTML tokeniser
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Tokeniser for XML / HTML. Distinguishes tag names, attribute names,
|
|
||||||
attribute values (string), and comments. Inside tags, attr=value pairs
|
|
||||||
are recognised; outside tags text is left in the default colour. */
|
|
||||||
private fun highlightXml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
|
||||||
val vN = inText.length
|
|
||||||
var vI = 0
|
|
||||||
while (vI < vN) {
|
|
||||||
val vC = inText[vI]
|
|
||||||
when {
|
|
||||||
// Comment block: <!-- ... -->
|
|
||||||
vC == '<' && inText.startsWith("<!--", vI) -> {
|
|
||||||
val vEnd = inText.indexOf("-->", vI + 4).let { if (it < 0) vN else it + 3 }
|
|
||||||
pushStyle(SpanStyle(color = inP.comment))
|
|
||||||
append(inText.substring(vI, vEnd))
|
|
||||||
pop()
|
|
||||||
vI = vEnd
|
|
||||||
}
|
|
||||||
vC == '<' -> {
|
|
||||||
// Tag open: <[/]name attr=val attr2="val2">
|
|
||||||
val vGt = inText.indexOf('>', vI).let { if (it < 0) vN else it + 1 }
|
|
||||||
pushStyle(SpanStyle(color = inP.punct)); append('<'); pop()
|
|
||||||
var vJ = vI + 1
|
|
||||||
if (vJ < vN && inText[vJ] == '/') { pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++ }
|
|
||||||
// Tag name.
|
|
||||||
val vTagStart = vJ
|
|
||||||
while (vJ < vN && !inText[vJ].isWhitespace() && inText[vJ] != '>' && inText[vJ] != '/') vJ++
|
|
||||||
if (vJ > vTagStart) {
|
|
||||||
pushStyle(SpanStyle(color = inP.tag))
|
|
||||||
append(inText.substring(vTagStart, vJ))
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
// Attributes — name="value" pairs.
|
|
||||||
while (vJ < vGt - 1) {
|
|
||||||
val vC2 = inText[vJ]
|
|
||||||
when {
|
|
||||||
vC2.isWhitespace() -> { append(vC2); vJ++ }
|
|
||||||
vC2 == '/' -> { pushStyle(SpanStyle(color = inP.punct)); append('/'); pop(); vJ++ }
|
|
||||||
vC2 == '=' -> { pushStyle(SpanStyle(color = inP.punct)); append('='); pop(); vJ++ }
|
|
||||||
vC2 == '"' || vC2 == '\'' -> {
|
|
||||||
val vEnd = inText.indexOf(vC2, vJ + 1).let { if (it < 0) vGt - 1 else it + 1 }
|
|
||||||
pushStyle(SpanStyle(color = inP.string))
|
|
||||||
append(inText.substring(vJ, vEnd))
|
|
||||||
pop()
|
|
||||||
vJ = vEnd
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val vAttrStart = vJ
|
|
||||||
while (vJ < vGt - 1 && !inText[vJ].isWhitespace() && inText[vJ] != '=' && inText[vJ] != '>' && inText[vJ] != '/') vJ++
|
|
||||||
pushStyle(SpanStyle(color = inP.key))
|
|
||||||
append(inText.substring(vAttrStart, vJ))
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (vGt > vI && inText.getOrNull(vGt - 1) == '>') {
|
|
||||||
pushStyle(SpanStyle(color = inP.punct)); append('>'); pop()
|
|
||||||
}
|
|
||||||
vI = vGt
|
|
||||||
}
|
|
||||||
else -> { append(vC); vI++ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: YAML tokeniser — line-based
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Tokeniser for YAML. Walks line by line because YAML's grammar is
|
|
||||||
indent-sensitive and most of the interesting colour decisions
|
|
||||||
(key vs value, list item vs scalar) depend on position within a
|
|
||||||
line. Comments (#), keys (text before ':'), quoted strings, numbers
|
|
||||||
and booleans are coloured; everything else stays default. */
|
|
||||||
private fun highlightYaml(inText: String, inP: SyntaxPalette): AnnotatedString = buildAnnotatedString {
|
|
||||||
val vLines = inText.split('\n')
|
|
||||||
for ((vIdx, vLineRaw) in vLines.withIndex()) {
|
|
||||||
val vLine = vLineRaw
|
|
||||||
// Comment: rest of the line after a # (not inside a quoted string).
|
|
||||||
val vCommentAt = findUnquotedHash(vLine)
|
|
||||||
val vCodePart = if (vCommentAt >= 0) vLine.substring(0, vCommentAt) else vLine
|
|
||||||
val vCommentPart = if (vCommentAt >= 0) vLine.substring(vCommentAt) else ""
|
|
||||||
|
|
||||||
// Find a key: indent + (optional "- ") + key + ':' + (space|eol).
|
|
||||||
val vColon = indexOfKeyColon(vCodePart)
|
|
||||||
if (vColon > 0) {
|
|
||||||
val vPrefix = vCodePart.substring(0, vColon)
|
|
||||||
val vRest = vCodePart.substring(vColon + 1)
|
|
||||||
// Lead whitespace + optional list marker, then key.
|
|
||||||
var vK = 0
|
|
||||||
while (vK < vPrefix.length && vPrefix[vK].isWhitespace()) { append(vPrefix[vK]); vK++ }
|
|
||||||
if (vK + 1 < vPrefix.length && vPrefix[vK] == '-' && vPrefix[vK + 1].isWhitespace()) {
|
|
||||||
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
|
|
||||||
append(vPrefix[vK + 1])
|
|
||||||
vK += 2
|
|
||||||
}
|
|
||||||
pushStyle(SpanStyle(color = inP.key))
|
|
||||||
append(vPrefix.substring(vK))
|
|
||||||
pop()
|
|
||||||
pushStyle(SpanStyle(color = inP.punct)); append(':'); pop()
|
|
||||||
appendYamlValue(vRest, inP)
|
|
||||||
} else {
|
|
||||||
appendYamlValue(vCodePart, inP)
|
|
||||||
}
|
|
||||||
if (vCommentPart.isNotEmpty()) {
|
|
||||||
pushStyle(SpanStyle(color = inP.comment))
|
|
||||||
append(vCommentPart)
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
if (vIdx < vLines.size - 1) append('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Render the value portion of a YAML line — applies string / number /
|
|
||||||
keyword / list-marker colours where applicable. */
|
|
||||||
private fun AnnotatedString.Builder.appendYamlValue(inSeg: String, inP: SyntaxPalette) {
|
|
||||||
val vTrimmed = inSeg.trimStart()
|
|
||||||
val vPad = inSeg.substring(0, inSeg.length - vTrimmed.length)
|
|
||||||
append(vPad)
|
|
||||||
when {
|
|
||||||
vTrimmed.isEmpty() -> { /* nothing */ }
|
|
||||||
vTrimmed.startsWith('-') && (vTrimmed.length == 1 || vTrimmed[1].isWhitespace()) -> {
|
|
||||||
pushStyle(SpanStyle(color = inP.punct)); append('-'); pop()
|
|
||||||
appendYamlValue(vTrimmed.substring(1), inP)
|
|
||||||
}
|
|
||||||
vTrimmed.startsWith('"') || vTrimmed.startsWith('\'') -> {
|
|
||||||
val vQ = vTrimmed[0]
|
|
||||||
val vEnd = vTrimmed.indexOf(vQ, 1).let { if (it < 0) vTrimmed.length else it + 1 }
|
|
||||||
pushStyle(SpanStyle(color = inP.string))
|
|
||||||
append(vTrimmed.substring(0, vEnd))
|
|
||||||
pop()
|
|
||||||
append(vTrimmed.substring(vEnd))
|
|
||||||
}
|
|
||||||
vTrimmed == "true" || vTrimmed == "false" || vTrimmed == "null" || vTrimmed == "~" -> {
|
|
||||||
pushStyle(SpanStyle(color = inP.keyword)); append(vTrimmed); pop()
|
|
||||||
}
|
|
||||||
vTrimmed.first().let { it.isDigit() || it == '-' && vTrimmed.length > 1 && vTrimmed[1].isDigit() } -> {
|
|
||||||
var vJ = 0
|
|
||||||
if (vTrimmed[0] == '-') vJ++
|
|
||||||
while (vJ < vTrimmed.length && (vTrimmed[vJ].isDigit() || vTrimmed[vJ] in ".eE+-")) vJ++
|
|
||||||
pushStyle(SpanStyle(color = inP.number))
|
|
||||||
append(vTrimmed.substring(0, vJ))
|
|
||||||
pop()
|
|
||||||
append(vTrimmed.substring(vJ))
|
|
||||||
}
|
|
||||||
else -> append(vTrimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Find the index of the first '#' that isn't inside a quoted string,
|
|
||||||
or -1 if there is none. Used to split a YAML line into code + comment. */
|
|
||||||
private fun findUnquotedHash(inLine: String): Int {
|
|
||||||
var vInSingle = false
|
|
||||||
var vInDouble = false
|
|
||||||
for (vI in inLine.indices) {
|
|
||||||
val vC = inLine[vI]
|
|
||||||
when {
|
|
||||||
vC == '"' && !vInSingle -> vInDouble = !vInDouble
|
|
||||||
vC == '\'' && !vInDouble -> vInSingle = !vInSingle
|
|
||||||
vC == '#' && !vInSingle && !vInDouble -> return vI
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Locate the colon that separates a YAML key from its value at the
|
|
||||||
current indent level — the FIRST unquoted ':' that's followed by
|
|
||||||
either whitespace or end-of-line. Returns -1 if the line has no key. */
|
|
||||||
private fun indexOfKeyColon(inLine: String): Int {
|
|
||||||
var vInSingle = false
|
|
||||||
var vInDouble = false
|
|
||||||
for (vI in inLine.indices) {
|
|
||||||
val vC = inLine[vI]
|
|
||||||
when {
|
|
||||||
vC == '"' && !vInSingle -> vInDouble = !vInDouble
|
|
||||||
vC == '\'' && !vInDouble -> vInSingle = !vInSingle
|
|
||||||
vC == ':' && !vInSingle && !vInDouble -> {
|
|
||||||
val vNext = if (vI + 1 < inLine.length) inLine[vI + 1] else ' '
|
|
||||||
if (vNext.isWhitespace() || vI + 1 == inLine.length) return vI
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package apidemo
|
|
||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Theme — switchable dark / light palette
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
class AppColors(
|
|
||||||
val bg: Color, val panel: Color, val field: Color, val border: Color,
|
|
||||||
val accent: Color, val text: Color, val dim: Color, val onAccent: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal val DarkColors = AppColors(
|
|
||||||
bg = Color(0xFF15161A), panel = Color(0xFF23252C), field = Color(0xFF2D2F37),
|
|
||||||
border = Color(0xFF474C57), accent = Color(0xFF9F88FF), text = Color(0xFFECEEF2),
|
|
||||||
dim = Color(0xFFAEB4BD), onAccent = Color(0xFFFFFFFF),
|
|
||||||
)
|
|
||||||
internal val LightColors = AppColors(
|
|
||||||
bg = Color(0xFFF3F4F7), panel = Color(0xFFFFFFFF), field = Color(0xFFFFFFFF),
|
|
||||||
border = Color(0xFFCED3DB), accent = Color(0xFF6B4BE6), text = Color(0xFF1B1D22),
|
|
||||||
dim = Color(0xFF5C636E), onAccent = Color(0xFFFFFFFF),
|
|
||||||
)
|
|
||||||
internal val LocalAppColors = staticCompositionLocalOf { DarkColors }
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package apidemo.compat
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import com.compose.sdl.LocalComposeNativeWindow
|
||||||
|
import com.compose.sdl.TextLayoutConfig
|
||||||
|
import com.compose.sdl.appDataDir
|
||||||
|
import com.compose.sdl.fileManagerName
|
||||||
|
import com.compose.sdl.registerMemoryResource
|
||||||
|
import com.compose.sdl.removeMemoryResource
|
||||||
|
import com.compose.sdl.res.ResourceKind
|
||||||
|
import com.compose.sdl.res.painterResource
|
||||||
|
import com.compose.sdl.revealInFileManager
|
||||||
|
import com.compose.sdl.showOpenFileDialog
|
||||||
|
import com.compose.sdl.showSaveFileDialog
|
||||||
|
import com.compose.sdl.text.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? =
|
||||||
|
appDataDir(inOrg, inApp)
|
||||||
|
|
||||||
|
actual fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)?) =
|
||||||
|
revealInFileManager(inPath, inOnResult)
|
||||||
|
|
||||||
|
actual fun fileManagerName(): String = fileManagerName()
|
||||||
|
|
||||||
|
actual fun showSaveFileDialog(inDefaultName: String?, inOnResult: (String?) -> Unit) =
|
||||||
|
showSaveFileDialog(inDefaultName, inOnResult)
|
||||||
|
|
||||||
|
actual fun showOpenFileDialog(inOnResult: (String?) -> Unit) =
|
||||||
|
showOpenFileDialog(inOnResult)
|
||||||
|
|
||||||
|
actual fun clipEntryOfText(inText: String): ClipEntry = ClipEntry.withPlainText(inText)
|
||||||
|
|
||||||
|
actual fun registerMemoryImage(inKey: String, inBytes: ByteArray) = registerMemoryResource(inKey, inBytes)
|
||||||
|
|
||||||
|
actual fun removeMemoryImage(inKey: String) = removeMemoryResource(inKey)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter =
|
||||||
|
painterResource(inKey, if (inSvg) ResourceKind.Svg else ResourceKind.Raster)
|
||||||
|
|
||||||
|
actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int =
|
||||||
|
currentTextMeasurer.wrap(inText, inFontPx, inMaxWidthPx, inFamilyName).lines.size
|
||||||
|
|
||||||
|
actual var editorTabWidth: Int
|
||||||
|
get() = TextLayoutConfig.tabWidth
|
||||||
|
set(value) {
|
||||||
|
TextLayoutConfig.tabWidth = value
|
||||||
|
}
|
||||||
|
|
||||||
|
internal actual val systemFileSystem: FileSystem = FileSystem.SYSTEM
|
||||||
|
|
||||||
|
actual fun createApiHttpClient(): HttpClient =
|
||||||
|
HttpClient(Curl)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean) {
|
||||||
|
val vWindow = LocalComposeNativeWindow.current
|
||||||
|
DisposableEffect(inOnCloseRequest, inOnKeyShortcut) {
|
||||||
|
vWindow.setOnCloseRequest(inOnCloseRequest)
|
||||||
|
vWindow.setOnKeyShortcut(inOnKeyShortcut)
|
||||||
|
onDispose { vWindow.setOnCloseRequest(null); vWindow.setOnKeyShortcut(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,20 @@
|
|||||||
// Root build — declare the Kotlin/Compose plugins once (apply false) so every
|
import kotlinx.validation.ExperimentalBCVApi
|
||||||
// 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.
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform) apply false
|
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.compose) apply false
|
||||||
alias(libs.plugins.kotlin.plugin.serialization) apply false
|
alias(libs.plugins.kotlin.plugin.serialization) apply false
|
||||||
alias(libs.plugins.compose.multiplatform) apply false
|
alias(libs.plugins.compose.multiplatform) apply false
|
||||||
// Public-ABI dumps (api/<module>.klib.api). Used by scripts/compose-fidelity-check.py
|
|
||||||
// to diff our androidx.compose.* surface against the official Compose klib ABI.
|
|
||||||
alias(libs.plugins.binary.compatibility.validator)
|
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 {
|
apiValidation {
|
||||||
|
@OptIn(ExperimentalBCVApi::class)
|
||||||
klib { enabled = true }
|
klib { enabled = true }
|
||||||
ignoredProjects.addAll(listOf("demo", "apidemo"))
|
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
|
// 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.
|
// 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")
|
val vPublishVersion = (System.getenv("PUBLISH_VERSION") ?: "0.0.0-SNAPSHOT").removePrefix("v")
|
||||||
@@ -36,6 +26,7 @@ allprojects {
|
|||||||
// ==================
|
// ==================
|
||||||
// MARK: Publish to GitHub Packages
|
// MARK: Publish to GitHub Packages
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
// Every library module (everything except the two demo apps) auto-registers a
|
// Every library module (everything except the two demo apps) auto-registers a
|
||||||
// MavenPublication via the kotlin-multiplatform plugin — one per target + one
|
// MavenPublication via the kotlin-multiplatform plugin — one per target + one
|
||||||
// for the shared kotlinMultiplatform metadata. The CI publish workflow runs on
|
// for the shared kotlinMultiplatform metadata. The CI publish workflow runs on
|
||||||
@@ -47,6 +38,7 @@ allprojects {
|
|||||||
val kAppModules = setOf(":demo", ":apidemo")
|
val kAppModules = setOf(":demo", ":apidemo")
|
||||||
val kPublishedLibs = setOf(
|
val kPublishedLibs = setOf(
|
||||||
":ui", ":ui-util", ":ui-geometry", ":ui-unit", ":ui-backhandler",
|
":ui", ":ui-util", ":ui-geometry", ":ui-unit", ":ui-backhandler",
|
||||||
|
":ui-tooling-preview",
|
||||||
":animation-core", ":animation", ":animation-graphics",
|
":animation-core", ":animation", ":animation-graphics",
|
||||||
":foundation", ":foundation-layout",
|
":foundation", ":foundation-layout",
|
||||||
":material3", ":material-ripple",
|
":material3", ":material-ripple",
|
||||||
@@ -63,6 +55,20 @@ val kPublishedLibs = setOf(
|
|||||||
val kUseGhPackages = (findProperty("useGithubPackages") as? String)?.toBoolean() == true
|
val kUseGhPackages = (findProperty("useGithubPackages") as? String)?.toBoolean() == true
|
||||||
val kConsumeVersion = (findProperty("consumeVersion") as? String) ?: "0.1.0"
|
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 {
|
subprojects {
|
||||||
if (kUseGhPackages && path in kAppModules) {
|
if (kUseGhPackages && path in kAppModules) {
|
||||||
configurations.configureEach {
|
configurations.configureEach {
|
||||||
@@ -76,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")
|
plugins.apply("maven-publish")
|
||||||
afterEvaluate {
|
afterEvaluate {
|
||||||
extensions.configure<PublishingExtension> {
|
extensions.configure<PublishingExtension> {
|
||||||
@@ -108,10 +123,46 @@ subprojects {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FULL-COMMONIZATION BRIDGE (repo-wide): a module may declare the OFFICIAL
|
||||||
|
// Maven Compose artifacts in its commonMain so metadata + jvm resolve them
|
||||||
|
// (e.g. :material-symbols' common API); every NATIVE target configuration
|
||||||
|
// swaps those modules for the port's project equivalents — the Maven
|
||||||
|
// artifacts ship no mingwX64/linux klibs. org.jetbrains.compose.runtime is
|
||||||
|
// deliberately NOT here: the port uses the official runtime klibs everywhere.
|
||||||
|
val vNativeTargetTokens = listOf("mingwX64", "linuxX64", "linuxArm64", "macosArm64")
|
||||||
|
allprojects {
|
||||||
|
configurations.configureEach {
|
||||||
|
if (vNativeTargetTokens.any { name.contains(it, ignoreCase = true) }) {
|
||||||
|
resolutionStrategy.dependencySubstitution {
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui")).using(project(":ui"))
|
||||||
|
// ui's split artifacts: ui-graphics + ui-text are merged INTO
|
||||||
|
// :ui in the port; ui-unit / ui-geometry have their own modules.
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-graphics")).using(project(":ui"))
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-text")).using(project(":ui"))
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-unit")).using(project(":ui-unit"))
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-geometry")).using(project(":ui-geometry"))
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-util")).using(project(":ui-util"))
|
||||||
|
substitute(module("org.jetbrains.compose.ui:ui-tooling-preview")).using(project(":ui-tooling-preview"))
|
||||||
|
substitute(module("org.jetbrains.compose.foundation:foundation")).using(project(":foundation"))
|
||||||
|
substitute(module("org.jetbrains.compose.foundation:foundation-layout")).using(project(":foundation-layout"))
|
||||||
|
substitute(module("org.jetbrains.compose.animation:animation")).using(project(":animation"))
|
||||||
|
substitute(module("org.jetbrains.compose.animation:animation-core")).using(project(":animation-core"))
|
||||||
|
substitute(module("org.jetbrains.compose.material3:material3")).using(project(":material3"))
|
||||||
|
// navigation3-ui: the JB Maven artifact has no K/N desktop
|
||||||
|
// klibs — the port vendors it as :navigation3-ui.
|
||||||
|
substitute(module("org.jetbrains.androidx.navigation3:navigation3-ui")).using(project(":navigation3-ui"))
|
||||||
|
// components-resources: the official resources runtime ships no
|
||||||
|
// mingwX64/linux klibs — the port vendors it as :components-resources.
|
||||||
|
substitute(module("org.jetbrains.compose.components:components-resources")).using(project(":components-resources"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Whether the current host can build the mingwX64 target. Kotlin/Native can
|
// Whether the current host can build the mingwX64 target. Kotlin/Native can
|
||||||
// only cross-compile mingwX64 cinterops from a Windows host — the sdl3_ttf /
|
// only cross-compile mingwX64 cinterops from a Windows host — the sdl3_ttf /
|
||||||
// sdl3_image cinterops need Windows SDL3 headers under libs/ (produced by
|
// sdl3_image cinterops need Windows SDL3 headers under libs/ (produced by
|
||||||
// scripts/build-sdl/build-*.sh, which only runs on Windows). Declaring `mingwX64()` on
|
// scripts/build-sdl/build-all.py run on a Windows host). Declaring `mingwX64()` on
|
||||||
// a non-Windows host is safe for pure-Kotlin modules but blows up the moment
|
// a non-Windows host is safe for pure-Kotlin modules but blows up the moment
|
||||||
// a `depends = sdl3` cinterop tries to include SDL3_ttf/SDL_ttf.h.
|
// a `depends = sdl3` cinterop tries to include SDL3_ttf/SDL_ttf.h.
|
||||||
// Override with `-PforceMingw=true` if you actually have the headers wired.
|
// Override with `-PforceMingw=true` if you actually have the headers wired.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Klib ABI Dump
|
||||||
|
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
|
||||||
|
// Rendering settings:
|
||||||
|
// - Signature version: 2
|
||||||
|
// - Show manifest properties: true
|
||||||
|
// - Show declarations: true
|
||||||
|
|
||||||
|
// Library unique name: <com.bitsycore.compose.sdl:components-resources>
|
||||||
|
open annotation class org.jetbrains.compose.resources/ExperimentalResourceApi : kotlin/Annotation { // org.jetbrains.compose.resources/ExperimentalResourceApi|null[0]
|
||||||
|
constructor <init>() // org.jetbrains.compose.resources/ExperimentalResourceApi.<init>|<init>(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
open annotation class org.jetbrains.compose.resources/InternalResourceApi : kotlin/Annotation { // org.jetbrains.compose.resources/InternalResourceApi|null[0]
|
||||||
|
constructor <init>() // org.jetbrains.compose.resources/InternalResourceApi.<init>|<init>(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
open annotation class org.jetbrains.compose.resources/ResourceContentHash : kotlin/Annotation { // org.jetbrains.compose.resources/ResourceContentHash|null[0]
|
||||||
|
constructor <init>(kotlin/Int) // org.jetbrains.compose.resources/ResourceContentHash.<init>|<init>(kotlin.Int){}[0]
|
||||||
|
|
||||||
|
final val hash // org.jetbrains.compose.resources/ResourceContentHash.hash|{}hash[0]
|
||||||
|
final fun <get-hash>(): kotlin/Int // org.jetbrains.compose.resources/ResourceContentHash.hash.<get-hash>|<get-hash>(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final enum class org.jetbrains.compose.resources/DensityQualifier : kotlin/Enum<org.jetbrains.compose.resources/DensityQualifier>, org.jetbrains.compose.resources/Qualifier { // org.jetbrains.compose.resources/DensityQualifier|null[0]
|
||||||
|
enum entry HDPI // org.jetbrains.compose.resources/DensityQualifier.HDPI|null[0]
|
||||||
|
enum entry LDPI // org.jetbrains.compose.resources/DensityQualifier.LDPI|null[0]
|
||||||
|
enum entry MDPI // org.jetbrains.compose.resources/DensityQualifier.MDPI|null[0]
|
||||||
|
enum entry XHDPI // org.jetbrains.compose.resources/DensityQualifier.XHDPI|null[0]
|
||||||
|
enum entry XXHDPI // org.jetbrains.compose.resources/DensityQualifier.XXHDPI|null[0]
|
||||||
|
enum entry XXXHDPI // org.jetbrains.compose.resources/DensityQualifier.XXXHDPI|null[0]
|
||||||
|
|
||||||
|
final val dpi // org.jetbrains.compose.resources/DensityQualifier.dpi|{}dpi[0]
|
||||||
|
final fun <get-dpi>(): kotlin/Int // org.jetbrains.compose.resources/DensityQualifier.dpi.<get-dpi>|<get-dpi>(){}[0]
|
||||||
|
final val entries // org.jetbrains.compose.resources/DensityQualifier.entries|#static{}entries[0]
|
||||||
|
final fun <get-entries>(): kotlin.enums/EnumEntries<org.jetbrains.compose.resources/DensityQualifier> // org.jetbrains.compose.resources/DensityQualifier.entries.<get-entries>|<get-entries>#static(){}[0]
|
||||||
|
|
||||||
|
final fun valueOf(kotlin/String): org.jetbrains.compose.resources/DensityQualifier // org.jetbrains.compose.resources/DensityQualifier.valueOf|valueOf#static(kotlin.String){}[0]
|
||||||
|
final fun values(): kotlin/Array<org.jetbrains.compose.resources/DensityQualifier> // org.jetbrains.compose.resources/DensityQualifier.values|values#static(){}[0]
|
||||||
|
|
||||||
|
final object Companion { // org.jetbrains.compose.resources/DensityQualifier.Companion|null[0]
|
||||||
|
final fun selectByDensity(kotlin/Float): org.jetbrains.compose.resources/DensityQualifier // org.jetbrains.compose.resources/DensityQualifier.Companion.selectByDensity|selectByDensity(kotlin.Float){}[0]
|
||||||
|
final fun selectByValue(kotlin/Int): org.jetbrains.compose.resources/DensityQualifier // org.jetbrains.compose.resources/DensityQualifier.Companion.selectByValue|selectByValue(kotlin.Int){}[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final enum class org.jetbrains.compose.resources/ThemeQualifier : kotlin/Enum<org.jetbrains.compose.resources/ThemeQualifier>, org.jetbrains.compose.resources/Qualifier { // org.jetbrains.compose.resources/ThemeQualifier|null[0]
|
||||||
|
enum entry DARK // org.jetbrains.compose.resources/ThemeQualifier.DARK|null[0]
|
||||||
|
enum entry LIGHT // org.jetbrains.compose.resources/ThemeQualifier.LIGHT|null[0]
|
||||||
|
|
||||||
|
final val entries // org.jetbrains.compose.resources/ThemeQualifier.entries|#static{}entries[0]
|
||||||
|
final fun <get-entries>(): kotlin.enums/EnumEntries<org.jetbrains.compose.resources/ThemeQualifier> // org.jetbrains.compose.resources/ThemeQualifier.entries.<get-entries>|<get-entries>#static(){}[0]
|
||||||
|
|
||||||
|
final fun valueOf(kotlin/String): org.jetbrains.compose.resources/ThemeQualifier // org.jetbrains.compose.resources/ThemeQualifier.valueOf|valueOf#static(kotlin.String){}[0]
|
||||||
|
final fun values(): kotlin/Array<org.jetbrains.compose.resources/ThemeQualifier> // org.jetbrains.compose.resources/ThemeQualifier.values|values#static(){}[0]
|
||||||
|
|
||||||
|
final object Companion { // org.jetbrains.compose.resources/ThemeQualifier.Companion|null[0]
|
||||||
|
final fun selectByValue(kotlin/Boolean): org.jetbrains.compose.resources/ThemeQualifier // org.jetbrains.compose.resources/ThemeQualifier.Companion.selectByValue|selectByValue(kotlin.Boolean){}[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract interface org.jetbrains.compose.resources/Qualifier // org.jetbrains.compose.resources/Qualifier|null[0]
|
||||||
|
|
||||||
|
abstract interface org.jetbrains.compose.resources/ResourceReader { // org.jetbrains.compose.resources/ResourceReader|null[0]
|
||||||
|
abstract fun getUri(kotlin/String): kotlin/String // org.jetbrains.compose.resources/ResourceReader.getUri|getUri(kotlin.String){}[0]
|
||||||
|
abstract suspend fun read(kotlin/String): kotlin/ByteArray // org.jetbrains.compose.resources/ResourceReader.read|read(kotlin.String){}[0]
|
||||||
|
abstract suspend fun readPart(kotlin/String, kotlin/Long, kotlin/Long): kotlin/ByteArray // org.jetbrains.compose.resources/ResourceReader.readPart|readPart(kotlin.String;kotlin.Long;kotlin.Long){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources.vector.xmldom/MalformedXMLException : kotlin/Exception { // org.jetbrains.compose.resources.vector.xmldom/MalformedXMLException|null[0]
|
||||||
|
constructor <init>(kotlin/String?) // org.jetbrains.compose.resources.vector.xmldom/MalformedXMLException.<init>|<init>(kotlin.String?){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources/DrawableResource : org.jetbrains.compose.resources/Resource { // org.jetbrains.compose.resources/DrawableResource|null[0]
|
||||||
|
constructor <init>(kotlin/String, kotlin.collections/Set<org.jetbrains.compose.resources/ResourceItem>) // org.jetbrains.compose.resources/DrawableResource.<init>|<init>(kotlin.String;kotlin.collections.Set<org.jetbrains.compose.resources.ResourceItem>){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources/FontResource : org.jetbrains.compose.resources/Resource { // org.jetbrains.compose.resources/FontResource|null[0]
|
||||||
|
constructor <init>(kotlin/String, kotlin.collections/Set<org.jetbrains.compose.resources/ResourceItem>) // org.jetbrains.compose.resources/FontResource.<init>|<init>(kotlin.String;kotlin.collections.Set<org.jetbrains.compose.resources.ResourceItem>){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources/LanguageQualifier : org.jetbrains.compose.resources/Qualifier { // org.jetbrains.compose.resources/LanguageQualifier|null[0]
|
||||||
|
constructor <init>(kotlin/String) // org.jetbrains.compose.resources/LanguageQualifier.<init>|<init>(kotlin.String){}[0]
|
||||||
|
|
||||||
|
final val language // org.jetbrains.compose.resources/LanguageQualifier.language|{}language[0]
|
||||||
|
final fun <get-language>(): kotlin/String // org.jetbrains.compose.resources/LanguageQualifier.language.<get-language>|<get-language>(){}[0]
|
||||||
|
|
||||||
|
final fun equals(kotlin/Any?): kotlin/Boolean // org.jetbrains.compose.resources/LanguageQualifier.equals|equals(kotlin.Any?){}[0]
|
||||||
|
final fun hashCode(): kotlin/Int // org.jetbrains.compose.resources/LanguageQualifier.hashCode|hashCode(){}[0]
|
||||||
|
final fun toString(): kotlin/String // org.jetbrains.compose.resources/LanguageQualifier.toString|toString(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources/MissingResourceException : kotlin/Exception { // org.jetbrains.compose.resources/MissingResourceException|null[0]
|
||||||
|
constructor <init>(kotlin/String) // org.jetbrains.compose.resources/MissingResourceException.<init>|<init>(kotlin.String){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources/PluralStringResource : org.jetbrains.compose.resources/Resource { // org.jetbrains.compose.resources/PluralStringResource|null[0]
|
||||||
|
constructor <init>(kotlin/String, kotlin/String, kotlin.collections/Set<org.jetbrains.compose.resources/ResourceItem>) // org.jetbrains.compose.resources/PluralStringResource.<init>|<init>(kotlin.String;kotlin.String;kotlin.collections.Set<org.jetbrains.compose.resources.ResourceItem>){}[0]
|
||||||
|
|
||||||
|
final val key // org.jetbrains.compose.resources/PluralStringResource.key|{}key[0]
|
||||||
|
final fun <get-key>(): kotlin/String // org.jetbrains.compose.resources/PluralStringResource.key.<get-key>|<get-key>(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources/RegionQualifier : org.jetbrains.compose.resources/Qualifier { // org.jetbrains.compose.resources/RegionQualifier|null[0]
|
||||||
|
constructor <init>(kotlin/String) // org.jetbrains.compose.resources/RegionQualifier.<init>|<init>(kotlin.String){}[0]
|
||||||
|
|
||||||
|
final val region // org.jetbrains.compose.resources/RegionQualifier.region|{}region[0]
|
||||||
|
final fun <get-region>(): kotlin/String // org.jetbrains.compose.resources/RegionQualifier.region.<get-region>|<get-region>(){}[0]
|
||||||
|
|
||||||
|
final fun equals(kotlin/Any?): kotlin/Boolean // org.jetbrains.compose.resources/RegionQualifier.equals|equals(kotlin.Any?){}[0]
|
||||||
|
final fun hashCode(): kotlin/Int // org.jetbrains.compose.resources/RegionQualifier.hashCode|hashCode(){}[0]
|
||||||
|
final fun toString(): kotlin/String // org.jetbrains.compose.resources/RegionQualifier.toString|toString(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources/ResourceEnvironment { // org.jetbrains.compose.resources/ResourceEnvironment|null[0]
|
||||||
|
final fun equals(kotlin/Any?): kotlin/Boolean // org.jetbrains.compose.resources/ResourceEnvironment.equals|equals(kotlin.Any?){}[0]
|
||||||
|
final fun hashCode(): kotlin/Int // org.jetbrains.compose.resources/ResourceEnvironment.hashCode|hashCode(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources/ResourceItem { // org.jetbrains.compose.resources/ResourceItem|null[0]
|
||||||
|
constructor <init>(kotlin.collections/Set<org.jetbrains.compose.resources/Qualifier>, kotlin/String, kotlin/Long, kotlin/Long) // org.jetbrains.compose.resources/ResourceItem.<init>|<init>(kotlin.collections.Set<org.jetbrains.compose.resources.Qualifier>;kotlin.String;kotlin.Long;kotlin.Long){}[0]
|
||||||
|
|
||||||
|
final fun copy(kotlin.collections/Set<org.jetbrains.compose.resources/Qualifier> = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/Long = ...): org.jetbrains.compose.resources/ResourceItem // org.jetbrains.compose.resources/ResourceItem.copy|copy(kotlin.collections.Set<org.jetbrains.compose.resources.Qualifier>;kotlin.String;kotlin.Long;kotlin.Long){}[0]
|
||||||
|
final fun equals(kotlin/Any?): kotlin/Boolean // org.jetbrains.compose.resources/ResourceItem.equals|equals(kotlin.Any?){}[0]
|
||||||
|
final fun hashCode(): kotlin/Int // org.jetbrains.compose.resources/ResourceItem.hashCode|hashCode(){}[0]
|
||||||
|
final fun toString(): kotlin/String // org.jetbrains.compose.resources/ResourceItem.toString|toString(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources/ScriptQualifier : org.jetbrains.compose.resources/Qualifier { // org.jetbrains.compose.resources/ScriptQualifier|null[0]
|
||||||
|
constructor <init>(kotlin/String) // org.jetbrains.compose.resources/ScriptQualifier.<init>|<init>(kotlin.String){}[0]
|
||||||
|
|
||||||
|
final val script // org.jetbrains.compose.resources/ScriptQualifier.script|{}script[0]
|
||||||
|
final fun <get-script>(): kotlin/String // org.jetbrains.compose.resources/ScriptQualifier.script.<get-script>|<get-script>(){}[0]
|
||||||
|
|
||||||
|
final fun equals(kotlin/Any?): kotlin/Boolean // org.jetbrains.compose.resources/ScriptQualifier.equals|equals(kotlin.Any?){}[0]
|
||||||
|
final fun hashCode(): kotlin/Int // org.jetbrains.compose.resources/ScriptQualifier.hashCode|hashCode(){}[0]
|
||||||
|
final fun isEmpty(): kotlin/Boolean // org.jetbrains.compose.resources/ScriptQualifier.isEmpty|isEmpty(){}[0]
|
||||||
|
final fun toString(): kotlin/String // org.jetbrains.compose.resources/ScriptQualifier.toString|toString(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources/StringArrayResource : org.jetbrains.compose.resources/Resource { // org.jetbrains.compose.resources/StringArrayResource|null[0]
|
||||||
|
constructor <init>(kotlin/String, kotlin/String, kotlin.collections/Set<org.jetbrains.compose.resources/ResourceItem>) // org.jetbrains.compose.resources/StringArrayResource.<init>|<init>(kotlin.String;kotlin.String;kotlin.collections.Set<org.jetbrains.compose.resources.ResourceItem>){}[0]
|
||||||
|
|
||||||
|
final val key // org.jetbrains.compose.resources/StringArrayResource.key|{}key[0]
|
||||||
|
final fun <get-key>(): kotlin/String // org.jetbrains.compose.resources/StringArrayResource.key.<get-key>|<get-key>(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class org.jetbrains.compose.resources/StringResource : org.jetbrains.compose.resources/Resource { // org.jetbrains.compose.resources/StringResource|null[0]
|
||||||
|
constructor <init>(kotlin/String, kotlin/String, kotlin.collections/Set<org.jetbrains.compose.resources/ResourceItem>) // org.jetbrains.compose.resources/StringResource.<init>|<init>(kotlin.String;kotlin.String;kotlin.collections.Set<org.jetbrains.compose.resources.ResourceItem>){}[0]
|
||||||
|
|
||||||
|
final val key // org.jetbrains.compose.resources/StringResource.key|{}key[0]
|
||||||
|
final fun <get-key>(): kotlin/String // org.jetbrains.compose.resources/StringResource.key.<get-key>|<get-key>(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class org.jetbrains.compose.resources/Resource { // org.jetbrains.compose.resources/Resource|null[0]
|
||||||
|
open fun equals(kotlin/Any?): kotlin/Boolean // org.jetbrains.compose.resources/Resource.equals|equals(kotlin.Any?){}[0]
|
||||||
|
open fun hashCode(): kotlin/Int // org.jetbrains.compose.resources/Resource.hashCode|hashCode(){}[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
final object org.jetbrains.compose.resources/ResourceCaches // org.jetbrains.compose.resources/ResourceCaches|null[0]
|
||||||
|
|
||||||
|
final val org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_And$stableprop // org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_And$stableprop|#static{}org_jetbrains_compose_resources_plural_PluralRule_Condition_And$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_Or$stableprop // org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_Or$stableprop|#static{}org_jetbrains_compose_resources_plural_PluralRule_Condition_Or$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_Relation$stableprop // org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_Relation$stableprop|#static{}org_jetbrains_compose_resources_plural_PluralRule_Condition_Relation$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources.vector.xmldom/org_jetbrains_compose_resources_vector_xmldom_MalformedXMLException$stableprop // org.jetbrains.compose.resources.vector.xmldom/org_jetbrains_compose_resources_vector_xmldom_MalformedXMLException$stableprop|#static{}org_jetbrains_compose_resources_vector_xmldom_MalformedXMLException$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/LocalResourceReader // org.jetbrains.compose.resources/LocalResourceReader|{}LocalResourceReader[0]
|
||||||
|
final fun <get-LocalResourceReader>(): androidx.compose.runtime/ProvidableCompositionLocal<org.jetbrains.compose.resources/ResourceReader> // org.jetbrains.compose.resources/LocalResourceReader.<get-LocalResourceReader>|<get-LocalResourceReader>(){}[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_DrawableResource$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_DrawableResource$stableprop|#static{}org_jetbrains_compose_resources_DrawableResource$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_FontResource$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_FontResource$stableprop|#static{}org_jetbrains_compose_resources_FontResource$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Bitmap$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Bitmap$stableprop|#static{}org_jetbrains_compose_resources_ImageCache_Bitmap$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Svg$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Svg$stableprop|#static{}org_jetbrains_compose_resources_ImageCache_Svg$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Vector$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Vector$stableprop|#static{}org_jetbrains_compose_resources_ImageCache_Vector$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_LanguageQualifier$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_LanguageQualifier$stableprop|#static{}org_jetbrains_compose_resources_LanguageQualifier$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_MissingResourceException$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_MissingResourceException$stableprop|#static{}org_jetbrains_compose_resources_MissingResourceException$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_PluralStringResource$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_PluralStringResource$stableprop|#static{}org_jetbrains_compose_resources_PluralStringResource$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_RegionQualifier$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_RegionQualifier$stableprop|#static{}org_jetbrains_compose_resources_RegionQualifier$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_Resource$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_Resource$stableprop|#static{}org_jetbrains_compose_resources_Resource$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceCaches$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceCaches$stableprop|#static{}org_jetbrains_compose_resources_ResourceCaches$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceEnvironment$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceEnvironment$stableprop|#static{}org_jetbrains_compose_resources_ResourceEnvironment$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceItem$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceItem$stableprop|#static{}org_jetbrains_compose_resources_ResourceItem$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_ScriptQualifier$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ScriptQualifier$stableprop|#static{}org_jetbrains_compose_resources_ScriptQualifier$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringArrayResource$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringArrayResource$stableprop|#static{}org_jetbrains_compose_resources_StringArrayResource$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Array$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Array$stableprop|#static{}org_jetbrains_compose_resources_StringItem_Array$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Plurals$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Plurals$stableprop|#static{}org_jetbrains_compose_resources_StringItem_Plurals$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Value$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Value$stableprop|#static{}org_jetbrains_compose_resources_StringItem_Value$stableprop[0]
|
||||||
|
final val org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringResource$stableprop // org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringResource$stableprop|#static{}org_jetbrains_compose_resources_StringResource$stableprop[0]
|
||||||
|
|
||||||
|
final fun (kotlin/ByteArray).org.jetbrains.compose.resources/decodeToImageBitmap(): androidx.compose.ui.graphics/ImageBitmap // org.jetbrains.compose.resources/decodeToImageBitmap|decodeToImageBitmap@kotlin.ByteArray(){}[0]
|
||||||
|
final fun (kotlin/ByteArray).org.jetbrains.compose.resources/decodeToImageVector(androidx.compose.ui.unit/Density): androidx.compose.ui.graphics.vector/ImageVector // org.jetbrains.compose.resources/decodeToImageVector|decodeToImageVector@kotlin.ByteArray(androidx.compose.ui.unit.Density){}[0]
|
||||||
|
final fun (kotlin/ByteArray).org.jetbrains.compose.resources/decodeToSvgPainter(androidx.compose.ui.unit/Density): androidx.compose.ui.graphics.painter/Painter // org.jetbrains.compose.resources/decodeToSvgPainter|decodeToSvgPainter@kotlin.ByteArray(androidx.compose.ui.unit.Density){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_And$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_And$stableprop_getter|org_jetbrains_compose_resources_plural_PluralRule_Condition_And$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_Or$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_Or$stableprop_getter|org_jetbrains_compose_resources_plural_PluralRule_Condition_Or$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_Relation$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources.plural/org_jetbrains_compose_resources_plural_PluralRule_Condition_Relation$stableprop_getter|org_jetbrains_compose_resources_plural_PluralRule_Condition_Relation$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources.vector.xmldom/org_jetbrains_compose_resources_vector_xmldom_MalformedXMLException$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources.vector.xmldom/org_jetbrains_compose_resources_vector_xmldom_MalformedXMLException$stableprop_getter|org_jetbrains_compose_resources_vector_xmldom_MalformedXMLException$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/Font(org.jetbrains.compose.resources/FontResource, androidx.compose.ui.text.font/FontWeight?, androidx.compose.ui.text.font/FontStyle, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.ui.text.font/Font // org.jetbrains.compose.resources/Font|Font(org.jetbrains.compose.resources.FontResource;androidx.compose.ui.text.font.FontWeight?;androidx.compose.ui.text.font.FontStyle;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/Font(org.jetbrains.compose.resources/FontResource, androidx.compose.ui.text.font/FontWeight?, androidx.compose.ui.text.font/FontStyle, androidx.compose.ui.text.font/FontVariation.Settings?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.ui.text.font/Font // org.jetbrains.compose.resources/Font|Font(org.jetbrains.compose.resources.FontResource;androidx.compose.ui.text.font.FontWeight?;androidx.compose.ui.text.font.FontStyle;androidx.compose.ui.text.font.FontVariation.Settings?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/getResourceUri(kotlin/String): kotlin/String // org.jetbrains.compose.resources/getResourceUri|getResourceUri(kotlin.String){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/getSystemResourceEnvironment(): org.jetbrains.compose.resources/ResourceEnvironment // org.jetbrains.compose.resources/getSystemResourceEnvironment|getSystemResourceEnvironment(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/imageResource(org.jetbrains.compose.resources/DrawableResource, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.ui.graphics/ImageBitmap // org.jetbrains.compose.resources/imageResource|imageResource(org.jetbrains.compose.resources.DrawableResource;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_DrawableResource$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_DrawableResource$stableprop_getter|org_jetbrains_compose_resources_DrawableResource$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_FontResource$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_FontResource$stableprop_getter|org_jetbrains_compose_resources_FontResource$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Bitmap$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Bitmap$stableprop_getter|org_jetbrains_compose_resources_ImageCache_Bitmap$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Svg$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Svg$stableprop_getter|org_jetbrains_compose_resources_ImageCache_Svg$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Vector$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ImageCache_Vector$stableprop_getter|org_jetbrains_compose_resources_ImageCache_Vector$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_LanguageQualifier$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_LanguageQualifier$stableprop_getter|org_jetbrains_compose_resources_LanguageQualifier$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_MissingResourceException$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_MissingResourceException$stableprop_getter|org_jetbrains_compose_resources_MissingResourceException$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_PluralStringResource$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_PluralStringResource$stableprop_getter|org_jetbrains_compose_resources_PluralStringResource$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_RegionQualifier$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_RegionQualifier$stableprop_getter|org_jetbrains_compose_resources_RegionQualifier$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_Resource$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_Resource$stableprop_getter|org_jetbrains_compose_resources_Resource$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceCaches$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceCaches$stableprop_getter|org_jetbrains_compose_resources_ResourceCaches$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceEnvironment$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceEnvironment$stableprop_getter|org_jetbrains_compose_resources_ResourceEnvironment$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceItem$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ResourceItem$stableprop_getter|org_jetbrains_compose_resources_ResourceItem$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_ScriptQualifier$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_ScriptQualifier$stableprop_getter|org_jetbrains_compose_resources_ScriptQualifier$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringArrayResource$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringArrayResource$stableprop_getter|org_jetbrains_compose_resources_StringArrayResource$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Array$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Array$stableprop_getter|org_jetbrains_compose_resources_StringItem_Array$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Plurals$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Plurals$stableprop_getter|org_jetbrains_compose_resources_StringItem_Plurals$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Value$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringItem_Value$stableprop_getter|org_jetbrains_compose_resources_StringItem_Value$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringResource$stableprop_getter(): kotlin/Int // org.jetbrains.compose.resources/org_jetbrains_compose_resources_StringResource$stableprop_getter|org_jetbrains_compose_resources_StringResource$stableprop_getter(){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/painterResource(org.jetbrains.compose.resources/DrawableResource, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.ui.graphics.painter/Painter // org.jetbrains.compose.resources/painterResource|painterResource(org.jetbrains.compose.resources.DrawableResource;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/pluralStringResource(org.jetbrains.compose.resources/PluralStringResource, kotlin/Int, androidx.compose.runtime/Composer?, kotlin/Int): kotlin/String // org.jetbrains.compose.resources/pluralStringResource|pluralStringResource(org.jetbrains.compose.resources.PluralStringResource;kotlin.Int;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/pluralStringResource(org.jetbrains.compose.resources/PluralStringResource, kotlin/Int, kotlin/Array<out kotlin/Any>..., androidx.compose.runtime/Composer?, kotlin/Int): kotlin/String // org.jetbrains.compose.resources/pluralStringResource|pluralStringResource(org.jetbrains.compose.resources.PluralStringResource;kotlin.Int;kotlin.Array<out|kotlin.Any>...;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/rememberResourceEnvironment(androidx.compose.runtime/Composer?, kotlin/Int): org.jetbrains.compose.resources/ResourceEnvironment // org.jetbrains.compose.resources/rememberResourceEnvironment|rememberResourceEnvironment(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/stringArrayResource(org.jetbrains.compose.resources/StringArrayResource, androidx.compose.runtime/Composer?, kotlin/Int): kotlin.collections/List<kotlin/String> // org.jetbrains.compose.resources/stringArrayResource|stringArrayResource(org.jetbrains.compose.resources.StringArrayResource;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/stringResource(org.jetbrains.compose.resources/StringResource, androidx.compose.runtime/Composer?, kotlin/Int): kotlin/String // org.jetbrains.compose.resources/stringResource|stringResource(org.jetbrains.compose.resources.StringResource;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/stringResource(org.jetbrains.compose.resources/StringResource, kotlin/Array<out kotlin/Any>..., androidx.compose.runtime/Composer?, kotlin/Int): kotlin/String // org.jetbrains.compose.resources/stringResource|stringResource(org.jetbrains.compose.resources.StringResource;kotlin.Array<out|kotlin.Any>...;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
|
final fun org.jetbrains.compose.resources/vectorResource(org.jetbrains.compose.resources/DrawableResource, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.ui.graphics.vector/ImageVector // org.jetbrains.compose.resources/vectorResource|vectorResource(org.jetbrains.compose.resources.DrawableResource;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getDrawableResourceBytes(org.jetbrains.compose.resources/ResourceEnvironment, org.jetbrains.compose.resources/DrawableResource): kotlin/ByteArray // org.jetbrains.compose.resources/getDrawableResourceBytes|getDrawableResourceBytes(org.jetbrains.compose.resources.ResourceEnvironment;org.jetbrains.compose.resources.DrawableResource){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getFontResourceBytes(org.jetbrains.compose.resources/ResourceEnvironment, org.jetbrains.compose.resources/FontResource): kotlin/ByteArray // org.jetbrains.compose.resources/getFontResourceBytes|getFontResourceBytes(org.jetbrains.compose.resources.ResourceEnvironment;org.jetbrains.compose.resources.FontResource){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getPluralString(org.jetbrains.compose.resources/PluralStringResource, kotlin/Int): kotlin/String // org.jetbrains.compose.resources/getPluralString|getPluralString(org.jetbrains.compose.resources.PluralStringResource;kotlin.Int){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getPluralString(org.jetbrains.compose.resources/PluralStringResource, kotlin/Int, kotlin/Array<out kotlin/Any>...): kotlin/String // org.jetbrains.compose.resources/getPluralString|getPluralString(org.jetbrains.compose.resources.PluralStringResource;kotlin.Int;kotlin.Array<out|kotlin.Any>...){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getPluralString(org.jetbrains.compose.resources/ResourceEnvironment, org.jetbrains.compose.resources/PluralStringResource, kotlin/Int): kotlin/String // org.jetbrains.compose.resources/getPluralString|getPluralString(org.jetbrains.compose.resources.ResourceEnvironment;org.jetbrains.compose.resources.PluralStringResource;kotlin.Int){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getPluralString(org.jetbrains.compose.resources/ResourceEnvironment, org.jetbrains.compose.resources/PluralStringResource, kotlin/Int, kotlin/Array<out kotlin/Any>...): kotlin/String // org.jetbrains.compose.resources/getPluralString|getPluralString(org.jetbrains.compose.resources.ResourceEnvironment;org.jetbrains.compose.resources.PluralStringResource;kotlin.Int;kotlin.Array<out|kotlin.Any>...){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getString(org.jetbrains.compose.resources/ResourceEnvironment, org.jetbrains.compose.resources/StringResource): kotlin/String // org.jetbrains.compose.resources/getString|getString(org.jetbrains.compose.resources.ResourceEnvironment;org.jetbrains.compose.resources.StringResource){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getString(org.jetbrains.compose.resources/ResourceEnvironment, org.jetbrains.compose.resources/StringResource, kotlin/Array<out kotlin/Any>...): kotlin/String // org.jetbrains.compose.resources/getString|getString(org.jetbrains.compose.resources.ResourceEnvironment;org.jetbrains.compose.resources.StringResource;kotlin.Array<out|kotlin.Any>...){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getString(org.jetbrains.compose.resources/StringResource): kotlin/String // org.jetbrains.compose.resources/getString|getString(org.jetbrains.compose.resources.StringResource){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getString(org.jetbrains.compose.resources/StringResource, kotlin/Array<out kotlin/Any>...): kotlin/String // org.jetbrains.compose.resources/getString|getString(org.jetbrains.compose.resources.StringResource;kotlin.Array<out|kotlin.Any>...){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getStringArray(org.jetbrains.compose.resources/ResourceEnvironment, org.jetbrains.compose.resources/StringArrayResource): kotlin.collections/List<kotlin/String> // org.jetbrains.compose.resources/getStringArray|getStringArray(org.jetbrains.compose.resources.ResourceEnvironment;org.jetbrains.compose.resources.StringArrayResource){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/getStringArray(org.jetbrains.compose.resources/StringArrayResource): kotlin.collections/List<kotlin/String> // org.jetbrains.compose.resources/getStringArray|getStringArray(org.jetbrains.compose.resources.StringArrayResource){}[0]
|
||||||
|
final suspend fun org.jetbrains.compose.resources/readResourceBytes(kotlin/String): kotlin/ByteArray // org.jetbrains.compose.resources/readResourceBytes|readResourceBytes(kotlin.String){}[0]
|
||||||
@@ -22,7 +22,7 @@ import sdl3.SDL_free
|
|||||||
// MARK: ResourceEnvironment — SDL actual
|
// 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
|
drawable-dark, …): locale from SDL_GetPreferredLocales, theme from
|
||||||
SDL_GetSystemTheme. Density is reported as 1f — under this port's Option-B
|
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
|
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
|
// 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
|
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
|
apps' Zip tasks), and :ui's ResourceIO serves entries by exact path with an
|
||||||
fseek+fread. The paths the generated Res accessors produce
|
fseek+fread. The paths the generated Res accessors produce
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ package org.jetbrains.compose.resources.vector.xmldom
|
|||||||
CDATA skipping, and the five predefined entities plus numeric character
|
CDATA skipping, and the five predefined entities plus numeric character
|
||||||
references. No DTD expansion, no processing-instruction handling beyond
|
references. No DTD expansion, no processing-instruction handling beyond
|
||||||
skipping — malformed input throws MalformedXMLException like upstream. */
|
skipping — malformed input throws MalformedXMLException like upstream. */
|
||||||
|
// VENDOR-BASE(COMPOSE_REF): components/resources/library/src/nativeMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt @ v1.12.0-beta03+dev4483
|
||||||
|
// (fresh REIMPL, not a copy-edit — the base ref marks the upstream API it tracks)
|
||||||
|
|
||||||
internal fun parse(xml: String): Element {
|
internal fun parse(xml: String): Element {
|
||||||
val vParser = XmlDomParser(xml)
|
val vParser = XmlDomParser(xml)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import com.compose.sdl.text.NamedFont
|
|||||||
// MARK: Font actuals — SDL renderer
|
// 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
|
registry (IconFont.registerIcon — the general byte-font table, not just
|
||||||
icons). A font resource therefore loads its bytes through the resource
|
icons). A font resource therefore loads its bytes through the resource
|
||||||
reader, registers them under a per-resource family name, and returns a
|
reader, registers them under a per-resource family name, and returns a
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ import com.compose.sdl.graphics.decodeEncodedImageBitmap
|
|||||||
// MARK: Image actuals — SDL renderer
|
// 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:
|
(SDL3_image, IMG auto-detect). resourceDensity/targetDensity are ignored:
|
||||||
under this port's Option-B density flow layout runs in physical pixels and
|
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. */
|
drawables ship at a single density, so no decode-time rescale applies. */
|
||||||
internal actual fun ByteArray.toImageBitmap(resourceDensity: Int, targetDensity: Int): ImageBitmap =
|
internal actual fun ByteArray.toImageBitmap(resourceDensity: Int, targetDensity: Int): ImageBitmap =
|
||||||
decodeEncodedImageBitmap(this)
|
decodeEncodedImageBitmap(this)
|
||||||
?: error("Image decode failed — is the SDL renderer initialised before painterResource ran?")
|
?: 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
|
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
|
bitmap. No DOM, no vector scaling — prefer XML vector drawables for
|
||||||
resolution-independent art on this renderer. */
|
resolution-independent art on this renderer. */
|
||||||
@@ -34,5 +34,5 @@ internal actual fun ByteArray.toSvgElement(): SvgElement = SvgElement(this)
|
|||||||
internal actual fun SvgElement.toSvgPainter(density: Density): Painter =
|
internal actual fun SvgElement.toSvgPainter(density: Density): Painter =
|
||||||
BitmapPainter(
|
BitmapPainter(
|
||||||
decodeEncodedImageBitmap(bytes)
|
decodeEncodedImageBitmap(bytes)
|
||||||
?: error("SVG rasterisation failed — is the SDL renderer initialised before painterResource ran?")
|
?: error("SVG rasterisation failed — is the render backend initialised before painterResource ran?")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2078,17 +2078,6 @@ final object com.compose.sdl.icons/IconDefaults { // com.compose.sdl.icons/IconD
|
|||||||
final fun <get-LocalContentColor>(): androidx.compose.ui.graphics/Color // com.compose.sdl.icons/IconDefaults.LocalContentColor.<get-LocalContentColor>|<get-LocalContentColor>(){}[0]
|
final fun <get-LocalContentColor>(): androidx.compose.ui.graphics/Color // com.compose.sdl.icons/IconDefaults.LocalContentColor.<get-LocalContentColor>|<get-LocalContentColor>(){}[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
final object com.compose.sdl.icons/MaterialIconAxisDefaults { // com.compose.sdl.icons/MaterialIconAxisDefaults|null[0]
|
|
||||||
final const val Fill // com.compose.sdl.icons/MaterialIconAxisDefaults.Fill|{}Fill[0]
|
|
||||||
final fun <get-Fill>(): kotlin/Float // com.compose.sdl.icons/MaterialIconAxisDefaults.Fill.<get-Fill>|<get-Fill>(){}[0]
|
|
||||||
final const val Grade // com.compose.sdl.icons/MaterialIconAxisDefaults.Grade|{}Grade[0]
|
|
||||||
final fun <get-Grade>(): kotlin/Int // com.compose.sdl.icons/MaterialIconAxisDefaults.Grade.<get-Grade>|<get-Grade>(){}[0]
|
|
||||||
final const val OpticalSize // com.compose.sdl.icons/MaterialIconAxisDefaults.OpticalSize|{}OpticalSize[0]
|
|
||||||
final fun <get-OpticalSize>(): kotlin/Int // com.compose.sdl.icons/MaterialIconAxisDefaults.OpticalSize.<get-OpticalSize>|<get-OpticalSize>(){}[0]
|
|
||||||
final const val Weight // com.compose.sdl.icons/MaterialIconAxisDefaults.Weight|{}Weight[0]
|
|
||||||
final fun <get-Weight>(): kotlin/Int // com.compose.sdl.icons/MaterialIconAxisDefaults.Weight.<get-Weight>|<get-Weight>(){}[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
final object com.compose.sdl.widgets/SplitPaneDefaults { // com.compose.sdl.widgets/SplitPaneDefaults|null[0]
|
final object com.compose.sdl.widgets/SplitPaneDefaults { // com.compose.sdl.widgets/SplitPaneDefaults|null[0]
|
||||||
final val DividerThickness // com.compose.sdl.widgets/SplitPaneDefaults.DividerThickness|{}DividerThickness[0]
|
final val DividerThickness // com.compose.sdl.widgets/SplitPaneDefaults.DividerThickness|{}DividerThickness[0]
|
||||||
final fun <get-DividerThickness>(): androidx.compose.ui.unit/Dp // com.compose.sdl.widgets/SplitPaneDefaults.DividerThickness.<get-DividerThickness>|<get-DividerThickness>(){}[0]
|
final fun <get-DividerThickness>(): androidx.compose.ui.unit/Dp // com.compose.sdl.widgets/SplitPaneDefaults.DividerThickness.<get-DividerThickness>|<get-DividerThickness>(){}[0]
|
||||||
@@ -2220,7 +2209,6 @@ final val androidx.compose.foundation/androidx_compose_foundation_MarqueeDefault
|
|||||||
final val androidx.compose.foundation/androidx_compose_foundation_MutatorMutex$stableprop // androidx.compose.foundation/androidx_compose_foundation_MutatorMutex$stableprop|#static{}androidx_compose_foundation_MutatorMutex$stableprop[0]
|
final val androidx.compose.foundation/androidx_compose_foundation_MutatorMutex$stableprop // androidx.compose.foundation/androidx_compose_foundation_MutatorMutex$stableprop|#static{}androidx_compose_foundation_MutatorMutex$stableprop[0]
|
||||||
final val androidx.compose.foundation/androidx_compose_foundation_ScrollState$stableprop // androidx.compose.foundation/androidx_compose_foundation_ScrollState$stableprop|#static{}androidx_compose_foundation_ScrollState$stableprop[0]
|
final val androidx.compose.foundation/androidx_compose_foundation_ScrollState$stableprop // androidx.compose.foundation/androidx_compose_foundation_ScrollState$stableprop|#static{}androidx_compose_foundation_ScrollState$stableprop[0]
|
||||||
final val com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop // com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop|#static{}com_compose_sdl_icons_IconDefaults$stableprop[0]
|
final val com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop // com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop|#static{}com_compose_sdl_icons_IconDefaults$stableprop[0]
|
||||||
final val com.compose.sdl.icons/com_compose_sdl_icons_MaterialIconAxisDefaults$stableprop // com.compose.sdl.icons/com_compose_sdl_icons_MaterialIconAxisDefaults$stableprop|#static{}com_compose_sdl_icons_MaterialIconAxisDefaults$stableprop[0]
|
|
||||||
final val com.compose.sdl.scrollbar/LocalScrollbarStyle // com.compose.sdl.scrollbar/LocalScrollbarStyle|{}LocalScrollbarStyle[0]
|
final val com.compose.sdl.scrollbar/LocalScrollbarStyle // com.compose.sdl.scrollbar/LocalScrollbarStyle|{}LocalScrollbarStyle[0]
|
||||||
final fun <get-LocalScrollbarStyle>(): androidx.compose.runtime/ProvidableCompositionLocal<com.compose.sdl.scrollbar/ScrollbarStyle> // com.compose.sdl.scrollbar/LocalScrollbarStyle.<get-LocalScrollbarStyle>|<get-LocalScrollbarStyle>(){}[0]
|
final fun <get-LocalScrollbarStyle>(): androidx.compose.runtime/ProvidableCompositionLocal<com.compose.sdl.scrollbar/ScrollbarStyle> // com.compose.sdl.scrollbar/LocalScrollbarStyle.<get-LocalScrollbarStyle>|<get-LocalScrollbarStyle>(){}[0]
|
||||||
final val com.compose.sdl.scrollbar/com_compose_sdl_scrollbar_ScrollbarStyle$stableprop // com.compose.sdl.scrollbar/com_compose_sdl_scrollbar_ScrollbarStyle$stableprop|#static{}com_compose_sdl_scrollbar_ScrollbarStyle$stableprop[0]
|
final val com.compose.sdl.scrollbar/com_compose_sdl_scrollbar_ScrollbarStyle$stableprop // com.compose.sdl.scrollbar/com_compose_sdl_scrollbar_ScrollbarStyle$stableprop|#static{}com_compose_sdl_scrollbar_ScrollbarStyle$stableprop[0]
|
||||||
@@ -2294,6 +2282,8 @@ final fun (androidx.compose.ui.input.pointer/PointerEvent).androidx.compose.foun
|
|||||||
final fun (androidx.compose.ui.input.pointer/PointerEvent).androidx.compose.foundation.gestures/calculateZoom(): kotlin/Float // androidx.compose.foundation.gestures/calculateZoom|calculateZoom@androidx.compose.ui.input.pointer.PointerEvent(){}[0]
|
final fun (androidx.compose.ui.input.pointer/PointerEvent).androidx.compose.foundation.gestures/calculateZoom(): kotlin/Float // androidx.compose.foundation.gestures/calculateZoom|calculateZoom@androidx.compose.ui.input.pointer.PointerEvent(){}[0]
|
||||||
final fun (androidx.compose.ui.text/AnnotatedString.Builder).androidx.compose.foundation.text/appendInlineContent(kotlin/String, kotlin/String = ...) // androidx.compose.foundation.text/appendInlineContent|appendInlineContent@androidx.compose.ui.text.AnnotatedString.Builder(kotlin.String;kotlin.String){}[0]
|
final fun (androidx.compose.ui.text/AnnotatedString.Builder).androidx.compose.foundation.text/appendInlineContent(kotlin/String, kotlin/String = ...) // androidx.compose.foundation.text/appendInlineContent|appendInlineContent@androidx.compose.ui.text.AnnotatedString.Builder(kotlin.String;kotlin.String){}[0]
|
||||||
final fun (androidx.compose.ui/Modifier).androidx.compose.foundation.content/contentReceiver(androidx.compose.foundation.content/ReceiveContentListener): androidx.compose.ui/Modifier // androidx.compose.foundation.content/contentReceiver|contentReceiver@androidx.compose.ui.Modifier(androidx.compose.foundation.content.ReceiveContentListener){}[0]
|
final fun (androidx.compose.ui/Modifier).androidx.compose.foundation.content/contentReceiver(androidx.compose.foundation.content/ReceiveContentListener): androidx.compose.ui/Modifier // androidx.compose.foundation.content/contentReceiver|contentReceiver@androidx.compose.ui.Modifier(androidx.compose.foundation.content.ReceiveContentListener){}[0]
|
||||||
|
final fun (androidx.compose.ui/Modifier).androidx.compose.foundation.draganddrop/dragAndDropSource(kotlin/Function1<androidx.compose.ui.geometry/Offset, androidx.compose.ui.draganddrop/DragAndDropTransferData?>): androidx.compose.ui/Modifier // androidx.compose.foundation.draganddrop/dragAndDropSource|dragAndDropSource@androidx.compose.ui.Modifier(kotlin.Function1<androidx.compose.ui.geometry.Offset,androidx.compose.ui.draganddrop.DragAndDropTransferData?>){}[0]
|
||||||
|
final fun (androidx.compose.ui/Modifier).androidx.compose.foundation.draganddrop/dragAndDropSource(kotlin/Function1<androidx.compose.ui.graphics.drawscope/DrawScope, kotlin/Unit>, kotlin/Function1<androidx.compose.ui.geometry/Offset, androidx.compose.ui.draganddrop/DragAndDropTransferData?>): androidx.compose.ui/Modifier // androidx.compose.foundation.draganddrop/dragAndDropSource|dragAndDropSource@androidx.compose.ui.Modifier(kotlin.Function1<androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit>;kotlin.Function1<androidx.compose.ui.geometry.Offset,androidx.compose.ui.draganddrop.DragAndDropTransferData?>){}[0]
|
||||||
final fun (androidx.compose.ui/Modifier).androidx.compose.foundation.draganddrop/dragAndDropTarget(kotlin/Function1<androidx.compose.ui.draganddrop/DragAndDropEvent, kotlin/Boolean>, androidx.compose.ui.draganddrop/DragAndDropTarget): androidx.compose.ui/Modifier // androidx.compose.foundation.draganddrop/dragAndDropTarget|dragAndDropTarget@androidx.compose.ui.Modifier(kotlin.Function1<androidx.compose.ui.draganddrop.DragAndDropEvent,kotlin.Boolean>;androidx.compose.ui.draganddrop.DragAndDropTarget){}[0]
|
final fun (androidx.compose.ui/Modifier).androidx.compose.foundation.draganddrop/dragAndDropTarget(kotlin/Function1<androidx.compose.ui.draganddrop/DragAndDropEvent, kotlin/Boolean>, androidx.compose.ui.draganddrop/DragAndDropTarget): androidx.compose.ui/Modifier // androidx.compose.foundation.draganddrop/dragAndDropTarget|dragAndDropTarget@androidx.compose.ui.Modifier(kotlin.Function1<androidx.compose.ui.draganddrop.DragAndDropEvent,kotlin.Boolean>;androidx.compose.ui.draganddrop.DragAndDropTarget){}[0]
|
||||||
final fun (androidx.compose.ui/Modifier).androidx.compose.foundation.gestures/draggable(androidx.compose.foundation.gestures/DraggableState, androidx.compose.foundation.gestures/Orientation, kotlin/Boolean = ..., androidx.compose.foundation.interaction/MutableInteractionSource? = ..., kotlin/Boolean = ..., kotlin.coroutines/SuspendFunction2<kotlinx.coroutines/CoroutineScope, androidx.compose.ui.geometry/Offset, kotlin/Unit> = ..., kotlin.coroutines/SuspendFunction2<kotlinx.coroutines/CoroutineScope, kotlin/Float, kotlin/Unit> = ..., kotlin/Boolean = ...): androidx.compose.ui/Modifier // androidx.compose.foundation.gestures/draggable|draggable@androidx.compose.ui.Modifier(androidx.compose.foundation.gestures.DraggableState;androidx.compose.foundation.gestures.Orientation;kotlin.Boolean;androidx.compose.foundation.interaction.MutableInteractionSource?;kotlin.Boolean;kotlin.coroutines.SuspendFunction2<kotlinx.coroutines.CoroutineScope,androidx.compose.ui.geometry.Offset,kotlin.Unit>;kotlin.coroutines.SuspendFunction2<kotlinx.coroutines.CoroutineScope,kotlin.Float,kotlin.Unit>;kotlin.Boolean){}[0]
|
final fun (androidx.compose.ui/Modifier).androidx.compose.foundation.gestures/draggable(androidx.compose.foundation.gestures/DraggableState, androidx.compose.foundation.gestures/Orientation, kotlin/Boolean = ..., androidx.compose.foundation.interaction/MutableInteractionSource? = ..., kotlin/Boolean = ..., kotlin.coroutines/SuspendFunction2<kotlinx.coroutines/CoroutineScope, androidx.compose.ui.geometry/Offset, kotlin/Unit> = ..., kotlin.coroutines/SuspendFunction2<kotlinx.coroutines/CoroutineScope, kotlin/Float, kotlin/Unit> = ..., kotlin/Boolean = ...): androidx.compose.ui/Modifier // androidx.compose.foundation.gestures/draggable|draggable@androidx.compose.ui.Modifier(androidx.compose.foundation.gestures.DraggableState;androidx.compose.foundation.gestures.Orientation;kotlin.Boolean;androidx.compose.foundation.interaction.MutableInteractionSource?;kotlin.Boolean;kotlin.coroutines.SuspendFunction2<kotlinx.coroutines.CoroutineScope,androidx.compose.ui.geometry.Offset,kotlin.Unit>;kotlin.coroutines.SuspendFunction2<kotlinx.coroutines.CoroutineScope,kotlin.Float,kotlin.Unit>;kotlin.Boolean){}[0]
|
||||||
final fun (androidx.compose.ui/Modifier).androidx.compose.foundation.gestures/draggable2D(androidx.compose.foundation.gestures/Draggable2DState, kotlin/Boolean = ..., androidx.compose.foundation.interaction/MutableInteractionSource? = ..., kotlin/Boolean = ..., kotlin/Function1<androidx.compose.ui.geometry/Offset, kotlin/Unit> = ..., kotlin/Function1<androidx.compose.ui.unit/Velocity, kotlin/Unit> = ..., kotlin/Boolean = ...): androidx.compose.ui/Modifier // androidx.compose.foundation.gestures/draggable2D|draggable2D@androidx.compose.ui.Modifier(androidx.compose.foundation.gestures.Draggable2DState;kotlin.Boolean;androidx.compose.foundation.interaction.MutableInteractionSource?;kotlin.Boolean;kotlin.Function1<androidx.compose.ui.geometry.Offset,kotlin.Unit>;kotlin.Function1<androidx.compose.ui.unit.Velocity,kotlin.Unit>;kotlin.Boolean){}[0]
|
final fun (androidx.compose.ui/Modifier).androidx.compose.foundation.gestures/draggable2D(androidx.compose.foundation.gestures/Draggable2DState, kotlin/Boolean = ..., androidx.compose.foundation.interaction/MutableInteractionSource? = ..., kotlin/Boolean = ..., kotlin/Function1<androidx.compose.ui.geometry/Offset, kotlin/Unit> = ..., kotlin/Function1<androidx.compose.ui.unit/Velocity, kotlin/Unit> = ..., kotlin/Boolean = ...): androidx.compose.ui/Modifier // androidx.compose.foundation.gestures/draggable2D|draggable2D@androidx.compose.ui.Modifier(androidx.compose.foundation.gestures.Draggable2DState;kotlin.Boolean;androidx.compose.foundation.interaction.MutableInteractionSource?;kotlin.Boolean;kotlin.Function1<androidx.compose.ui.geometry.Offset,kotlin.Unit>;kotlin.Function1<androidx.compose.ui.unit.Velocity,kotlin.Unit>;kotlin.Boolean){}[0]
|
||||||
@@ -2620,14 +2610,13 @@ final fun androidx.compose.foundation/rememberBasicTooltipState(kotlin/Boolean,
|
|||||||
final fun androidx.compose.foundation/rememberOverscrollEffect(androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.foundation/OverscrollEffect? // androidx.compose.foundation/rememberOverscrollEffect|rememberOverscrollEffect(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
final fun androidx.compose.foundation/rememberOverscrollEffect(androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.foundation/OverscrollEffect? // androidx.compose.foundation/rememberOverscrollEffect|rememberOverscrollEffect(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
final fun androidx.compose.foundation/rememberScrollState(kotlin/Int, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.foundation/ScrollState // androidx.compose.foundation/rememberScrollState|rememberScrollState(kotlin.Int;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
final fun androidx.compose.foundation/rememberScrollState(kotlin/Int, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.foundation/ScrollState // androidx.compose.foundation/rememberScrollState|rememberScrollState(kotlin.Int;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||||
final fun com.compose.sdl.icons/IconFontIcon(kotlin/Int, kotlin/String, kotlin/String?, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/Dp, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.icons/IconFontIcon|IconFontIcon(kotlin.Int;kotlin.String;kotlin.String?;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.Dp;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
final fun com.compose.sdl.icons/IconFontIcon(kotlin/Int, kotlin/String, kotlin/String?, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/Dp, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.icons/IconFontIcon|IconFontIcon(kotlin.Int;kotlin.String;kotlin.String?;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.Dp;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||||
final fun com.compose.sdl.icons/MaterialIconAxes(kotlin/Float = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ...): kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting> // com.compose.sdl.icons/MaterialIconAxes|MaterialIconAxes(kotlin.Float;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
|
|
||||||
final fun com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop_getter(): kotlin/Int // com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop_getter|com_compose_sdl_icons_IconDefaults$stableprop_getter(){}[0]
|
final fun com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop_getter(): kotlin/Int // com.compose.sdl.icons/com_compose_sdl_icons_IconDefaults$stableprop_getter|com_compose_sdl_icons_IconDefaults$stableprop_getter(){}[0]
|
||||||
final fun com.compose.sdl.icons/com_compose_sdl_icons_MaterialIconAxisDefaults$stableprop_getter(): kotlin/Int // com.compose.sdl.icons/com_compose_sdl_icons_MaterialIconAxisDefaults$stableprop_getter|com_compose_sdl_icons_MaterialIconAxisDefaults$stableprop_getter(){}[0]
|
|
||||||
final fun com.compose.sdl.modifier/rememberMutableInteractionSource(androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.foundation.interaction/MutableInteractionSource // com.compose.sdl.modifier/rememberMutableInteractionSource|rememberMutableInteractionSource(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
final fun com.compose.sdl.modifier/rememberMutableInteractionSource(androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.foundation.interaction/MutableInteractionSource // com.compose.sdl.modifier/rememberMutableInteractionSource|rememberMutableInteractionSource(androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
final fun com.compose.sdl.scrollbar/HorizontalScrollbar(com.compose.sdl.scrollbar/ScrollbarAdapter, androidx.compose.ui/Modifier?, kotlin/Boolean, com.compose.sdl.scrollbar/ScrollbarStyle?, androidx.compose.foundation.interaction/MutableInteractionSource?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.scrollbar/HorizontalScrollbar|HorizontalScrollbar(com.compose.sdl.scrollbar.ScrollbarAdapter;androidx.compose.ui.Modifier?;kotlin.Boolean;com.compose.sdl.scrollbar.ScrollbarStyle?;androidx.compose.foundation.interaction.MutableInteractionSource?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
final fun com.compose.sdl.scrollbar/HorizontalScrollbar(com.compose.sdl.scrollbar/ScrollbarAdapter, androidx.compose.ui/Modifier?, kotlin/Boolean, com.compose.sdl.scrollbar/ScrollbarStyle?, androidx.compose.foundation.interaction/MutableInteractionSource?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.scrollbar/HorizontalScrollbar|HorizontalScrollbar(com.compose.sdl.scrollbar.ScrollbarAdapter;androidx.compose.ui.Modifier?;kotlin.Boolean;com.compose.sdl.scrollbar.ScrollbarStyle?;androidx.compose.foundation.interaction.MutableInteractionSource?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||||
final fun com.compose.sdl.scrollbar/VerticalScrollbar(com.compose.sdl.scrollbar/ScrollbarAdapter, androidx.compose.ui/Modifier?, kotlin/Boolean, com.compose.sdl.scrollbar/ScrollbarStyle?, androidx.compose.foundation.interaction/MutableInteractionSource?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.scrollbar/VerticalScrollbar|VerticalScrollbar(com.compose.sdl.scrollbar.ScrollbarAdapter;androidx.compose.ui.Modifier?;kotlin.Boolean;com.compose.sdl.scrollbar.ScrollbarStyle?;androidx.compose.foundation.interaction.MutableInteractionSource?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
final fun com.compose.sdl.scrollbar/VerticalScrollbar(com.compose.sdl.scrollbar/ScrollbarAdapter, androidx.compose.ui/Modifier?, kotlin/Boolean, com.compose.sdl.scrollbar/ScrollbarStyle?, androidx.compose.foundation.interaction/MutableInteractionSource?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.scrollbar/VerticalScrollbar|VerticalScrollbar(com.compose.sdl.scrollbar.ScrollbarAdapter;androidx.compose.ui.Modifier?;kotlin.Boolean;com.compose.sdl.scrollbar.ScrollbarStyle?;androidx.compose.foundation.interaction.MutableInteractionSource?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||||
final fun com.compose.sdl.scrollbar/com_compose_sdl_scrollbar_ScrollbarStyle$stableprop_getter(): kotlin/Int // com.compose.sdl.scrollbar/com_compose_sdl_scrollbar_ScrollbarStyle$stableprop_getter|com_compose_sdl_scrollbar_ScrollbarStyle$stableprop_getter(){}[0]
|
final fun com.compose.sdl.scrollbar/com_compose_sdl_scrollbar_ScrollbarStyle$stableprop_getter(): kotlin/Int // com.compose.sdl.scrollbar/com_compose_sdl_scrollbar_ScrollbarStyle$stableprop_getter|com_compose_sdl_scrollbar_ScrollbarStyle$stableprop_getter(){}[0]
|
||||||
final fun com.compose.sdl.scrollbar/defaultScrollbarStyle(): com.compose.sdl.scrollbar/ScrollbarStyle // com.compose.sdl.scrollbar/defaultScrollbarStyle|defaultScrollbarStyle(){}[0]
|
final fun com.compose.sdl.scrollbar/defaultScrollbarStyle(): com.compose.sdl.scrollbar/ScrollbarStyle // com.compose.sdl.scrollbar/defaultScrollbarStyle|defaultScrollbarStyle(){}[0]
|
||||||
|
final fun com.compose.sdl.scrollbar/rememberScrollbarAdapter(androidx.compose.foundation.lazy/LazyListState, androidx.compose.runtime/Composer?, kotlin/Int): com.compose.sdl.scrollbar/ScrollbarAdapter // com.compose.sdl.scrollbar/rememberScrollbarAdapter|rememberScrollbarAdapter(androidx.compose.foundation.lazy.LazyListState;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
final fun com.compose.sdl.scrollbar/rememberScrollbarAdapter(androidx.compose.foundation/ScrollState, androidx.compose.runtime/Composer?, kotlin/Int): com.compose.sdl.scrollbar/ScrollbarAdapter // com.compose.sdl.scrollbar/rememberScrollbarAdapter|rememberScrollbarAdapter(androidx.compose.foundation.ScrollState;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
final fun com.compose.sdl.scrollbar/rememberScrollbarAdapter(androidx.compose.foundation/ScrollState, androidx.compose.runtime/Composer?, kotlin/Int): com.compose.sdl.scrollbar/ScrollbarAdapter // com.compose.sdl.scrollbar/rememberScrollbarAdapter|rememberScrollbarAdapter(androidx.compose.foundation.ScrollState;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||||
final fun com.compose.sdl.text/IconText(kotlin/String, kotlin/String, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/TextUnit, androidx.compose.ui.text.style/TextAlign, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.text/IconText|IconText(kotlin.String;kotlin.String;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.text.style.TextAlign;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
final fun com.compose.sdl.text/IconText(kotlin/String, kotlin/String, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/TextUnit, androidx.compose.ui.text.style/TextAlign, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.text/IconText|IconText(kotlin.String;kotlin.String;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.text.style.TextAlign;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||||
final fun com.compose.sdl.text/IconText(kotlin/String, kotlin/String, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/TextUnit, androidx.compose.ui.text.style/TextAlign?, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.text/IconText|IconText(kotlin.String;kotlin.String;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.text.style.TextAlign?;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
final fun com.compose.sdl.text/IconText(kotlin/String, kotlin/String, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Color, androidx.compose.ui.unit/TextUnit, androidx.compose.ui.text.style/TextAlign?, kotlin.collections/List<androidx.compose.ui.text.font/FontVariation.Setting>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.compose.sdl.text/IconText|IconText(kotlin.String;kotlin.String;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Color;androidx.compose.ui.unit.TextUnit;androidx.compose.ui.text.style.TextAlign?;kotlin.collections.List<androidx.compose.ui.text.font.FontVariation.Setting>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
// MANUAL VENDOR of upstream compose/foundation/foundation/src/commonMain/kotlin/
|
// MANUAL VENDOR of upstream compose/foundation/foundation/src/commonMain/kotlin/
|
||||||
// androidx/compose/foundation/text/selection/SelectionLayout.kt.
|
// androidx/compose/foundation/text/selection/SelectionLayout.kt.
|
||||||
|
// VENDOR-BASE: compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionLayout.kt @ v1.12.0-beta03+dev4483
|
||||||
//
|
//
|
||||||
// Change vs upstream: MultiSelectionLayout.crossStatus and slotToIndex clamp their
|
// Change vs upstream: MultiSelectionLayout.crossStatus and slotToIndex clamp their
|
||||||
// infoList index to the valid range. Upstream assumes that if the drag position never
|
// infoList index to the valid range. Upstream assumes that if the drag position never
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import androidx.compose.runtime.remember
|
|||||||
// MARK: InteractionSource convenience
|
// MARK: InteractionSource convenience
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/* Convenience around the official MutableInteractionSource() factory. Official
|
/** Convenience around the official MutableInteractionSource() factory. Official
|
||||||
Compose uses `remember { MutableInteractionSource() }` directly; this helper
|
Compose uses `remember { MutableInteractionSource() }` directly; this helper
|
||||||
is a project shorthand. Lives in :foundation because it references
|
is a project shorthand. Lives in :foundation because it references
|
||||||
androidx.compose.foundation.interaction.MutableInteractionSource. */
|
androidx.compose.foundation.interaction.MutableInteractionSource. */
|
||||||
|
|||||||
@@ -30,16 +30,19 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
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 androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: Scrollbar (Compose Desktop-style)
|
// 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
|
You overlay a VerticalScrollbar / HorizontalScrollbar on a scrollable and feed
|
||||||
it a ScrollbarAdapter built from the same ScrollState. This project's Box has
|
it a ScrollbarAdapter built from the same ScrollState. This project's Box has
|
||||||
no BoxScope (so no Modifier.align) — pin the bar with the Box's
|
no BoxScope (so no Modifier.align) — pin the bar with the Box's
|
||||||
@@ -68,7 +71,7 @@ class ScrollbarStyle(
|
|||||||
val hoverColor: Color,
|
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. */
|
provide a white-tinted style via LocalScrollbarStyle or the `style` param. */
|
||||||
fun defaultScrollbarStyle(): ScrollbarStyle = ScrollbarStyle(
|
fun defaultScrollbarStyle(): ScrollbarStyle = ScrollbarStyle(
|
||||||
minimalHeight = 16.dp,
|
minimalHeight = 16.dp,
|
||||||
@@ -85,7 +88,7 @@ val LocalScrollbarStyle = compositionLocalOf { defaultScrollbarStyle() }
|
|||||||
// MARK: ScrollbarAdapter
|
// 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
|
contentSize / viewportSize are along the scroll axis; the thumb is sized by
|
||||||
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
|
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
|
||||||
interface ScrollbarAdapter {
|
interface ScrollbarAdapter {
|
||||||
@@ -112,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
|
size of its VISIBLE items, so exact pixel geometry of off-screen content is
|
||||||
unknowable — we estimate it from the average visible item size × total item
|
unknowable — we estimate it from the average visible item size × total item
|
||||||
count (the same approach as Compose Desktop). Accurate enough for a thumb
|
count (the same approach as Compose Desktop). Accurate enough for a thumb
|
||||||
@@ -141,6 +144,19 @@ private class LazyListScrollbarAdapter(private val state: LazyListState) : Scrol
|
|||||||
get() = (state.layoutInfo.viewportEndOffset - state.layoutInfo.viewportStartOffset).toDouble()
|
get() = (state.layoutInfo.viewportEndOffset - state.layoutInfo.viewportStartOffset).toDouble()
|
||||||
|
|
||||||
override suspend fun scrollTo(scrollOffset: Double) {
|
override suspend fun scrollTo(scrollOffset: Double) {
|
||||||
|
val vDistance = scrollOffset - this.scrollOffset
|
||||||
|
// Within a viewport, scroll by EXACT pixels so a drag stays smooth. A lazy
|
||||||
|
// list's pixel geometry is only ESTIMATED (average visible item size), and
|
||||||
|
// that estimate drifts as differently-sized items scroll through the
|
||||||
|
// viewport — so index-snapping every frame makes the thumb jump. scrollBy
|
||||||
|
// sidesteps the estimate entirely. This is what Compose Desktop's own
|
||||||
|
// LazyListScrollbarAdapter does, and for the same reason.
|
||||||
|
if (abs(vDistance) <= viewportSize) {
|
||||||
|
state.scrollBy(vDistance.toFloat())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Far jump (track-click paging past a viewport): no exact geometry to lean
|
||||||
|
// on, so estimate an index/offset from the average and snap to it.
|
||||||
val vAvg = averageItemSize()
|
val vAvg = averageItemSize()
|
||||||
if (vAvg <= 0.0) return
|
if (vAvg <= 0.0) return
|
||||||
val vTarget = scrollOffset.coerceAtLeast(0.0)
|
val vTarget = scrollOffset.coerceAtLeast(0.0)
|
||||||
@@ -173,7 +189,7 @@ fun HorizontalScrollbar(
|
|||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = false)
|
) = 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,
|
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
|
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
|
track-relative coordinates (a stable origin), so the grab point stays under
|
||||||
@@ -193,7 +209,6 @@ private fun Scrollbar(
|
|||||||
val vHoverSource = remember { MutableInteractionSource() }
|
val vHoverSource = remember { MutableInteractionSource() }
|
||||||
val vHovered by vHoverSource.collectIsHoveredAsState()
|
val vHovered by vHoverSource.collectIsHoveredAsState()
|
||||||
var vDraggingThumb by remember { mutableStateOf(false) }
|
var vDraggingThumb by remember { mutableStateOf(false) }
|
||||||
var vGrab by remember { mutableStateOf(0) }
|
|
||||||
// onSizeChanged reports PHYSICAL pixels (Option-B density flow), so every
|
// onSizeChanged reports PHYSICAL pixels (Option-B density flow), so every
|
||||||
// int/px carried through here — vTrackPx, vThumbLen, vThumbPos — is in
|
// int/px carried through here — vTrackPx, vThumbLen, vThumbPos — is in
|
||||||
// physical pixels. Passing them into `.dp` doubles again through
|
// physical pixels. Passing them into `.dp` doubles again through
|
||||||
@@ -230,15 +245,6 @@ private fun Scrollbar(
|
|||||||
val vRawPos = if (vTravel <= 0) 0 else (vTravel * (vOffset / vMaxScroll)).roundToInt()
|
val vRawPos = if (vTravel <= 0) 0 else (vTravel * (vOffset / vMaxScroll)).roundToInt()
|
||||||
val vThumbPos = (if (reverseLayout) vTravel - vRawPos else vRawPos).coerceIn(0, vTravel)
|
val vThumbPos = (if (reverseLayout) vTravel - vRawPos else vRawPos).coerceIn(0, vTravel)
|
||||||
|
|
||||||
// ============
|
|
||||||
// Map a track-relative position to a scroll offset and apply it.
|
|
||||||
fun scrollToTrackPos(inThumbTop: Int) {
|
|
||||||
if (vTravel <= 0) return
|
|
||||||
val vPos = inThumbTop.coerceIn(0, vTravel)
|
|
||||||
val vFrac = (if (reverseLayout) vTravel - vPos else vPos).toDouble() / vTravel
|
|
||||||
vScope.launch { adapter.scrollTo(vFrac * vMaxScroll) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val vThumbLenDp = with(vDensity) { vThumbLen.toDp() }
|
val vThumbLenDp = with(vDensity) { vThumbLen.toDp() }
|
||||||
val vThumbMod = (
|
val vThumbMod = (
|
||||||
if (isVertical) Modifier.offset { IntOffset(0, vThumbPos) }.width(style.thickness).height(vThumbLenDp)
|
if (isVertical) Modifier.offset { IntOffset(0, vThumbPos) }.width(style.thickness).height(vThumbLenDp)
|
||||||
@@ -251,39 +257,84 @@ private fun Scrollbar(
|
|||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Drag the thumb / page on track click — handled on the track so the
|
// Drag the thumb / page on track click — handled on the track so the
|
||||||
// coordinates have a stable (non-moving) origin.
|
// coordinates have a stable (non-moving) origin. The press is CONSUMED:
|
||||||
|
// a scrollbar owns the pointer input it handles, so content sharing this
|
||||||
|
// region (the scrollable beneath, or a parent that also reads pointer
|
||||||
|
// input) never reacts to the same press. Geometry is read LIVE from the
|
||||||
|
// adapter at press time — not from the captured render metrics above,
|
||||||
|
// which go stale between pointerInput relaunches — so a grab after a
|
||||||
|
// wheel-scroll still hit-tests against the thumb's current position.
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.matchTrackSize(isVertical, with(vDensity) { vTrackPx.toDp() }, style.thickness)
|
.matchTrackSize(isVertical, with(vDensity) { vTrackPx.toDp() }, style.thickness)
|
||||||
.pointerInput(isVertical, adapter, vTrackPx) {
|
.pointerInput(isVertical, adapter, vTrackPx, reverseLayout) {
|
||||||
detectDragGestures(
|
awaitEachGesture {
|
||||||
onDragStart = { offset ->
|
val vDown = awaitFirstDown(requireUnconsumed = false)
|
||||||
val vPress = (if (isVertical) offset.y else offset.x).toInt()
|
vDown.consume()
|
||||||
if (vPress in vThumbPos..(vThumbPos + vThumbLen)) {
|
|
||||||
vDraggingThumb = true
|
// Live geometry — stable for the drag's duration (content /
|
||||||
vGrab = vPress - vThumbPos
|
// viewport / track sizes don't change mid-drag).
|
||||||
} else {
|
val vContentPx = adapter.contentSize
|
||||||
// Page toward the click by one viewport.
|
val vViewportPx = adapter.viewportSize
|
||||||
vDraggingThumb = false
|
val vMaxScrollPx = vContentPx - vViewportPx
|
||||||
val vDir = if (vPress < vThumbPos) -1.0 else 1.0
|
if (vMaxScrollPx <= 0.0 || vTrackPx <= 0) return@awaitEachGesture
|
||||||
vScope.launch { adapter.scrollTo(vOffset + vDir * vViewport) }
|
val vMinLen = style.minimalHeight.value.toInt()
|
||||||
}
|
val vLen = (vTrackPx * (vViewportPx / vContentPx)).roundToInt()
|
||||||
},
|
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
|
||||||
onDrag = { change, _ ->
|
val vTravelPx = vTrackPx - vLen
|
||||||
|
val vRaw = if (vTravelPx <= 0) 0
|
||||||
|
else (vTravelPx * (adapter.scrollOffset / vMaxScrollPx)).roundToInt()
|
||||||
|
val vPos = (if (reverseLayout) vTravelPx - vRaw else vRaw).coerceIn(0, vTravelPx)
|
||||||
|
|
||||||
|
// Map a track-relative thumb-top to a scroll offset. The
|
||||||
|
// target uses the LIVE max scroll (read fresh each move),
|
||||||
|
// not the press-time value — the adapter compares it against
|
||||||
|
// its live scrollOffset, and mixing a stale scale with a live
|
||||||
|
// one makes the delta run away (grab at the bottom, drag up,
|
||||||
|
// and the list overshoots to the middle).
|
||||||
|
fun scrollToTrackPos(inThumbTop: Int) {
|
||||||
|
if (vTravelPx <= 0) return
|
||||||
|
val vLiveMax = adapter.contentSize - adapter.viewportSize
|
||||||
|
if (vLiveMax <= 0.0) return
|
||||||
|
val vClamped = inThumbTop.coerceIn(0, vTravelPx)
|
||||||
|
val vFrac = (if (reverseLayout) vTravelPx - vClamped else vClamped).toDouble() / vTravelPx
|
||||||
|
vScope.launch { adapter.scrollTo(vFrac * vLiveMax) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val vPress = (if (isVertical) vDown.position.y else vDown.position.x).toInt()
|
||||||
|
val vGrab: Int
|
||||||
|
if (vPress in vPos..(vPos + vLen)) {
|
||||||
|
vDraggingThumb = true
|
||||||
|
vGrab = vPress - vPos
|
||||||
|
} else {
|
||||||
|
// Page toward the click by one viewport.
|
||||||
|
vDraggingThumb = false
|
||||||
|
vGrab = 0
|
||||||
|
val vDir = if (vPress < vPos) -1.0 else 1.0
|
||||||
|
vScope.launch { adapter.scrollTo(adapter.scrollOffset + vDir * vViewportPx) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow the pressed pointer to release, consuming every
|
||||||
|
// change so the interaction can't leak. Only a thumb grab
|
||||||
|
// drags-to-scroll; a track press just consumes.
|
||||||
|
do {
|
||||||
|
val vEvent = awaitPointerEvent()
|
||||||
|
val vChange = vEvent.changes.firstOrNull { it.id == vDown.id } ?: break
|
||||||
if (vDraggingThumb) {
|
if (vDraggingThumb) {
|
||||||
val vCur = (if (isVertical) change.position.y else change.position.x).toInt()
|
val vCur = (if (isVertical) vChange.position.y else vChange.position.x).toInt()
|
||||||
scrollToTrackPos(vCur - vGrab)
|
scrollToTrackPos(vCur - vGrab)
|
||||||
}
|
}
|
||||||
},
|
vChange.consume()
|
||||||
onDragEnd = { vDraggingThumb = false },
|
} while (vEvent.changes.any { it.id == vDown.id && it.pressed })
|
||||||
onDragCancel = { vDraggingThumb = false },
|
|
||||||
)
|
vDraggingThumb = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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
|
comes from the caller pre-converted via LocalDensity (Option-B: layout
|
||||||
coords are physical px, so px-to-dp goes through the current density). */
|
coords are physical px, so px-to-dp goes through the current density). */
|
||||||
private fun Modifier.matchTrackSize(inVertical: Boolean, inTrackDp: Dp, inThickness: Dp): Modifier =
|
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
|
// MARK: IconText — project-only text composable for icon fonts
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
A minimal project text composable that renders a codepoint through a named
|
A minimal project text composable that renders a codepoint through a named
|
||||||
icon font with optional variable-font axis settings (Material Symbols).
|
icon font with optional variable-font axis settings (Material Symbols).
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
// MARK: SelectionRect
|
// 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.
|
Internal render glue shared by BasicText (selection-aware) and BasicTextField.
|
||||||
Sizes come from Paragraph.getBoundingBox / getPathForRange — already pixels
|
Sizes come from Paragraph.getBoundingBox / getPathForRange — already pixels
|
||||||
(see § HiDPI in CLAUDE.md), so use the pixel-based offset + a raw Layout
|
(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
|
// 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. */
|
compose root is never inside a platform scrollable container. */
|
||||||
internal actual val TapIndicationDelay: Long = 0L
|
internal actual val TapIndicationDelay: Long = 0L
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import androidx.compose.runtime.CompositionLocalAccessorScope
|
|||||||
// MARK: Overscroll actuals — desktop no-op
|
// MARK: Overscroll actuals — desktop no-op
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Actuals for vendored Overscroll.kt's two expect declarations. Desktop/native
|
Actuals for vendored Overscroll.kt's two expect declarations. Desktop/native
|
||||||
has no built-in overscroll animation (Android/iOS use platform-native bounce);
|
has no built-in overscroll animation (Android/iOS use platform-native bounce);
|
||||||
both actuals return null, matching upstream's desktopMain actual.
|
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.draw.DrawResult
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
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`,
|
* Upstream's skiko actuals use Skiko-JVM extensions (`asComposeCanvas`,
|
||||||
* `skiaCanvas`, `PictureRecorder`) plus `PointerMatcher.mouse(...)` from the
|
* `skiaCanvas`, `PictureRecorder`) plus `PointerMatcher.mouse(...)` from the
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import androidx.compose.runtime.Composable
|
|||||||
// MARK: ContextMenu — native actuals
|
// MARK: ContextMenu — native actuals
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Mirrors upstream macosMain / desktopMain / iosMain — all three overloads
|
Mirrors upstream macosMain / desktopMain / iosMain — all three overloads
|
||||||
delegate to CommonContextMenuArea (vendored). This unblocks vendored
|
delegate to CommonContextMenuArea (vendored). This unblocks vendored
|
||||||
BasicTextField / SelectionContainer / CoreTextField call sites.
|
BasicTextField / SelectionContainer / CoreTextField call sites.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||||||
// MARK: CoreTextField / TextFieldKeyInput / TextFieldPointerModifier — native actuals
|
// MARK: CoreTextField / TextFieldKeyInput / TextFieldPointerModifier — native actuals
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Byte-identical mirror of upstream macosMain — all three CoreTextField
|
Byte-identical mirror of upstream macosMain — all three CoreTextField
|
||||||
expects delegate to default helpers baked into CoreTextField.kt /
|
expects delegate to default helpers baked into CoreTextField.kt /
|
||||||
TextFieldPointerModifier.common.kt. `isTypedEvent` uses the desktop-shape
|
TextFieldPointerModifier.common.kt. `isTypedEvent` uses the desktop-shape
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import kotlin.native.Platform
|
|||||||
// MARK: platformDefaultKeyMapping — native actual
|
// MARK: platformDefaultKeyMapping — native actual
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Actual for vendored KeyMapping.kt's expect val. Upstream ships this per
|
Actual for vendored KeyMapping.kt's expect val. Upstream ships this per
|
||||||
platform (macos: createMacOsDefaultKeyMapping, ios: create.../etc). We use
|
platform (macos: createMacOsDefaultKeyMapping, ios: create.../etc). We use
|
||||||
Kotlin/Native's `Platform.osFamily` to pick at startup:
|
Kotlin/Native's `Platform.osFamily` to pick at startup:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package androidx.compose.foundation.text
|
|||||||
// MARK: StringHelpers native actuals
|
// MARK: StringHelpers native actuals
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Actuals for vendored StringHelpers.kt. Upstream's nonJvm actual calls
|
Actuals for vendored StringHelpers.kt. Upstream's nonJvm actual calls
|
||||||
StringBuilder.appendCodePoint() which is a JVM-only extension; on
|
StringBuilder.appendCodePoint() which is a JVM-only extension; on
|
||||||
Kotlin/Native, StringBuilder holds UTF-16 code units, so we encode
|
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
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Byte-identical extract of `internal fun CharSequence.offsetByCodePoints`
|
Byte-identical extract of `internal fun CharSequence.offsetByCodePoints`
|
||||||
from upstream `foundation.text.StringHelpers.skiko.kt`. The skiko file
|
from upstream `foundation.text.StringHelpers.skiko.kt`. The skiko file
|
||||||
pulls org.jetbrains.skia.BreakIterator, so we can't vendor it whole; this
|
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
|
// MARK: TextFieldScroll — native actuals
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Byte-identical mirror of upstream desktopMain — no overscroll on desktop,
|
Byte-identical mirror of upstream desktopMain — no overscroll on desktop,
|
||||||
textFieldScroll delegates to `defaultTextFieldScroll` (a private helper in
|
textFieldScroll delegates to `defaultTextFieldScroll` (a private helper in
|
||||||
the vendored `TextFieldScroll.kt`).
|
the vendored `TextFieldScroll.kt`).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import androidx.compose.ui.text.TextRange
|
|||||||
// MARK: TextFieldCoreModifier — native actuals
|
// MARK: TextFieldCoreModifier — native actuals
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Byte-identical mirror of upstream macosMain — both delegate to the default
|
Byte-identical mirror of upstream macosMain — both delegate to the default
|
||||||
selection-highlight and cursor draw helpers baked into the vendored
|
selection-highlight and cursor draw helpers baked into the vendored
|
||||||
TextFieldCoreModifier.kt.
|
TextFieldCoreModifier.kt.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package androidx.compose.foundation.text.input.internal
|
|||||||
// MARK: createTextFieldKeyEventHandler — native actual
|
// MARK: createTextFieldKeyEventHandler — native actual
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Mirrors upstream macosMain / desktopMain / iosMain / webMain — all
|
Mirrors upstream macosMain / desktopMain / iosMain / webMain — all
|
||||||
delegate to `createSkikoTextFieldKeyEventHandler` from the already-vendored
|
delegate to `createSkikoTextFieldKeyEventHandler` from the already-vendored
|
||||||
`TextFieldKeyEventHandler.skiko.kt`.
|
`TextFieldKeyEventHandler.skiko.kt`.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import androidx.compose.foundation.text.input.internal.TransformedTextFieldState
|
|||||||
// MARK: TextFieldMagnifierNode — native actual (no-op)
|
// MARK: TextFieldMagnifierNode — native actual (no-op)
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Mirrors upstream macosMain / desktopMain — no magnifier on desktop.
|
Mirrors upstream macosMain / desktopMain — no magnifier on desktop.
|
||||||
*/
|
*/
|
||||||
internal actual fun textFieldMagnifierNode(
|
internal actual fun textFieldMagnifierNode(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
// MARK: TextFieldSelectionState — native actuals
|
// MARK: TextFieldSelectionState — native actuals
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Byte-close mirror of upstream macosMain — desktop tap / selection gestures
|
Byte-close mirror of upstream macosMain — desktop tap / selection gestures
|
||||||
delegate to the default helpers baked into TextFieldSelectionState.
|
delegate to the default helpers baked into TextFieldSelectionState.
|
||||||
addBasicTextFieldTextContextMenuComponents = no-op (TODO CMP-7819).
|
addBasicTextFieldTextContextMenuComponents = no-op (TODO CMP-7819).
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import androidx.compose.ui.layout.LayoutCoordinates
|
|||||||
// MARK: makeSelectionModifier — native actual
|
// MARK: makeSelectionModifier — native actual
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Mirrors upstream desktopMain / macosMain / iosMain actuals — all just
|
Mirrors upstream desktopMain / macosMain / iosMain actuals — all just
|
||||||
delegate to `makeDefaultSelectionModifier`. That helper lives inside
|
delegate to `makeDefaultSelectionModifier`. That helper lives inside
|
||||||
`SelectionController.kt` (vendored) and installs a SelectionModifierNode
|
`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.
|
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.
|
time — accept both, matches Ctrl+C on Windows/Linux and Cmd+C on macOS.
|
||||||
Also honours the dedicated Copy key. */
|
Also honours the dedicated Copy key. */
|
||||||
internal actual fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean =
|
internal actual fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean =
|
||||||
keyEvent.key == Key.Copy ||
|
keyEvent.key == Key.Copy ||
|
||||||
(keyEvent.key == Key.C && (keyEvent.isMetaPressed || keyEvent.isCtrlPressed))
|
(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
|
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
|
items yet. TODO(CMP-7819): wire the paste-into-selection / clear
|
||||||
/ select-all extras through here when the toolbar lands. */
|
/ select-all extras through here when the toolbar lands. */
|
||||||
internal actual fun Modifier.addSelectionContainerTextContextMenuComponents(
|
internal actual fun Modifier.addSelectionContainerTextContextMenuComponents(
|
||||||
selectionManager: SelectionManager
|
selectionManager: SelectionManager
|
||||||
): Modifier = this
|
): 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
|
internal actual val FirstLongPressSelectionAdjustment: SelectionAdjustment
|
||||||
get() = SelectionAdjustment.Word
|
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.
|
mouse drag directly extends the selection, no handle bubble.
|
||||||
TODO: If touch input lands (SDL_EVENT_FINGER_*), route to a real
|
TODO: If touch input lands (SDL_EVENT_FINGER_*), route to a real
|
||||||
handle Composable driven by SelectionHandleInfo. */
|
handle Composable driven by SelectionHandleInfo. */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
// MARK: TextFieldSelectionManager — native actuals
|
// MARK: TextFieldSelectionManager — native actuals
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Byte-identical mirror of upstream macosMain — magnification and toolbar
|
Byte-identical mirror of upstream macosMain — magnification and toolbar
|
||||||
context-menu components are desktop no-ops. `isSelectionHandleInVisibleBound`
|
context-menu components are desktop no-ops. `isSelectionHandleInVisibleBound`
|
||||||
delegates to the default-inside-visible-rect check (defined in the vendored
|
delegates to the default-inside-visible-rect check (defined in the vendored
|
||||||
|
|||||||