diff --git a/.github/ci/apple-checks.json b/.github/ci/apple-checks.json index ec63e4e97c1..a0a9f2895ca 100644 --- a/.github/ci/apple-checks.json +++ b/.github/ci/apple-checks.json @@ -48,6 +48,7 @@ "scripts/unity-compat-samples/gallery/**", "maven/cil-translator/**", "maven/unity-compat/**", + "maven/compose-compat/**", "maven/build-engine/src/main/java/com/codename1/maven/Unity*", "maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*", "scripts/unity-gallery-prebuilt.sh", @@ -102,6 +103,7 @@ "scripts/unity-compat-samples/gallery/**", "maven/cil-translator/**", "maven/unity-compat/**", + "maven/compose-compat/**", "maven/build-engine/src/main/java/com/codename1/maven/Unity*", "maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*", "scripts/unity-gallery-prebuilt.sh", @@ -145,6 +147,7 @@ "scripts/unity-compat-samples/gallery/**", "maven/cil-translator/**", "maven/unity-compat/**", + "maven/compose-compat/**", "maven/build-engine/src/main/java/com/codename1/maven/Unity*", "maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*", "scripts/unity-gallery-prebuilt.sh", @@ -186,6 +189,7 @@ "scripts/unity-compat-samples/gallery/**", "maven/cil-translator/**", "maven/unity-compat/**", + "maven/compose-compat/**", "maven/build-engine/src/main/java/com/codename1/maven/Unity*", "maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*", "scripts/unity-gallery-prebuilt.sh", @@ -214,6 +218,7 @@ "scripts/unity-compat-samples/gallery/**", "maven/cil-translator/**", "maven/unity-compat/**", + "maven/compose-compat/**", "maven/build-engine/src/main/java/com/codename1/maven/Unity*", "maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*", "scripts/unity-gallery-prebuilt.sh", @@ -240,6 +245,7 @@ "scripts/unity-compat-samples/gallery/**", "maven/cil-translator/**", "maven/unity-compat/**", + "maven/compose-compat/**", "maven/build-engine/src/main/java/com/codename1/maven/Unity*", "maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*", "scripts/unity-gallery-prebuilt.sh", @@ -284,6 +290,7 @@ "scripts/unity-compat-samples/gallery/**", "maven/cil-translator/**", "maven/unity-compat/**", + "maven/compose-compat/**", "maven/build-engine/src/main/java/com/codename1/maven/Unity*", "maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*", "scripts/unity-gallery-prebuilt.sh", @@ -320,6 +327,7 @@ "scripts/unity-compat-samples/gallery/**", "maven/cil-translator/**", "maven/unity-compat/**", + "maven/compose-compat/**", "maven/build-engine/src/main/java/com/codename1/maven/Unity*", "maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*", "scripts/unity-gallery-prebuilt.sh", diff --git a/.github/workflows/archetype-smoke.yml b/.github/workflows/archetype-smoke.yml index 424545f63ce..9b6d522cfcc 100644 --- a/.github/workflows/archetype-smoke.yml +++ b/.github/workflows/archetype-smoke.yml @@ -73,7 +73,9 @@ concurrency: jobs: archetype-smoke: runs-on: ubuntu-24.04 - timeout-minutes: 30 + # A green run takes about 28 minutes (the reactor build alone is 8-9), so 30 left + # no room for a slow runner and cancelled healthy runs mid-step. + timeout-minutes: 45 steps: - uses: actions/checkout@v6 # A JDK that is NOT 8, for the `cn1:backend-package` check at the end of the diff --git a/.github/workflows/car-android.yml b/.github/workflows/car-android.yml index 33a19b2588e..e2a9533aba5 100644 --- a/.github/workflows/car-android.yml +++ b/.github/workflows/car-android.yml @@ -13,6 +13,7 @@ name: Car (Android Auto) integration - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -46,6 +47,7 @@ name: Car (Android Auto) integration - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' diff --git a/.github/workflows/car-ios.yml b/.github/workflows/car-ios.yml index caf083dcf16..fa8e248dc9b 100644 --- a/.github/workflows/car-ios.yml +++ b/.github/workflows/car-ios.yml @@ -14,6 +14,7 @@ name: Car (CarPlay) integration - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -37,6 +38,7 @@ name: Car (CarPlay) integration - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' diff --git a/.github/workflows/check-doc-snippets.yml b/.github/workflows/check-doc-snippets.yml index 4dea0bf5d2f..f7130371a8a 100644 --- a/.github/workflows/check-doc-snippets.yml +++ b/.github/workflows/check-doc-snippets.yml @@ -17,6 +17,7 @@ on: - 'scripts/savor/**' - 'scripts/bastion/**' - 'scripts/skirmish/**' + - 'scripts/hellocodenameone/common/src/main/kmp/**' - 'docs/website/content/blog/**' - 'CodenameOne/src/**/*.java' - 'Ports/CLDC11/src/**/*.java' diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index 0a7b8557278..8bfbe259398 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -21,6 +21,7 @@ on: - 'scripts/bastion/**' - 'scripts/skirmish/**' - 'scripts/parley/**' + - 'scripts/hellocodenameone/common/src/main/kmp/**' - 'scripts/reel/**' # The API-name check derives the published pages from the javadoc's own # sources, and the guide links into the java.* compatibility classes -- @@ -133,7 +134,7 @@ jobs: - 'Ports/JavaSE/**' - 'maven/core/**' - 'maven/javase/**' - # The Wayline, Savor, Parley and Bastion chapters include these trees by tag, so a + # The Wayline, Savor, Parley, Bastion and Kotlin Multiplatform chapters include these trees by tag, so a # renamed or deleted tag there has to reach the snippet check. - 'scripts/wayline/**' - 'scripts/savor/**' @@ -141,6 +142,7 @@ jobs: - 'scripts/parley/**' - 'scripts/reel/**' - 'scripts/bastion/**' + - 'scripts/hellocodenameone/common/src/main/kmp/**' workflow: - '.github/workflows/developer-guide-docs.yml' diff --git a/.github/workflows/health-android.yml b/.github/workflows/health-android.yml index 3a4c7bca2db..7bf02cee531 100644 --- a/.github/workflows/health-android.yml +++ b/.github/workflows/health-android.yml @@ -20,6 +20,7 @@ name: Health (Health Connect) integration - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -45,6 +46,7 @@ name: Health (Health Connect) integration - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' diff --git a/.github/workflows/kmp-compat.yml b/.github/workflows/kmp-compat.yml new file mode 100644 index 00000000000..4353b14bb6e --- /dev/null +++ b/.github/workflows/kmp-compat.yml @@ -0,0 +1,106 @@ +name: Kotlin Multiplatform compatibility + +# Compose Multiplatform code built as a Codename One application: the Compose UI +# layer written over Codename One (maven/compose-compat, which carries the real +# Compose runtime and kotlinx.coroutines as they are), the headless harness its +# tests run on (maven/compat-testing) and the build steps that import and +# package it (build-engine, the Maven and Gradle plugins, the archetype's +# compose profile). +# +# unit tests layout, recomposition, text and input, and the +# benchmark's Keynote application driven screen by +# screen, headless; JDK 17, as the Compose runtime's +# inline functions are JVM 11 bytecode +# kmp-compat-test.sh every sample under scripts/kmp-compat-samples +# imported unmodified, built by Maven and Gradle with +# the Compose compiler, and staged for iOS, Android, +# JavaScript and the desktop with identical uploads +# from both tools +# +# No display anywhere: the CSS compiler is headless, and so is every test. +on: + pull_request: + branches: [ master ] + paths: + - 'maven/compose-compat/**' + - 'maven/compat-testing/**' + - 'maven/build-engine/**' + - 'maven/codenameone-maven-plugin/**' + - 'maven/codenameone-gradle-plugin/**' + - 'maven/project-model/**' + - 'maven/cn1app-archetype/**' + - 'maven/pom.xml' + - 'maven/integration-tests/kmp-compat-test.sh' + - 'maven/integration-tests/inc/**' + - 'scripts/kmp-compat-samples/**' + # The unit tests drive the benchmark's shared application. + - 'scripts/kmp-bench/app/shared/**' + - '.github/workflows/kmp-compat.yml' + push: + branches: [ master ] + paths: + - 'maven/compose-compat/**' + - 'maven/compat-testing/**' + - 'maven/build-engine/**' + - 'maven/codenameone-maven-plugin/**' + - 'maven/codenameone-gradle-plugin/**' + - 'maven/project-model/**' + - 'maven/cn1app-archetype/**' + - 'maven/pom.xml' + - 'maven/integration-tests/kmp-compat-test.sh' + - 'maven/integration-tests/inc/**' + - 'scripts/kmp-compat-samples/**' + - 'scripts/kmp-bench/app/shared/**' + - '.github/workflows/kmp-compat.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + kmp-compat: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CN1_GRADLE_REQUIRED: '1' + steps: + - uses: actions/checkout@v6 + # Installed FIRST so the JDK 8 below is the one JAVA_HOME names. + - name: Set up JDK 17 for Gradle and the Compose tests + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + - run: echo "GRADLE_JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + java-version: '8' + distribution: 'temurin' + cache: 'maven' + - name: Build Codename One Maven artifacts (includes archetypes and both plugins) + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + cd maven + bash ../scripts/ci/retry.sh mvn install -Plocal-dev-javase -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true + - name: Unit tests + working-directory: maven + run: JAVA_HOME="$GRADLE_JAVA_HOME" mvn -B -Plocal-dev-javase -pl compose-compat test + - name: Refresh archetype catalog + run: | + cd maven + mvn archetype:update-local-catalog -Plocal-dev-javase + mvn archetype:crawl -Plocal-dev-javase + - name: Kotlin Multiplatform samples, imported and built by Maven and Gradle + working-directory: maven/integration-tests + run: bash kmp-compat-test.sh + - name: Upload the test projects' logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: kmp-compat-logs + path: maven/integration-tests/build/kmp-compat/**/*.log + if-no-files-found: ignore diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index ada0e96fdd6..faf61d9f108 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -37,6 +37,7 @@ on: - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -76,6 +77,7 @@ on: - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -162,7 +164,7 @@ jobs: run: | cd maven JAVA_HOME="$JDK_8_HOME" bash ../scripts/ci/retry.sh \ - mvn -B -pl codenameone-maven-plugin,cn1-ads-mock,backend,backend-test -am \ + mvn -B -pl codenameone-maven-plugin,cn1-ads-mock,compose-compat,backend,backend-test -am \ -DskipTests '-Dmaven.javadoc.skip=true' '-Plocal-dev-javase' install # After everything that fills the local Maven repository and before the @@ -233,6 +235,20 @@ jobs: - name: Install clang and time run: bash scripts/ci/apt-get-install.sh clang time + # The suite's C build compiles ~7,000 translation units, several of them + # large, with full debug info. On ubuntu-24.04-arm it exhausted memory and + # the runner was shut down mid-build, twice at the same step; extra swap + # lets the peak spill instead of taking the machine down. + - name: Add swap (arm64) + if: matrix.arch == 'arm64' + run: | + free -m + sudo fallocate -l 12G /cn1-swapfile + sudo chmod 600 /cn1-swapfile + sudo mkswap /cn1-swapfile + sudo swapon /cn1-swapfile + free -m + - name: Set up JDK 8 uses: actions/setup-java@v5 with: { distribution: 'temurin', java-version: '8' } diff --git a/.github/workflows/parparvm-tests-windows.yml b/.github/workflows/parparvm-tests-windows.yml index 63893065026..204b568b0a5 100644 --- a/.github/workflows/parparvm-tests-windows.yml +++ b/.github/workflows/parparvm-tests-windows.yml @@ -30,6 +30,7 @@ on: - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -63,6 +64,7 @@ on: - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -444,7 +446,7 @@ jobs: Write-Host "mvn failed; retrying in $delay s..." Start-Sleep -Seconds $delay } - mvn -B -pl windows,codenameone-maven-plugin,cn1-ads-mock,backend,backend-test -am -DskipTests '-Dmaven.javadoc.skip=true' '-Plocal-dev-javase' install + mvn -B -pl windows,codenameone-maven-plugin,cn1-ads-mock,compose-compat,backend,backend-test -am -DskipTests '-Dmaven.javadoc.skip=true' '-Plocal-dev-javase' install if ($LASTEXITCODE -eq 0) { $ok = $true; break } } if (-not $ok) { throw "mvn install failed after all retries" } diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c1f925931e2..0efe70b9db7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -402,7 +402,7 @@ jobs: -Dcn1.binaries="${CN1_BINARIES}" \ -Pcompile-android \ -DbackendAnalysis \ - -pl android,ios,project-model,build-engine,codenameone-maven-plugin,codenameone-gradle-plugin,backend,backend-test,backend-parparvm,javac,android-res-compiler,android-compat,swing-compat,javafx-compat,fxml-compiler,desktop-compat-runtime -am verify + -pl android,ios,project-model,build-engine,codenameone-maven-plugin,codenameone-gradle-plugin,backend,backend-test,backend-parparvm,javac,android-res-compiler,android-compat,swing-compat,javafx-compat,compose-compat,fxml-compiler,desktop-compat-runtime -am verify - name: Type-check the SQLCipher-backed database package if: ${{ matrix.java-version == 8 }} env: @@ -620,7 +620,7 @@ jobs: - name: Generate static analysis HTML summaries if: ${{ !cancelled() && matrix.java-version == 8 }} env: - QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-engine/target:maven/project-model/target:maven/codenameone-gradle-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-test/target:maven/backend-parparvm/target:maven/javac/target:maven/android-res-compiler/target:maven/android-compat/target:maven/swing-compat/target:maven/javafx-compat/target:maven/fxml-compiler/target:maven/desktop-compat-runtime/target + QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-engine/target:maven/project-model/target:maven/codenameone-gradle-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-test/target:maven/backend-parparvm/target:maven/javac/target:maven/android-res-compiler/target:maven/android-compat/target:maven/swing-compat/target:maven/javafx-compat/target:maven/compose-compat/target:maven/fxml-compiler/target:maven/desktop-compat-runtime/target QUALITY_REPORT_SERVER_URL: ${{ github.server_url }} QUALITY_REPORT_REPOSITORY: ${{ github.repository }} QUALITY_REPORT_REF: ${{ github.event.pull_request.head.sha || github.sha }} @@ -644,6 +644,7 @@ jobs: "android-compat:maven/android-compat/target" \ "swing-compat:maven/swing-compat/target" \ "javafx-compat:maven/javafx-compat/target" \ + "compose-compat:maven/compose-compat/target" \ "fxml-compiler:maven/fxml-compiler/target" \ "desktop-compat-runtime:maven/desktop-compat-runtime/target" \ "project-model:maven/project-model/target" \ @@ -776,11 +777,11 @@ jobs: - name: Generate quality report summary if: ${{ !cancelled() && matrix.java-version == 8 }} env: - QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-engine/target:maven/project-model/target:maven/codenameone-gradle-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-test/target:maven/backend-parparvm/target:maven/javac/target:maven/android-res-compiler/target:maven/android-compat/target:maven/swing-compat/target:maven/javafx-compat/target:maven/fxml-compiler/target:maven/desktop-compat-runtime/target + QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-engine/target:maven/project-model/target:maven/codenameone-gradle-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-test/target:maven/backend-parparvm/target:maven/javac/target:maven/android-res-compiler/target:maven/android-compat/target:maven/swing-compat/target:maven/javafx-compat/target:maven/compose-compat/target:maven/fxml-compiler/target:maven/desktop-compat-runtime/target # Every project listed here must produce a SpotBugs report and that # report must be empty. A missing report fails the gate too, so a # SpotBugs run that silently stops running is not mistaken for clean. - QUALITY_REPORT_REQUIRED_SPOTBUGS: core-unittests:android:ios:ByteCodeTranslator:codenameone-maven-plugin:build-engine:project-model:codenameone-gradle-plugin:build-hint-catalog:build-hint-tools:backend:backend-test:javac:android-res-compiler:android-compat:swing-compat:javafx-compat:fxml-compiler:desktop-compat-runtime + QUALITY_REPORT_REQUIRED_SPOTBUGS: core-unittests:android:ios:ByteCodeTranslator:codenameone-maven-plugin:build-engine:project-model:codenameone-gradle-plugin:build-hint-catalog:build-hint-tools:backend:backend-test:javac:android-res-compiler:android-compat:swing-compat:javafx-compat:compose-compat:fxml-compiler:desktop-compat-runtime # PMD and Checkstyle run on the core and on the backend runtime, and each # must produce its report: without one, the PMD gate below it # has nothing to read and passes. diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index 7b6986efa41..49a2faa393d 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -15,6 +15,7 @@ name: Test Android build scripts - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -72,6 +73,7 @@ name: Test Android build scripts - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml index 571c51dd780..65d3d66239a 100644 --- a/.github/workflows/scripts-javascript.yml +++ b/.github/workflows/scripts-javascript.yml @@ -20,6 +20,7 @@ on: - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -76,6 +77,7 @@ on: - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' diff --git a/.github/workflows/scripts-macos.yml b/.github/workflows/scripts-macos.yml index efd1bdec976..988651ebeec 100644 --- a/.github/workflows/scripts-macos.yml +++ b/.github/workflows/scripts-macos.yml @@ -27,6 +27,7 @@ on: - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -91,6 +92,7 @@ on: - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' diff --git a/.github/workflows/unity-compat.yml b/.github/workflows/unity-compat.yml index 81600bdabb4..4ee9a2a25f2 100644 --- a/.github/workflows/unity-compat.yml +++ b/.github/workflows/unity-compat.yml @@ -330,7 +330,7 @@ jobs: run: | rm -rf ~/.m2/repository/com/codenameone/codenameone-unity-compat ~/.m2/repository/com/codenameone/codenameone-cil-translator cd maven - bash ../scripts/ci/retry.sh xvfb-run -a mvn -B install -Plocal-dev-javase -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true -pl codenameone-maven-plugin,cn1-ads-mock,android-compat -am + bash ../scripts/ci/retry.sh xvfb-run -a mvn -B install -Plocal-dev-javase -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true -pl codenameone-maven-plugin,cn1-ads-mock,compose-compat,android-compat -am test ! -e ~/.m2/repository/com/codenameone/codenameone-unity-compat - name: Unpack the Unity gallery prebuilt uses: ./.github/actions/unity-gallery-prebuilt diff --git a/.github/workflows/unity-gallery-prebuilt.yml b/.github/workflows/unity-gallery-prebuilt.yml index 29e9859de4b..1ffae407378 100644 --- a/.github/workflows/unity-gallery-prebuilt.yml +++ b/.github/workflows/unity-gallery-prebuilt.yml @@ -112,7 +112,7 @@ jobs: if: steps.pack.outputs.cache-hit != 'true' run: | cd maven - bash ../scripts/ci/retry.sh xvfb-run -a mvn -B install -Plocal-dev-javase -Dunity-compat -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true -pl codenameone-maven-plugin,cn1-ads-mock,android-compat,cil-translator,unity-compat -am + bash ../scripts/ci/retry.sh xvfb-run -a mvn -B install -Plocal-dev-javase -Dunity-compat -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true -pl codenameone-maven-plugin,cn1-ads-mock,compose-compat,android-compat,cil-translator,unity-compat -am # generate-sources is as far as the application has to go: compile-unity # is bound to it, and nothing after it writes what the archive carries. diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 90bc6c9338a..90b782d0671 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -67,6 +67,7 @@ on: - 'scripts/bastion/**' - 'scripts/skirmish/**' - 'scripts/parley/**' + - 'scripts/hellocodenameone/common/src/main/kmp/**' - 'scripts/website/**' - 'scripts/skindesigner/**' - 'Ports/JavaScriptPort/**' diff --git a/.github/workflows/windows-cross-build-run.yml b/.github/workflows/windows-cross-build-run.yml index e5ca2637355..ca002958f13 100644 --- a/.github/workflows/windows-cross-build-run.yml +++ b/.github/workflows/windows-cross-build-run.yml @@ -31,6 +31,7 @@ on: - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -59,6 +60,7 @@ on: - 'scripts/unity-compat-samples/gallery/**' - 'maven/cil-translator/**' - 'maven/unity-compat/**' + - 'maven/compose-compat/**' - 'maven/build-engine/src/main/java/com/codename1/maven/Unity*' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Unity*' - 'scripts/unity-gallery-prebuilt.sh' @@ -219,7 +221,7 @@ jobs: run: | cd maven JAVA_HOME="$JDK_8_HOME" bash ../scripts/ci/retry.sh \ - mvn -B -pl windows,codenameone-maven-plugin,cn1-ads-mock -am \ + mvn -B -pl windows,codenameone-maven-plugin,cn1-ads-mock,compose-compat -am \ -DskipTests '-Dmaven.javadoc.skip=true' '-Plocal-dev-javase' install test -f core/target/classes/com/codename1/ui/Form.class test -f windows/target/classes/com/codename1/impl/windows/WindowsImplementation.class diff --git a/CLAUDE.md b/CLAUDE.md index 8a8978da375..e80f4dd8644 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1321,6 +1321,66 @@ Traps that have already cost a fix: port turns Space and Enter into the same key code, and drops every Ctrl/Alt/Meta combination before the form sees it. +### Kotlin Multiplatform: Compose code as a Codename One app + +A KMP module's `commonMain` (and its JVM source set, minus the file that opens a +desktop window) dropped into `common/src/main/kmp` -- or imported with +`cn1:import-kmp-project` (`KmpProjectImporter`) -- builds as a Codename One +application. There is no transpiler: kotlinc compiles it for the JVM, with the +Compose compiler plugin, and ParparVM translates the classes like any others. + +- **The Compose runtime and `kotlinx.coroutines` ship as they are.** Only the + UI layer is ours: `maven/compose-compat` is `androidx.compose.{ui,foundation, + material3,animation}` written over Codename One components and `Graphics`, + authored under the real names. `androidx/compose/` is **never relocated** + (`Relocation.isCompose`), not even when the Android layer is active: the + runtime the application was compiled against is the one it runs. +- **Measuring is Compose's, not Codename One's.** `UiNode` is a `Container` + whose layout walks the modifier chain outermost first under Compose + constraints (`BoxPolicy`, `LinearPolicy`, `LeafPolicy`); the Codename One + layout pass only copies the result out. `ComposeHost` runs the `Recomposer` + with a `BroadcastFrameClock` fed by `Form.registerAnimated`. +- **`Dispatchers.Main` is the EDT**, found by kotlinx.coroutines through + `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` + (`EdtDispatcherFactory`). The translator compiles that file into a registry; + there is no service loader on the device. +- **JVM 11, not 8.** The Compose runtime's inline functions are JVM 11 + bytecode, so the archetype's `compose` profile (active when `src/main/kmp` + exists) sets kotlinc's `jvmTarget` and javac's source/target to 11. The + compiler plugin version must equal the Kotlin version. +- **Gradle**: the plugin adds compose-compat and the kmp source roots, and + refuses the build, naming the line to add, unless + `org.jetbrains.kotlin.plugin.compose` is applied; `convert-to-gradle` writes + it. The settings plugin adds Google's repository, limited to `androidx.*`. + +Traps that have already cost a fix: + +- **The runtime's own dependencies are in Gradle metadata only.** + `runtime-desktop`'s pom does not name `androidx.collection`, so Maven + resolved the runtime without it, every JVM test passed, and the Xcode build + failed on a missing `androidx_collection_ScatterSet.h`. compose-compat + declares them itself. +- **A public inline function cannot call internal code**, and the Compose + layer's internals are internal: `Box`, `Row` and `Column` are not inline + here, as they are upstream. +- **Tests need JDK 17** (class version 55); on JDK 8 the module builds and + the `jdk8-no-tests` profile skips them. SpotBugs still runs on the JDK 8 leg. +- **`--` ends an XML comment.** A `spotbugs-exclude.xml` with one in a comment + is rejected without an error, and every finding comes back. +- **`LazyColumn`/`LazyRow` compose only what is in view** (`LazyListNode`): + every item is a composition of its own under the list's, made within a + quarter view of the window and disposed of half a view past it. Sizes of + items never measured are estimated from the average, and every pass anchors + on the first visible item, so learning a size above the view cannot move + what is on screen. `scrollToItem` repeats until the target's real position + holds. `reverseLayout = true` throws; it is not implemented. + +`maven/integration-tests/kmp-compat-test.sh` imports every sample under +`scripts/kmp-compat-samples`, builds it with Maven and Gradle and stages every +target's upload (`.github/workflows/kmp-compat.yml`). `scripts/kmp-bench` runs +the same Compose source against Compose Multiplatform on an iOS simulator; see +its README for the numbers and what they do not show. + ### Integration Tests Located in `maven/integration-tests/`: diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java index 1e41c8273d6..3b00374f7d1 100644 --- a/CodenameOne/src/com/codename1/ui/Component.java +++ b/CodenameOne/src/com/codename1/ui/Component.java @@ -3100,6 +3100,9 @@ final void paintInternal(Graphics g, boolean paintIntersects) { if (!isVisible() || doNotPaint) { return; } + // Painted, whether as a root or by a parent: a later region repaint + // must not be dropped as already covered by a full one. + repaintPending = false; if (paintLockImage != null) { if (paintLockImage instanceof Image) { diff --git a/CodenameOne/src/com/codename1/ui/Container.java b/CodenameOne/src/com/codename1/ui/Container.java index 7d252c6f81c..8df363a4f09 100644 --- a/CodenameOne/src/com/codename1/ui/Container.java +++ b/CodenameOne/src/com/codename1/ui/Container.java @@ -2932,6 +2932,13 @@ private boolean snapToSafeAreaInternal() { } boolean changed = false; + // The padding set here lives for one measurement: the caller hands it + // straight back to TmpInsets.restore, which suppresses its events too. + // Announcing it made Component.styleChanged queue a revalidate of the + // whole Form from inside its own layout, so every pass queued the next + // and a form with a safe-area container never stopped laying out. + boolean suppressEvents = style.isSuppressChangeEvents(); + style.setSuppressChangeEvents(true); if (newPaddingTop != paddingTop || newPaddingBottom != paddingBottom) { if (!hasScrollableYParentInternal()) { changed = true; @@ -2964,31 +2971,26 @@ private boolean snapToSafeAreaInternal() { } } + style.setSuppressChangeEvents(suppressEvents); + if (changed && !shouldCalcPreferredSize) { + // What the suppressed PADDING event did, minus its revalidateLater + // of the parent: this and every nested container measure and lay + // out again against the snapped padding. Without it a nested + // container kept a layout made for the unsnapped one. + setShouldCalcPreferredSize(true); + } return changed; } - // NOT suppressed, deliberately -- see the history before changing this. - // - // The padding written by the safe-area snap is scaffolding: doLayout and - // calcPreferredSize set it, use it for one measurement, and hand it straight - // back to TmpInsets.restore, which has always suppressed events. Only the - // SETTING half announces, and a padding change is exactly what - // Component.styleChanged answers with revalidateLater() on the parent -- so - // laying out a safe-area container queues another revalidate of the whole - // Form, which lays it out again, which queues another. On a busy event - // dispatch thread that treadmill measured ~200ms per pass against ~15ms of - // real painting. - // - // Suppressing the announcement removes the treadmill and is WRONG here - // anyway: peer components rely on those repaints. The JavaSE video peer - // fills its buffer from the AWT side and never asks for a repaint itself - // (Peer.paint deliberately calls paintOnBuffer rather than cnt.repaint, to - // avoid a loop), so with nothing else repainting the form its frames never - // reach the screen -- a video that decoded correctly and displayed nothing. - // - // Fixing the treadmill therefore needs the peers to drive their own - // repaints first; until then the wasted passes are the cheaper defect. + // The safe-area snap's padding is suppressed (see snapToSafeAreaInternal). + // It was once left announcing because peers seemed to depend on the + // resulting treadmill: the JavaSE video peer showed nothing without it. The + // real cause was Component.repaint(int, int, int, int) dropping every + // region repaint while repaintPending was still set, and that flag was only + // cleared when the component was painted as a root -- never when a parent + // painted it. Component.paintInternal now clears it too, so a peer's own + // region repaints reach the screen without the form laying out each cycle. void doLayout() { doLayoutDepth++; diff --git a/CodenameOne/src/com/codename1/ui/Font.java b/CodenameOne/src/com/codename1/ui/Font.java index d2251579f8c..15359aa963d 100644 --- a/CodenameOne/src/com/codename1/ui/Font.java +++ b/CodenameOne/src/com/codename1/ui/Font.java @@ -153,6 +153,11 @@ public class Font extends CN { private static final Hashtable bitmapCache = new Hashtable(); private static final HashMap derivedFontCache = new HashMap(); + /// What [#derive(float, int)] made, per unique id. A hit costs a map lookup + /// and a short scan, and allocates nothing: a String key of id, size and + /// weight made three objects per call even when the font was already there, + /// and a theme load derives every font of every style. + private static final HashMap derivedBySize = new HashMap(); /// Internal theme hook: inherit only native UI fonts, never icon or bundled fonts. public Font deriveNativeThemeFont(String family, float size) { @@ -183,12 +188,14 @@ public Font deriveNativeThemeFont(String family, float size) { /// constants instead of returning a stale pre-theme paint. public static void clearDerivedFontCache() { derivedFontCache.clear(); + derivedBySize.clear(); } private static Font defaultFont = new Font(null); private static boolean enableBitmapFont = true; private static float fontReturnedHeight; private Object font; private boolean ttf; + private int derivedWeight; private float pixelSize = -1; // for derived fonts only, the size that was requested private String fontUniqueId; @@ -603,16 +610,39 @@ public Font deriveLetterSpacing(float letterSpacing) { public Font derive(float sizePixels, int weight) { if (fontUniqueId != null) { // derive should recycle instances of Font to allow smarter caching and logic on the native side of the fence + Font[] derived = derivedBySize.get(fontUniqueId); + int count = 0; + if (derived != null) { + int bits = Float.floatToIntBits(sizePixels); + for (; count < derived.length && derived[count] != null; count++) { + Font d = derived[count]; + if (d.derivedWeight == weight && Float.floatToIntBits(d.pixelSize) == bits) { + return d; + } + } + } + // A miss falls back to the shared cache, whose keys createTrueTypeFont + // also writes: a derive of a font it made, at that font's own size, + // answers that font rather than a second native one, and the reverse. String key = fontUniqueId + "_" + sizePixels + "_" + weight; Font f = derivedFontCache.get(key); - if (f != null) { - return f; + if (f == null) { + f = new Font(Display.impl.deriveTrueTypeFont(font, sizePixels, weight)); + f.pixelSize = sizePixels; + f.fontUniqueId = fontUniqueId; + f.ttf = true; + derivedFontCache.put(key, f); + } + f.derivedWeight = weight; + if (derived == null || count == derived.length) { + Font[] grown = new Font[derived == null ? 4 : derived.length * 2]; + if (derived != null) { + System.arraycopy(derived, 0, grown, 0, derived.length); + } + derived = grown; + derivedBySize.put(fontUniqueId, derived); } - f = new Font(Display.impl.deriveTrueTypeFont(font, sizePixels, weight)); - f.pixelSize = sizePixels; - f.fontUniqueId = fontUniqueId; - f.ttf = true; - derivedFontCache.put(key, f); + derived[count] = f; return f; } else { // not sure if this ever happens but don't want to break that code diff --git a/CodenameOne/src/com/codename1/ui/geom/Geometry.java b/CodenameOne/src/com/codename1/ui/geom/Geometry.java index 1bd48848fef..49fff4cf852 100644 --- a/CodenameOne/src/com/codename1/ui/geom/Geometry.java +++ b/CodenameOne/src/com/codename1/ui/geom/Geometry.java @@ -649,18 +649,20 @@ public void segment(Rectangle2D rect, List out) { n() == 3 ? ShapeUtil.intersectCubic(x[0], y[0], x[1], y[1], x[2], y[2], x[3], y[3], rect.getX(), rect.getY(), rect.getX() + rect.getWidth(), rect.getHeight() + rect.getY()) : n() == 1 ? ShapeUtil.intersectLine(x[0], y[0], x[1], y[1], rect.getX(), rect.getY(), rect.getX() + rect.getWidth(), rect.getY() + rect.getHeight()) : -1; - if (numIntersections == -1) { + if (n() < 1 || n() > 3) { throw new IllegalArgumentException("Cannot segment bezier curve of this order: " + n()); } + // The intersect functions return a signed crossing count (-1 for a + // curve running right to left) or CROSSING, not a number of points, + // so only zero is meaningful here. if (numIntersections == 0) { out.add(new BezierCurve(this)); return; } - - // To store t values of intersection points - double[] tvals = new double[numIntersections]; + // To store t values of intersection points: up to n() per edge. + double[] tvals = new double[4 * n()]; int nextTvalIndex = 0; double[] res = new double[3]; int numMatches = 0; diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index 7321dee6a9a..541f9d89d2d 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -289,7 +289,7 @@ private void restoreNativeBorderColors() { nativeBorderDerived.clear(); } - private void recordNativeThemeOwnership(Hashtable incoming) { + private void recordNativeThemeOwnership(HashMap incoming) { if (refreshingTheme) { return; } @@ -303,14 +303,14 @@ private void recordNativeThemeOwnership(Hashtable incoming) { nativeBaseFontSize = nativeFontSize((Font) base); } } - for (Object item : incoming.keySet()) { - String key = (String) item; + for (Map.Entry e : incoming.entrySet()) { + String key = e.getKey(); if (key.startsWith("@")) { if (!nativeLayer) { applicationConstants.add(key.substring(1)); } } else if (nativeLayer) { - if (incoming.get(key) instanceof Font) { + if (e.getValue() instanceof Font) { nativeFontKeys.add(key); } if (key.endsWith("border")) { @@ -566,7 +566,7 @@ private static Font parseFont(String fontStr) { /// Resources theme = null; /// try { /// theme = Resources.openLayered(resourceFile); - /// UIManager.getInstance().setThemeProps(theme.getTheme(theme.getThemeResourceNames()[0])); + /// UIManager.getInstance().setThemeMap(theme.getThemeMap(theme.getThemeResourceNames()[0])); /// } catch (IOException e) { /// Log.e(e); /// } @@ -582,7 +582,7 @@ private static Font parseFont(String fontStr) { public static Resources initFirstTheme(String resourceFile) { try { Resources theme = Resources.openLayered(resourceFile); - UIManager.getInstance().setThemeProps(theme.getTheme(theme.getThemeResourceNames()[0])); + UIManager.getInstance().setThemeMap(theme.getThemeMap(theme.getThemeResourceNames()[0])); Resources.setGlobalResources(theme); return theme; } catch (IOException e) { @@ -605,7 +605,7 @@ public static Resources initFirstTheme(String resourceFile) { public static Resources initNamedTheme(String resourceFile, String resName) { try { Resources theme = Resources.openLayered(resourceFile); - UIManager.getInstance().setThemeProps(theme.getTheme(resName)); + UIManager.getInstance().setThemeMap(theme.getThemeMap(resName)); Resources.setGlobalResources(theme); return theme; } catch (IOException e) { @@ -1113,11 +1113,46 @@ HashMap getThemeProps() { /// /// - `themeProps`: the properties of the given theme public void setThemeProps(Hashtable themeProps) { + setThemePropsFrom(themeMap(themeProps), themeProps); + } + + /// Allows manual theme loading from a map of key/value pairs. Prefer this to + /// [#setThemeProps(Hashtable)]: a `HashMap` is what the theme is built from, + /// so nothing is copied, and its loops compile to direct table walks with + /// no iterator and no locking. + /// + /// #### Parameters + /// + /// - `themeProps`: the properties of the given theme + public void setThemeMap(HashMap themeProps) { + setThemePropsFrom(themeProps, themeProps); + } + + /// A legacy theme table as the map the theme is built from. Keys that are + /// not strings are not theme entries and are left out. + private static HashMap themeMap(Hashtable themeProps) { + if (themeProps == null) { + return null; + } + HashMap map = new HashMap(themeProps.size() * 2); + Enumeration e = themeProps.keys(); + while (e.hasMoreElements()) { + Object key = e.nextElement(); + if (key instanceof String) { + map.put((String) key, themeProps.get(key)); + } + } + return map; + } + + /// `source` is what the theme listeners are handed: the table the caller + /// passed, so a listener written against the legacy API still sees one. + private void setThemePropsFrom(HashMap themeProps, Object source) { // Replacing the whole theme supersedes a deferred base theme: it would be // reset away here before anything saw it. See setDeferredBaseTheme. deferredBaseTheme = null; if (accessible) { - setThemePropsImpl(themeProps); + setThemePropsImpl(themeProps, source); } wasThemeInstalled = true; } @@ -1129,7 +1164,7 @@ public void setThemeProps(Hashtable themeProps) { /// - `installedTheme`: @param installedTheme the theme to be installed or null, this is used /// to check if style inheritance is used in which case we must NOT init /// style defaults for that particular component - private void resetThemeProps(Hashtable installedTheme) { + private void resetThemeProps(HashMap installedTheme) { themeProps = new HashMap(); wasThemeInstalled = false; if (current == null) { @@ -2064,6 +2099,17 @@ private void resetThemeProps(Hashtable installedTheme) { /// /// - `themeProps`: the properties of the given theme public void addThemeProps(Hashtable themeProps) { + addThemeMap(themeMap(themeProps)); + } + + /// Adds the given theme properties on top of the existing properties without + /// clearing the existing theme first. Prefer this to [#addThemeProps(Hashtable)], + /// which copies the table into a map first. + /// + /// #### Parameters + /// + /// - `themeProps`: the properties of the given theme + public void addThemeMap(HashMap themeProps) { // Layered ON the base theme, so a deferred one has to be installed first. ensureBaseTheme(); if (accessible) { @@ -2098,17 +2144,11 @@ public void addThemeProps(Hashtable themeProps) { /// staying in place). For each style key being re-set by the incoming /// load that does NOT re-assert its binding, drop the matching binding /// from `themeConstants` so the new literal wins. - private void dropSupersededBindings(Hashtable themeProps) { + private void dropSupersededBindings(HashMap themeProps) { if (themeProps == null || themeConstants == null || themeConstants.isEmpty()) { return; } - Enumeration e = themeProps.keys(); - while (e.hasMoreElements()) { - Object keyObj = e.nextElement(); - if (!(keyObj instanceof String)) { - continue; - } - String key = (String) keyObj; + for (String key : themeProps.keySet()) { if (key.startsWith("@")) { continue; } @@ -2232,10 +2272,7 @@ public void refreshTheme() { } restoreLargerTextFonts(); restoreNativeThemeValues(); - Hashtable props = new Hashtable(); - for (Map.Entry e : themeProps.entrySet()) { - props.put(e.getKey(), e.getValue()); - } + HashMap props = new HashMap(themeProps); // buildTheme strips `@`-prefixed constants into themeConstants and // drops them from the main themeProps map. Round-tripping through // setThemePropsImpl would therefore lose every constant - so @@ -2248,7 +2285,7 @@ public void refreshTheme() { } refreshingTheme = true; try { - setThemePropsImpl(props); + setThemePropsImpl(props, props); } finally { refreshingTheme = false; } @@ -2377,7 +2414,12 @@ public Object getThemeMaskConstant(String constantName) { return o; } + // for internal use by the resource editor, which installs a theme it is editing void setThemePropsImpl(Hashtable themeProps) { + setThemePropsImpl(themeMap(themeProps), themeProps); + } + + void setThemePropsImpl(HashMap themeProps, Object source) { if (!refreshingTheme && buildThemeDepth == 0) { restoreNativeBorderColors(); nativeFontKeys.clear(); @@ -2394,7 +2436,7 @@ void setThemePropsImpl(Hashtable themeProps) { themeGeneration++; imageCache.clear(); if (themelisteners != null) { - themelisteners.fireActionEvent(new ActionEvent(themeProps, ActionEvent.Type.Theme)); + themelisteners.fireActionEvent(new ActionEvent(source, ActionEvent.Type.Theme)); } buildTheme(themeProps); breakTitleAreaToolbarDeriveCycle(); @@ -2444,7 +2486,7 @@ private void breakTitleAreaToolbarDeriveCycle() { } } - private void buildTheme(Hashtable themeProps) { + private void buildTheme(HashMap themeProps) { buildThemeDepth++; try { buildThemeImpl(themeProps); @@ -2453,7 +2495,7 @@ private void buildTheme(Hashtable themeProps) { } } - private void buildThemeImpl(Hashtable themeProps) { + private void buildThemeImpl(HashMap themeProps) { // A new theme may change constants that the platform consults while deriving // fonts (e.g. a native theme's text letter spacing). Flush the derived-font // cache so those fonts are rebuilt against the incoming constants rather than @@ -2472,16 +2514,14 @@ private void buildThemeImpl(Hashtable themeProps) { if (!refreshingTheme && !"true".equals(themeProps.get("@nativeThemeDefaultsBool"))) { dropSupersededBindings(themeProps); } - Enumeration e = themeProps.keys(); - while (e.hasMoreElements()) { - String key = (String) e.nextElement(); - + for (Map.Entry e : themeProps.entrySet()) { + String key = e.getKey(); // this is a constant not a theme entry if (key.startsWith("@")) { - themeConstants.put(key.substring(1), themeProps.get(key)); + themeConstants.put(key.substring(1), e.getValue()); continue; } - this.themeProps.put(key, themeProps.get(key)); + this.themeProps.put(key, e.getValue()); } String overlayThemes = (String) themeProps.get("@OverlayThemes"); @@ -2496,7 +2536,7 @@ private void buildThemeImpl(Hashtable themeProps) { Resources res = Resources.openLayered("/" + th); boolean a = accessible; accessible = true; - addThemeProps(res.getTheme(res.getThemeResourceNames()[0])); + addThemeMap(res.getThemeMap(res.getThemeResourceNames()[0])); accessible = a; } catch (Exception ex) { System.err.println("Failed to load overlay theme file specified by @overlayThemes theme constant: " + th); @@ -2586,6 +2626,10 @@ private void applyThemeBindings() { return; } final String prefix = "cn1-bind:"; + // One normalized copy per override: every key bound to `--accent-color` + // shares the String it resolves to, where normalizing per key stored 230 + // equal copies of the same few colors in the theme. + HashMap normalized = null; for (Map.Entry entry : themeConstants.entrySet()) { String constantKey = entry.getKey(); if (constantKey == null || !constantKey.startsWith(prefix)) { @@ -2616,9 +2660,17 @@ private void applyThemeBindings() { } String overrideValue = (String) override; if (themeKey.endsWith("Color")) { - overrideValue = normalizeBoundColorValue(overrideValue); + if (normalized == null) { + normalized = new HashMap(); + } + String raw = overrideValue; + overrideValue = normalized.get(raw); if (overrideValue == null) { - continue; + overrideValue = normalizeBoundColorValue(raw); + if (overrideValue == null) { + continue; + } + normalized.put(raw, overrideValue); } } themeProps.put(themeKey, overrideValue); @@ -2657,6 +2709,9 @@ private void syncBoundRoundBorderColor(String themeKey, String colorValue, boole } } + /// Scratch for `normalizeBoundColorValue`, which always produces 6 digits. + private static final char[] HEX_COLOR_BUFFER = new char[6]; + /// `loadTheme` stores color theme entries as plain hex strings (no `#`, /// lowercase). User-supplied overrides may use either form, so trim a /// leading `#` and lowercase the value before assigning it to a bound @@ -2667,29 +2722,34 @@ private static String normalizeBoundColorValue(String raw) { return null; } String value = raw.trim(); - if (value.length() == 0) { + int len = value.length(); + int start = len > 0 && value.charAt(0) == '#' ? 1 : 0; + int digits = len - start; + if (digits != 3 && digits != 6) { return null; } - if (value.charAt(0) == '#') { - value = value.substring(1); - } - if (value.length() == 3) { - char r = value.charAt(0); - char g = value.charAt(1); - char b = value.charAt(2); - value = "" + r + r + g + g + b + b; - } - if (value.length() != 6) { - return null; - } - for (int i = 0; i < 6; i++) { - char c = value.charAt(i); - boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); - if (!hex) { + // One pass into a reused buffer: validate, expand the 3-digit form and + // fold hex digits by hand, since toLowerCase() is locale sensitive. + // Themes are installed from one thread at a time, as the rest of + // UIManager assumes. + char[] c = HEX_COLOR_BUFFER; + boolean changed = start != 0 || digits == 3; + for (int i = 0; i < digits; i++) { + char x = value.charAt(start + i); + if (x >= 'A' && x <= 'F') { + x = (char) (x + 32); + changed = true; + } else if (!((x >= '0' && x <= '9') || (x >= 'a' && x <= 'f'))) { return null; } + if (digits == 3) { + c[2 * i] = x; + c[2 * i + 1] = x; + } else { + c[i] = x; + } } - return value.toLowerCase(); + return changed ? new String(c) : value; } private Map parseCache() { diff --git a/CodenameOne/src/com/codename1/ui/util/Resources.java b/CodenameOne/src/com/codename1/ui/util/Resources.java index 91075fa8a88..19aa2708eac 100644 --- a/CodenameOne/src/com/codename1/ui/util/Resources.java +++ b/CodenameOne/src/com/codename1/ui/util/Resources.java @@ -519,7 +519,7 @@ private void openFileImpl(InputStream input) throws IOException { continue; case MAGIC_THEME: case MAGIC_THEME_LEGACY: - setResource(id, MAGIC_THEME, loadTheme(id, magic == MAGIC_THEME)); + setResource(id, MAGIC_THEME, loadThemeMap(id, magic == MAGIC_THEME)); continue; case MAGIC_IMAGE: Image img = createImage(); @@ -1058,51 +1058,121 @@ public Font getFont(String id) { /// /// cached theme instance public Hashtable getTheme(String id) { - Hashtable h = (Hashtable) resources.get(id); + Object stored = resources.get(id); + if (stored instanceof HashMap) { + // Callers of this method edit the table in place (the CSS compiler, the + // resource editor), so the table handed out has to be the stored one. + HashMap map = getThemeMap(id); + Hashtable h = new Hashtable(map.size() * 2); + for (Map.Entry e : map.entrySet()) { + Object value = e.getValue(); + if (value != null) { + h.put(e.getKey(), value); + } + } + resources.put(id, h); + return h; + } + Hashtable h = (Hashtable) stored; // theme can be null in valid use cases such as the resource editor if (h != null && h.containsKey("uninitialized")) { Enumeration e = h.keys(); while (e.hasMoreElements()) { String key = (String) e.nextElement(); - if (key.endsWith("font") || (key.endsWith("Image") && !key.endsWith("scaledImage"))) { - Object value = h.get(key); - if (value == null) { - throw new IllegalArgumentException("Couldn't find resource: " + key); - } + Object value = resolveThemeEntry(key, h.get(key)); + if (value != null) { + h.put(key, value); + } + } + h.remove("uninitialized"); + } + return h; + } + + /// Returns the theme resource from the file as the map + /// [com.codename1.ui.plaf.UIManager#setThemeMap(HashMap)] takes. Prefer this + /// to [#getTheme(String)]: the theme is loaded as this map, so nothing is + /// copied, and its loops compile to direct table walks with no iterator and + /// no locking. + /// + /// #### Parameters + /// + /// - `id`: name of the theme resource + /// + /// #### Returns + /// + /// cached theme instance + public HashMap getThemeMap(String id) { + Object stored = resources.get(id); + if (stored instanceof Hashtable) { + // Set as a table (MutableResource, the resource editor) or handed out + // by getTheme: resolve it there, then keep the map from now on. + Hashtable h = getTheme(id); + HashMap map = new HashMap(h.size() * 2); + Enumeration e = h.keys(); + while (e.hasMoreElements()) { + Object key = e.nextElement(); + if (key instanceof String) { + map.put((String) key, h.get(key)); + } + } + resources.put(id, map); + return map; + } + if (!(stored instanceof HashMap)) { + return null; + } + HashMap map = (HashMap) stored; + if (map.containsKey("uninitialized")) { + for (Map.Entry e : map.entrySet()) { + Object value = resolveThemeEntry(e.getKey(), e.getValue()); + if (value != null) { + e.setValue(value); + } + } + map.remove("uninitialized"); + } + return map; + } - // the resource was not already loaded when we loaded the theme - // it must be loaded now so we can resolve the temporary name - if (value instanceof String) { - Object o; + /// The loaded form of a theme entry that still names a resource loaded after + /// the theme, or null when the entry needs nothing. + private Object resolveThemeEntry(String key, Object value) { + Object result = null; + if (key.endsWith("font") || (key.endsWith("Image") && !key.endsWith("scaledImage"))) { + if (value == null) { + throw new IllegalArgumentException("Couldn't find resource: " + key); + } - // we need to trigger multi image logic in the resource editor - if (key.endsWith("Image")) { - o = getImage((String) value); - } else { - o = resources.get(value); - } - if (o == null) { - throw new IllegalArgumentException("Theme entry for " + key + " could not be found: " + value); - } - h.put(key, o); - } + // the resource was not already loaded when we loaded the theme + // it must be loaded now so we can resolve the temporary name + if (value instanceof String) { + Object o; + + // we need to trigger multi image logic in the resource editor + if (key.endsWith("Image")) { + o = getImage((String) value); + } else { + o = resources.get(value); } - // if this is a border we might need to do additional work for older versions - // of Codename One and for the case of an image border where the images might not have - // been loaded yet when the border was created - if (key.endsWith("order")) { - Border b = confirmBorder(h, key); - h.put(key, b); + if (o == null) { + throw new IllegalArgumentException("Theme entry for " + key + " could not be found: " + value); } + result = o; + value = o; } - h.remove("uninitialized"); } - return h; + // if this is a border we might need to do additional work for older versions + // of Codename One and for the case of an image border where the images might not have + // been loaded yet when the border was created + if (key.endsWith("order")) { + result = confirmBorder(value); + } + return result; } - private Border confirmBorder(Hashtable h, String key) { - Object val = h.get(key); + private Border confirmBorder(Object val) { if (val == null) { return null; } @@ -1556,7 +1626,21 @@ private com.codename1.ui.Gradient readGradient(DataInputStream input) throws IOE } Hashtable loadTheme(String id, boolean newerVersion) throws IOException { - Hashtable theme = new Hashtable(); + HashMap map = loadThemeMap(id, newerVersion); + Hashtable theme = new Hashtable(map.size() * 2); + for (Map.Entry e : map.entrySet()) { + Object value = e.getValue(); + if (value != null) { + theme.put(e.getKey(), value); + } + } + return theme; + } + + /// Reads a theme as the map [#getThemeMap(String)] hands out; what a resource + /// file load stores. [#loadTheme(String, boolean)] is the same table copied. + HashMap loadThemeMap(String id, boolean newerVersion) throws IOException { + HashMap theme = new HashMap(); String densityStr = Display.getInstance().getDensityStr(); String platformName = Display.getInstance().getPlatformName(); if ("HTML5".equals(platformName)) { @@ -1572,14 +1656,15 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { // marks the theme as uninitialized so we can finish "wiring" cached resources theme.put("uninitialized", Boolean.TRUE); int size = input.readShort(); + ThemeStrings values = new ThemeStrings(size); List fontKeys = new ArrayList(); Map fontScaleRules = null; Map mediaRules = new HashMap(); int defaultFontSizeSetPriority = 0; for (int iter = 0; iter < size; iter++) { - String key = input.readUTF(); + String key = values.readUTF(input); if (key.startsWith("@")) { - theme.put(key, input.readUTF()); + theme.put(key, values.readUTF(input)); if (enableMediaQueries) { if (key.endsWith("-font-scale")) { if (fontScaleRules == null) { @@ -1648,7 +1733,10 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { } } - if (mediaMatch) { + // A key with no media prefix is its own translation, and a rule for it + // never wins over one that matched a prefix. A rule per plain key was + // 5,000 objects and map entries of garbage per theme load. + if (mediaMatch && matchCount > 0) { MediaRule rule = mediaRules.get(subkey); boolean replace = rule == null; if (!replace && rule.matchCount <= matchCount) { @@ -1674,7 +1762,7 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { // if this is a simple numeric value if (key.endsWith("Color")) { - theme.put(key, Integer.toHexString(input.readInt())); + theme.put(key, values.hex(input.readInt())); continue; } @@ -1685,7 +1773,7 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { // if this is a short numeric value for transparency if (key.endsWith("ransparency")) { - theme.put(key, "" + (input.readByte() & 0xff)); + theme.put(key, values.decimal(input.readByte() & 0xff)); continue; } @@ -1694,7 +1782,7 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { continue; } if (key.endsWith("opacity")) { - theme.put(key, "" + (input.readInt() & 0xff)); + theme.put(key, values.decimal(input.readInt() & 0xff)); continue; } @@ -1730,13 +1818,13 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { float p2 = input.readFloat(); float p3 = input.readFloat(); float p4 = input.readFloat(); - theme.put(key, "" + p1 + "," + p2 + "," + p3 + "," + p4); + theme.put(key, values.floats(p1, p2, p3, p4)); } else { int p1 = input.readByte() & 0xff; int p2 = input.readByte() & 0xff; int p3 = input.readByte() & 0xff; int p4 = input.readByte() & 0xff; - theme.put(key, "" + p1 + "," + p2 + "," + p3 + "," + p4); + theme.put(key, values.ints(p1, p2, p3, p4)); } continue; } @@ -1765,7 +1853,7 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { // is this a new font? if (input.readBoolean()) { - String fontId = input.readUTF(); + String fontId = values.readUTF(input); f = (Font) resources.get(fontId); // if the font is not yet loaded @@ -1778,8 +1866,8 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { if (minorVersion > 4) { boolean hasTTF = input.readBoolean(); if (hasTTF) { - String fileName = input.readUTF(); - String fontName = input.readUTF(); + String fileName = values.readUTF(input); + String fontName = values.readUTF(input); int sizeSetting = input.readInt(); float fontSize = input.readFloat(); if (isTrueTypeFileSupported()) { @@ -1810,7 +1898,7 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { // Tiled Both Image case MAGIC_INDEXED_IMAGE_LEGACY: // the image name coupled with the type - theme.put(key + Style.BG_IMAGE, input.readUTF()); + theme.put(key + Style.BG_IMAGE, values.readUTF(input)); break; // Aligned Image @@ -1820,7 +1908,7 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { // Tiled Horizontally Image case 0xF3: // the image name coupled with the type and with alignment information - String imageName = input.readUTF(); + String imageName = values.readUTF(input); theme.put(key + Style.BG_IMAGE, imageName); byte align = input.readByte(); theme.put(key + Style.BACKGROUND_ALIGNMENT, Byte.valueOf(align)); @@ -1858,13 +1946,13 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { // if this is a background image bgImage if (key.endsWith("derive")) { - theme.put(key, input.readUTF()); + theme.put(key, values.readUTF(input)); continue; } // if this is a background image bgImage if (key.endsWith("bgImage")) { - String imageId = input.readUTF(); + String imageId = values.readUTF(input); Image i = getImage(imageId); // if the font is not yet loaded diff --git a/CodenameOne/src/com/codename1/ui/util/ThemeStrings.java b/CodenameOne/src/com/codename1/ui/util/ThemeStrings.java new file mode 100644 index 00000000000..9ed73190ab4 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/util/ThemeStrings.java @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.ui.util; + +import java.io.DataInputStream; +import java.io.IOException; + +/// The strings of one theme load, each stored once. +/// +/// A theme repeats a few values thousands of times -- `0`, `ffffff`, +/// `native:MainRegular` -- and reading each with `readUTF`, `toHexString` or +/// `"" + int` made a byte array, a char array and a String per entry, almost all +/// of them garbage on arrival. The pages that garbage filled stay resident after +/// it is collected, so deduplicating the result afterwards saves nothing. This +/// decodes into reusable buffers and creates a String only for a value it has not +/// seen in this load; a repeated value allocates nothing. +final class ThemeStrings { + private byte[] bytes = new byte[64]; + private char[] chars = new char[64]; + private String[] table; + private int size; + + /// `entries` is the entry count the theme declares. Every key is a distinct + /// string and the values add a few hundred more, so a table sized for that up + /// front never has to grow: growing a 5,000 entry theme from a small table + /// discarded four tables on the way. + ThemeStrings(int entries) { + int want = Math.max(entries, 0) * 2 + 1024; + int len = 1024; + while (len < want && len < (1 << 20)) { + len <<= 1; + } + table = new String[len]; + } + + /// Reads a string written by `DataOutputStream.writeUTF` (modified UTF-8). + String readUTF(DataInputStream in) throws IOException { + int len = in.readUnsignedShort(); + if (bytes.length < len) { + bytes = new byte[len]; + } + in.readFully(bytes, 0, len); + if (chars.length < len) { + chars = new char[len]; + } + byte[] b = bytes; + char[] c = chars; + int n = 0; + int i = 0; + while (i < len) { + int x = b[i] & 0xff; + if (x < 0x80) { + c[n++] = (char) x; + i++; + } else if ((x & 0xe0) == 0xc0) { + if (i + 1 >= len || (b[i + 1] & 0xc0) != 0x80) { + throw new IOException("Malformed UTF-8 in theme"); + } + c[n++] = (char) (((x & 0x1f) << 6) | (b[i + 1] & 0x3f)); + i += 2; + } else if ((x & 0xf0) == 0xe0) { + if (i + 2 >= len || (b[i + 1] & 0xc0) != 0x80 || (b[i + 2] & 0xc0) != 0x80) { + throw new IOException("Malformed UTF-8 in theme"); + } + c[n++] = (char) (((x & 0x0f) << 12) | ((b[i + 1] & 0x3f) << 6) | (b[i + 2] & 0x3f)); + i += 3; + } else { + throw new IOException("Malformed UTF-8 in theme"); + } + } + return intern(n); + } + + /// `Integer.toHexString(v)`. + String hex(int v) { + char[] c = chars; + int n = 0; + int shift = 28; + while (shift > 0 && ((v >>> shift) & 0xf) == 0) { + shift -= 4; + } + for (; shift >= 0; shift -= 4) { + int d = (v >>> shift) & 0xf; + c[n++] = (char) (d < 10 ? '0' + d : 'a' + d - 10); + } + return intern(n); + } + + /// `"" + v` for a value from 0 to 255, which is all a theme stores this way. + String decimal(int v) { + char[] c = chars; + int n = 0; + if (v >= 100) { + c[n++] = (char) ('0' + v / 100); + } + if (v >= 10) { + c[n++] = (char) ('0' + v / 10 % 10); + } + c[n++] = (char) ('0' + v % 10); + return intern(n); + } + + /// `"" + a + "," + b + "," + c + "," + d`, the form padding and margin are + /// stored in. Each `+` on a float made a String of its own. + String floats(float a, float b, float c, float d) { + ensureChars(64); + int n = appendFloat(a, 0); + chars[n++] = ','; + n = appendFloat(b, n); + chars[n++] = ','; + n = appendFloat(c, n); + chars[n++] = ','; + n = appendFloat(d, n); + return intern(n); + } + + /// `"" + a + "," + b + "," + c + "," + d` for the integer form older themes use. + String ints(int a, int b, int c, int d) { + ensureChars(64); + int n = appendInt(a, 0); + chars[n++] = ','; + n = appendInt(b, n); + chars[n++] = ','; + n = appendInt(c, n); + chars[n++] = ','; + n = appendInt(d, n); + return intern(n); + } + + /// Appends `Float.toString(f)`. A theme's values have at most one decimal + /// (2.0, 0.5, 48.9); those are written digit by digit, which is what + /// `Float.toString` prints for them, and anything else is handed to it. + /// The digits are the shortest form that reads back as `f`; a VM whose + /// `Float.toString` prints more digits for such a value produced a + /// longer string before, never a different float once the theme parses it. + private int appendFloat(float f, int n) { + float tenths = f * 10f; + int t = (int) tenths; + if (t == tenths && t > -10000000 && t < 10000000 && (float) (t / 10.0) == f && !(t == 0 && 1 / f < 0)) { + if (t < 0) { + chars[n++] = '-'; + t = -t; + } + n = appendInt(t / 10, n); + chars[n++] = '.'; + chars[n++] = (char) ('0' + t % 10); + return n; + } + String s = Float.toString(f); + int len = s.length(); + ensureChars(n + len + 48); + s.getChars(0, len, chars, n); + return n + len; + } + + private int appendInt(int v, int n) { + if (v < 0) { + String s = String.valueOf(v); + int len = s.length(); + s.getChars(0, len, chars, n); + return n + len; + } + int start = n; + do { + chars[n++] = (char) ('0' + v % 10); + v /= 10; + } while (v != 0); + for (int i = start, j = n - 1; i < j; i++, j--) { + char x = chars[i]; + chars[i] = chars[j]; + chars[j] = x; + } + return n; + } + + private void ensureChars(int n) { + if (chars.length < n) { + char[] c = new char[n]; + System.arraycopy(chars, 0, c, 0, chars.length); + chars = c; + } + } + + /// The shared copy of a string built elsewhere. + String share(String s) { + int n = s.length(); + if (chars.length < n) { + chars = new char[n]; + } + s.getChars(0, n, chars, 0); + return intern(n); + } + + private String intern(int n) { + char[] c = chars; + int h = 0; + for (int i = 0; i < n; i++) { + h = 31 * h + c[i]; + } + String[] t = table; + int mask = t.length - 1; + int i = (h ^ (h >>> 16)) & mask; + while (true) { + String s = t[i]; + if (s == null) { + break; + } + if (s.length() == n && same(s, c, n)) { + return s; + } + i = (i + 1) & mask; + } + String s = new String(c, 0, n); + t[i] = s; + size++; + if (size * 2 > t.length) { + grow(); + } + return s; + } + + private static boolean same(String s, char[] c, int n) { + for (int i = 0; i < n; i++) { + if (s.charAt(i) != c[i]) { + return false; + } + } + return true; + } + + private void grow() { + String[] old = table; + String[] t = new String[old.length * 2]; + int mask = t.length - 1; + for (String s : old) { + if (s != null) { + int h = s.hashCode(); + int i = (h ^ (h >>> 16)) & mask; + while (t[i] != null) { + i = (i + 1) & mask; + } + t[i] = s; + } + } + table = t; + } +} diff --git a/CodenameOneDesigner/src/com/codename1/designer/PreviewInSimulator.java b/CodenameOneDesigner/src/com/codename1/designer/PreviewInSimulator.java index 5166642ed75..cd6e593d8f4 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/PreviewInSimulator.java +++ b/CodenameOneDesigner/src/com/codename1/designer/PreviewInSimulator.java @@ -144,7 +144,7 @@ public void run() { com.codename1.ui.Display.getInstance().installNativeTheme(); } } else { - com.codename1.ui.plaf.UIManager.getInstance().setThemeProps(res.getTheme(theme)); + com.codename1.ui.plaf.UIManager.getInstance().setThemeMap(res.getThemeMap(theme)); } com.codename1.ui.util.UIBuilder.registerCustomComponent("Table", com.codename1.ui.table.Table.class); com.codename1.ui.util.UIBuilder.registerCustomComponent("MediaPlayer", com.codename1.components.MediaPlayer.class); diff --git a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java index 482bcfb99a9..6da49f378ec 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java +++ b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java @@ -833,7 +833,7 @@ private static void migrateGuiBuilder(File projectDir, EditableResources res, St stateMachineBaseSource.append(" String[] themes = res.getThemeResourceNames();\n"); stateMachineBaseSource.append(" Resources.setGlobalResources(res);\n"); stateMachineBaseSource.append(" if(themes != null && themes.length > 0) {\n"); - stateMachineBaseSource.append(" UIManager.getInstance().setThemeProps(res.getTheme(themes[0]));\n"); + stateMachineBaseSource.append(" UIManager.getInstance().setThemeMap(res.getThemeMap(themes[0]));\n"); stateMachineBaseSource.append(" }\n"); stateMachineBaseSource.append(" }\n\n\n"); stateMachineBaseSource.append(" public StateMachineBase() {\n instance = this;\n"); diff --git a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java index 954b9fc0bcf..be8effb58dc 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java +++ b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java @@ -2491,7 +2491,7 @@ protected com.codename1.ui.Component createComponentInstance(String componentTyp w.write(" protected void initTheme(Resources res) {\n"); w.write(" String[] themes = res.getThemeResourceNames();\n"); w.write(" if(themes != null && themes.length > 0) {\n"); - w.write(" UIManager.getInstance().setThemeProps(res.getTheme(themes[0]));\n"); + w.write(" UIManager.getInstance().setThemeMap(res.getThemeMap(themes[0]));\n"); w.write(" }\n"); w.write(" }\n\n"); diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 6e41882b06a..33f4b83feb0 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -6152,9 +6152,9 @@ public void installNativeTheme() { is = getResourceAsStream(getClass(), "/android_holo_light.res"); } Resources r = Resources.open(is); - Hashtable h = r.getTheme(r.getThemeResourceNames()[0]); + HashMap h = r.getThemeMap(r.getThemeResourceNames()[0]); h.put("@commandBehavior", "Native"); - UIManager.getInstance().setThemeProps(h); + UIManager.getInstance().setThemeMap(h); is.close(); Display.getInstance().setCommandBehavior(Display.COMMAND_BEHAVIOR_NATIVE); } catch (IOException ex) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java b/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java index 9e21723f662..fc195c13955 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java @@ -672,7 +672,7 @@ public void run() { try { System.out.println("CSS File "+compilerArgs.input+" has been updated. Reloading styles from "+fDestFile); Resources res = Resources.open(new FileInputStream(fDestFile)); - UIManager.getInstance().addThemeProps(res.getTheme(res.getThemeResourceNames()[0])); + UIManager.getInstance().addThemeMap(res.getThemeMap(res.getThemeResourceNames()[0])); Form f = CN.getCurrentForm(); if (f != null) { f.refreshTheme(); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java b/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java index a411dd92c40..d5442b336c3 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java @@ -742,7 +742,7 @@ public void run() { public void run() { try { Resources res = Resources.open(new FileInputStream(themeFile)); - UIManager.getInstance().addThemeProps(res.getTheme(themeName)); + UIManager.getInstance().addThemeMap(res.getThemeMap(themeName)); Display.getInstance().getCurrent().refreshTheme(); Display.getInstance().getCurrent().revalidate(); } catch(Exception err) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index ecb051aa5d6..54b7db38f0f 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -3446,7 +3446,7 @@ public void installNativeTheme() { if (nativeTheme != null) { try { Resources r = Resources.open(nativeTheme); - Hashtable h = r.getTheme(r.getThemeResourceNames()[0]); + HashMap h = r.getThemeMap(r.getThemeResourceNames()[0]); Preferences pref = Preferences.userNodeForPackage(JavaSEPort.class); boolean desktopSkin = pref.getBoolean("desktopSkin", false); if(desktopSkin) { @@ -3455,15 +3455,15 @@ public void installNativeTheme() { h.remove("@paintsTitleBarBool"); } injectDesktopThemeConstants(h); - UIManager.getInstance().setThemeProps(h); + UIManager.getInstance().setThemeMap(h); } catch (IOException ex) { ex.printStackTrace(); } } else { if (nativeThemeRes != null) { - Hashtable h = nativeThemeRes.getTheme(nativeThemeRes.getThemeResourceNames()[0]); + HashMap h = nativeThemeRes.getThemeMap(nativeThemeRes.getThemeResourceNames()[0]); injectDesktopThemeConstants(h); - UIManager.getInstance().setThemeProps(h); + UIManager.getInstance().setThemeMap(h); } } } @@ -3474,7 +3474,7 @@ public void installNativeTheme() { /// them on mobile because this method is a no-op there. Values come from the static config /// (set by the generated desktop Stub) with a {@code codename1.arg.desktop.*} system-property /// fallback so build hints work too. - private void injectDesktopThemeConstants(Hashtable h) { + private void injectDesktopThemeConstants(HashMap h) { if (h == null || !isDesktop()) { return; } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/ffmpeg/FFMPEGMedia.java b/Ports/JavaSE/src/com/codename1/impl/javase/ffmpeg/FFMPEGMedia.java index 670ca89804e..d1f517a4f8b 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/ffmpeg/FFMPEGMedia.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/ffmpeg/FFMPEGMedia.java @@ -51,6 +51,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import javax.sound.sampled.AudioFormat; import javax.sound.sampled.AudioSystem; import javax.sound.sampled.FloatControl; @@ -321,11 +323,14 @@ public Component getVideoComponent() { } if (SwingUtilities.isEventDispatchThread()) { videoComponent = new JavaSEPort.Peer(frame, panel); + panel.attach(videoComponent); } else { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { - videoComponent = new JavaSEPort.Peer(frame, panel); + JavaSEPort.Peer created = new JavaSEPort.Peer(frame, panel); + panel.attach(created); + videoComponent = created; } }); CN.invokeAndBlock(new Runnable() { @@ -706,6 +711,8 @@ private static class ProbeData { private static class VideoPanel extends JPanel implements HierarchyListener { private volatile BufferedImage image; private volatile int frameCount; + private final AtomicReference peer = new AtomicReference(); + private final AtomicBoolean framePending = new AtomicBoolean(); VideoPanel(int width, int height) { setBackground(Color.BLACK); @@ -730,6 +737,38 @@ void updateFrame(byte[] bytes, int width, int height) { image = next; frameCount++; repaint(); + showFrameInPeer(); + } + + void attach(JavaSEPort.Peer p) { + peer.set(p); + } + + // A new frame repaints this Swing panel, but the Codename One form + // draws the peer from a buffer copied out of it, and nothing else + // asks for that copy or for the form to repaint. It used to work by + // accident: a safe-area form relaid itself out every EDT cycle, which + // repainted the peer, which refreshed the buffer. Each frame now + // copies the buffer on the AWT thread and repaints the peer on the + // EDT itself, coalesced so a fast decoder queues at most one of each. + private void showFrameInPeer() { + final JavaSEPort.Peer p = peer.get(); + if (p == null || !framePending.compareAndSet(false, true)) { + return; + } + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + framePending.set(false); + p.paintOnBuffer(); + CN.callSerially(new Runnable() { + @Override + public void run() { + p.repaint(); + } + }); + } + }); } boolean hasFrame() { diff --git a/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java index dc231c7c132..a147c3d8584 100644 --- a/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java +++ b/Ports/JavaSE/src/com/codename1/testing/junit/CodenameOneExtension.java @@ -39,7 +39,7 @@ import java.awt.GraphicsEnvironment; import java.lang.reflect.Method; -import java.util.Hashtable; +import java.util.HashMap; import java.util.concurrent.atomic.AtomicReference; /** @@ -336,8 +336,8 @@ private static void installTheme(String resourcePath, ExtensionContext context) throw new IllegalStateException( "Theme resource " + resourcePath + " contains no themes"); } - Hashtable themeProps = r.getTheme(names[0]); - UIManager.getInstance().setThemeProps(themeProps); + HashMap themeProps = r.getThemeMap(names[0]); + UIManager.getInstance().setThemeMap(themeProps); context.getStore(FONT_STATE).put("restore", restore); } catch (java.io.IOException | RuntimeException | Error e) { restore.run(); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 1ad4c6cef0b..48bfe21e6d9 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -5566,7 +5566,7 @@ public void installNativeTheme(){ String fallback = isAndroid_() ? "/android_holo_light.res" : "/iOS7Theme.res"; r = Resources.open(fallback); } - Hashtable tp = r.getTheme(r.getThemeResourceNames()[0]); + HashMap tp = r.getThemeMap(r.getThemeResourceNames()[0]); // The browser has no OS status bar / notch, so the app must not // reserve a status-bar strip at the top of the Form the way iOS @@ -5593,7 +5593,7 @@ public void installNativeTheme(){ // native menu bar at that moment -- which it does until the HTML chrome exists. Forms // without a Toolbar then never published their MenuBar to it. installDesktopChromeIfRequested(); - UIManager.getInstance().setThemeProps(tp); + UIManager.getInstance().setThemeMap(tp); return; } catch (IOException ex){ Log.e(ex); diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 03baee5b7e5..748cfa23e36 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -494,7 +494,7 @@ public void installNativeTheme() { com.codename1.ui.util.Resources r = com.codename1.ui.util.Resources.open(in); String[] names = r.getThemeResourceNames(); if (names != null && names.length > 0) { - com.codename1.ui.plaf.UIManager.getInstance().setThemeProps(r.getTheme(names[0])); + com.codename1.ui.plaf.UIManager.getInstance().setThemeMap(r.getThemeMap(names[0])); } } catch (Throwable t) { // A bad/absent theme must not stop the app from starting. diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index f74de30fb51..73ab1291934 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -382,7 +382,7 @@ public void installNativeTheme() { com.codename1.ui.util.Resources r = com.codename1.ui.util.Resources.open(in); String[] names = r.getThemeResourceNames(); if (names != null && names.length > 0) { - com.codename1.ui.plaf.UIManager.getInstance().setThemeProps(r.getTheme(names[0])); + com.codename1.ui.plaf.UIManager.getInstance().setThemeMap(r.getThemeMap(names[0])); } } catch (Throwable t) { // A bad/absent theme must not stop the app from starting. diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index cd47ef1d7a5..d728ddb0147 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -3165,9 +3165,9 @@ public void installNativeTheme() { InputStream sub = getResourceAsStream("/" + subclassTheme + ".res"); if (sub != null) { r = Resources.open(sub); - Hashtable tp = r.getTheme(r.getThemeResourceNames()[0]); + HashMap tp = r.getThemeMap(r.getThemeResourceNames()[0]); injectDesktopThemeConstants(tp); - UIManager.getInstance().setThemeProps(tp); + UIManager.getInstance().setThemeMap(tp); return; } // Not in the bundle (a framework build that has not generated it yet): @@ -3200,9 +3200,9 @@ public void installNativeTheme() { } if (in != null) { r = Resources.open(in); - Hashtable tp = r.getTheme(r.getThemeResourceNames()[0]); + HashMap tp = r.getThemeMap(r.getThemeResourceNames()[0]); injectDesktopThemeConstants(tp); - UIManager.getInstance().setThemeProps(tp); + UIManager.getInstance().setThemeMap(tp); return; } // Modern theme isn't in the jar (e.g. framework build hasn't @@ -3210,19 +3210,19 @@ public void installNativeTheme() { } if(mode.equals("ios7") || mode.equals("flat") || mode.equals("auto") || mode.equals("modern") || mode.equals("liquid")) { r = Resources.open("/iOS7Theme.res"); - Hashtable tp = r.getTheme(r.getThemeResourceNames()[0]); + HashMap tp = r.getThemeMap(r.getThemeResourceNames()[0]); if(!nativeInstance.isIOS7()) { tp.put("TitleArea.padding", "0,0,0,0"); } injectDesktopThemeConstants(tp); - UIManager.getInstance().setThemeProps(tp); + UIManager.getInstance().setThemeMap(tp); return; } // "legacy" / "iphone" / anything else: pre-flat iPhone theme. r = Resources.open("/iPhoneTheme.res"); - Hashtable tp = r.getTheme(r.getThemeResourceNames()[0]); + HashMap tp = r.getThemeMap(r.getThemeResourceNames()[0]); injectDesktopThemeConstants(tp); - UIManager.getInstance().setThemeProps(tp); + UIManager.getInstance().setThemeMap(tp); } catch (IOException ex) { ex.printStackTrace(); } @@ -3234,7 +3234,7 @@ public void installNativeTheme() { * native menu bar). These theme constants are injected only on the desktop so iOS * phones/tablets are unaffected. Mirrors JavaSEPort.injectDesktopThemeConstants. */ - private void injectDesktopThemeConstants(Hashtable tp) { + private void injectDesktopThemeConstants(HashMap tp) { if (tp == null || !isDesktop()) { return; } @@ -3272,7 +3272,7 @@ private void injectDesktopThemeConstants(Hashtable tp) { * system accent is used rather than a colour invented here, and the guard * leaves any theme that does define the UIID alone. */ - private void injectListFocusStyle(Hashtable tp) { + private void injectListFocusStyle(HashMap tp) { // Any entry at all under the UIID means the theme has an opinion; leave it. for (Object key : tp.keySet()) { if (key instanceof String && ((String) key).indexOf("ListRendererFocus") > -1) { diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/kmp/SessionCard.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/kmp/SessionCard.java new file mode 100644 index 00000000000..80a271b2d9f --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/kmp/SessionCard.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.kmp; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; + +// tag::kotlin-multiplatform-interop-session-card[] +public class SessionCard extends Container { + public final Label time = new Label("", "SessionTime"); + public final Label title = new Label("", "SessionTitle"); + public final Label speaker = new Label("", "SessionSpeaker"); + public final Button heart = new Button("", "Favorite"); + + public SessionCard(Runnable onFavorite) { + super(new BorderLayout(), "SessionCard"); + add(BorderLayout.CENTER, BoxLayout.encloseY(time, title, speaker)); + add(BorderLayout.EAST, heart); + heart.addActionListener(e -> onFavorite.run()); + } + + public void bind(String when, String talk, String by, boolean favorite) { + time.setText(when); + title.setText(talk); + speaker.setText(by); + FontImage.setMaterialIcon(heart, favorite + ? FontImage.MATERIAL_FAVORITE : FontImage.MATERIAL_FAVORITE_BORDER); + } +} +// end::kotlin-multiplatform-interop-session-card[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/kotlin-multiplatform-interop.sh b/docs/demos/common/src/main/snippets/developer-guide/kotlin-multiplatform-interop.sh new file mode 100644 index 00000000000..9941d76fe14 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/kotlin-multiplatform-interop.sh @@ -0,0 +1,10 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::kotlin-multiplatform-interop-bash-001[] +mvn cn1:import-kmp-project -Dcn1.kmp.import=/path/to/MyKmpApp +// end::kotlin-multiplatform-interop-bash-001[] + +// tag::kotlin-multiplatform-interop-bash-002[] +mvn cn1:run # the simulator +mvn cn1:build -Dcodename1.platform=ios # an iOS build on the build server +// end::kotlin-multiplatform-interop-bash-002[] diff --git a/docs/demos/common/src/test/java/com/codenameone/developerguide/SessionCardTest.java b/docs/demos/common/src/test/java/com/codenameone/developerguide/SessionCardTest.java new file mode 100644 index 00000000000..3cdebf8b60d --- /dev/null +++ b/docs/demos/common/src/test/java/com/codenameone/developerguide/SessionCardTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide; + +// tag::kotlin-multiplatform-interop-session-card-test[] +import com.codename1.testing.AbstractTest; +import com.codenameone.developerguide.kmp.SessionCard; + +public class SessionCardTest extends AbstractTest { + @Override + public boolean runTest() throws Exception { + int[] taps = {0}; + SessionCard card = new SessionCard(() -> taps[0]++); + card.bind("09:00 - Hall A", "Keynote", "A. Speaker, Codename One", false); + + assertEqual("Keynote", card.title.getText()); + + card.heart.released(); // a tap + assertTrue(taps[0] == 1, "the heart reports the tap"); + return true; + } +} +// end::kotlin-multiplatform-interop-session-card-test[] diff --git a/docs/developer-guide/Android-Interop.asciidoc b/docs/developer-guide/Android-Interop.asciidoc index 4174ed3461e..919c0e371b2 100644 --- a/docs/developer-guide/Android-Interop.asciidoc +++ b/docs/developer-guide/Android-Interop.asciidoc @@ -1,3 +1,4 @@ +[[android-interop]] == Running Android applications A classic Android application -- activities, XML layouts, `res/values`, diff --git a/docs/developer-guide/Kotlin-Multiplatform-Interop.asciidoc b/docs/developer-guide/Kotlin-Multiplatform-Interop.asciidoc new file mode 100644 index 00000000000..25edf36286f --- /dev/null +++ b/docs/developer-guide/Kotlin-Multiplatform-Interop.asciidoc @@ -0,0 +1,335 @@ +[[kmp-interop]] +== Running Kotlin Multiplatform and Compose applications + +A Kotlin Multiplatform (KMP) module whose user interface is written with +Compose Multiplatform builds as a Codename One application. The shared +`commonMain` code is compiled unchanged, the composables render as Codename +One components, and the result ships to every Codename One target: iOS, +Android, the web, macOS, Windows and Linux (as native binaries with no JVM), +watchOS and tvOS. + +=== Motivation + +Kotlin Multiplatform with Compose Multiplatform is the toolkit closest to +Codename One's own model: one codebase, one declarative UI, logic shared +between every client. It's a good way to write an application. Codename One +takes that code further -- onto more platforms, into a smaller and faster +application, and next to a Java API that every target shares. + +Compose Multiplatform runs on Kotlin/Native on iOS and links its own drawing +engine into the application. Codename One takes the JVM half of a KMP project +instead. kotlinc already compiles `commonMain` to ordinary class files for +Android and the desktop. Those classes, together with the real Compose runtime +and the real `kotlinx.coroutines`, are exactly what Codename One's translator +reads. Only the UI layer -- `androidx.compose.ui`, `foundation` and +`material3` -- is replaced, by one written over Codename One components and +`Graphics`. + +The same application, built from the same `commonMain` both ways and measured +on the same iPhone simulator, with Compose Multiplatform as 100%. The second +bar is the same screens written in Java against Codename One directly, which +the next section explains: + +image::img/kmp-bench-results.svg["Size, memory, start-up, recomposition and scrolling of the same application built by Codename One from Compose and from Java, relative to Compose Multiplatform",scaledwidth=95%] + +- **The same Compose code runs in a quarter of the CPU time.** A state change + is drawn in 4.6 ms against 19.2 ms, and one step of scrolling in 4.0 ms + against 17.0 ms. Both keep pace with the display, so the difference is + headroom: for heavier screens, older devices and battery. +- **The executable is 27% smaller** (16.8 MB against 22.9 MB) and the bundle + 20% smaller, although the Codename One one carries the whole iOS port. +- **Start-up is faster**, 961 ms against 1008 ms cold and 580 ms against + 604 ms warm. +- **Memory is level**, 47 MB of physical footprint against 48 MB. + +The benchmark, its method and how to run it are in `scripts/kmp-bench` in the +Codename One repository. These are simulator numbers; they show where each +runtime spends CPU in identical code, not a device's frame rate. + +image::img/kmp-keynote-schedule.png["The Keynote schedule screen rendered by Compose Multiplatform (left) and by Codename One (right)",scaledwidth=70%] + +Beyond size, the composables become real components, so a Compose screen +uses the platform's own text input and accessibility, can sit inside a +Codename One form next to any other component, and reaches every Codename One +API directly -- push notifications, in-app purchase, the native maps, the +backend -- with no `expect`/`actual` plumbing per platform. + +The same build also reaches targets Compose Multiplatform's UI doesn't run +on: watchOS, tvOS, and Windows, Linux and macOS as native binaries with no JVM +to install. The Codename One backend runs the same Kotlin on the server, so a +data class or validation rule in `commonMain` is shared by the app and the +service that feeds it. + +=== Dropping to Java where it pays + +A Compose screen and a Codename One screen are the same kind of object at run +time -- a component in a form -- so an application doesn't have to choose one +for all its screens. Kotlin calls every Codename One class directly, and a Java +class can sit beside the composables in the same module. Compose +Multiplatform has no such floor: below `commonMain` there is a different +language and a different UI toolkit on every platform. + +The benchmark measures what that floor is worth. `JavaBench.java` in +`scripts/kmp-bench/app/java` is the Keynote screens written against Codename +One directly, timed by the same steps. Its list is an `InfiniteContainer`, +which makes cards a batch at a time as the scroll nears the end -- as +`LazyColumn` composes only what comes into view -- and rebinds a card it has +already made instead of building another. It ships no Kotlin standard +library, no Compose runtime and no coroutines: + +- **A state change is drawn in 1.5 ms**, thirteen times faster than Compose + Multiplatform's 19.2 ms and three times faster than the Compose build. +- **A step of scrolling takes 1.8 ms**, against 17.0 ms for Compose + Multiplatform. +- **The executable is 8.9 MB**, 39% of Compose Multiplatform's 22.9 MB, and + the bundle is 10.2 MB. +- **Memory is level** with both, at 47 MB. + +The time gap is mostly the price of the reactive model. A composition +records the state it read, compares what it emits against what it emitted +last time and applies the difference; an imperative update sets three labels +on a card that already exists. The size gap is Kotlin: the Java build carries +neither the Kotlin standard library nor the Compose runtime. + +Nor does the declarative form buy readability. Here is one session card both +ways. The Compose version, from the benchmark: + +[source,kotlin] +---- +include::../../scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/GuideSessionCard.kt[tag=kotlin-multiplatform-interop-session-card-compose,indent=0] +---- + +And in Codename One, with the look in the theme's CSS where a designer can +change it: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/kmp/SessionCard.java[tag=kotlin-multiplatform-interop-session-card,indent=0] +---- + +The Java card says what happens and when: the components are made once, +`bind` fills them, a tap calls back as `onFavorite` does. Nothing reruns +behind the code's back, so a breakpoint in `bind` stops exactly when a card +shows a session -- the question a recomposition count leaves open. + +It tests as directly. A component is an ordinary object, so a unit test makes +one, binds it and presses its button, with no test rule, semantics tree or +tags kept only for the test: + +[source,java,role=cn1-no-try] +---- +include::../demos/common/src/test/java/com/codenameone/developerguide/SessionCardTest.java[tag=kotlin-multiplatform-interop-session-card-test,indent=0] +---- + +The card's components are public final fields, so the test reads them +directly. It +runs in the simulator with `mvn cn1:test`, like every other Codename One test. + +A hot list, a custom chart or a screen that has to fit a watch is where the +imperative path pays first; nothing stops a team writing every screen that +way. + +=== One platform API on every target + +The `expect`/`actual` declarations a KMP project writes per platform are +mostly calls Codename One already makes the same everywhere. Composable code +calls each of them as an ordinary Kotlin function: + +- <>, in-app purchase and + subscriptions, and <>. +- <> and <> + hardening, as build options rather than per-platform SDKs. +- <> and + <>. +- Storage, SQLite, networking, the camera, location, sharing and the native + maps, with one implementation per platform maintained in Codename One. + +The targets go past phones and desktops too: Apple Watch and Wear OS from the +same project as the phone application, CarPlay and Android Auto, tvOS, +embedded Linux boards with no X11 and no JVM, the browser, and the +<<_server_side_backend,server-side backend>>. + +=== Quick start + +Create a Codename One application (for example at +https://start.codenameone.com) and import the KMP project into it: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/kotlin-multiplatform-interop.sh[tag=kotlin-multiplatform-interop-bash-001,indent=0] +---- + +The goal copies the module's `commonMain` and its JVM source set (`jvmMain`, +else `desktopMain`) into `common/src/main/kmp`, and rewrites the +application's main class so that it shows the module's root composable. Two +options cover projects it can't decide alone: + +`-Dcn1.kmp.module=composeApp`:: The module to import, when more than one has a +`src/commonMain`. +`-Dcn1.kmp.entry=com.example.App`:: The fully qualified root composable, when +`commonMain` declares no single `@Composable fun App()`. + +The Android and iOS source sets aren't copied. They call those platforms +directly, and Codename One supplies both. A JVM file whose `main` opens a +desktop window (`application { Window { App() } }`) is left out too: the +generated main class starts the application instead. + +Then build and run as usual: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/kotlin-multiplatform-interop.sh[tag=kotlin-multiplatform-interop-bash-002,indent=0] +---- + +==== Gradle + +A Gradle application needs the Compose compiler plugin, +`id("org.jetbrains.kotlin.plugin.compose")` at the application's Kotlin +version, applied next to the Codename One plugin. The build stops with the +line to add when it's missing. + +`convert-to-gradle` writes it for you. The Codename One settings plugin adds +Google's Maven repository, limited to the `androidx.*` groups, which is where +the Compose runtime is published. + +==== Placing the sources by hand + +`common/src/main/kmp/commonMain/kotlin` and `common/src/main/kmp/jvmMain/kotlin` +are ordinary Kotlin source roots. The archetype's `compose` profile turns on +whenever `common/src/main/kmp` exists: it adds `codenameone-compose-compat`, +applies the Compose compiler plugin and sets both kotlinc's `jvmTarget` and +javac's source and target to 11, because the Compose runtime's inline functions are +JVM 11 bytecode. The Compose compiler plugin's version must equal the Kotlin +version, which is why both come from one property. + +=== The entry point + +A composition is shown in a `ComposeForm`, or embedded anywhere as a +`ComposeView`, which is an ordinary Codename One `Container`: + +[source,kotlin] +---- +include::../../scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/GuideSnippets.kt[tag=kotlin-multiplatform-interop-kotlin-002,indent=0] +---- + +Or beside other components: + +[source,kotlin] +---- +include::../../scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/GuideSnippets.kt[tag=kotlin-multiplatform-interop-kotlin-003,indent=0] +---- + +Both live in `com.codename1.compose`. From Java, a `ComposeView` is created +by a small Kotlin function that returns one, since a composable lambda can +only be written in Kotlin. + +=== How the build works + +image::img/kmp-build-pipeline.svg["KMP sources compiled by kotlinc with the Compose plugin, shipped with the unmodified Compose runtime and coroutines, then translated by ParparVM",scaledwidth=95%] + +There is no transpiler. kotlinc compiles the sources for the JVM, with the +Compose compiler plugin, against `compose-compat`. The application's +classes, the unmodified Compose runtime, `kotlinx.coroutines` and +`compose-compat` are then handled like any other Codename One code: the +bytecode compliance check runs over them, and ParparVM translates them to C +for iOS, the watch, TV and the native desktop targets. Android runs the +classes on ART and the simulator on the JVM. + +`androidx/compose/` is never relocated, not even when the +<> is active in the +same application: the Compose runtime the application was compiled against +is the one it runs. + +=== How a composable becomes components + +image::img/kmp-runtime-layers.svg["The application's composables over the unmodified Compose runtime, over compose-compat, over Codename One core and the port",scaledwidth=95%] + +Composition, snapshots, `remember`, `LaunchedEffect` and recomposition are +the real Compose runtime's. What it emits are `UiNode` objects, each a +Codename One `Container`: + +* **Measuring is Compose's.** A node walks its modifier chain outermost first + under Compose constraints (`Box`, `Row`/`Column` and leaf policies). The + Codename One layout pass only copies the result out, so `weight`, + `fillMaxWidth`, `wrapContentSize` and intrinsic padding behave as they do + in Compose. +* **Frames come from the Codename One animation loop.** `ComposeHost` runs + the `Recomposer` with a `BroadcastFrameClock` that `Form.registerAnimated` + ticks, so `withFrameNanos`, `animateFloatAsState` and `AnimatedVisibility` run + in step with every other Codename One animation, and an idle screen costs + nothing. +* **`Dispatchers.Main` is the EDT.** kotlinx.coroutines finds its main + dispatcher through a service file. There is no service loader on the + device, so the translator compiles that file into a registry at build time. +* **Lazy lists compose what's in view.** `LazyColumn` and `LazyRow` compose + each item as its own composition when it comes within a quarter of a view + of the window and dispose of it half a view past it. `scrollToItem` works + on items that were never measured. +* **The system bars are respected.** `TopAppBar`, `CenterAlignedTopAppBar` + and `NavigationBar` pad themselves by the display's safe area, read when + they're measured, so a notch or a home indicator that the native view + reports late still moves them. + +=== Supported API + +`compose-compat` covers what typical application screens use. Anything else +is a build error (see below), never a silent no-op. + +Layout:: `Box`, `Row`, `Column`, `Spacer`, `LazyColumn`, `LazyRow`, +`Arrangement`, `Alignment`, `PaddingValues`, `Canvas`. +Modifiers:: size (`size`, `width`, `height`, `fillMax*`, `wrapContent*`, +`*In`, `required*`, `defaultMinSize`, `aspectRatio`), `padding`, `offset`, +`weight`, `background`, `border`, `clip`, `alpha`, `drawBehind`, +`clickable`, `combinedClickable`, `verticalScroll`, `horizontalScroll`, +`testTag`. +Text:: `Text`, `BasicText`, `TextField`, `OutlinedTextField`, +`BasicTextField`, `TextStyle`, `FontWeight`, the Material 3 type scale. +Material 3:: `MaterialTheme` and `ColorScheme`, `Scaffold`, `Surface`, +`TopAppBar`, `CenterAlignedTopAppBar`, `NavigationBar` and +`NavigationBarItem`, `Card`, `ElevatedCard`, `OutlinedCard`, `Button`, +`ElevatedButton`, `FilledTonalButton`, `OutlinedButton`, `TextButton`, +`IconButton`, `FloatingActionButton`, `ExtendedFloatingActionButton`, +`AssistChip`, `FilterChip`, `Checkbox`, `Switch`, the progress indicators, +dividers, `Icon` with `Icons.Filled`, `Icons.Outlined` and +`Icons.AutoMirrored`. +Animation:: `AnimatedVisibility`, `animateFloatAsState`, `animateDpAsState`, `animateColorAsState`, +`rememberInfiniteTransition` with `animateFloat`. +Images:: `Image` with `ImageVector` and `ContentScale`. + +`LazyColumn(reverseLayout = true)` throws: it's not implemented. + +==== Unsupported API is a build error + +The bytecode compliance check runs over the compiled classes. A composable or +modifier that `compose-compat` doesn't provide is a reference to something +that doesn't exist, and the build reports it under its original name with +the source line, before anything is translated. Port such a screen by +replacing the call, or by writing the missing piece in the application with +the supported building blocks -- most custom components are a `Box` with a +`drawBehind`. + +=== Testing + +Compose screens are tested the way any Codename One screen is: a +`ComposeView` is a component, so screenshot tests and the component inspector +see it like any other. The Codename One test application +(`scripts/hellocodenameone`) carries Compose screens under +`common/src/main/kmp` -- layout and modifiers, typography and buttons, a +`Scaffold` over a `LazyColumn` of cards, and state with `LaunchedEffect` and +`AnimatedVisibility` -- and its `KmpCompat*` screenshot tests compare them on +every platform the test suite runs on. + +=== Troubleshooting + +The Compose compiler plugin reports a Kotlin version mismatch:: The plugin +and kotlinc are different versions. Take both from one property. +kotlinc or javac reject JVM 11 bytecode or inline functions:: The module is +compiling below JVM 11. The `compose` profile sets it: check that +`common/src/main/kmp` exists and that nothing in the pom overrides +`kotlin.compiler.jvmTarget`. +The Gradle build stops asking for `org.jetbrains.kotlin.plugin.compose`:: Add +the plugin line shown in the Gradle section. +`Incremental compilation failed` after changing the Kotlin version:: Kotlin's +incremental cache from the old compiler is in `target`. Build once with +`clean`. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 3377bf5ba5d..91fbebd15d6 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -286,6 +286,8 @@ include::Unity-Interop.asciidoc[] include::Desktop-Interop.asciidoc[] +include::Kotlin-Multiplatform-Interop.asciidoc[] + include::Working-with-Mac-OS-X.asciidoc[] include::Working-with-Mac-Catalyst.asciidoc[] diff --git a/docs/developer-guide/img/kmp-bench-results.svg b/docs/developer-guide/img/kmp-bench-results.svg new file mode 100644 index 00000000000..ca32ed2c4cb --- /dev/null +++ b/docs/developer-guide/img/kmp-bench-results.svg @@ -0,0 +1,61 @@ + + + Keynote on an iPhone simulator, relative to Compose Multiplatform + The dashed line is Compose Multiplatform (100%); each bar is a share of it. Shorter is better on every row. + + Codename One, same Compose source + + Codename One, the same screens in Java + Executable, stripped + + 73% (16.8 MB vs 22.9 MB) + + 39% (8.9 MB vs 22.9 MB) + App bundle + + 80% (18.3 MB vs 22.9 MB) + + 45% (10.2 MB vs 22.9 MB) + Physical footprint + + 98% (47.0 MB vs 48.0 MB) + + 98% (47.0 MB vs 48.0 MB) + Cold start + + 95% (961 ms vs 1008 ms) + + 80% (805 ms vs 1008 ms) + Warm start, median + + 96% (580 ms vs 604 ms) + + 95% (572 ms vs 604 ms) + Recomposition, median + + 24% (4.6 ms vs 19 ms) + + 8% (1.5 ms vs 19 ms) + Recomposition, p95 + + 27% (6.2 ms vs 23 ms) + + 9% (2.0 ms vs 23 ms) + Scroll work per frame, median + + 24% (4.0 ms vs 17 ms) + + 10% (1.8 ms vs 17 ms) + Scroll work per frame, p95 + + 36% (6.6 ms vs 18 ms) + + 15% (2.7 ms vs 18 ms) + Scroll frame interval, median + + 106% (35 ms vs 33 ms) + + 104% (35 ms vs 33 ms) + + Compose Multiplatform = 100% + diff --git a/docs/developer-guide/img/kmp-build-pipeline.svg b/docs/developer-guide/img/kmp-build-pipeline.svg new file mode 100644 index 00000000000..2362b903fb1 --- /dev/null +++ b/docs/developer-guide/img/kmp-build-pipeline.svg @@ -0,0 +1,77 @@ + + + + What the build does with Kotlin Multiplatform sources + kotlinc compiles commonMain for the JVM with the Compose compiler plugin, exactly as an Android build would. + The classes, the unmodified Compose runtime and kotlinx.coroutines are then translated like any other Codename One code. + Your module + + commonMain + shared Kotlin, @Composable UI + + jvmMain / desktopMain + minus the file that opens a window + + common/src/main/java + generated main class + cn1:import-kmp-project copies the + first two into common/src/main/kmp + Compile + + kotlinc + Compose plugin + JVM target 11 + against compose-compat + + + + Classes that ship + + your classes + + Compose runtime + unmodified jar + + kotlinx.coroutines + unmodified jar + + compose-compat + the UI layer, ours + + + + bytecode-compliance + unsupported API = build error + + + + Targets + + ParparVM: bytecode to C, Xcode / native toolchain + + iOS + + + watchOS / tvOS + + + Windows / Linux + no JVM + + + macOS + + + Android + classes run on ART + + + Simulator / desktop + classes run on the JVM + + + One Compose source, one app + Gradle or Maven, the same plugin goals + Dispatchers.Main is the EDT + no Skia, no Kotlin/Native runtime + native theme, native text input + diff --git a/docs/developer-guide/img/kmp-keynote-schedule.png b/docs/developer-guide/img/kmp-keynote-schedule.png new file mode 100644 index 00000000000..c7bbf1a4be9 Binary files /dev/null and b/docs/developer-guide/img/kmp-keynote-schedule.png differ diff --git a/docs/developer-guide/img/kmp-runtime-layers.svg b/docs/developer-guide/img/kmp-runtime-layers.svg new file mode 100644 index 00000000000..1b64b17ff55 --- /dev/null +++ b/docs/developer-guide/img/kmp-runtime-layers.svg @@ -0,0 +1,44 @@ + + + + How a composable becomes Codename One components + Only the UI layer is replaced. Composition, state and coroutines are the real libraries, + and every node they emit is a Codename One container that the port draws natively. + + Your composables + Scaffold { LazyColumn { items(sessions) { Card { ... } } } } + + + Compose runtime (unmodified) + Composer, slot table, snapshots, Recomposer, remember, LaunchedEffect + + + compose-compat (Codename One) + Modifier chains, Box/Row/Column/Lazy policies, Text, material3, animation + + + Codename One core + UiNode is a Container: Graphics, fonts, text fields, scrolling, accessibility + + + Port + iOS (UIKit + Metal), Android, JavaScript, desktop, watch, TV + + ComposeHost + runs the Recomposer + BroadcastFrameClock fed by + Form.registerAnimated + = one frame per CN1 animation tick + + Dispatchers.Main + the EDT, found through a + build-time service registry + + Measuring + Compose constraints, outermost + modifier first; CN1 layout only + copies the result out + + + + diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index a604b227b00..798f31b7ada 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -858,6 +858,9 @@ blocklist blocklists Sepolia ntfy +composables +Skia +kotlinc MQTT PKCS DTOs? diff --git a/docs/website/data/port_status.json b/docs/website/data/port_status.json index 9d3e19373b3..0f9c461132e 100644 --- a/docs/website/data/port_status.json +++ b/docs/website/data/port_status.json @@ -152,6 +152,13 @@ "description": "Runs unmodified Swing, SwingX and JavaFX code -- layout managers, the standard controls, tables, trees and lists with custom renderers, Graphics2D and Canvas painting, FXML with controllers and style sheets, and modal dialogs that block their caller -- through the desktop compatibility runtimes, embedded in a Codename One form.", "tests": ["DesktopCompatSwingControlsScreenshotTest","DesktopCompatSwingTablesScreenshotTest","DesktopCompatSwingTreesScreenshotTest","DesktopCompatSwingPaintingScreenshotTest","DesktopCompatSwingContainersScreenshotTest","DesktopCompatSwingOptionPaneScreenshotTest","DesktopCompatSwingXScreenshotTest","DesktopCompatFxControlsScreenshotTest","DesktopCompatFxDataScreenshotTest","DesktopCompatFxShapesScreenshotTest","DesktopCompatFxFxmlScreenshotTest","DesktopCompatFxPanesScreenshotTest","DesktopCompatFxAlertScreenshotTest"] }, + { + "id": "kmp-compose-compatibility", + "category": "Application runtime", + "name": "Kotlin Multiplatform and Compose applications (androidx.compose.*)", + "description": "Runs Compose Multiplatform code from a Kotlin Multiplatform commonMain -- Box, Row and Column with modifiers, the Material 3 type scale, buttons, cards, Scaffold with a top app bar over a LazyColumn, state, effects and AnimatedVisibility -- on the unmodified Compose runtime, with every node a Codename One component, embedded in a Codename One form.", + "tests": ["KmpCompatLayoutScreenshotTest","KmpCompatTypographyScreenshotTest","KmpCompatListScreenshotTest","KmpCompatStateScreenshotTest"] + }, { "id": "ads", "category": "Application runtime", @@ -1695,6 +1702,22 @@ { "pattern": "DesktopCompatFxAlert", "test": "DesktopCompatFxAlertScreenshotTest" + }, + { + "pattern": "KmpCompatLayout", + "test": "KmpCompatLayoutScreenshotTest" + }, + { + "pattern": "KmpCompatTypography", + "test": "KmpCompatTypographyScreenshotTest" + }, + { + "pattern": "KmpCompatList", + "test": "KmpCompatListScreenshotTest" + }, + { + "pattern": "KmpCompatState", + "test": "KmpCompatStateScreenshotTest" } ] } diff --git a/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index c7b4d3a411a..7f75976bca6 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -1844,6 +1844,7 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc extendAppCompatActivity = request.getArg("android.extendAppCompatActivity", "false").equals("true"); // When using gradle 8 we need to strip kotlin files from user classes otherwise we get duplicate class errors stripKotlinFromUserClasses = useGradle8; + gradleProvidedLibraryDependencies = ""; if (!request.getArg("android.java8", "true").equals("true")) { // Java 6 source level was produced by running retrolambda over the compiled // classes. Retrolambda is gone (it never supported the JDK 17 that Gradle 8 @@ -2491,6 +2492,7 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc } if (stripKotlinFromUserClasses) { stripKotlin(dummyClassesDir); + gradleProvidedLibraryDependencies = stripGradleProvidedLibraries(dummyClassesDir); } File appDir = buildToolsVersionInt >= 27 ? @@ -8324,6 +8326,32 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { if (hasKotlinSources && kotlinVersion.length() == 0 && !builtInKotlin) { kotlinVersion = useGradle8 ? "1.9.22" : "1.7.22"; } + // A declared kotlinx-coroutines brings its own kotlin-stdlib (2.1 for coroutines + // 1.10), and an older compiler refuses that stdlib's metadata ("compiled with an + // incompatible version of Kotlin"), so the compiler is raised to read it. That + // includes a pinned version: the Health Connect floor above pins 1.9.x, and no + // lower pin can compile against the stdlib the application itself shipped. + if (hasKotlinSources && kotlinVersion.length() > 0 && useGradle8 + && gradleProvidedLibraryDependencies.contains("kotlinx-coroutines-core")) { + String comparable = HealthManifestFragments.numericVersionPrefix(kotlinVersion); + if (comparable == null + || compareVersions(comparable, KOTLIN_FOR_GRADLE_PROVIDED_COROUTINES) < 0) { + if (request.getArg("android.topDependency", "").contains("kotlin-gradle-plugin")) { + // The app's own kotlin-gradle-plugin line wins over ours, so only + // the app can raise the compiler; say so instead of claiming to. + log("WARNING: the application ships kotlinx-coroutines, whose kotlin-stdlib" + + " needs Kotlin " + KOTLIN_FOR_GRADLE_PROVIDED_COROUTINES + + " or newer, but android.topDependency pins its own" + + " kotlin-gradle-plugin; raise it if :app:compileDebugKotlin" + + " reports an incompatible Kotlin version"); + } else { + log("The application ships kotlinx-coroutines, whose kotlin-stdlib Kotlin " + + kotlinVersion + " cannot read; compiling with Kotlin " + + KOTLIN_FOR_GRADLE_PROVIDED_COROUTINES + " instead"); + kotlinVersion = KOTLIN_FOR_GRADLE_PROVIDED_COROUTINES; + } + } + } String kotlinPluginApply = ""; String kotlinRuntimeDependency = ""; if (hasKotlinSources && kotlinVersion.length() > 0) { @@ -8677,6 +8705,7 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { + " "+compile+" fileTree(dir: 'libs', include: ['*.jar'])\n" + request.getArg("android.supportv4Dep",supportV4Default) + "\n" + kotlinRuntimeDependency + + gradleProvidedLibraryDependencies + addNewlineIfMissing(additionalDependencies) + addNewlineIfMissing(aiExtraGradleDependencies.toString()) + addNewlineIfMissing(request.getArg("android.gradleDep", "")) @@ -12710,6 +12739,67 @@ private boolean hasSourceFileWithExtension(File dir, String extension) { return false; } + // Libraries a Compose application (src/main/kmp) carries in its own jar that the + // Android Gradle graph also supplies: AndroidX itself depends on all three. Left in + // userClasses.jar they fail checkDebugDuplicateClasses, so the classes are dropped + // and the coordinate is declared instead, at the version compose-compat was built + // against; Gradle then resolves the higher of that and what AndroidX asks for, never + // an older androidx.collection without the ScatterSet the Compose runtime calls. + private static final String[][] GRADLE_PROVIDED_LIBRARIES = { + // Raising this also raises KOTLIN_FOR_GRADLE_PROVIDED_COROUTINES. + {"kotlinx/coroutines", "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2"}, + // The coroutines jar's debugger frames, outside its package; no coordinate + // of its own, the line above already declares the jar. + {"_COROUTINE", null}, + {"androidx/collection", "androidx.collection:collection:1.5.0"}, + {"androidx/annotation", "androidx.annotation:annotation:1.9.1"} + }; + + /// The Kotlin compiler a Kotlin app gets by default when the coroutines entry of + /// GRADLE_PROVIDED_LIBRARIES is declared: the stdlib that coroutines version depends + /// on, or newer. Raise it together with that coordinate. + private static final String KOTLIN_FOR_GRADLE_PROVIDED_COROUTINES = "2.1.21"; + + private String gradleProvidedLibraryDependencies = ""; + + private String stripGradleProvidedLibraries(File dummyClassesDir) { + StringBuilder deps = new StringBuilder(); + for (String[] lib : GRADLE_PROVIDED_LIBRARIES) { + File directory = new File(dummyClassesDir, lib[0].replace('/', File.separatorChar)); + if (directory.isDirectory()) { + String coordinate = lib[1]; + if (lib[0].equals("kotlinx/coroutines")) { + // The version the application shipped, so a newer one is never + // downgraded; the jar records it in this file. + String shipped = readTrimmed(new File(dummyClassesDir, + "META-INF" + File.separator + "kotlinx_coroutines_core.version")); + if (shipped != null && shipped.length() > 0) { + coordinate = coordinate.substring(0, coordinate.lastIndexOf(':') + 1) + shipped; + } + } + delTree(directory, true); + if (coordinate == null) { + log("Deleted directory " + directory); + continue; + } + log("Deleted directory " + directory + "; declaring " + coordinate + " instead"); + deps.append(" implementation '").append(coordinate).append("'\n"); + } + } + return deps.toString(); + } + + private static String readTrimmed(File f) { + if (!f.isFile()) { + return null; + } + try { + return new String(java.nio.file.Files.readAllBytes(f.toPath()), "UTF-8").trim(); + } catch (IOException ex) { + return null; + } + } + private void stripKotlin(File dummyClassesDir) { String[] skipDirectories = new String[] { "org" + File.separator + "jetbrains" + File.separator + "annotations", diff --git a/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java index e93312440f8..e20dc09906c 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1514,6 +1514,14 @@ boolean usesWearable() { * description.

*/ private boolean usesContactPicker; + + /** + * Whether the application reads or writes the address book, decided by + * the scan the Android builder asks the same question with. Such an + * application gets AddressBook linked and, when it declares none, a + * generic {@code NSContactsUsageDescription}. + */ + private final ContactsPermissionScan contactsScan = new ContactsPermissionScan(); private boolean usesCalendarEventApi; private boolean usesCalendarTaskApi; private String firstBonjourType; @@ -2761,6 +2769,7 @@ public void usesClass(String cls) { && "com/codename1/contacts/ContactPicker".equals(cls)) { usesContactPicker = true; } + contactsScan.usesClass(cls); if (!usesLocalNotifications && cls.indexOf("com/codename1/notifications/LocalNotification") == 0) { usesLocalNotifications = true; } @@ -3127,6 +3136,7 @@ public void usesClassMethod(String cls, String method) { || method.indexOf("isContactPickerSupported") > -1)) { usesContactPicker = true; } + contactsScan.usesClassMethod(cls, method); // Health.getStore()/getWorkouts() mean a real platform // store; Health.getSensors() means BLE only. The class // reference alone cannot tell them apart, so the facade @@ -3792,6 +3802,17 @@ public void usesClassMethod(String cls, String method) { } } + boolean usesContactsApi = contactsScan.readPermissionRequired() + || contactsScan.writePermissionRequired(); + if (usesContactsApi) { + // iOS terminates an app that opens the address book with no purpose + // string, so one the developer did not write is supplied. + String contacts = privacyUsageDescriptions.get("NSContactsUsageDescription"); + if (contacts == null || contacts.trim().length() == 0) { + privacyUsageDescriptions.put("NSContactsUsageDescription", + "Some functionality of the application requires access to your contacts."); + } + } if (xcodeVersion >= 9) { try { for (String privacyKey : privacyUsageDescriptions.keySet()) { @@ -4864,6 +4885,15 @@ public void usesClassMethod(String cls, String method) { } } + // Linked with the define the AddressBook natives compile under, + // INCLUDE_CONTACTS_USAGE, which the contacts usage description turns + // on; the scan supplies that description for an app that calls the + // contacts API without one. + if (contactsScan.readPermissionRequired() || contactsScan.writePermissionRequired() + || privacyUsageDescriptions.containsKey("NSContactsUsageDescription")) { + addLibs = appendFrameworks(addLibs, "AddressBookUI.framework", "AddressBook.framework"); + } + // LocalAuthentication is required only when the app actually uses // com.codename1.security.Biometrics / SecureStorage. The scanner // above sets usesBiometrics if any com/codename1/security/ class diff --git a/maven/build-engine/src/main/java/com/codename1/maven/GradleConversion.java b/maven/build-engine/src/main/java/com/codename1/maven/GradleConversion.java index 70338d573c7..1a31f14427d 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/GradleConversion.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/GradleConversion.java @@ -423,6 +423,11 @@ public ProjectLayout convert(File sourceDir, File targetDir, String cn1Version) // nothing: the app would build, minus its Kotlin classes. addKotlinPlugin(new File(targetDir, "build.gradle.kts"), true); } + if (KmpProjectImporter.isKmpProject(KmpProjectImporter.kmpDir(targetDir))) { + // Compose sources compile only with the Compose compiler, and + // the plugin refuses the build without it rather than guess. + addComposePlugin(new File(targetDir, "build.gradle.kts")); + } } catch (IOException ex) { throw new BuildFailureException("Could not convert " + sourceDir + ": " + ex.getMessage(), ex); } @@ -1044,6 +1049,26 @@ static void addKotlinPlugin(File buildScript, boolean withVersion) throws IOExce Files.write(buildScript.toPath(), updated.getBytes(StandardCharsets.UTF_8)); } + /// Adds the Compose compiler plugin, at the Kotlin plugin's version (the two + /// are released together and must match), to the `plugins {}` block + /// [#addKotlinPlugin] wrote. + static void addComposePlugin(File buildScript) throws IOException { + String text = new String(Files.readAllBytes(buildScript.toPath()), StandardCharsets.UTF_8); + if (text.contains(KmpProjectImporter.COMPOSE_GRADLE_PLUGIN)) { + return; + } + int at = text.indexOf("kotlin(\"jvm\")"); + int eol = at < 0 ? -1 : text.indexOf('\n', at); + if (eol < 0) { + addKotlinPlugin(buildScript, true); + addComposePlugin(buildScript); + return; + } + String line = "\n id(\"" + KmpProjectImporter.COMPOSE_GRADLE_PLUGIN + "\") version \"" + KOTLIN_VERSION + "\""; + Files.write(buildScript.toPath(), (text.substring(0, eol) + line + text.substring(eol)) + .getBytes(StandardCharsets.UTF_8)); + } + /// The iOS app extensions and localized strings, from wherever the source /// build tool keeps them to the Gradle layout's own directories. private static void copyIosExtras(ProjectLayout from, ProjectLayout to) throws IOException { @@ -1077,7 +1102,9 @@ private static void copyPlatformResources(ProjectLayout from, ProjectLayout to) // Added by the plugin for a project with src/main/unity, at its own // version, together with the translator of that version: a // declaration carried over from the pom could name another one. - "codenameone-unity-compat")); + "codenameone-unity-compat", + // Likewise for a project with src/main/kmp. + "codenameone-compose-compat")); /// `build.gradle.kts` lines for the dependencies a module's pom declares /// itself, leaving out the framework, which the plugin adds. A dependency of diff --git a/maven/build-engine/src/main/java/com/codename1/maven/KmpProjectImporter.java b/maven/build-engine/src/main/java/com/codename1/maven/KmpProjectImporter.java new file mode 100644 index 00000000000..dd463b36f80 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/KmpProjectImporter.java @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import com.codename1.builders.BuildException; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/// Imports a Kotlin Multiplatform module into a Codename One application. +/// +/// The module's `commonMain` and its JVM source set (`jvmMain`, else +/// `desktopMain`) are copied into `common/src/main/kmp`, which the +/// archetype's `compose` profile compiles with the Compose compiler. The +/// Android and iOS source sets are not copied: they call the platforms +/// directly, and Codename One supplies both platforms itself. +/// +/// The application's main class is rewritten to show the module's root +/// composable in a `ComposeForm`. The composable is the one named, or the +/// only `@Composable fun App()` the common sources declare. +public final class KmpProjectImporter { + /// What an import did. + public static final class Result { + private File module; + private int copiedFiles; + private String entry; + private File mainClass; + private File setAside; + private final List skippedSourceSets = new ArrayList(); + private final List skippedEntryPoints = new ArrayList(); + + /// The module whose sources were imported. + public File getModule() { + return module; + } + + /// How many source files were copied. + public int getCopiedFiles() { + return copiedFiles; + } + + /// The fully qualified root composable the application shows. + public String getEntry() { + return entry; + } + + /// The main class the import wrote. + public File getMainClass() { + return mainClass; + } + + /// The previous main class, renamed out of the build, or null. + public File getSetAside() { + return setAside; + } + + /// Platform source sets that were not imported. + public List getSkippedSourceSets() { + return skippedSourceSets; + } + + /// JVM sources left out because they start the desktop application. + public List getSkippedEntryPoints() { + return skippedEntryPoints; + } + } + + /// The artifact a project with Kotlin Multiplatform sources depends on. + public static final String COMPOSE_ARTIFACT = "codenameone-compose-compat"; + /// The Kotlin compiler plugin that makes a function composable, applied by + /// a Gradle build under this id. + public static final String COMPOSE_GRADLE_PLUGIN = "org.jetbrains.kotlin.plugin.compose"; + + /// `src/main/kmp` of a common module: where the shared sources are imported. + public static File kmpDir(File common) { + return new File(common, "src/main/kmp"); + } + + /// The Kotlin source roots under [#kmpDir] a build compiles. + public static File[] kotlinDirs(File kmpDir) { + return new File[] {new File(kmpDir, "commonMain/kotlin"), new File(kmpDir, "jvmMain/kotlin")}; + } + + /// Whether the module has imported Kotlin Multiplatform sources. + public static boolean isKmpProject(File kmpDir) { + return kmpDir.isDirectory(); + } + + private static final String[] JVM_SETS = {"jvmMain", "desktopMain"}; + private static final String[] SKIPPED_SETS = {"androidMain", "iosMain", "appleMain", "nativeMain", "jsMain", + "wasmJsMain"}; + private static final Pattern PACKAGE = Pattern.compile("^\\s*package\\s+([\\w.]+)", Pattern.MULTILINE); + private static final Pattern WINDOW = Pattern.compile("^\\s*import\\s+androidx\\.compose\\.ui\\.window\\.", + Pattern.MULTILINE); + private static final Pattern APP = Pattern.compile("@Composable\\s+(?:public\\s+)?fun\\s+App\\s*\\(\\s*\\)"); + + /// The directory of the module to import: `source` itself when it has a + /// `src/commonMain`, else its only child that has one, else `module`. + public static File moduleDir(File source, String module) throws BuildException { + if (module != null && module.length() > 0) { + File m = new File(source, module); + if (!new File(m, "src/commonMain").isDirectory()) { + throw new BuildException("Module " + module + " of " + source + " has no src/commonMain"); + } + return m; + } + if (new File(source, "src/commonMain").isDirectory()) { + return source; + } + List found = new ArrayList(); + File[] kids = source.listFiles(); + if (kids != null) { + for (File k : kids) { + if (new File(k, "src/commonMain").isDirectory()) { + found.add(k); + } + } + } + if (found.size() == 1) { + return found.get(0); + } + if (found.isEmpty()) { + throw new BuildException("No Kotlin Multiplatform module (a directory with src/commonMain) in " + source); + } + StringBuilder names = new StringBuilder(); + for (File f : found) { + names.append(' ').append(f.getName()); + } + throw new BuildException("Several modules have a src/commonMain; choose one with -Dcn1.kmp.module:" + names); + } + + /// Copies the module's sources into `common` and writes the main class + /// `packageName`.`mainName`. `entry` is the fully qualified root + /// composable, or null to find `App()`. + public Result importProject(File source, String module, File common, String entry, String packageName, + String mainName) throws BuildException { + if (packageName == null || mainName == null) { + throw new BuildException("codename1.packageName and codename1.mainName must be set in " + + "codenameone_settings.properties"); + } + Result r = new Result(); + r.module = moduleDir(source, module); + File kmp = kmpDir(common); + File commonSrc = new File(r.module, "src/commonMain/kotlin"); + if (!commonSrc.isDirectory()) { + throw new BuildException(r.module + " has no src/commonMain/kotlin"); + } + try { + r.copiedFiles += copyTree(commonSrc, new File(kmp, "commonMain/kotlin")); + for (String set : JVM_SETS) { + File d = new File(r.module, "src/" + set + "/kotlin"); + if (d.isDirectory()) { + r.copiedFiles += copyJvmTree(d, new File(kmp, "jvmMain/kotlin"), r); + break; + } + } + for (String set : SKIPPED_SETS) { + if (new File(r.module, "src/" + set).isDirectory()) { + r.skippedSourceSets.add(set); + } + } + r.entry = entry != null && entry.length() > 0 ? entry : findApp(commonSrc); + writeMain(common, packageName, mainName, r); + } catch (IOException e) { + throw new BuildException("Importing " + r.module + " failed: " + e.getMessage(), e); + } + return r; + } + + private static String findApp(File dir) throws IOException, BuildException { + List found = new ArrayList(); + collectApps(dir, found); + if (found.size() == 1) { + return found.get(0); + } + throw new BuildException((found.isEmpty() ? "No" : "More than one") + " @Composable fun App() in the common " + + "sources" + (found.isEmpty() ? "" : " (" + found + ")") + + "; name the root composable with -Dcn1.kmp.entry=com.example.App"); + } + + private static void collectApps(File dir, List out) throws IOException { + File[] kids = dir.listFiles(); + if (kids == null) { + return; + } + for (File k : kids) { + if (k.isDirectory()) { + collectApps(k, out); + } else if (k.getName().endsWith(".kt")) { + String text = new String(Files.readAllBytes(k.toPath()), StandardCharsets.UTF_8); + if (APP.matcher(text).find()) { + Matcher p = PACKAGE.matcher(text); + out.add(p.find() ? p.group(1) + ".App" : "App"); + } + } + } + } + + private static void writeMain(File common, String pkg, String name, Result r) throws IOException { + String path = pkg.replace('.', '/'); + File kotlin = new File(common, "src/main/kotlin/" + path + "/" + name + "Compose.kt"); + kotlin.getParentFile().mkdirs(); + write(kotlin, "package " + pkg + "\n\n" + + "import com.codename1.compose.ComposeForm\n\n" + + "/// Shows the imported application's root composable. Called from " + name + ".runApp.\n" + + "fun startCompose() {\n" + + " ComposeForm { " + r.entry + "() }.show()\n" + + "}\n"); + File java = new File(common, "src/main/java/" + path + "/" + name + ".java"); + String source = "package " + pkg + ";\n\n" + + "import com.codename1.system.Lifecycle;\n\n" + + "/// Starts the imported Compose Multiplatform application, whose root\n" + + "/// composable " + name + "Compose.kt shows.\n" + + "public class " + name + " extends Lifecycle {\n" + + " @Override\n" + + " public void runApp() {\n" + + " " + name + "ComposeKt.startCompose();\n" + + " }\n" + + "}\n"; + if (java.isFile()) { + String old = new String(Files.readAllBytes(java.toPath()), StandardCharsets.UTF_8); + if (!old.equals(source)) { + File aside = new File(java.getParentFile(), name + ".java.before-kmp-import"); + Files.move(java.toPath(), aside.toPath(), StandardCopyOption.REPLACE_EXISTING); + r.setAside = aside; + } + } + java.getParentFile().mkdirs(); + write(java, source); + r.mainClass = java; + } + + private static void write(File f, String text) throws IOException { + Files.write(f.toPath(), text.getBytes(StandardCharsets.UTF_8)); + } + + /// Like [#copyTree], minus the files that open a desktop window. A desktop + /// target's `main` is `application { Window { App() } }`, which is Compose + /// for Desktop's windowing API; here the generated main class starts the + /// application instead, and that API does not exist. + private static int copyJvmTree(File from, File to, Result r) throws IOException { + int n = 0; + File[] kids = from.listFiles(); + if (kids == null) { + return 0; + } + to.mkdirs(); + for (File k : kids) { + File dest = new File(to, k.getName()); + if (k.isDirectory()) { + n += copyJvmTree(k, dest, r); + } else if (k.getName().endsWith(".kt") && WINDOW.matcher(new String(Files.readAllBytes(k.toPath()), + StandardCharsets.UTF_8)).find()) { + r.skippedEntryPoints.add(k); + } else { + Files.copy(k.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING); + n++; + } + } + return n; + } + + private static int copyTree(File from, File to) throws IOException { + int n = 0; + File[] kids = from.listFiles(); + if (kids == null) { + return 0; + } + to.mkdirs(); + for (File k : kids) { + File dest = new File(to, k.getName()); + if (k.isDirectory()) { + n += copyTree(k, dest); + } else { + Files.copy(k.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING); + n++; + } + } + return n; + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/Relocation.java b/maven/build-engine/src/main/java/com/codename1/maven/Relocation.java index b45763156c9..d6bac8dcedc 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/Relocation.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/Relocation.java @@ -128,6 +128,9 @@ String shim(String internalName) { /// The relocated name when `internalName` falls under this layer's bridge /// package or one of its prefixes, else null. String relocate(String internalName) { + if (isCompose(internalName)) { + return null; + } if (runtimePackage != null && internalName.startsWith(runtimePackage)) { return runtimeTarget + internalName.substring(runtimePackage.length()); } @@ -143,6 +146,9 @@ String relocate(String internalName) { /// part of the API this layer provides. Unlike [#relocate] this leaves the /// bridge package out: that one is the layer's own, never an API name. boolean owns(String internalName) { + if (isCompose(internalName)) { + return false; + } for (String p : prefixes) { if (internalName.startsWith(p)) { return true; @@ -155,13 +161,29 @@ boolean owns(String internalName) { /// this layer's packages (`javax.swing.`), in an import or anywhere else. boolean namedInSource(String source) { for (String p : prefixes) { - if (source.indexOf(p.replace('/', '.')) >= 0) { - return true; + String dotted = p.replace('/', '.'); + int at = source.indexOf(dotted); + while (at >= 0) { + if (!source.startsWith(COMPOSE_DOTTED, at)) { + return true; + } + at = source.indexOf(dotted, at + 1); } } return false; } + /// Compose's `androidx.compose` packages belong to the Compose layer, which + /// ships them under their own names: the Android layer's `androidx/` prefix + /// must neither relocate them nor take an application naming them for an + /// Android one. + static boolean isCompose(String internalName) { + return internalName.startsWith(COMPOSE); + } + + static final String COMPOSE = "androidx/compose/"; + private static final String COMPOSE_DOTTED = "androidx.compose."; + /// The name the application was compiled against, when `relocated` is one /// this layer produced from a prefix, else null. Build messages use it: /// a developer knows `javax.swing.JTable`, not where it ships. diff --git a/maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java index 1bbf17282a1..9a1502ae1c7 100644 --- a/maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java +++ b/maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java @@ -83,10 +83,13 @@ public final class AndroidDependencyCoordinates { static final Map NOT_SHIPPED = new LinkedHashMap(); static { + // GRADLE_PROVIDED_LIBRARIES: a Compose application's copies, declared instead. + INJECTED.put("androidx.annotation:annotation", "1.9.1"); INJECTED.put("androidx.appcompat:appcompat", "1.6.1"); INJECTED.put("androidx.browser:browser", "1.8.0"); INJECTED.put("androidx.car.app:app", "1.4.0"); INJECTED.put("androidx.car.app:app-projected", "1.4.0"); + INJECTED.put("androidx.collection:collection", "1.5.0"); INJECTED.put("androidx.concurrent:concurrent-futures", "1.1.0"); INJECTED.put("androidx.credentials:credentials", "1.3.0"); INJECTED.put("androidx.credentials:credentials-play-services-auth", "1.3.0"); @@ -117,6 +120,7 @@ public final class AndroidDependencyCoordinates { INJECTED.put("com.huawei.hms:push", "6.3.0.302"); INJECTED.put("com.scottyab:rootbeer-lib", "0.1.2"); INJECTED.put("org.jetbrains.kotlin:kotlin-stdlib", "1.9.22"); + INJECTED.put("org.jetbrains.kotlinx:kotlinx-coroutines-core", "1.10.2"); INJECTED.put("org.unifiedpush.android:connector", "3.3.5"); NOT_SHIPPED.put("com.android.tools.build:gradle", "the Android Gradle plugin"); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/KmpProjectImporterTest.java b/maven/build-engine/src/test/java/com/codename1/maven/KmpProjectImporterTest.java new file mode 100644 index 00000000000..9bdf79068e8 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/KmpProjectImporterTest.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class KmpProjectImporterTest { + @TempDir + Path tmp; + + private static void write(File f, String text) throws IOException { + f.getParentFile().mkdirs(); + Files.write(f.toPath(), text.getBytes(StandardCharsets.UTF_8)); + } + + private static String read(File f) throws IOException { + return new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + } + + @Test + void importsSharedSourcesAndLeavesOutTheDesktopWindow() throws Exception { + File source = tmp.resolve("kmp").toFile(); + File module = new File(source, "composeApp"); + write(new File(module, "src/commonMain/kotlin/demo/App.kt"), + "package demo\n\n@Composable\nfun App() {\n}\n"); + write(new File(module, "src/desktopMain/kotlin/demo/Main.kt"), + "package demo\n\nimport androidx.compose.ui.window.application\n\nfun main() = application { }\n"); + write(new File(module, "src/desktopMain/kotlin/demo/Platform.kt"), "package demo\n\nval name = \"jvm\"\n"); + write(new File(module, "src/iosMain/kotlin/demo/Ios.kt"), "package demo\n"); + File common = tmp.resolve("app/common").toFile(); + common.mkdirs(); + + KmpProjectImporter.Result r = new KmpProjectImporter().importProject(source, null, common, null, + "com.acme", "Main"); + + assertEquals(module, r.getModule()); + assertEquals("demo.App", r.getEntry()); + File kmp = KmpProjectImporter.kmpDir(common); + assertTrue(new File(kmp, "commonMain/kotlin/demo/App.kt").isFile()); + assertTrue(new File(kmp, "jvmMain/kotlin/demo/Platform.kt").isFile()); + assertFalse(new File(kmp, "jvmMain/kotlin/demo/Main.kt").exists(), "the desktop window was copied"); + assertEquals(1, r.getSkippedEntryPoints().size()); + assertTrue(r.getSkippedSourceSets().contains("iosMain")); + assertTrue(read(r.getMainClass()).contains("extends Lifecycle")); + } + + @Test + void conversionAppliesTheComposeCompilerAtTheKotlinVersion() throws Exception { + File script = tmp.resolve("build.gradle.kts").toFile(); + write(script, "// header\n\ndependencies {\n}\n"); + GradleConversion.addKotlinPlugin(script, true); + GradleConversion.addComposePlugin(script); + GradleConversion.addComposePlugin(script); + String text = read(script); + assertTrue(text.contains("plugins {\n kotlin(\"jvm\") version \"" + GradleConversion.KOTLIN_VERSION + + "\"\n id(\"org.jetbrains.kotlin.plugin.compose\") version \"" + + GradleConversion.KOTLIN_VERSION + "\"\n}"), text); + assertEquals(text.indexOf("plugin.compose"), text.lastIndexOf("plugin.compose"), "added twice"); + } +} diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml index 58cb693c8e3..19380f4dfe6 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml @@ -143,6 +143,68 @@ + + + compose + + + ${basedir}/src/main/kmp + + + + + 11 + + 23.0.0 + + + + + google + https://maven.google.com + + + + + com.codenameone + codenameone-compose-compat + + + + + + + maven-compiler-plugin + + 11 + 11 + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-compose-compiler-plugin-embeddable + ${kotlin.version} + + + + + + ${project.basedir}/src/main/desktop/kotlin ${project.basedir}/src/main/desktop/java + + ${project.basedir}/src/main/kmp/commonMain/kotlin + ${project.basedir}/src/main/kmp/jvmMain/kotlin -no-reflect diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml index f74281b64f4..68b2db3fe01 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml @@ -28,6 +28,9 @@ ${cn1PluginVersion} ${cn1Version} UTF-8 + + 13.0 1.8 11 @@ -83,6 +86,11 @@ ${cn1.version} provided + + com.codenameone + codenameone-compose-compat + ${cn1.version} + + 2.2.10 + 1.12.1 + + 1.10.2 + + + + + + google + https://maven.google.com + + + + + + com.codenameone + codenameone-core + provided + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + androidx.compose.runtime + runtime-desktop + ${compose.runtime.version} + + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + + + + androidx.compose.runtime + runtime-annotation-jvm + ${compose.runtime.version} + + + androidx.collection + collection-jvm + 1.5.0 + + + androidx.annotation + annotation-jvm + 1.9.1 + + + + org.jetbrains + annotations + 23.0.0 + + + org.jetbrains.kotlinx + kotlinx-coroutines-core-jvm + ${coroutines.version} + + + junit + junit + test + + + com.codenameone + codenameone-compat-testing + ${project.version} + test + + + + + src/main/kotlin + src/test/kotlin + + + com.github.spotbugs + spotbugs-maven-plugin + 4.7.3.4 + + Max + Low + false + true + ${project.build.directory} + ${project.basedir}/spotbugs-exclude.xml + + + + spotbugs + verify + + spotbugs + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.4.0 + + + hero-app + generate-test-sources + + add-test-source + + + + ${project.basedir}/../../scripts/kmp-bench/app/shared/commonMain/kotlin + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + 11 + + -no-reflect + + + + + compile + + compile + + + + test-compile + + test-compile + + + + + + + org.jetbrains.kotlin + kotlin-compose-compiler-plugin-embeddable + ${kotlin.version} + + + + + + + + + jdk8-no-tests + + 1.8 + + + true + + + + diff --git a/maven/compose-compat/spotbugs-exclude.xml b/maven/compose-compat/spotbugs-exclude.xml new file mode 100644 index 00000000000..41eec971d31 --- /dev/null +++ b/maven/compose-compat/spotbugs-exclude.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/animation/AnimatedVisibility.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/animation/AnimatedVisibility.kt new file mode 100644 index 00000000000..d3349c78bad --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/animation/AnimatedVisibility.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.animation + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha + +/// How content appears. Every transition fades; sizes and slides fade too. +class EnterTransition internal constructor(internal val millis: Int) { + operator fun plus(other: EnterTransition): EnterTransition = EnterTransition(Math.max(millis, other.millis)) +} + +class ExitTransition internal constructor(internal val millis: Int) { + operator fun plus(other: ExitTransition): ExitTransition = ExitTransition(Math.max(millis, other.millis)) +} + +fun fadeIn(animationSpec: AnimationSpec? = null): EnterTransition = EnterTransition(220) +fun fadeOut(animationSpec: AnimationSpec? = null): ExitTransition = ExitTransition(220) +fun expandVertically(): EnterTransition = EnterTransition(220) +fun shrinkVertically(): ExitTransition = ExitTransition(220) +fun slideInVertically(initialOffsetY: (Int) -> Int = { -it / 2 }): EnterTransition = EnterTransition(220) +fun slideOutVertically(targetOffsetY: (Int) -> Int = { -it / 2 }): ExitTransition = ExitTransition(220) +fun scaleIn(): EnterTransition = EnterTransition(220) +fun scaleOut(): ExitTransition = ExitTransition(220) + +@Composable +fun AnimatedVisibility( + visible: Boolean, + modifier: Modifier = Modifier, + enter: EnterTransition = fadeIn() + expandVertically(), + exit: ExitTransition = fadeOut() + shrinkVertically(), + label: String = "AnimatedVisibility", + content: @Composable () -> Unit +) { + val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(if (visible) enter.millis else exit.millis)) + if (visible || alpha > 0f) { + Box(modifier.alpha(alpha)) { + content() + } + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/animation/core/Animation.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/animation/core/Animation.kt new file mode 100644 index 00000000000..77ce654cfda --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/animation/core/Animation.kt @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.animation.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp + +/// Maps a fraction of an animation's time to a fraction of its distance. +@Immutable +fun interface Easing { + fun transform(fraction: Float): Float +} + +/// A cubic bezier from (0, 0) to (1, 1) through two control points, as CSS +/// defines `cubic-bezier`. +@Immutable +class CubicBezierEasing(private val a: Float, private val b: Float, private val c: Float, private val d: Float) : Easing { + private fun bezier(t: Float, p1: Float, p2: Float): Float { + val u = 1 - t + return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t + } + + override fun transform(fraction: Float): Float { + if (fraction <= 0f) return 0f + if (fraction >= 1f) return 1f + var lo = 0f + var hi = 1f + var t = fraction + for (i in 0 until 20) { + val x = bezier(t, a, c) + if (Math.abs(x - fraction) < 0.0005f) break + if (x < fraction) lo = t else hi = t + t = (lo + hi) / 2 + } + return bezier(t, b, d) + } +} + +val FastOutSlowInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f) +val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f) +val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1.0f) +val LinearEasing: Easing = Easing { it } +val EaseInOut: Easing = CubicBezierEasing(0.42f, 0.0f, 0.58f, 1.0f) + +/// How a value moves to its target. +interface AnimationSpec + +/// Answers the value at a time for an animation of a float from `from` to `to`. +internal interface FloatSpec { + /// The value at `playTimeNanos`, or null once the animation is over. + fun valueAt(playTimeNanos: Long, from: Float, to: Float, velocity: FloatArray): Float? +} + +@Immutable +class TweenSpec(val durationMillis: Int = DefaultDurationMillis, val delay: Int = 0, val easing: Easing = FastOutSlowInEasing) : + AnimationSpec, FloatSpec { + override fun valueAt(playTimeNanos: Long, from: Float, to: Float, velocity: FloatArray): Float? { + val ms = playTimeNanos / 1_000_000L - delay + if (ms < 0) return from + if (ms >= durationMillis) return null + val f = easing.transform(ms.toFloat() / Math.max(1, durationMillis)) + return from + (to - from) * f + } +} + +/// A damped spring. Integrated per frame from the velocity it carries. +@Immutable +class SpringSpec(val dampingRatio: Float = Spring.DampingRatioNoBouncy, val stiffness: Float = Spring.StiffnessMedium) : + AnimationSpec, FloatSpec { + override fun valueAt(playTimeNanos: Long, from: Float, to: Float, velocity: FloatArray): Float? { + // velocity[0] = current value, velocity[1] = velocity, velocity[2] = last time. + if (velocity[2] < 0f) { + velocity[0] = from + velocity[1] = 0f + velocity[2] = 0f + } + val t = playTimeNanos / 1e9f + var dt = t - velocity[2] + velocity[2] = t + val omega = Math.sqrt(stiffness.toDouble()).toFloat() + var x = velocity[0] + var v = velocity[1] + while (dt > 0f) { + val step = Math.min(dt, 1f / 240f) + val a = -stiffness * (x - to) - 2f * dampingRatio * omega * v + v += a * step + x += v * step + dt -= step + } + velocity[0] = x + velocity[1] = v + val range = Math.max(Math.abs(to - from), 1f) + if (Math.abs(x - to) < range * 0.001f && Math.abs(v) < range * 0.01f) return null + return x + } +} + +object Spring { + const val DampingRatioHighBouncy: Float = 0.2f + const val DampingRatioMediumBouncy: Float = 0.5f + const val DampingRatioLowBouncy: Float = 0.75f + const val DampingRatioNoBouncy: Float = 1f + const val StiffnessHigh: Float = 10_000f + const val StiffnessMedium: Float = 1500f + const val StiffnessMediumLow: Float = 400f + const val StiffnessLow: Float = 200f + const val StiffnessVeryLow: Float = 50f +} + +const val DefaultDurationMillis: Int = 300 + +fun tween(durationMillis: Int = DefaultDurationMillis, delayMillis: Int = 0, easing: Easing = FastOutSlowInEasing): TweenSpec = + TweenSpec(durationMillis, delayMillis, easing) + +fun spring(dampingRatio: Float = Spring.DampingRatioNoBouncy, stiffness: Float = Spring.StiffnessMedium, + visibilityThreshold: T? = null): SpringSpec = SpringSpec(dampingRatio, stiffness) + +enum class RepeatMode { Restart, Reverse } + +@Immutable +class InfiniteRepeatableSpec(val animation: TweenSpec, val repeatMode: RepeatMode) : AnimationSpec + +fun infiniteRepeatable(animation: TweenSpec, repeatMode: RepeatMode = RepeatMode.Restart): InfiniteRepeatableSpec = + InfiniteRepeatableSpec(animation, repeatMode) + +/// A float that animates to targets on the frame clock. +class Animatable(initialValue: Float) { + private var current by mutableFloatStateOf(initialValue) + private var running by mutableStateOf(false) + var targetValue: Float = initialValue + private set + + val value: Float get() = current + val isRunning: Boolean get() = running + + suspend fun snapTo(targetValue: Float) { + this.targetValue = targetValue + current = targetValue + } + + suspend fun animateTo(targetValue: Float, animationSpec: AnimationSpec = spring()) { + this.targetValue = targetValue + val spec = (animationSpec as? FloatSpec) ?: SpringSpec() + val from = current + if (from == targetValue) return + val state = floatArrayOf(0f, 0f, -1f) + running = true + try { + var start = -1L + while (true) { + val done = withFrameNanos { now -> + if (start < 0) start = now + val v = spec.valueAt(now - start, from, targetValue, state) + if (v == null) { + current = targetValue + true + } else { + current = v + false + } + } + if (done) break + } + } finally { + running = false + } + } + + fun asState(): State = object : State { + override val value: Float get() = current + } +} + +@Composable +fun animateFloatAsState(targetValue: Float, animationSpec: AnimationSpec = spring(), label: String = "FloatAnimation", + finishedListener: ((Float) -> Unit)? = null): State { + val anim = remember { Animatable(targetValue) } + LaunchedEffect(targetValue) { + anim.animateTo(targetValue, animationSpec) + finishedListener?.invoke(targetValue) + } + return anim.asState() +} + +@Composable +fun animateDpAsState(targetValue: Dp, animationSpec: AnimationSpec = spring(), label: String = "DpAnimation"): State { + @Suppress("UNCHECKED_CAST") + val f = animateFloatAsState(targetValue.value, animationSpec as AnimationSpec, label) + return remember(f) { object : State { override val value: Dp get() = Dp(f.value) } } +} + +/// Runs animations that never end: the values a [rememberInfiniteTransition] +/// produces. +class InfiniteTransition internal constructor() { + internal var startNanos by mutableStateOf(-1L) + internal var nowNanos by mutableStateOf(0L) +} + +@Composable +fun rememberInfiniteTransition(label: String = "InfiniteTransition"): InfiniteTransition { + val t = remember { InfiniteTransition() } + LaunchedEffect(t) { + while (true) { + withFrameNanos { now -> + if (t.startNanos < 0) t.startNanos = now + t.nowNanos = now - t.startNanos + } + } + } + return t +} + +@Composable +fun InfiniteTransition.animateFloat(initialValue: Float, targetValue: Float, animationSpec: InfiniteRepeatableSpec, + label: String = "FloatAnimation"): State { + val t = this + return remember(initialValue, targetValue, animationSpec) { + object : State { + override val value: Float + get() { + val spec = animationSpec.animation + val period = (spec.durationMillis + spec.delay).toLong() * 1_000_000L + if (period <= 0) return targetValue + val cycle = t.nowNanos / period + val inCycle = t.nowNanos % period + val reverse = animationSpec.repeatMode == RepeatMode.Reverse && cycle % 2 == 1L + val v = spec.valueAt(inCycle, initialValue, targetValue, floatArrayOf(0f, 0f, -1f)) ?: targetValue + return if (reverse) initialValue + targetValue - v else v + } + } + } +} + +@Composable +fun animateColorAsState(targetValue: Color, animationSpec: AnimationSpec = spring(), label: String = "ColorAnimation"): State { + val from = remember { mutableStateOf(targetValue) } + val to = remember { mutableStateOf(targetValue) } + if (to.value != targetValue) { + from.value = to.value + to.value = targetValue + } + val anim = remember { Animatable(1f) } + LaunchedEffect(targetValue) { + anim.snapTo(0f) + anim.animateTo(1f, animationSpec) + } + return remember { + object : State { + override val value: Color get() = androidx.compose.ui.graphics.lerp(from.value, to.value, anim.value) + } + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/Foundation.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/Foundation.kt new file mode 100644 index 00000000000..0244a5e1bdf --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/Foundation.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.foundation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.Dp +import com.codename1.compose.internal.BackgroundElement +import com.codename1.compose.internal.BorderElement +import com.codename1.compose.internal.ClickableElement +import com.codename1.compose.internal.DrawBehindElement +import com.codename1.compose.internal.ImageNode +import com.codename1.compose.internal.LeafNode +import com.codename1.compose.internal.LeafPolicy +import com.codename1.compose.internal.ScrollElement +import com.codename1.compose.internal.ScrollStateHandle +import com.codename1.compose.internal.UiNode +import androidx.compose.runtime.ComposeNode +import com.codename1.compose.internal.NodeApplier + +fun Modifier.background(color: Color, shape: Shape = RectangleShape): Modifier = + this.then(BackgroundElement(color, null, shape)) + +fun Modifier.background(brush: Brush, shape: Shape = RectangleShape, alpha: Float = 1f): Modifier = + this.then(BackgroundElement(Color.Unspecified, brush, shape)) + +fun Modifier.border(width: Dp, color: Color, shape: Shape = RectangleShape): Modifier = + this.then(BorderElement(width, color, shape)) + +fun Modifier.border(border: BorderStroke, shape: Shape = RectangleShape): Modifier = + this.then(BorderElement(border.width, border.color, shape)) + +class BorderStroke(val width: Dp, val color: Color) + +fun Modifier.clickable(enabled: Boolean = true, onClickLabel: String? = null, onClick: () -> Unit): Modifier = + this.then(ClickableElement(enabled, onClick, null, true)) + +fun Modifier.combinedClickable(enabled: Boolean = true, onLongClick: (() -> Unit)? = null, onClick: () -> Unit): Modifier = + this.then(ClickableElement(enabled, onClick, onLongClick, true)) + +/// The scroll position of a [verticalScroll] or [horizontalScroll] modifier, in +/// pixels. +@Stable +class ScrollState(initial: Int) { + private var node: UiNode? = null + private var vertical = true + private var stored by mutableIntStateOf(initial) + + val value: Int + get() { + val n = node ?: return stored + return if (vertical) n.scrollY else n.scrollX + } + + val maxValue: Int + get() { + val n = node ?: return 0 + return if (vertical) Math.max(0, n.scrollDimension.height - n.height) + else Math.max(0, n.scrollDimension.width - n.width) + } + + fun scrollTo(value: Int) { + stored = value + val n = node ?: return + if (vertical) n.cn1ScrollY(value) else n.cn1ScrollX(value) + } + + suspend fun animateScrollTo(value: Int) { + scrollTo(value) + } + + internal val handle: ScrollStateHandle = object : ScrollStateHandle { + override fun attach(node: UiNode, vertical: Boolean) { + this@ScrollState.node = node + this@ScrollState.vertical = vertical + if (stored != 0) { + node.cn1ScrollPending(stored, vertical) + } + } + + override fun detach(node: UiNode) { + if (this@ScrollState.node === node) this@ScrollState.node = null + } + } +} + +@Composable +fun rememberScrollState(initial: Int = 0): ScrollState = remember { ScrollState(initial) } + +fun Modifier.verticalScroll(state: ScrollState, enabled: Boolean = true): Modifier = + this.then(ScrollElement(state.handle, true)) + +fun Modifier.horizontalScroll(state: ScrollState, enabled: Boolean = true): Modifier = + this.then(ScrollElement(state.handle, false)) + +/// A box that draws with [onDraw]; its size comes from `modifier`. +@Composable +fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) { + LeafNode(modifier.then(DrawBehindElement(onDraw)), LeafPolicy) +} + +@Composable +fun Image( + bitmap: ImageBitmap, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: androidx.compose.ui.layout.ContentScale = androidx.compose.ui.layout.ContentScale.Fit, + alpha: Float = 1f +) { + ComposeNode( + factory = { ImageNode() }, + update = { + set(modifier) { this.modifier = it } + set(bitmap) { this.bitmap = it } + set(contentScale) { this.contentScale = it } + } + ) +} + +@Composable +fun isSystemInDarkTheme(): Boolean = com.codename1.compose.internal.Theme.isDark() diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/layout/Arrangement.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/layout/Arrangement.kt new file mode 100644 index 00000000000..afbdbd355d9 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/layout/Arrangement.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.foundation.layout + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import com.codename1.compose.internal.Density + +/// How a Row or Column distributes its children along its main axis. +object Arrangement { + @Immutable + interface Horizontal { + val spacing: Dp get() = Dp(0f) + fun arrange(totalSize: Int, sizes: IntArray, layoutDirection: LayoutDirection, outPositions: IntArray) + } + + @Immutable + interface Vertical { + val spacing: Dp get() = Dp(0f) + fun arrange(totalSize: Int, sizes: IntArray, outPositions: IntArray) + } + + @Immutable + interface HorizontalOrVertical : Horizontal, Vertical { + override val spacing: Dp get() = Dp(0f) + } + + private class Kind(val name: String, val gap: (Int, Int) -> Pair) : HorizontalOrVertical { + // gap(free, count) answers (leading, between). + override fun arrange(totalSize: Int, sizes: IntArray, layoutDirection: LayoutDirection, outPositions: IntArray) = + arrange(totalSize, sizes, outPositions) + + override fun arrange(totalSize: Int, sizes: IntArray, outPositions: IntArray) { + var used = 0 + for (s in sizes) used += s + val (lead, between) = gap(Math.max(0, totalSize - used), sizes.size) + var p = lead + for (i in sizes.indices) { + outPositions[i] = p + p += sizes[i] + between + } + } + + override fun toString(): String = "Arrangement#$name" + } + + val Start: Horizontal = Kind("Start") { _, _ -> 0 to 0 } + val End: Horizontal = Kind("End") { f, _ -> f to 0 } + val Top: Vertical = Kind("Top") { _, _ -> 0 to 0 } + val Bottom: Vertical = Kind("Bottom") { f, _ -> f to 0 } + val Center: HorizontalOrVertical = Kind("Center") { f, _ -> f / 2 to 0 } + val SpaceBetween: HorizontalOrVertical = Kind("SpaceBetween") { f, n -> 0 to if (n > 1) f / (n - 1) else 0 } + val SpaceEvenly: HorizontalOrVertical = Kind("SpaceEvenly") { f, n -> f / (n + 1) to f / (n + 1) } + val SpaceAround: HorizontalOrVertical = Kind("SpaceAround") { f, n -> + if (n == 0) 0 to 0 else f / (2 * n) to f / n + } + + /// Places children `space` apart, from the start. + fun spacedBy(space: Dp): HorizontalOrVertical = SpacedAligned(space, null, null) + + fun spacedBy(space: Dp, alignment: Alignment.Horizontal): Horizontal = SpacedAligned(space, alignment, null) + + fun spacedBy(space: Dp, alignment: Alignment.Vertical): Vertical = SpacedAligned(space, null, alignment) + + private data class SpacedAligned(val space: Dp, val h: Alignment.Horizontal?, val v: Alignment.Vertical?) : + HorizontalOrVertical { + override val spacing: Dp get() = space + + override fun arrange(totalSize: Int, sizes: IntArray, layoutDirection: LayoutDirection, outPositions: IntArray) { + place(totalSize, sizes, outPositions) { used -> h?.align(used, totalSize, layoutDirection) ?: 0 } + } + + override fun arrange(totalSize: Int, sizes: IntArray, outPositions: IntArray) { + place(totalSize, sizes, outPositions) { used -> v?.align(used, totalSize) ?: 0 } + } + + private inline fun place(totalSize: Int, sizes: IntArray, out: IntArray, lead: (Int) -> Int) { + val gap = Density.px(space) + var used = 0 + for (s in sizes) used += s + if (sizes.size > 1) used += gap * (sizes.size - 1) + var p = lead(used) + for (i in sizes.indices) { + out[i] = p + p += sizes[i] + gap + } + } + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/layout/Layout.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/layout/Layout.kt new file mode 100644 index 00000000000..c775163725d --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/layout/Layout.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.foundation.layout + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.codename1.compose.internal.AlignSelfElement +import com.codename1.compose.internal.AspectRatioElement +import com.codename1.compose.internal.BoxPolicy +import com.codename1.compose.internal.FillElement +import com.codename1.compose.internal.LayoutNode +import com.codename1.compose.internal.LeafNode +import com.codename1.compose.internal.LeafPolicy +import com.codename1.compose.internal.LinearPolicy +import com.codename1.compose.internal.MatchParentSizeElement +import com.codename1.compose.internal.OffsetElement +import com.codename1.compose.internal.PaddingElement +import com.codename1.compose.internal.SizeElement +import com.codename1.compose.internal.WeightElement +import com.codename1.compose.internal.WrapElement + +// --- containers ------------------------------------------------------------- + +@Stable +interface BoxScope { + fun Modifier.align(alignment: Alignment): Modifier = this.then(AlignSelfElement(null, null, alignment)) + fun Modifier.matchParentSize(): Modifier = this.then(MatchParentSizeElement) +} + +@Stable +interface RowScope { + fun Modifier.weight(weight: Float, fill: Boolean = true): Modifier = this.then(WeightElement(weight, fill)) + fun Modifier.align(alignment: Alignment.Vertical): Modifier = this.then(AlignSelfElement(null, alignment, null)) +} + +@Stable +interface ColumnScope { + fun Modifier.weight(weight: Float, fill: Boolean = true): Modifier = this.then(WeightElement(weight, fill)) + fun Modifier.align(alignment: Alignment.Horizontal): Modifier = this.then(AlignSelfElement(alignment, null, null)) +} + +internal object BoxScopeInstance : BoxScope +internal object RowScopeInstance : RowScope +internal object ColumnScopeInstance : ColumnScope + +@Composable +fun Box( + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + propagateMinConstraints: Boolean = false, + content: @Composable BoxScope.() -> Unit +) { + val policy = remember(contentAlignment, propagateMinConstraints) { BoxPolicy(contentAlignment, propagateMinConstraints) } + LayoutNode(modifier, policy) { BoxScopeInstance.content() } +} + +@Composable +fun Box(modifier: Modifier) { + LeafNode(modifier, BoxPolicy.TopStart) +} + +@Composable +fun Row( + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.Top, + content: @Composable RowScope.() -> Unit +) { + val policy = remember(horizontalArrangement, verticalAlignment) { + LinearPolicy(false, horizontalArrangement, null, null, verticalAlignment) + } + LayoutNode(modifier, policy) { RowScopeInstance.content() } +} + +@Composable +fun Column( + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: @Composable ColumnScope.() -> Unit +) { + val policy = remember(verticalArrangement, horizontalAlignment) { + LinearPolicy(true, null, verticalArrangement, horizontalAlignment, null) + } + LayoutNode(modifier, policy) { ColumnScopeInstance.content() } +} + +@Composable +fun Spacer(modifier: Modifier) { + LeafNode(modifier, LeafPolicy) +} + +// --- padding ---------------------------------------------------------------- + +@Immutable +interface PaddingValues { + fun calculateLeftPadding(layoutDirection: androidx.compose.ui.unit.LayoutDirection): Dp + fun calculateTopPadding(): Dp + fun calculateRightPadding(layoutDirection: androidx.compose.ui.unit.LayoutDirection): Dp + fun calculateBottomPadding(): Dp +} + +@Immutable +private data class PaddingValuesImpl(val start: Dp, val top: Dp, val end: Dp, val bottom: Dp) : PaddingValues { + override fun calculateLeftPadding(layoutDirection: androidx.compose.ui.unit.LayoutDirection): Dp = start + override fun calculateTopPadding(): Dp = top + override fun calculateRightPadding(layoutDirection: androidx.compose.ui.unit.LayoutDirection): Dp = end + override fun calculateBottomPadding(): Dp = bottom +} + +fun PaddingValues(all: Dp): PaddingValues = PaddingValuesImpl(all, all, all, all) +fun PaddingValues(horizontal: Dp = Dp(0f), vertical: Dp = Dp(0f)): PaddingValues = + PaddingValuesImpl(horizontal, vertical, horizontal, vertical) +fun PaddingValues(start: Dp = Dp(0f), top: Dp = Dp(0f), end: Dp = Dp(0f), bottom: Dp = Dp(0f)): PaddingValues = + PaddingValuesImpl(start, top, end, bottom) + +fun Modifier.padding(all: Dp): Modifier = this.then(PaddingElement(all, all, all, all)) +fun Modifier.padding(horizontal: Dp = Dp(0f), vertical: Dp = Dp(0f)): Modifier = + this.then(PaddingElement(horizontal, vertical, horizontal, vertical)) +fun Modifier.padding(start: Dp = Dp(0f), top: Dp = Dp(0f), end: Dp = Dp(0f), bottom: Dp = Dp(0f)): Modifier = + this.then(PaddingElement(start, top, end, bottom)) +fun Modifier.padding(paddingValues: PaddingValues): Modifier = this.then(PaddingElement( + paddingValues.calculateLeftPadding(androidx.compose.ui.unit.LayoutDirection.Ltr), paddingValues.calculateTopPadding(), + paddingValues.calculateRightPadding(androidx.compose.ui.unit.LayoutDirection.Ltr), paddingValues.calculateBottomPadding())) + +// --- size ------------------------------------------------------------------- + +fun Modifier.size(size: Dp): Modifier = this.then(SizeElement(size, size, size, size)) +fun Modifier.size(width: Dp, height: Dp): Modifier = this.then(SizeElement(width, width, height, height)) +fun Modifier.width(width: Dp): Modifier = this.then(SizeElement(minWidth = width, maxWidth = width)) +fun Modifier.height(height: Dp): Modifier = this.then(SizeElement(minHeight = height, maxHeight = height)) +fun Modifier.requiredSize(size: Dp): Modifier = this.then(SizeElement(size, size, size, size, false)) +fun Modifier.requiredWidth(width: Dp): Modifier = this.then(SizeElement(minWidth = width, maxWidth = width, enforceIncoming = false)) +fun Modifier.requiredHeight(height: Dp): Modifier = this.then(SizeElement(minHeight = height, maxHeight = height, enforceIncoming = false)) +fun Modifier.sizeIn(minWidth: Dp = Dp.Unspecified, minHeight: Dp = Dp.Unspecified, maxWidth: Dp = Dp.Unspecified, + maxHeight: Dp = Dp.Unspecified): Modifier = this.then(SizeElement(minWidth, maxWidth, minHeight, maxHeight)) +fun Modifier.widthIn(min: Dp = Dp.Unspecified, max: Dp = Dp.Unspecified): Modifier = + this.then(SizeElement(minWidth = min, maxWidth = max)) +fun Modifier.heightIn(min: Dp = Dp.Unspecified, max: Dp = Dp.Unspecified): Modifier = + this.then(SizeElement(minHeight = min, maxHeight = max)) +fun Modifier.defaultMinSize(minWidth: Dp = Dp.Unspecified, minHeight: Dp = Dp.Unspecified): Modifier = + this.then(SizeElement(minWidth = minWidth, minHeight = minHeight)) + +fun Modifier.fillMaxWidth(fraction: Float = 1f): Modifier = this.then(FillElement(true, false, fraction)) +fun Modifier.fillMaxHeight(fraction: Float = 1f): Modifier = this.then(FillElement(false, true, fraction)) +fun Modifier.fillMaxSize(fraction: Float = 1f): Modifier = this.then(FillElement(true, true, fraction)) + +fun Modifier.wrapContentWidth(align: Alignment.Horizontal = Alignment.CenterHorizontally, unbounded: Boolean = false): Modifier = + this.then(WrapElement(true, false, Alignment { s, sp, d -> androidx.compose.ui.unit.IntOffset(align.align(s.width, sp.width, d), 0) }, unbounded)) +fun Modifier.wrapContentHeight(align: Alignment.Vertical = Alignment.CenterVertically, unbounded: Boolean = false): Modifier = + this.then(WrapElement(false, true, Alignment { s, sp, _ -> androidx.compose.ui.unit.IntOffset(0, align.align(s.height, sp.height)) }, unbounded)) +fun Modifier.wrapContentSize(align: Alignment = Alignment.Center, unbounded: Boolean = false): Modifier = + this.then(WrapElement(true, true, align, unbounded)) + +fun Modifier.offset(x: Dp = Dp(0f), y: Dp = Dp(0f)): Modifier = this.then(OffsetElement(x, y)) + +fun Modifier.aspectRatio(ratio: Float): Modifier = this.then(AspectRatioElement(ratio)) diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/lazy/LazyList.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/lazy/LazyList.kt new file mode 100644 index 00000000000..bb04c56b2bd --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/lazy/LazyList.kt @@ -0,0 +1,657 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.foundation.lazy + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.codename1.compose.internal.BoxPolicy +import com.codename1.compose.internal.Constraints +import com.codename1.compose.internal.Density +import com.codename1.compose.internal.LTR +import com.codename1.compose.internal.NodeApplier +import com.codename1.compose.internal.UiNode +import com.codename1.compose.internal.h +import com.codename1.compose.internal.measureChild +import com.codename1.compose.internal.size +import com.codename1.compose.internal.w +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxHeight + +/// The receiver of an item's content. +@Stable +interface LazyItemScope { + fun Modifier.fillParentMaxWidth(fraction: Float = 1f): Modifier = this.fillMaxWidth(fraction) + fun Modifier.fillParentMaxHeight(fraction: Float = 1f): Modifier = this.fillMaxHeight(fraction) + fun Modifier.animateItem(): Modifier = this +} + +private object LazyItemScopeInstance : LazyItemScope + +/// What a [LazyColumn] or [LazyRow] content block declares. +interface LazyListScope { + fun item(key: Any? = null, contentType: Any? = null, content: @Composable LazyItemScope.() -> Unit) + fun items(count: Int, key: ((Int) -> Any)? = null, contentType: (Int) -> Any? = { null }, + itemContent: @Composable LazyItemScope.(Int) -> Unit) + fun stickyHeader(key: Any? = null, contentType: Any? = null, content: @Composable LazyItemScope.() -> Unit) = + item(key, contentType, content) +} + +inline fun LazyListScope.items(items: List, noinline key: ((T) -> Any)? = null, + crossinline itemContent: @Composable LazyItemScope.(T) -> Unit) { + items(items.size, if (key != null) { i: Int -> key(items[i]) } else null) { itemContent(items[it]) } +} + +inline fun LazyListScope.itemsIndexed(items: List, noinline key: ((Int, T) -> Any)? = null, + crossinline itemContent: @Composable LazyItemScope.(Int, T) -> Unit) { + items(items.size, if (key != null) { i: Int -> key(i, items[i]) } else null) { itemContent(it, items[it]) } +} + +private class Entry(val start: Int, val count: Int, val key: ((Int) -> Any)?, + val content: @Composable LazyItemScope.(Int) -> Unit) + +/// What one run of a lazy list's content block declared. Only the items in view +/// are ever composed from it. +internal class LazyItems : LazyListScope { + private val entries = ArrayList(2) + var count = 0 + private set + + override fun item(key: Any?, contentType: Any?, content: @Composable LazyItemScope.() -> Unit) { + entries.add(Entry(count, 1, if (key != null) { _: Int -> key } else null) { content() }) + count++ + } + + override fun items(count: Int, key: ((Int) -> Any)?, contentType: (Int) -> Any?, + itemContent: @Composable LazyItemScope.(Int) -> Unit) { + entries.add(Entry(this.count, count, key, itemContent)) + this.count += count + } + + private fun entryAt(index: Int): Entry { + var e = entries[0] + for (k in 1 until entries.size) { + val n = entries[k] + if (n.start > index) break + e = n + } + return e + } + + /// The item's key: the one the content block gave it, or its position. + fun keyAt(index: Int): Any { + val e = entryAt(index) + val k = e.key ?: return IndexKey(index) + return k(index - e.start) + } + + @Composable + fun Content(index: Int) { + val e = entryAt(index) + e.content(LazyItemScopeInstance, index - e.start) + } +} + +private data class IndexKey(val index: Int) + +/// The scroll position of a lazy list. +@Stable +class LazyListState internal constructor(internal val scroll: ScrollState) { + internal var node: LazyListNode? = null + + val firstVisibleItemIndex: Int get() = node?.cn1FirstVisible() ?: 0 + val firstVisibleItemScrollOffset: Int get() = scroll.value + val canScrollForward: Boolean get() = scroll.value < scroll.maxValue + val canScrollBackward: Boolean get() = scroll.value > 0 + + suspend fun scrollToItem(index: Int, scrollOffset: Int = 0) { + val n = node + if (n != null) n.cn1ScrollToItem(index, scrollOffset) else scroll.scrollTo(scrollOffset) + } + + suspend fun animateScrollToItem(index: Int, scrollOffset: Int = 0) = scrollToItem(index, scrollOffset) +} + +@Composable +fun rememberLazyListState(): LazyListState = remember { LazyListState(ScrollState(0)) } + +/// A vertically scrolling list that composes only the items in view, and +/// disposes of an item's composition once it has scrolled well out of it. +@Composable +fun LazyColumn( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit +) { + LazyList(true, modifier, state, contentPadding, verticalArrangement.spacing, horizontalAlignment, null, + reverseLayout, userScrollEnabled, content) +} + +@Composable +fun LazyRow( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(), + reverseLayout: Boolean = false, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.Top, + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit +) { + LazyList(false, modifier, state, contentPadding, horizontalArrangement.spacing, null, verticalAlignment, + reverseLayout, userScrollEnabled, content) +} + +@Composable +private fun LazyList(vertical: Boolean, modifier: Modifier, state: LazyListState, padding: PaddingValues, + spacing: androidx.compose.ui.unit.Dp, horizontal: Alignment.Horizontal?, + verticalAlign: Alignment.Vertical?, reverseLayout: Boolean, userScrollEnabled: Boolean, + content: LazyListScope.() -> Unit) { + // Laying the items out from the end is not implemented. Refusing is better than drawing a + // chat-style list upside down and letting it look like it works. + require(!reverseLayout) { "reverseLayout = true is not supported by LazyColumn/LazyRow" } + val context = rememberCompositionContext() + val holder = remember { NodeHolder() } + DisposableEffect(holder) { + onDispose { holder.node?.cn1Dispose() } + } + // The declared items are rebuilt only when the content block changes or a state it read does. + // A parent recomposition that hands back the same block (the Compose compiler memoizes it when + // its captures are stable) reuses the same LazyItems, so `set(items)` below does not fire: no + // remap, and no item composition is given new content. + val items = remember(content) { derivedStateOf { LazyItems().also(content) } }.value + ComposeNode( + factory = { LazyListNode(vertical).also { holder.node = it } }, + update = { + set(context) { this.context = it } + set(state) { this.state = it } + set(padding) { this.padding = it } + set(spacing) { this.spacing = it } + set(horizontal) { this.horizontalAlignment = it } + set(verticalAlign) { this.verticalAlignment = it } + set(userScrollEnabled) { this.userScroll = it } + set(modifier) { this.modifier = it } + set(items) { this.items = it } + } + ) +} + +/// Lets the list's item compositions be disposed of with the list. +private class NodeHolder { + var node: LazyListNode? = null +} + +/// The node behind [LazyColumn] and [LazyRow]. It is its own scroll container, +/// and every item in view is a composition of its own under the list's, rooted +/// at a node that is this one's child. Item sizes are learnt as items are +/// measured; the extent of the ones never seen is estimated from the average. +/// +/// A pass costs the window, not the list: measured extents and their count are +/// kept in two Fenwick trees, so where any item starts is O(log n) and the item +/// at a position O(log^2 n), and the pass walks only the items within reach of +/// the view. Only a change of content is O(n), to carry sizes over by key. +internal class LazyListNode(private val vertical: Boolean) : UiNode() { + private class Slot(val key: Any, val root: UiNode, val composition: Composition) { + var index = -1 + var items: LazyItems? = null + var live = false + var start = 0 + } + + var context: CompositionContext? = null + var state: LazyListState? = null + set(v) { + if (field !== v) { + field?.node = null + field?.scroll?.handle?.detach(this) + field = v + v?.node = this + v?.scroll?.handle?.attach(this, vertical) + } + } + var padding: PaddingValues = PaddingValues() + set(v) { if (field != v) { field = v; invalidateMeasure() } } + var spacing: androidx.compose.ui.unit.Dp = androidx.compose.ui.unit.Dp(0f) + set(v) { if (field != v) { field = v; invalidateMeasure() } } + var userScroll = true + set(v) { + if (field != v) { + field = v + if (vertical) isScrollableY = v else isScrollableX = v + } + } + var horizontalAlignment: Alignment.Horizontal? = null + var verticalAlignment: Alignment.Vertical? = null + var items: LazyItems? = null + set(v) { + val old = field + field = v + if (v != null) remap(old, v) + invalidateMeasure() + } + + // Composed items, by key and as a list; the list is what a pass walks, so that + // nothing allocates an iterator. + private val slots = HashMap() + private val slotList = ArrayList() + // Per item: its key, and its extent along the scroll axis once measured (-1 before). + private var keys = arrayOfNulls(0) + private var extents = IntArray(0) + // Fenwick trees over the measured extents and over how many are measured. + private var sumTree = IntArray(1) + private var countTree = IntArray(1) + private var knownCount = 0 + private var knownSum = 0 + // The geometry of the last pass, which positions are computed with until the next. + private var lead = 0 + private var gap = 0 + private var estimate = 0 + private var total = 0 + private var crossExtent = 0 + private var windowAt = Int.MIN_VALUE + private var viewport = 0 + private var measuring = false + // The item the view is anchored on across a pass that learns sizes: the first one in view and + // how far into it the view starts. Without it an item above the view measured larger than its + // estimate pushes everything in view down, and the list jumps under a still finger. + private var anchorKey: Any? = null + private var anchorOffset = 0 + private var anchorIndex = -1 + // Set when the anchor was taken by a remap or by scrollToItem and must be used as it is; + // otherwise every pass takes it afresh from where the view is now. + private var anchorFixed = false + + init { + policy = BoxPolicy.TopStart + if (vertical) isScrollableY = true else isScrollableX = true + } + + override fun lazyAxis(): Int = if (!userScroll) 0 else if (vertical) 1 else 2 + + private fun treeAdd(t: IntArray, index: Int, v: Int) { + var i = index + 1 + while (i < t.size) { + t[i] += v + i += i and -i + } + } + + /// The sum over items [0, index). + private fun treeSum(t: IntArray, index: Int): Int { + var i = index + var s = 0 + while (i > 0) { + s += t[i] + i -= i and -i + } + return s + } + + private fun setExtent(i: Int, e: Int) { + val old = extents[i] + if (old == e) return + extents[i] = e + if (old < 0) { + treeAdd(countTree, i, 1) + knownCount++ + treeAdd(sumTree, i, e) + knownSum += e + } else { + treeAdd(sumTree, i, e - old) + knownSum += e - old + } + } + + /// Where item `i` starts (or, for `i == n`, where the gap after the last one ends). + private fun startOf(i: Int): Int { + val kc = treeSum(countTree, i) + // In Long and clamped: a list of millions of estimated items passes Int.MAX_VALUE pixels. + val v = lead.toLong() + treeSum(sumTree, i) + (i - kc).toLong() * estimate + i.toLong() * gap + return if (v > MAX_EXTENT) MAX_EXTENT.toInt() else v.toInt() + } + + private companion object { + // Below Constraints.INF, so a clamped extent never reads as "unbounded". + const val MAX_EXTENT = (Int.MAX_VALUE / 2).toLong() + } + + private fun extentOf(i: Int): Int = if (extents[i] >= 0) extents[i] else estimate + + /// The first item that ends past `at`, or -1 for an empty list. + private fun firstAt(at: Int): Int { + val n = keys.size + var lo = 0 + var hi = n - 1 + var found = -1 + while (lo <= hi) { + val mid = (lo + hi) ushr 1 + if (startOf(mid) + extentOf(mid) > at) { + found = mid + hi = mid - 1 + } else { + lo = mid + 1 + } + } + return found + } + + /// Carries what is known of each item over to a new content block, by key. + private fun remap(old: LazyItems?, now: LazyItems) { + captureAnchor() + anchorIndex = -1 + anchorFixed = true + val n = now.count + val oldKeys = keys + val oldExtents = extents + val newKeys = arrayOfNulls(n) + val newExtents = IntArray(n) + var byKey: HashMap? = null + val ak = anchorKey + for (i in 0 until n) { + val k = now.keyAt(i) + newKeys[i] = k + if (anchorIndex < 0 && ak != null && k == ak) anchorIndex = i + var e = -1 + if (i < oldKeys.size && oldKeys[i] == k) { + e = oldExtents[i] + } else if (old != null && oldKeys.isNotEmpty()) { + if (byKey == null) { + byKey = HashMap(oldKeys.size * 2) + for (j in oldKeys.indices) { + // The first of a repeated key wins; a repeat is refused once composed. + val ok = oldKeys[j]!! + if (!byKey.containsKey(ok)) byKey[ok] = j + } + } + val j = byKey[k] + if (j != null) e = oldExtents[j] + } + newExtents[i] = e + } + keys = newKeys + extents = IntArray(n) { -1 } + sumTree = IntArray(n + 1) + countTree = IntArray(n + 1) + knownCount = 0 + knownSum = 0 + for (i in 0 until n) { + if (newExtents[i] >= 0) setExtent(i, newExtents[i]) + } + } + + override fun measureContent(c: Constraints): Long { + val list = items + val ctx = context + val n = list?.count ?: 0 + if (list == null || ctx == null || n == 0) { + disposeAll() + total = 0 + return size(c.minWidth, c.minHeight) + } + measuring = true + try { + return measureItems(list, ctx, c, n) + } finally { + measuring = false + } + } + + private fun measureItems(list: LazyItems, ctx: CompositionContext, c: Constraints, n: Int): Long { + // The anchor is taken with the geometry the view was drawn with, before this pass + // changes the estimate. + if (!anchorFixed) { + captureAnchor() + } + val d = Density + val p = padding + lead = if (vertical) d.px(p.calculateTopPadding()) else d.px(p.calculateLeftPadding(LTR)) + val trail = if (vertical) d.px(p.calculateBottomPadding()) else d.px(p.calculateRightPadding(LTR)) + val crossLead = if (vertical) d.px(p.calculateLeftPadding(LTR)) else d.px(p.calculateTopPadding()) + val crossTrail = if (vertical) d.px(p.calculateRightPadding(LTR)) else d.px(p.calculateBottomPadding()) + gap = d.px(spacing) + // Fixed for the pass, so that every position in it is computed the same way. + estimate = if (knownCount > 0) knownSum / knownCount else d.px(androidx.compose.ui.unit.Dp(72f)) + val viewMain = if (vertical) c.maxHeight else c.maxWidth + val crossMax = if (vertical) c.maxWidth else c.maxHeight + val crossAvail = if (crossMax == Constraints.INF) Constraints.INF else Math.max(0, crossMax - crossLead - crossTrail) + val itemC = if (vertical) Constraints(0, crossAvail, 0, Constraints.INF) + else Constraints(0, Constraints.INF, 0, crossAvail) + val unbounded = viewMain == Constraints.INF + var at = if (vertical) scrollY else scrollX + val ai0 = anchorIndex + if (!unbounded && at > 0 && ai0 in 0 until n) { + // Where the anchor is under this pass's estimate. The estimate moves as sizes are + // learnt, and a window chosen from the old position can miss every item. + val want = Math.max(0, startOf(ai0) + anchorOffset) + if (want != at) { + at = want + if (vertical) scrollY = at else scrollX = at + } + } + // Compose what is in view and a quarter of a view either side; keep what was + // composed until it is half a view away, so a slow scroll does not churn. + val margin = if (unbounded) 0 else viewMain / 4 + val composeFrom = if (unbounded) Int.MIN_VALUE else at - margin + val composeTo = if (unbounded) Int.MAX_VALUE else at + viewMain + margin + val keepFrom = if (unbounded) Int.MIN_VALUE else at - 2 * margin + val keepTo = if (unbounded) Int.MAX_VALUE else at + viewMain + 2 * margin + for (k in 0 until slotList.size) slotList[k].live = false + var i = if (unbounded) 0 else Math.max(0, firstAt(keepFrom)) + var pos = startOf(i) + var cross = 0 + while (i < n && pos < keepTo) { + var e = extents[i] + val guess = if (e >= 0) e else estimate + val key = keys[i]!! + var slot = slots[key] + val overlaps = pos + guess > composeFrom && pos < composeTo + val kept = slot != null && pos + guess > keepFrom + if (overlaps || kept) { + if (slot == null) { + slot = compose(key, ctx) + } else if (slot.live) { + // Two items share a slot; the second would silently take the first's + // composition. Like Compose's own, the check covers the items composed. + throw IllegalArgumentException("Key " + key + " was already used. Every item of a " + + "lazy list must have a unique key.") + } + if (slot.items !== list || slot.index != i) { + slot.items = list + slot.index = i + val l = list + val idx = i + slot.composition.setContent { l.Content(idx) } + } + val m = measureChild(slot.root, itemC) + e = if (vertical) m.h() else m.w() + setExtent(i, e) + cross = Math.max(cross, if (vertical) m.w() else m.h()) + slot.live = true + slot.start = pos + } else { + e = guess + } + pos += e + gap + i++ + } + val end = startOf(n) - gap + trail + if (total != end) { + total = end + // Component caches the scroll extent and clamps setScrollY against it; without this + // the anchor below is clamped to the extent the estimate gave last pass. + setScrollSize(null) + } + disposeDead() + crossExtent = cross + crossLead + crossTrail + val crossSize = crossExtent + // Place what is composed; positions are along the whole scrolled extent. + for (k in 0 until slotList.size) { + val s = slotList[k] + val ci = getComponentIndex(s.root) + if (ci < 0) continue + val iw = s.root.measuredWidth + val ih = s.root.measuredHeight + if (vertical) { + val a = horizontalAlignment + val x = crossLead + (if (a == null || crossAvail == Constraints.INF) 0 else a.align(iw, crossAvail, LTR)) + place(ci, x, s.start, iw, ih) + } else { + val a = verticalAlignment + val y = crossLead + (if (a == null || crossAvail == Constraints.INF) 0 else a.align(ih, crossAvail)) + place(ci, s.start, y, iw, ih) + } + } + viewport = if (unbounded) total else viewMain + var now = at + val ai = anchorIndex + if (!unbounded && at > 0 && ai in 0 until n) { + val want = Math.max(0, Math.min(startOf(ai) + anchorOffset, total - viewport)) + if (want != at) { + now = want + if (vertical) scrollY = want else scrollX = want + } + } + windowAt = now + anchorFixed = false + return if (vertical) size(c.constrainWidth(crossSize), c.constrainHeight(total)) + else size(c.constrainWidth(total), c.constrainHeight(crossSize)) + } + + private fun compose(key: Any, ctx: CompositionContext): Slot { + val root = UiNode() + addComponent(root) + val s = Slot(key, root, Composition(NodeApplier(root), ctx)) + slots[key] = s + slotList.add(s) + return s + } + + private fun disposeDead() { + var w = 0 + for (k in 0 until slotList.size) { + val s = slotList[k] + if (s.live) { + slotList[w++] = s + } else { + slots.remove(s.key) + s.composition.dispose() + removeComponent(s.root) + } + } + while (slotList.size > w) slotList.removeAt(slotList.size - 1) + } + + private fun disposeAll() { + for (k in 0 until slotList.size) slotList[k].live = false + disposeDead() + } + + override fun calcScrollSize(): com.codename1.ui.geom.Dimension = + if (vertical) com.codename1.ui.geom.Dimension(Math.max(width, crossExtent), Math.max(height, total)) + else com.codename1.ui.geom.Dimension(Math.max(width, total), Math.max(height, crossExtent)) + + override fun onScrollY(scrollY: Int) { + super.onScrollY(scrollY) + if (vertical) scrolled(scrollY) + } + + override fun onScrollX(scrollX: Int) { + super.onScrollX(scrollX) + if (!vertical) scrolled(scrollX) + } + + /// Composes what scrolled into view before it is painted: a quarter of a view + /// of travel since the last pass. + private fun scrolled(at: Int) { + if (measuring || windowAt == Int.MIN_VALUE) return + if (Math.abs(at - windowAt) * 4 >= viewport) { + remeasureInPlace() + } + } + + private fun captureAnchor() { + val at = if (vertical) scrollY else scrollX + val i = firstAt(at) + if (i < 0) { + anchorKey = null + anchorIndex = -1 + return + } + anchorKey = keys[i] + anchorIndex = i + anchorOffset = at - startOf(i) + } + + internal fun cn1FirstVisible(): Int = Math.max(0, firstAt(if (vertical) scrollY else scrollX)) + + /// Scrolls so that `index` starts `offset` into the view. The item is composed and measured + /// first, so the position is its real one and not the estimate it had while out of view. + internal fun cn1ScrollToItem(index: Int, offset: Int) { + // A pass composes around where the estimate put the item; measuring its neighbours can + // move it, or the end of the list, so repeat until the position holds. Each pass leaves + // fewer estimates behind it, and two are the usual case. + for (pass in 0 until 4) { + if (index !in keys.indices) return + val t = Math.max(0, Math.min(startOf(index) + offset, total - viewport)) + anchorKey = keys[index] + anchorIndex = index + anchorOffset = offset + anchorFixed = true + if ((if (vertical) scrollY else scrollX) == t && pass > 0) return + if (vertical) scrollY = t else scrollX = t + measuring = true + try { + remeasureInPlace() + } finally { + measuring = false + } + } + } + + /// How many items the list has, and how many of them are composed. + fun cn1ItemCount(): Int = items?.count ?: 0 + + fun cn1ComposedCount(): Int = slots.size + + /// Disposes every item composition; the list is leaving the composition. + internal fun cn1Dispose() { + disposeAll() + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/shape/Shapes.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/shape/Shapes.kt new file mode 100644 index 00000000000..e4bea72456e --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/shape/Shapes.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.foundation.shape + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection + +/// A corner's size, resolved against the shape's size. +@Immutable +interface CornerSize { + fun toPx(shapeSize: Size, density: Density): Float +} + +private data class DpCornerSize(val size: Dp) : CornerSize { + override fun toPx(shapeSize: Size, density: Density): Float = size.value * density.density +} + +private data class PercentCornerSize(val percent: Float) : CornerSize { + override fun toPx(shapeSize: Size, density: Density): Float = shapeSize.minDimension * percent / 100f +} + +private data class PxCornerSize(val px: Float) : CornerSize { + override fun toPx(shapeSize: Size, density: Density): Float = px +} + +fun CornerSize(size: Dp): CornerSize = DpCornerSize(size) +fun CornerSize(percent: Int): CornerSize = PercentCornerSize(percent.toFloat()) +fun CornerSize(size: Float): CornerSize = PxCornerSize(size) + +@Immutable +open class RoundedCornerShape( + val topStart: CornerSize, val topEnd: CornerSize, val bottomEnd: CornerSize, val bottomStart: CornerSize +) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val ts = topStart.toPx(size, density) + val te = topEnd.toPx(size, density) + val be = bottomEnd.toPx(size, density) + val bs = bottomStart.toPx(size, density) + if (ts + te + be + bs == 0f) return Outline.Rectangle(size.width, size.height) + return Outline.Rounded(size.width, size.height, ts, te, be, bs) + } + + override fun equals(other: Any?): Boolean = other is RoundedCornerShape && other.topStart == topStart && + other.topEnd == topEnd && other.bottomEnd == bottomEnd && other.bottomStart == bottomStart + + override fun hashCode(): Int = ((topStart.hashCode() * 31 + topEnd.hashCode()) * 31 + bottomEnd.hashCode()) * 31 + + bottomStart.hashCode() + + fun copy(topStart: CornerSize = this.topStart, topEnd: CornerSize = this.topEnd, bottomEnd: CornerSize = this.bottomEnd, + bottomStart: CornerSize = this.bottomStart): RoundedCornerShape = + RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart) +} + +fun RoundedCornerShape(corner: CornerSize): RoundedCornerShape = RoundedCornerShape(corner, corner, corner, corner) +fun RoundedCornerShape(size: Dp): RoundedCornerShape = RoundedCornerShape(CornerSize(size)) +fun RoundedCornerShape(percent: Int): RoundedCornerShape = RoundedCornerShape(CornerSize(percent)) +fun RoundedCornerShape(topStart: Dp = Dp(0f), topEnd: Dp = Dp(0f), bottomEnd: Dp = Dp(0f), bottomStart: Dp = Dp(0f)): RoundedCornerShape = + RoundedCornerShape(CornerSize(topStart), CornerSize(topEnd), CornerSize(bottomEnd), CornerSize(bottomStart)) + +val CircleShape: RoundedCornerShape = RoundedCornerShape(50) diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/text/BasicText.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/text/BasicText.kt new file mode 100644 index 00000000000..9283388ea8e --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/foundation/text/BasicText.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.foundation.text + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import com.codename1.compose.internal.NodeApplier +import com.codename1.compose.internal.TextNode + +@Composable +fun BasicText( + text: String, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE +) { + ComposeNode( + factory = { TextNode() }, + update = { + set(modifier) { this.modifier = it } + set(text) { this.text = it } + set(style) { this.style = it } + set(overflow) { this.overflow = it } + set(softWrap) { this.softWrap = it } + set(maxLines) { this.maxLines = it } + } + ) +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/Icons.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/Icons.kt new file mode 100644 index 00000000000..b98bf58ff17 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/Icons.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.material.icons + +/// The material icon sets. Each icon is an extension property in the set's +/// package (`androidx.compose.material.icons.filled.Favorite`), as in Compose. +object Icons { + object Filled + object Outlined + object Rounded + object AutoMirrored { + object Filled + object Outlined + } + + val Default: Filled = Filled +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirrored.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirrored.kt new file mode 100644 index 00000000000..3e8891b4453 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirrored.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.material.icons.automirrored.filled + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.vector.ImageVector + +private val mirroredArrowBack: ImageVector = ImageVector.cn1Material("Filled.ArrowBack", '\uE5C4') +val Icons.AutoMirrored.Filled.ArrowBack: ImageVector get() = mirroredArrowBack + +private val mirroredKeyboardArrowRight: ImageVector = ImageVector.cn1Material("Filled.KeyboardArrowRight", '\uE315') +val Icons.AutoMirrored.Filled.KeyboardArrowRight: ImageVector get() = mirroredKeyboardArrowRight + +private val mirroredList: ImageVector = ImageVector.cn1Material("Filled.List", '\uE896') +val Icons.AutoMirrored.Filled.List: ImageVector get() = mirroredList + +private val mirroredSend: ImageVector = ImageVector.cn1Material("Filled.Send", '\uE163') +val Icons.AutoMirrored.Filled.Send: ImageVector get() = mirroredSend diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/filled/Filled.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/filled/Filled.kt new file mode 100644 index 00000000000..8250e8dbb32 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/filled/Filled.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.material.icons.filled + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.vector.ImageVector + +private val filledFavorite: ImageVector = ImageVector.cn1Material("Filled.Favorite", '\uE87D') +val Icons.Filled.Favorite: ImageVector get() = filledFavorite + +private val filledFavoriteBorder: ImageVector = ImageVector.cn1Material("Filled.FavoriteBorder", '\uE87E') +val Icons.Filled.FavoriteBorder: ImageVector get() = filledFavoriteBorder + +private val filledSearch: ImageVector = ImageVector.cn1Material("Filled.Search", '\uE8B6') +val Icons.Filled.Search: ImageVector get() = filledSearch + +private val filledClose: ImageVector = ImageVector.cn1Material("Filled.Close", '\uE5CD') +val Icons.Filled.Close: ImageVector get() = filledClose + +private val filledArrowBack: ImageVector = ImageVector.cn1Material("Filled.ArrowBack", '\uE5C4') +val Icons.Filled.ArrowBack: ImageVector get() = filledArrowBack + +private val filledAdd: ImageVector = ImageVector.cn1Material("Filled.Add", '\uE145') +val Icons.Filled.Add: ImageVector get() = filledAdd + +private val filledHome: ImageVector = ImageVector.cn1Material("Filled.Home", '\uE88A') +val Icons.Filled.Home: ImageVector get() = filledHome + +private val filledPerson: ImageVector = ImageVector.cn1Material("Filled.Person", '\uE7FD') +val Icons.Filled.Person: ImageVector get() = filledPerson + +private val filledSettings: ImageVector = ImageVector.cn1Material("Filled.Settings", '\uE8B8') +val Icons.Filled.Settings: ImageVector get() = filledSettings + +private val filledStar: ImageVector = ImageVector.cn1Material("Filled.Star", '\uE838') +val Icons.Filled.Star: ImageVector get() = filledStar + +private val filledStarBorder: ImageVector = ImageVector.cn1Material("Filled.StarBorder", '\uE83A') +val Icons.Filled.StarBorder: ImageVector get() = filledStarBorder + +private val filledMenu: ImageVector = ImageVector.cn1Material("Filled.Menu", '\uE5D2') +val Icons.Filled.Menu: ImageVector get() = filledMenu + +private val filledMoreVert: ImageVector = ImageVector.cn1Material("Filled.MoreVert", '\uE5D4') +val Icons.Filled.MoreVert: ImageVector get() = filledMoreVert + +private val filledCheck: ImageVector = ImageVector.cn1Material("Filled.Check", '\uE5CA') +val Icons.Filled.Check: ImageVector get() = filledCheck + +private val filledInfo: ImageVector = ImageVector.cn1Material("Filled.Info", '\uE88E') +val Icons.Filled.Info: ImageVector get() = filledInfo + +private val filledSchedule: ImageVector = ImageVector.cn1Material("Filled.Schedule", '\uE8B5') +val Icons.Filled.Schedule: ImageVector get() = filledSchedule + +private val filledPlace: ImageVector = ImageVector.cn1Material("Filled.Place", '\uE55F') +val Icons.Filled.Place: ImageVector get() = filledPlace + +private val filledShare: ImageVector = ImageVector.cn1Material("Filled.Share", '\uE80D') +val Icons.Filled.Share: ImageVector get() = filledShare + +private val filledDelete: ImageVector = ImageVector.cn1Material("Filled.Delete", '\uE872') +val Icons.Filled.Delete: ImageVector get() = filledDelete + +private val filledEdit: ImageVector = ImageVector.cn1Material("Filled.Edit", '\uE3C9') +val Icons.Filled.Edit: ImageVector get() = filledEdit + +private val filledRefresh: ImageVector = ImageVector.cn1Material("Filled.Refresh", '\uE5D5') +val Icons.Filled.Refresh: ImageVector get() = filledRefresh + +private val filledNotifications: ImageVector = ImageVector.cn1Material("Filled.Notifications", '\uE7F4') +val Icons.Filled.Notifications: ImageVector get() = filledNotifications + +private val filledDateRange: ImageVector = ImageVector.cn1Material("Filled.DateRange", '\uE916') +val Icons.Filled.DateRange: ImageVector get() = filledDateRange + +private val filledList: ImageVector = ImageVector.cn1Material("Filled.List", '\uE896') +val Icons.Filled.List: ImageVector get() = filledList + +private val filledThumbUp: ImageVector = ImageVector.cn1Material("Filled.ThumbUp", '\uE8DC') +val Icons.Filled.ThumbUp: ImageVector get() = filledThumbUp + +private val filledPlayArrow: ImageVector = ImageVector.cn1Material("Filled.PlayArrow", '\uE037') +val Icons.Filled.PlayArrow: ImageVector get() = filledPlayArrow + +private val filledKeyboardArrowRight: ImageVector = ImageVector.cn1Material("Filled.KeyboardArrowRight", '\uE315') +val Icons.Filled.KeyboardArrowRight: ImageVector get() = filledKeyboardArrowRight + +private val filledKeyboardArrowDown: ImageVector = ImageVector.cn1Material("Filled.KeyboardArrowDown", '\uE313') +val Icons.Filled.KeyboardArrowDown: ImageVector get() = filledKeyboardArrowDown + +private val filledAccountCircle: ImageVector = ImageVector.cn1Material("Filled.AccountCircle", '\uE853') +val Icons.Filled.AccountCircle: ImageVector get() = filledAccountCircle + +private val filledCalendarToday: ImageVector = ImageVector.cn1Material("Filled.CalendarToday", '\uE935') +val Icons.Filled.CalendarToday: ImageVector get() = filledCalendarToday + +private val filledEvent: ImageVector = ImageVector.cn1Material("Filled.Event", '\uE878') +val Icons.Filled.Event: ImageVector get() = filledEvent + +private val filledMic: ImageVector = ImageVector.cn1Material("Filled.Mic", '\uE029') +val Icons.Filled.Mic: ImageVector get() = filledMic + +private val filledBookmark: ImageVector = ImageVector.cn1Material("Filled.Bookmark", '\uE866') +val Icons.Filled.Bookmark: ImageVector get() = filledBookmark + +private val filledBookmarkBorder: ImageVector = ImageVector.cn1Material("Filled.BookmarkBorder", '\uE867') +val Icons.Filled.BookmarkBorder: ImageVector get() = filledBookmarkBorder + +private val filledFilterList: ImageVector = ImageVector.cn1Material("Filled.FilterList", '\uE152') +val Icons.Filled.FilterList: ImageVector get() = filledFilterList + +private val filledClear: ImageVector = ImageVector.cn1Material("Filled.Clear", '\uE14C') +val Icons.Filled.Clear: ImageVector get() = filledClear + +private val filledSend: ImageVector = ImageVector.cn1Material("Filled.Send", '\uE163') +val Icons.Filled.Send: ImageVector get() = filledSend + +private val filledLocationOn: ImageVector = ImageVector.cn1Material("Filled.LocationOn", '\uE0C8') +val Icons.Filled.LocationOn: ImageVector get() = filledLocationOn + +private val filledEmail: ImageVector = ImageVector.cn1Material("Filled.Email", '\uE0BE') +val Icons.Filled.Email: ImageVector get() = filledEmail + +private val filledPhone: ImageVector = ImageVector.cn1Material("Filled.Phone", '\uE0CD') +val Icons.Filled.Phone: ImageVector get() = filledPhone + +private val filledWarning: ImageVector = ImageVector.cn1Material("Filled.Warning", '\uE002') +val Icons.Filled.Warning: ImageVector get() = filledWarning diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/outlined/Outlined.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/outlined/Outlined.kt new file mode 100644 index 00000000000..b8a6c885d53 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/material/icons/outlined/Outlined.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.material.icons.outlined + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.vector.ImageVector + +private val outlinedFavorite: ImageVector = ImageVector.cn1Material("Outlined.Favorite", '\uE87E') +val Icons.Outlined.Favorite: ImageVector get() = outlinedFavorite + +private val outlinedFavoriteBorder: ImageVector = ImageVector.cn1Material("Outlined.FavoriteBorder", '\uE87E') +val Icons.Outlined.FavoriteBorder: ImageVector get() = outlinedFavoriteBorder + +private val outlinedSearch: ImageVector = ImageVector.cn1Material("Outlined.Search", '\uE8B6') +val Icons.Outlined.Search: ImageVector get() = outlinedSearch + +private val outlinedClose: ImageVector = ImageVector.cn1Material("Outlined.Close", '\uE5CD') +val Icons.Outlined.Close: ImageVector get() = outlinedClose + +private val outlinedArrowBack: ImageVector = ImageVector.cn1Material("Outlined.ArrowBack", '\uE5C4') +val Icons.Outlined.ArrowBack: ImageVector get() = outlinedArrowBack + +private val outlinedAdd: ImageVector = ImageVector.cn1Material("Outlined.Add", '\uE145') +val Icons.Outlined.Add: ImageVector get() = outlinedAdd + +private val outlinedHome: ImageVector = ImageVector.cn1Material("Outlined.Home", '\uE88A') +val Icons.Outlined.Home: ImageVector get() = outlinedHome + +private val outlinedPerson: ImageVector = ImageVector.cn1Material("Outlined.Person", '\uE7FD') +val Icons.Outlined.Person: ImageVector get() = outlinedPerson + +private val outlinedSettings: ImageVector = ImageVector.cn1Material("Outlined.Settings", '\uE8B8') +val Icons.Outlined.Settings: ImageVector get() = outlinedSettings + +private val outlinedStar: ImageVector = ImageVector.cn1Material("Outlined.Star", '\uE83A') +val Icons.Outlined.Star: ImageVector get() = outlinedStar + +private val outlinedStarBorder: ImageVector = ImageVector.cn1Material("Outlined.StarBorder", '\uE83A') +val Icons.Outlined.StarBorder: ImageVector get() = outlinedStarBorder + +private val outlinedMenu: ImageVector = ImageVector.cn1Material("Outlined.Menu", '\uE5D2') +val Icons.Outlined.Menu: ImageVector get() = outlinedMenu + +private val outlinedMoreVert: ImageVector = ImageVector.cn1Material("Outlined.MoreVert", '\uE5D4') +val Icons.Outlined.MoreVert: ImageVector get() = outlinedMoreVert + +private val outlinedCheck: ImageVector = ImageVector.cn1Material("Outlined.Check", '\uE5CA') +val Icons.Outlined.Check: ImageVector get() = outlinedCheck + +private val outlinedInfo: ImageVector = ImageVector.cn1Material("Outlined.Info", '\uE88E') +val Icons.Outlined.Info: ImageVector get() = outlinedInfo + +private val outlinedSchedule: ImageVector = ImageVector.cn1Material("Outlined.Schedule", '\uE8B5') +val Icons.Outlined.Schedule: ImageVector get() = outlinedSchedule + +private val outlinedPlace: ImageVector = ImageVector.cn1Material("Outlined.Place", '\uE55F') +val Icons.Outlined.Place: ImageVector get() = outlinedPlace + +private val outlinedShare: ImageVector = ImageVector.cn1Material("Outlined.Share", '\uE80D') +val Icons.Outlined.Share: ImageVector get() = outlinedShare + +private val outlinedDelete: ImageVector = ImageVector.cn1Material("Outlined.Delete", '\uE872') +val Icons.Outlined.Delete: ImageVector get() = outlinedDelete + +private val outlinedEdit: ImageVector = ImageVector.cn1Material("Outlined.Edit", '\uE3C9') +val Icons.Outlined.Edit: ImageVector get() = outlinedEdit + +private val outlinedRefresh: ImageVector = ImageVector.cn1Material("Outlined.Refresh", '\uE5D5') +val Icons.Outlined.Refresh: ImageVector get() = outlinedRefresh + +private val outlinedNotifications: ImageVector = ImageVector.cn1Material("Outlined.Notifications", '\uE7F4') +val Icons.Outlined.Notifications: ImageVector get() = outlinedNotifications + +private val outlinedDateRange: ImageVector = ImageVector.cn1Material("Outlined.DateRange", '\uE916') +val Icons.Outlined.DateRange: ImageVector get() = outlinedDateRange + +private val outlinedList: ImageVector = ImageVector.cn1Material("Outlined.List", '\uE896') +val Icons.Outlined.List: ImageVector get() = outlinedList + +private val outlinedThumbUp: ImageVector = ImageVector.cn1Material("Outlined.ThumbUp", '\uE8DC') +val Icons.Outlined.ThumbUp: ImageVector get() = outlinedThumbUp + +private val outlinedPlayArrow: ImageVector = ImageVector.cn1Material("Outlined.PlayArrow", '\uE037') +val Icons.Outlined.PlayArrow: ImageVector get() = outlinedPlayArrow + +private val outlinedKeyboardArrowRight: ImageVector = ImageVector.cn1Material("Outlined.KeyboardArrowRight", '\uE315') +val Icons.Outlined.KeyboardArrowRight: ImageVector get() = outlinedKeyboardArrowRight + +private val outlinedKeyboardArrowDown: ImageVector = ImageVector.cn1Material("Outlined.KeyboardArrowDown", '\uE313') +val Icons.Outlined.KeyboardArrowDown: ImageVector get() = outlinedKeyboardArrowDown + +private val outlinedAccountCircle: ImageVector = ImageVector.cn1Material("Outlined.AccountCircle", '\uE853') +val Icons.Outlined.AccountCircle: ImageVector get() = outlinedAccountCircle + +private val outlinedCalendarToday: ImageVector = ImageVector.cn1Material("Outlined.CalendarToday", '\uE935') +val Icons.Outlined.CalendarToday: ImageVector get() = outlinedCalendarToday + +private val outlinedEvent: ImageVector = ImageVector.cn1Material("Outlined.Event", '\uE878') +val Icons.Outlined.Event: ImageVector get() = outlinedEvent + +private val outlinedMic: ImageVector = ImageVector.cn1Material("Outlined.Mic", '\uE029') +val Icons.Outlined.Mic: ImageVector get() = outlinedMic + +private val outlinedBookmark: ImageVector = ImageVector.cn1Material("Outlined.Bookmark", '\uE867') +val Icons.Outlined.Bookmark: ImageVector get() = outlinedBookmark + +private val outlinedBookmarkBorder: ImageVector = ImageVector.cn1Material("Outlined.BookmarkBorder", '\uE867') +val Icons.Outlined.BookmarkBorder: ImageVector get() = outlinedBookmarkBorder + +private val outlinedFilterList: ImageVector = ImageVector.cn1Material("Outlined.FilterList", '\uE152') +val Icons.Outlined.FilterList: ImageVector get() = outlinedFilterList + +private val outlinedClear: ImageVector = ImageVector.cn1Material("Outlined.Clear", '\uE14C') +val Icons.Outlined.Clear: ImageVector get() = outlinedClear + +private val outlinedSend: ImageVector = ImageVector.cn1Material("Outlined.Send", '\uE163') +val Icons.Outlined.Send: ImageVector get() = outlinedSend + +private val outlinedLocationOn: ImageVector = ImageVector.cn1Material("Outlined.LocationOn", '\uE0C8') +val Icons.Outlined.LocationOn: ImageVector get() = outlinedLocationOn + +private val outlinedEmail: ImageVector = ImageVector.cn1Material("Outlined.Email", '\uE0BE') +val Icons.Outlined.Email: ImageVector get() = outlinedEmail + +private val outlinedPhone: ImageVector = ImageVector.cn1Material("Outlined.Phone", '\uE0CD') +val Icons.Outlined.Phone: ImageVector get() = outlinedPhone + +private val outlinedWarning: ImageVector = ImageVector.cn1Material("Outlined.Warning", '\uE002') +val Icons.Outlined.Warning: ImageVector get() = outlinedWarning diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/material3/ColorScheme.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/material3/ColorScheme.kt new file mode 100644 index 00000000000..cd040b70809 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/material3/ColorScheme.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.material3 + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +/// The Material 3 color roles. The defaults are the baseline scheme. +@Immutable +data class ColorScheme( val primary: Color, + val onPrimary: Color, + val primaryContainer: Color, + val onPrimaryContainer: Color, + val inversePrimary: Color, + val secondary: Color, + val onSecondary: Color, + val secondaryContainer: Color, + val onSecondaryContainer: Color, + val tertiary: Color, + val onTertiary: Color, + val tertiaryContainer: Color, + val onTertiaryContainer: Color, + val background: Color, + val onBackground: Color, + val surface: Color, + val onSurface: Color, + val surfaceVariant: Color, + val onSurfaceVariant: Color, + val surfaceTint: Color, + val inverseSurface: Color, + val inverseOnSurface: Color, + val error: Color, + val onError: Color, + val errorContainer: Color, + val onErrorContainer: Color, + val outline: Color, + val outlineVariant: Color, + val scrim: Color, + val surfaceBright: Color, + val surfaceDim: Color, + val surfaceContainer: Color, + val surfaceContainerHigh: Color, + val surfaceContainerHighest: Color, + val surfaceContainerLow: Color, + val surfaceContainerLowest: Color) + +fun lightColorScheme( primary: Color = Color(0xFF6750A4), + onPrimary: Color = Color(0xFFFFFFFF), + primaryContainer: Color = Color(0xFFEADDFF), + onPrimaryContainer: Color = Color(0xFF21005D), + inversePrimary: Color = Color(0xFFD0BCFF), + secondary: Color = Color(0xFF625B71), + onSecondary: Color = Color(0xFFFFFFFF), + secondaryContainer: Color = Color(0xFFE8DEF8), + onSecondaryContainer: Color = Color(0xFF1D192B), + tertiary: Color = Color(0xFF7D5260), + onTertiary: Color = Color(0xFFFFFFFF), + tertiaryContainer: Color = Color(0xFFFFD8E4), + onTertiaryContainer: Color = Color(0xFF31111D), + background: Color = Color(0xFFFEF7FF), + onBackground: Color = Color(0xFF1D1B20), + surface: Color = Color(0xFFFEF7FF), + onSurface: Color = Color(0xFF1D1B20), + surfaceVariant: Color = Color(0xFFE7E0EC), + onSurfaceVariant: Color = Color(0xFF49454F), + surfaceTint: Color = Color(0xFF6750A4), + inverseSurface: Color = Color(0xFF322F35), + inverseOnSurface: Color = Color(0xFFF5EFF7), + error: Color = Color(0xFFB3261E), + onError: Color = Color(0xFFFFFFFF), + errorContainer: Color = Color(0xFFF9DEDC), + onErrorContainer: Color = Color(0xFF410E0B), + outline: Color = Color(0xFF79747E), + outlineVariant: Color = Color(0xFFCAC4D0), + scrim: Color = Color(0xFF000000), + surfaceBright: Color = Color(0xFFFEF7FF), + surfaceDim: Color = Color(0xFFDED8E1), + surfaceContainer: Color = Color(0xFFF3EDF7), + surfaceContainerHigh: Color = Color(0xFFECE6F0), + surfaceContainerHighest: Color = Color(0xFFE6E0E9), + surfaceContainerLow: Color = Color(0xFFF7F2FA), + surfaceContainerLowest: Color = Color(0xFFFFFFFF)): ColorScheme = ColorScheme(primary, onPrimary, primaryContainer, onPrimaryContainer, inversePrimary, secondary, onSecondary, secondaryContainer, onSecondaryContainer, tertiary, onTertiary, tertiaryContainer, onTertiaryContainer, background, onBackground, surface, onSurface, surfaceVariant, onSurfaceVariant, surfaceTint, inverseSurface, inverseOnSurface, error, onError, errorContainer, onErrorContainer, outline, outlineVariant, scrim, surfaceBright, surfaceDim, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest, surfaceContainerLow, surfaceContainerLowest) + +fun darkColorScheme( primary: Color = Color(0xFFD0BCFF), + onPrimary: Color = Color(0xFF381E72), + primaryContainer: Color = Color(0xFF4F378B), + onPrimaryContainer: Color = Color(0xFFEADDFF), + inversePrimary: Color = Color(0xFF6750A4), + secondary: Color = Color(0xFFCCC2DC), + onSecondary: Color = Color(0xFF332D41), + secondaryContainer: Color = Color(0xFF4A4458), + onSecondaryContainer: Color = Color(0xFFE8DEF8), + tertiary: Color = Color(0xFFEFB8C8), + onTertiary: Color = Color(0xFF492532), + tertiaryContainer: Color = Color(0xFF633B48), + onTertiaryContainer: Color = Color(0xFFFFD8E4), + background: Color = Color(0xFF141218), + onBackground: Color = Color(0xFFE6E0E9), + surface: Color = Color(0xFF141218), + onSurface: Color = Color(0xFFE6E0E9), + surfaceVariant: Color = Color(0xFF49454F), + onSurfaceVariant: Color = Color(0xFFCAC4D0), + surfaceTint: Color = Color(0xFFD0BCFF), + inverseSurface: Color = Color(0xFFE6E0E9), + inverseOnSurface: Color = Color(0xFF322F35), + error: Color = Color(0xFFF2B8B5), + onError: Color = Color(0xFF601410), + errorContainer: Color = Color(0xFF8C1D18), + onErrorContainer: Color = Color(0xFFF9DEDC), + outline: Color = Color(0xFF938F99), + outlineVariant: Color = Color(0xFF49454F), + scrim: Color = Color(0xFF000000), + surfaceBright: Color = Color(0xFF3B383E), + surfaceDim: Color = Color(0xFF141218), + surfaceContainer: Color = Color(0xFF211F26), + surfaceContainerHigh: Color = Color(0xFF2B2930), + surfaceContainerHighest: Color = Color(0xFF36343B), + surfaceContainerLow: Color = Color(0xFF1D1B20), + surfaceContainerLowest: Color = Color(0xFF0F0D13)): ColorScheme = ColorScheme(primary, onPrimary, primaryContainer, onPrimaryContainer, inversePrimary, secondary, onSecondary, secondaryContainer, onSecondaryContainer, tertiary, onTertiary, tertiaryContainer, onTertiaryContainer, background, onBackground, surface, onSurface, surfaceVariant, onSurfaceVariant, surfaceTint, inverseSurface, inverseOnSurface, error, onError, errorContainer, onErrorContainer, outline, outlineVariant, scrim, surfaceBright, surfaceDim, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest, surfaceContainerLow, surfaceContainerLowest) + +/// The color that goes on `background`: its `on` role, or the content color in +/// effect when the color is not one of the scheme's. +fun ColorScheme.contentColorFor(backgroundColor: Color): Color = when (backgroundColor) { + primary -> onPrimary + secondary -> onSecondary + tertiary -> onTertiary + background -> onBackground + error -> onError + primaryContainer -> onPrimaryContainer + secondaryContainer -> onSecondaryContainer + tertiaryContainer -> onTertiaryContainer + errorContainer -> onErrorContainer + inverseSurface -> inverseOnSurface + surface, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest, surfaceContainerLow, + surfaceContainerLowest, surfaceBright, surfaceDim -> onSurface + surfaceVariant -> onSurfaceVariant + else -> Color.Unspecified +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Components.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Components.kt new file mode 100644 index 00000000000..b8ae21b434d --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Components.kt @@ -0,0 +1,411 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.material3 + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.animation.core.animateFloat +import com.codename1.compose.internal.IconNode +import com.codename1.compose.internal.NodeApplier + +// --- text and icons --------------------------------------------------------- + +@Composable +fun Text( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontWeight: FontWeight? = null, + textAlign: TextAlign = TextAlign.Unspecified, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + style: TextStyle = LocalTextStyle.current +) { + val c = if (color != Color.Unspecified) color else if (style.color != Color.Unspecified) style.color else LocalContentColor.current + val merged = style.merge(TextStyle(color = c, fontSize = fontSize, fontWeight = fontWeight, textAlign = textAlign, + lineHeight = lineHeight)) + BasicText(text, modifier, merged, overflow, softWrap, maxLines) +} + +@Composable +fun Icon(imageVector: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current) { + ComposeNode( + factory = { IconNode() }, + update = { + set(modifier) { this.modifier = it } + set(imageVector) { this.glyph = com.codename1.compose.internal.Glyphs.of(it) } + set(tint) { this.tint = it } + } + ) +} + +// --- surfaces --------------------------------------------------------------- + +@Composable +fun Surface( + modifier: Modifier = Modifier, + shape: Shape = RectangleShape, + color: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(color), + tonalElevation: Dp = 0.dp, + shadowElevation: Dp = 0.dp, + border: BorderStroke? = null, + content: @Composable () -> Unit +) { + val m = modifier.surface(shape, tint(color, tonalElevation), border) + CompositionLocalProvider(LocalContentColor provides contentColor) { + Box(m, propagateMinConstraints = true) { content() } + } +} + +@Composable +fun Surface( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = RectangleShape, + color: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(color), + tonalElevation: Dp = 0.dp, + shadowElevation: Dp = 0.dp, + border: BorderStroke? = null, + content: @Composable () -> Unit +) { + val m = modifier.surface(shape, tint(color, tonalElevation), border).clickable(enabled, onClick = onClick) + CompositionLocalProvider(LocalContentColor provides contentColor) { + Box(m, propagateMinConstraints = true) { content() } + } +} + +private fun Modifier.surface(shape: Shape, color: Color, border: BorderStroke?): Modifier { + var m = this.clip(shape).background(color, shape) + if (border != null) m = m.border(border.width, border.color, shape) + return m +} + +@Composable +private fun tint(color: Color, elevation: Dp): Color { + val cs = MaterialTheme.colorScheme + if (color != cs.surface || elevation.value <= 0f) return color + val f = Math.min(0.14f, elevation.value * 0.02f) + return androidx.compose.ui.graphics.lerp(color, cs.surfaceTint, f) +} + +@Immutable +class CardColors(val containerColor: Color, val contentColor: Color) + +object CardDefaults { + val shape: Shape @Composable get() = MaterialTheme.shapes.medium + + @Composable + fun cardColors(containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor: Color = contentColorFor(containerColor)): CardColors = CardColors(containerColor, contentColor) + + @Composable + fun elevatedCardColors(containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor: Color = contentColorFor(containerColor)): CardColors = CardColors(containerColor, contentColor) + + @Composable + fun outlinedCardColors(containerColor: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(containerColor)): CardColors = CardColors(containerColor, contentColor) + + @Composable + fun cardElevation(defaultElevation: Dp = 0.dp): Dp = defaultElevation + + @Composable + fun elevatedCardElevation(defaultElevation: Dp = 1.dp): Dp = defaultElevation +} + +@Composable +fun Card(modifier: Modifier = Modifier, shape: Shape = CardDefaults.shape, colors: CardColors = CardDefaults.cardColors(), + elevation: Dp = 0.dp, border: BorderStroke? = null, content: @Composable ColumnScope.() -> Unit) { + Surface(modifier, shape, colors.containerColor, colors.contentColor, elevation, border = border) { + Column(content = content) + } +} + +@Composable +fun Card(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, shape: Shape = CardDefaults.shape, + colors: CardColors = CardDefaults.cardColors(), elevation: Dp = 0.dp, border: BorderStroke? = null, + content: @Composable ColumnScope.() -> Unit) { + Surface(onClick, modifier, enabled, shape, colors.containerColor, colors.contentColor, elevation, border = border) { + Column(content = content) + } +} + +@Composable +fun ElevatedCard(modifier: Modifier = Modifier, shape: Shape = CardDefaults.shape, + colors: CardColors = CardDefaults.elevatedCardColors(), content: @Composable ColumnScope.() -> Unit) = + Card(modifier, shape, colors, 1.dp, null, content) + +@Composable +fun OutlinedCard(modifier: Modifier = Modifier, shape: Shape = CardDefaults.shape, + colors: CardColors = CardDefaults.outlinedCardColors(), content: @Composable ColumnScope.() -> Unit) = + Card(modifier, shape, colors, 0.dp, BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), content) + +// --- buttons ---------------------------------------------------------------- + +@Immutable +class ButtonColors(val containerColor: Color, val contentColor: Color, val disabledContainerColor: Color, + val disabledContentColor: Color) { + internal fun container(enabled: Boolean) = if (enabled) containerColor else disabledContainerColor + internal fun content(enabled: Boolean) = if (enabled) contentColor else disabledContentColor +} + +object ButtonDefaults { + val ContentPadding: PaddingValues = PaddingValues(start = 24.dp, top = 8.dp, end = 24.dp, bottom = 8.dp) + val TextButtonContentPadding: PaddingValues = PaddingValues(start = 12.dp, top = 8.dp, end = 12.dp, bottom = 8.dp) + val MinWidth: Dp = 58.dp + val MinHeight: Dp = 40.dp + val IconSize: Dp = 18.dp + val IconSpacing: Dp = 8.dp + val shape: Shape @Composable get() = CircleShape + + @Composable + fun buttonColors(containerColor: Color = MaterialTheme.colorScheme.primary, + contentColor: Color = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + disabledContentColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)): ButtonColors = + ButtonColors(containerColor, contentColor, disabledContainerColor, disabledContentColor) + + @Composable + fun filledTonalButtonColors(containerColor: Color = MaterialTheme.colorScheme.secondaryContainer, + contentColor: Color = MaterialTheme.colorScheme.onSecondaryContainer): ButtonColors = + buttonColors(containerColor, contentColor) + + @Composable + fun outlinedButtonColors(containerColor: Color = Color.Transparent, + contentColor: Color = MaterialTheme.colorScheme.primary): ButtonColors = + ButtonColors(containerColor, contentColor, Color.Transparent, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)) + + @Composable + fun textButtonColors(containerColor: Color = Color.Transparent, + contentColor: Color = MaterialTheme.colorScheme.primary): ButtonColors = + ButtonColors(containerColor, contentColor, Color.Transparent, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)) + + @Composable + fun elevatedButtonColors(containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor: Color = MaterialTheme.colorScheme.primary): ButtonColors = + buttonColors(containerColor, contentColor) +} + +@Composable +fun Button( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = ButtonDefaults.shape, + colors: ButtonColors = ButtonDefaults.buttonColors(), + border: BorderStroke? = null, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit +) { + Surface(onClick, modifier, enabled, shape, colors.container(enabled), colors.content(enabled), border = border) { + ProvideTextStyle(MaterialTheme.typography.labelLarge) { + Row(Modifier.defaultMinSize(ButtonDefaults.MinWidth, ButtonDefaults.MinHeight).padding(contentPadding), + horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, content = content) + } + } +} + +@Composable +fun FilledTonalButton(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + shape: Shape = ButtonDefaults.shape, colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(), + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, content: @Composable RowScope.() -> Unit) = + Button(onClick, modifier, enabled, shape, colors, null, contentPadding, content) + +@Composable +fun ElevatedButton(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + shape: Shape = ButtonDefaults.shape, colors: ButtonColors = ButtonDefaults.elevatedButtonColors(), + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, content: @Composable RowScope.() -> Unit) = + Button(onClick, modifier, enabled, shape, colors, null, contentPadding, content) + +@Composable +fun OutlinedButton(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + shape: Shape = ButtonDefaults.shape, colors: ButtonColors = ButtonDefaults.outlinedButtonColors(), + border: BorderStroke? = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, content: @Composable RowScope.() -> Unit) = + Button(onClick, modifier, enabled, shape, colors, border, contentPadding, content) + +@Composable +fun TextButton(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + shape: Shape = ButtonDefaults.shape, colors: ButtonColors = ButtonDefaults.textButtonColors(), + contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding, content: @Composable RowScope.() -> Unit) = + Button(onClick, modifier, enabled, shape, colors, null, contentPadding, content) + +@Composable +fun IconButton(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable () -> Unit) { + val color = if (enabled) LocalContentColor.current else LocalContentColor.current.copy(alpha = 0.38f) + Box(modifier.size(40.dp).clip(CircleShape).clickable(enabled, onClick = onClick), contentAlignment = Alignment.Center) { + CompositionLocalProvider(LocalContentColor provides color) { content() } + } +} + +@Composable +fun FloatingActionButton(onClick: () -> Unit, modifier: Modifier = Modifier, + shape: Shape = MaterialTheme.shapes.large, + containerColor: Color = MaterialTheme.colorScheme.primaryContainer, + contentColor: Color = contentColorFor(containerColor), + content: @Composable () -> Unit) { + Surface(onClick, modifier, true, shape, containerColor, contentColor, 6.dp) { + Box(Modifier.defaultMinSize(56.dp, 56.dp), contentAlignment = Alignment.Center) { content() } + } +} + +@Composable +fun ExtendedFloatingActionButton(text: @Composable () -> Unit, icon: @Composable () -> Unit, onClick: () -> Unit, + modifier: Modifier = Modifier, expanded: Boolean = true, + containerColor: Color = MaterialTheme.colorScheme.primaryContainer, + contentColor: Color = contentColorFor(containerColor)) { + Surface(onClick, modifier, true, MaterialTheme.shapes.large, containerColor, contentColor, 6.dp) { + Row(Modifier.defaultMinSize(80.dp, 56.dp).padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { + icon() + if (expanded) { + Spacer(Modifier.width(12.dp)) + ProvideTextStyle(MaterialTheme.typography.labelLarge) { text() } + } + } + } +} + +// --- chips ------------------------------------------------------------------ + +@Composable +fun FilterChip(selected: Boolean, onClick: () -> Unit, label: @Composable () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, leadingIcon: (@Composable () -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null) { + val cs = MaterialTheme.colorScheme + val container = if (selected) cs.secondaryContainer else Color.Transparent + val content = if (selected) cs.onSecondaryContainer else cs.onSurfaceVariant + Surface(onClick, modifier, enabled, MaterialTheme.shapes.small, container, content, + border = if (selected) null else BorderStroke(1.dp, cs.outline)) { + Row(Modifier.height(32.dp).padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (leadingIcon != null) Box(Modifier.size(18.dp)) { leadingIcon() } + ProvideTextStyle(MaterialTheme.typography.labelLarge) { label() } + if (trailingIcon != null) Box(Modifier.size(18.dp)) { trailingIcon() } + } + } +} + +@Composable +fun AssistChip(onClick: () -> Unit, label: @Composable () -> Unit, modifier: Modifier = Modifier, + leadingIcon: (@Composable () -> Unit)? = null) = + FilterChip(false, onClick, label, modifier, true, leadingIcon) + +// --- dividers and progress -------------------------------------------------- + +@Composable +fun HorizontalDivider(modifier: Modifier = Modifier, thickness: Dp = 1.dp, + color: Color = MaterialTheme.colorScheme.outlineVariant) { + Box(modifier.fillMaxWidth().height(thickness).background(color)) +} + +@Composable +fun VerticalDivider(modifier: Modifier = Modifier, thickness: Dp = 1.dp, + color: Color = MaterialTheme.colorScheme.outlineVariant) { + Box(modifier.width(thickness).background(color)) +} + +@Composable +fun LinearProgressIndicator(progress: () -> Float, modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + trackColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest) { + androidx.compose.foundation.Canvas(modifier.fillMaxWidth().height(4.dp)) { + drawRoundRect(trackColor, cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.height / 2)) + val p = Math.max(0f, Math.min(1f, progress())) + drawRoundRect(color, size = androidx.compose.ui.geometry.Size(size.width * p, size.height), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.height / 2)) + } +} + +@Composable +fun CircularProgressIndicator(modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.primary, + strokeWidth: Dp = 4.dp) { + val t = androidx.compose.animation.core.rememberInfiniteTransition() + val angle = t.animateFloat(0f, 360f, androidx.compose.animation.core.infiniteRepeatable( + androidx.compose.animation.core.tween(1100, easing = androidx.compose.animation.core.LinearEasing))) + androidx.compose.foundation.Canvas(modifier.size(40.dp)) { + val w = strokeWidth.toPx() + drawArc(color, angle.value, 270f, false, androidx.compose.ui.geometry.Offset(w / 2, w / 2), + androidx.compose.ui.geometry.Size(size.width - w, size.height - w), + style = androidx.compose.ui.graphics.drawscope.Stroke(w, androidx.compose.ui.graphics.StrokeCap.Round)) + } +} + +@Composable +fun CircularProgressIndicator(progress: () -> Float, modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, strokeWidth: Dp = 4.dp, + trackColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest) { + androidx.compose.foundation.Canvas(modifier.size(40.dp)) { + val w = strokeWidth.toPx() + val o = androidx.compose.ui.geometry.Offset(w / 2, w / 2) + val s = androidx.compose.ui.geometry.Size(size.width - w, size.height - w) + val st = androidx.compose.ui.graphics.drawscope.Stroke(w, androidx.compose.ui.graphics.StrokeCap.Round) + drawArc(trackColor, 0f, 360f, false, o, s, style = st) + drawArc(color, -90f, 360f * Math.max(0f, Math.min(1f, progress())), false, o, s, style = st) + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Structure.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Structure.kt new file mode 100644 index 00000000000..9d21293e313 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Structure.kt @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.material3 + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +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.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.animation.core.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.runtime.getValue +import com.codename1.compose.internal.Fonts +import com.codename1.compose.internal.SafeInsetsElement +import com.codename1.compose.internal.NodeApplier +import com.codename1.compose.internal.TextInputNode + +// --- scaffold --------------------------------------------------------------- + +@Composable +fun Scaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + containerColor: Color = MaterialTheme.colorScheme.background, + contentColor: Color = contentColorFor(containerColor), + content: @Composable (PaddingValues) -> Unit +) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + Column(modifier.fillMaxSize().background(containerColor)) { + topBar() + Box(Modifier.weight(1f).fillMaxWidth()) { + // The bars are laid out above and below the content, not over + // it, so the content needs no padding for them; upstream hands + // their heights and the window insets here because its bars + // overlap the content. A Scaffold with no top bar in a host under + // the status bar therefore starts its content there; the top bar + // is what pads for it. + Box(Modifier.fillMaxSize()) { content(PaddingValues(0.dp)) } + Box(Modifier.align(Alignment.BottomEnd).padding(16.dp)) { floatingActionButton() } + Box(Modifier.align(Alignment.BottomCenter)) { snackbarHost() } + } + bottomBar() + } + } +} + +// --- app bars --------------------------------------------------------------- + +@Immutable +class TopAppBarColors(val containerColor: Color, val titleContentColor: Color, val navigationIconContentColor: Color, + val actionIconContentColor: Color) + +object TopAppBarDefaults { + @Composable + fun topAppBarColors(containerColor: Color = MaterialTheme.colorScheme.surface, + titleContentColor: Color = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor: Color = MaterialTheme.colorScheme.onSurface, + actionIconContentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant): TopAppBarColors = + TopAppBarColors(containerColor, titleContentColor, navigationIconContentColor, actionIconContentColor) + + @Composable + fun centerAlignedTopAppBarColors(containerColor: Color = MaterialTheme.colorScheme.surface, + titleContentColor: Color = MaterialTheme.colorScheme.onSurface): TopAppBarColors = + topAppBarColors(containerColor, titleContentColor) +} + +@Composable +fun TopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors() +) = AppBar(title, modifier, navigationIcon, actions, colors, false) + +@Composable +fun CenterAlignedTopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors() +) = AppBar(title, modifier, navigationIcon, actions, colors, true) + +@Composable +private fun AppBar(title: @Composable () -> Unit, modifier: Modifier, navigationIcon: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit, colors: TopAppBarColors, centered: Boolean) { + Row(modifier.fillMaxWidth().background(colors.containerColor).then(SafeInsetsElement(top = true, bottom = false)).height(64.dp) + .padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { + CompositionLocalProvider(LocalContentColor provides colors.navigationIconContentColor) { navigationIcon() } + Box(Modifier.weight(1f).padding(horizontal = 12.dp), + contentAlignment = if (centered) Alignment.Center else Alignment.CenterStart) { + CompositionLocalProvider(LocalContentColor provides colors.titleContentColor) { + ProvideTextStyle(MaterialTheme.typography.titleLarge) { title() } + } + } + CompositionLocalProvider(LocalContentColor provides colors.actionIconContentColor) { + Row(verticalAlignment = Alignment.CenterVertically, content = actions) + } + } +} + +// --- navigation bar --------------------------------------------------------- + +@Composable +fun NavigationBar(modifier: Modifier = Modifier, containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, + contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, content: @Composable RowScope.() -> Unit) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + Row(modifier.fillMaxWidth().background(containerColor).then(SafeInsetsElement(top = false, bottom = true)).height(80.dp), + horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, content = content) + } +} + +@Composable +fun RowScope.NavigationBarItem(selected: Boolean, onClick: () -> Unit, icon: @Composable () -> Unit, + modifier: Modifier = Modifier, enabled: Boolean = true, + label: (@Composable () -> Unit)? = null, alwaysShowLabel: Boolean = true) { + val cs = MaterialTheme.colorScheme + val indicator by animateColorAsState(if (selected) cs.secondaryContainer else cs.secondaryContainer.copy(alpha = 0f)) + Column(modifier.weight(1f).fillMaxSize().clickable(enabled, onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Box(Modifier.size(64.dp, 32.dp).background(indicator, CircleShape), contentAlignment = Alignment.Center) { + CompositionLocalProvider(LocalContentColor provides if (selected) cs.onSecondaryContainer else cs.onSurfaceVariant) { + icon() + } + } + if (label != null && (alwaysShowLabel || selected)) { + Spacer(Modifier.height(4.dp)) + CompositionLocalProvider(LocalContentColor provides if (selected) cs.onSurface else cs.onSurfaceVariant) { + ProvideTextStyle(MaterialTheme.typography.labelMedium) { label() } + } + } + } +} + +// --- selection controls ----------------------------------------------------- + +@Composable +fun Switch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, modifier: Modifier = Modifier, enabled: Boolean = true) { + val cs = MaterialTheme.colorScheme + val track by animateColorAsState(if (checked) cs.primary else cs.surfaceContainerHighest) + val thumb by animateColorAsState(if (checked) cs.onPrimary else cs.outline) + val offset by animateDpAsState(if (checked) 24.dp else 4.dp) + val thumbSize by animateDpAsState(if (checked) 24.dp else 16.dp) + var m = modifier.size(52.dp, 32.dp).clip(CircleShape).background(track, CircleShape) + if (!checked) m = m.border(2.dp, cs.outline, CircleShape) + if (onCheckedChange != null) m = m.clickable(enabled) { onCheckedChange(!checked) } + Box(m, contentAlignment = Alignment.CenterStart) { + Box(Modifier.offset(x = offset + (24.dp - thumbSize) / 2).size(thumbSize).background(thumb, CircleShape)) + } +} + +@Composable +fun Checkbox(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, modifier: Modifier = Modifier, enabled: Boolean = true) { + val cs = MaterialTheme.colorScheme + var m = modifier.size(40.dp) + if (onCheckedChange != null) m = m.clip(CircleShape).clickable(enabled) { onCheckedChange(!checked) } + Box(m, contentAlignment = Alignment.Center) { + val box = Modifier.size(18.dp).clip(RoundedCornerShape(2.dp)) + if (checked) { + Box(box.background(cs.primary), contentAlignment = Alignment.Center) { + Icon(androidx.compose.ui.graphics.vector.ImageVector.cn1Material("Check", '\ue5ca'), null, + Modifier.size(16.dp), cs.onPrimary) + } + } else { + Box(box.border(2.dp, cs.onSurfaceVariant, RoundedCornerShape(2.dp))) + } + } +} + +// --- text fields ------------------------------------------------------------ + +@Composable +private fun TextInput(value: String, onValueChange: (String) -> Unit, modifier: Modifier, enabled: Boolean, + singleLine: Boolean, hint: String, style: TextStyle, color: Color) { + val font = Fonts.get(Math.max(1, Math.round((if (style.fontSize.isSpecified) style.fontSize.value else 16f) * + com.codename1.compose.internal.Density.density)), style.fontWeight) + ComposeNode( + factory = { TextInputNode() }, + update = { + set(modifier) { this.modifier = it } + set(value) { this.setValue(it) } + set(onValueChange) { this.onValueChange = it } + set(enabled) { this.enabledInput = it } + set(singleLine) { this.singleLine = it } + set(hint) { this.hint = it } + set(color) { this.textColor = it } + set(font) { this.font = it } + } + ) +} + +@Composable +fun BasicTextField(value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + textStyle: TextStyle = TextStyle.Default, singleLine: Boolean = false) { + TextInput(value, onValueChange, modifier, enabled, singleLine, "", textStyle, + if (textStyle.color != Color.Unspecified) textStyle.color else LocalContentColor.current) +} + +@Composable +private fun DecoratedField(value: String, onValueChange: (String) -> Unit, modifier: Modifier, enabled: Boolean, + label: (@Composable () -> Unit)?, placeholder: (@Composable () -> Unit)?, leadingIcon: (@Composable () -> Unit)?, + trailingIcon: (@Composable () -> Unit)?, singleLine: Boolean, shape: Shape, + container: Color, outline: Color?) { + val cs = MaterialTheme.colorScheme + var m = modifier.clip(shape).background(container, shape) + if (outline != null) m = m.border(1.dp, outline, shape) + Row(m.height(if (singleLine) 56.dp else 112.dp).padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + if (leadingIcon != null) { + CompositionLocalProvider(LocalContentColor provides cs.onSurfaceVariant) { leadingIcon() } + Spacer(Modifier.width(12.dp)) + } + Column(Modifier.weight(1f)) { + if (label != null && value.isNotEmpty()) { + CompositionLocalProvider(LocalContentColor provides cs.primary) { + ProvideTextStyle(MaterialTheme.typography.bodySmall) { label() } + } + } + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) { + TextInput(value, onValueChange, Modifier.fillMaxWidth(), enabled, singleLine, "", + MaterialTheme.typography.bodyLarge, cs.onSurface) + // The placeholder, or the label while there is no value, paints over the + // empty field; text takes no touches, so the field still gets them. + val hint = placeholder ?: label + if (value.isEmpty() && hint != null) { + CompositionLocalProvider(LocalContentColor provides cs.onSurfaceVariant) { + ProvideTextStyle(MaterialTheme.typography.bodyLarge) { hint() } + } + } + } + } + if (trailingIcon != null) { + Spacer(Modifier.width(12.dp)) + CompositionLocalProvider(LocalContentColor provides cs.onSurfaceVariant) { trailingIcon() } + } + } +} + +/// A filled text field. The placeholder (or the label) shows while the field is empty. +/// Editing is the platform's own: the field is a Codename One `TextField`. +@Composable +fun TextField(value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + label: (@Composable () -> Unit)? = null, placeholder: (@Composable () -> Unit)? = null, + leadingIcon: (@Composable () -> Unit)? = null, trailingIcon: (@Composable () -> Unit)? = null, + singleLine: Boolean = false, shape: Shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) { + DecoratedField(value, onValueChange, modifier, enabled, label, placeholder, leadingIcon, trailingIcon, singleLine, + shape, MaterialTheme.colorScheme.surfaceContainerHighest, null) +} + +@Composable +fun OutlinedTextField(value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + label: (@Composable () -> Unit)? = null, placeholder: (@Composable () -> Unit)? = null, + leadingIcon: (@Composable () -> Unit)? = null, trailingIcon: (@Composable () -> Unit)? = null, + singleLine: Boolean = false, shape: Shape = RoundedCornerShape(4.dp)) { + DecoratedField(value, onValueChange, modifier, enabled, label, placeholder, leadingIcon, trailingIcon, singleLine, + shape, Color.Transparent, MaterialTheme.colorScheme.outline) +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Theme.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Theme.kt new file mode 100644 index 00000000000..6becd25c2bb --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Theme.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.material3 + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +/// The Material 3 shape scale. +@Immutable +data class Shapes( + val extraSmall: RoundedCornerShape = RoundedCornerShape(4.dp), + val small: RoundedCornerShape = RoundedCornerShape(8.dp), + val medium: RoundedCornerShape = RoundedCornerShape(12.dp), + val large: RoundedCornerShape = RoundedCornerShape(16.dp), + val extraLarge: RoundedCornerShape = RoundedCornerShape(28.dp) +) + +internal val LocalColorScheme = staticCompositionLocalOf { lightColorScheme() } +internal val LocalTypography = staticCompositionLocalOf { Typography() } +internal val LocalShapes = staticCompositionLocalOf { Shapes() } + +/// The color content is drawn in: text and icons read it when no color is given. +val LocalContentColor = compositionLocalOf { Color.Black } + +/// The style [Text] starts from. +val LocalTextStyle = compositionLocalOf { TextStyle.Default } + +@Composable +fun MaterialTheme( + colorScheme: ColorScheme = MaterialTheme.colorScheme, + shapes: Shapes = MaterialTheme.shapes, + typography: Typography = MaterialTheme.typography, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalColorScheme provides colorScheme, + LocalTypography provides typography, + LocalShapes provides shapes, + LocalContentColor provides colorScheme.onSurface, + LocalTextStyle provides typography.bodyLarge + ) { + content() + } +} + +object MaterialTheme { + val colorScheme: ColorScheme + @Composable @ReadOnlyComposable get() = LocalColorScheme.current + val typography: Typography + @Composable @ReadOnlyComposable get() = LocalTypography.current + val shapes: Shapes + @Composable @ReadOnlyComposable get() = LocalShapes.current +} + +@Composable +fun ProvideTextStyle(value: TextStyle, content: @Composable () -> Unit) { + val merged = LocalTextStyle.current.merge(value) + CompositionLocalProvider(LocalTextStyle provides merged, content = content) +} + +@Composable +@ReadOnlyComposable +fun contentColorFor(backgroundColor: Color): Color { + val c = MaterialTheme.colorScheme.contentColorFor(backgroundColor) + return if (c == Color.Unspecified) LocalContentColor.current else c +} + +internal val CornerFull = RoundedCornerShape(CornerSize(50)) diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Typography.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Typography.kt new file mode 100644 index 00000000000..446c824a7fd --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/material3/Typography.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.material3 + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/// The Material 3 type scale; the defaults are the baseline sizes. +@Immutable +data class Typography( + val displayLarge: TextStyle = TextStyle(fontSize = 57.sp, lineHeight = 64.sp, fontWeight = FontWeight(400)), + val displayMedium: TextStyle = TextStyle(fontSize = 45.sp, lineHeight = 52.sp, fontWeight = FontWeight(400)), + val displaySmall: TextStyle = TextStyle(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight(400)), + val headlineLarge: TextStyle = TextStyle(fontSize = 32.sp, lineHeight = 40.sp, fontWeight = FontWeight(400)), + val headlineMedium: TextStyle = TextStyle(fontSize = 28.sp, lineHeight = 36.sp, fontWeight = FontWeight(400)), + val headlineSmall: TextStyle = TextStyle(fontSize = 24.sp, lineHeight = 32.sp, fontWeight = FontWeight(400)), + val titleLarge: TextStyle = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight(400)), + val titleMedium: TextStyle = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight(500)), + val titleSmall: TextStyle = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight(500)), + val bodyLarge: TextStyle = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight(400)), + val bodyMedium: TextStyle = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight(400)), + val bodySmall: TextStyle = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight(400)), + val labelLarge: TextStyle = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight(500)), + val labelMedium: TextStyle = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight(500)), + val labelSmall: TextStyle = TextStyle(fontSize = 11.sp, lineHeight = 16.sp, fontWeight = FontWeight(500)) +) diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/Alignment.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/Alignment.kt new file mode 100644 index 00000000000..e8048675804 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/Alignment.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection + +/// Where a child of a given size goes in the space it has. +@Immutable +fun interface Alignment { + fun align(size: IntSize, space: IntSize, layoutDirection: LayoutDirection): IntOffset + + @Immutable + fun interface Horizontal { + fun align(size: Int, space: Int, layoutDirection: LayoutDirection): Int + } + + @Immutable + fun interface Vertical { + fun align(size: Int, space: Int): Int + } + + companion object { + val TopStart: Alignment = BiasAlignment(-1f, -1f) + val TopCenter: Alignment = BiasAlignment(0f, -1f) + val TopEnd: Alignment = BiasAlignment(1f, -1f) + val CenterStart: Alignment = BiasAlignment(-1f, 0f) + val Center: Alignment = BiasAlignment(0f, 0f) + val CenterEnd: Alignment = BiasAlignment(1f, 0f) + val BottomStart: Alignment = BiasAlignment(-1f, 1f) + val BottomCenter: Alignment = BiasAlignment(0f, 1f) + val BottomEnd: Alignment = BiasAlignment(1f, 1f) + + val Top: Vertical = BiasAlignment.Vertical(-1f) + val CenterVertically: Vertical = BiasAlignment.Vertical(0f) + val Bottom: Vertical = BiasAlignment.Vertical(1f) + + val Start: Horizontal = BiasAlignment.Horizontal(-1f) + val CenterHorizontally: Horizontal = BiasAlignment.Horizontal(0f) + val End: Horizontal = BiasAlignment.Horizontal(1f) + } +} + +/// An alignment expressed as a bias: -1 is the start or top, 0 the center, 1 the +/// end or bottom. +@Immutable +data class BiasAlignment(val horizontalBias: Float, val verticalBias: Float) : Alignment { + override fun align(size: IntSize, space: IntSize, layoutDirection: LayoutDirection): IntOffset { + val h = Horizontal(horizontalBias).align(size.width, space.width, layoutDirection) + val v = Vertical(verticalBias).align(size.height, space.height) + return IntOffset(h, v) + } + + @Immutable + data class Horizontal(val bias: Float) : Alignment.Horizontal { + override fun align(size: Int, space: Int, layoutDirection: LayoutDirection): Int { + val b = if (layoutDirection == LayoutDirection.Ltr) bias else -bias + return Math.round((space - size) / 2f * (1 + b)) + } + } + + @Immutable + data class Vertical(val bias: Float) : Alignment.Vertical { + override fun align(size: Int, space: Int): Int = Math.round((space - size) / 2f * (1 + bias)) + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/Modifier.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/Modifier.kt new file mode 100644 index 00000000000..61ca8377ed4 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/Modifier.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui + +import androidx.compose.runtime.Stable + +/// An ordered chain of elements that decorate or configure a layout: sizes, +/// padding, backgrounds, click handling. Elements apply outermost first, so +/// `Modifier.padding(8.dp).background(c)` paints the background inside the +/// padding and `Modifier.background(c).padding(8.dp)` paints it under it. +@Stable +interface Modifier { + fun foldIn(initial: R, operation: (R, Element) -> R): R + fun foldOut(initial: R, operation: (Element, R) -> R): R + fun any(predicate: (Element) -> Boolean): Boolean + fun all(predicate: (Element) -> Boolean): Boolean + + infix fun then(other: Modifier): Modifier = + if (other === Modifier) this else CombinedModifier(this, other) + + interface Element : Modifier { + override fun foldIn(initial: R, operation: (R, Element) -> R): R = operation(initial, this) + override fun foldOut(initial: R, operation: (Element, R) -> R): R = operation(this, initial) + override fun any(predicate: (Element) -> Boolean): Boolean = predicate(this) + override fun all(predicate: (Element) -> Boolean): Boolean = predicate(this) + } + + /// The empty modifier, and the receiver every chain starts from. + companion object : Modifier { + override fun foldIn(initial: R, operation: (R, Element) -> R): R = initial + override fun foldOut(initial: R, operation: (Element, R) -> R): R = initial + override fun any(predicate: (Element) -> Boolean): Boolean = false + override fun all(predicate: (Element) -> Boolean): Boolean = true + override infix fun then(other: Modifier): Modifier = other + override fun toString(): String = "Modifier" + } +} + +class CombinedModifier(internal val outer: Modifier, internal val inner: Modifier) : Modifier { + override fun foldIn(initial: R, operation: (R, Modifier.Element) -> R): R = + inner.foldIn(outer.foldIn(initial, operation), operation) + + override fun foldOut(initial: R, operation: (Modifier.Element, R) -> R): R = + outer.foldOut(inner.foldOut(initial, operation), operation) + + override fun any(predicate: (Modifier.Element) -> Boolean): Boolean = outer.any(predicate) || inner.any(predicate) + override fun all(predicate: (Modifier.Element) -> Boolean): Boolean = outer.all(predicate) && inner.all(predicate) + + override fun equals(other: Any?): Boolean = + other is CombinedModifier && outer == other.outer && inner == other.inner + + override fun hashCode(): Int = outer.hashCode() + 31 * inner.hashCode() + + override fun toString(): String = "[" + foldIn("") { acc, e -> if (acc.isEmpty()) e.toString() else "$acc, $e" } + "]" +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/draw/Draw.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/draw/Draw.kt new file mode 100644 index 00000000000..5e1c86aef9f --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/draw/Draw.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui.draw + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import com.codename1.compose.internal.AlphaElement +import com.codename1.compose.internal.ClipElement +import com.codename1.compose.internal.DrawBehindElement + +fun Modifier.clip(shape: Shape): Modifier = this.then(ClipElement(shape)) +fun Modifier.alpha(alpha: Float): Modifier = this.then(AlphaElement(alpha)) +fun Modifier.drawBehind(onDraw: DrawScope.() -> Unit): Modifier = this.then(DrawBehindElement(onDraw)) diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/geometry/Geometry.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/geometry/Geometry.kt new file mode 100644 index 00000000000..42e2d9e8f25 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/geometry/Geometry.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui.geometry + +/// A point in pixels, relative to the drawing area's top left corner. +data class Offset(val x: Float, val y: Float) { + operator fun plus(other: Offset): Offset = Offset(x + other.x, y + other.y) + operator fun minus(other: Offset): Offset = Offset(x - other.x, y - other.y) + operator fun times(operand: Float): Offset = Offset(x * operand, y * operand) + operator fun div(operand: Float): Offset = Offset(x / operand, y / operand) + operator fun unaryMinus(): Offset = Offset(-x, -y) + + companion object { + val Zero: Offset = Offset(0f, 0f) + } +} + +/// A width and a height in pixels. +data class Size(val width: Float, val height: Float) { + val minDimension: Float get() = Math.min(width, height) + val maxDimension: Float get() = Math.max(width, height) + val center: Offset get() = Offset(width / 2f, height / 2f) + operator fun times(operand: Float): Size = Size(width * operand, height * operand) + operator fun div(operand: Float): Size = Size(width / operand, height / operand) + + companion object { + val Zero: Size = Size(0f, 0f) + } +} + +/// A rectangle in pixels. +data class Rect(val left: Float, val top: Float, val right: Float, val bottom: Float) { + constructor(offset: Offset, size: Size) : this(offset.x, offset.y, offset.x + size.width, offset.y + size.height) + + val width: Float get() = right - left + val height: Float get() = bottom - top + val size: Size get() = Size(width, height) + val topLeft: Offset get() = Offset(left, top) + val center: Offset get() = Offset(left + width / 2f, top + height / 2f) +} + +/// A corner radius in pixels. +data class CornerRadius(val x: Float, val y: Float = x) { + companion object { + val Zero: CornerRadius = CornerRadius(0f, 0f) + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/graphics/Graphics.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/graphics/Graphics.kt new file mode 100644 index 00000000000..7bebd1e0539 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/graphics/Graphics.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui.graphics + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection + +/// An sRGB color. The value is packed as Compose packs an sRGB color: the ARGB +/// integer in the upper 32 bits. +@JvmInline +value class Color(val value: ULong) { + val alpha: Float get() = ((argb ushr 24) and 0xff) / 255f + val red: Float get() = ((argb ushr 16) and 0xff) / 255f + val green: Float get() = ((argb ushr 8) and 0xff) / 255f + val blue: Float get() = (argb and 0xff) / 255f + + internal val argb: Int get() = (value shr 32).toInt() + + fun copy(alpha: Float = this.alpha, red: Float = this.red, green: Float = this.green, blue: Float = this.blue): Color = + Color(red, green, blue, alpha) + + override fun toString(): String = + if (this == Unspecified) "Color.Unspecified" else "Color(#" + Integer.toHexString(argb) + ")" + + companion object { + val Black: Color = Color(0xFF000000) + val DarkGray: Color = Color(0xFF444444) + val Gray: Color = Color(0xFF888888) + val LightGray: Color = Color(0xFFCCCCCC) + val White: Color = Color(0xFFFFFFFF) + val Red: Color = Color(0xFFFF0000) + val Green: Color = Color(0xFF00FF00) + val Blue: Color = Color(0xFF0000FF) + val Yellow: Color = Color(0xFFFFFF00) + val Cyan: Color = Color(0xFF00FFFF) + val Magenta: Color = Color(0xFFFF00FF) + val Transparent: Color = Color(0x00000000) + + /// Distinct from every real color: the default of a parameter that means + /// "use the theme's". + val Unspecified: Color = Color(0x10UL) + } +} + +fun Color(color: Long): Color = Color(((color and 0xffffffffL).toULong()) shl 32) + +fun Color(color: Int): Color = Color((color.toLong() and 0xffffffffL).toULong() shl 32) + +fun Color(red: Float, green: Float, blue: Float, alpha: Float = 1f): Color { + fun c(v: Float): Int = Math.round(Math.max(0f, Math.min(1f, v)) * 255f) + return Color((c(alpha) shl 24) or (c(red) shl 16) or (c(green) shl 8) or c(blue)) +} + +fun Color(red: Int, green: Int, blue: Int, alpha: Int = 0xff): Color = + Color(((alpha and 0xff) shl 24) or ((red and 0xff) shl 16) or ((green and 0xff) shl 8) or (blue and 0xff)) + +val Color.isSpecified: Boolean get() = this != Color.Unspecified +val Color.isUnspecified: Boolean get() = this == Color.Unspecified + +inline fun Color.takeOrElse(block: () -> Color): Color = if (isSpecified) this else block() + +fun Color.toArgb(): Int = argb + +fun lerp(start: Color, stop: Color, fraction: Float): Color { + fun mix(a: Float, b: Float): Float = a + (b - a) * fraction + return Color(mix(start.red, stop.red), mix(start.green, stop.green), mix(start.blue, stop.blue), + mix(start.alpha, stop.alpha)) +} + +/// What fills a shape: one color, or a gradient sampled along an axis. +sealed class Brush { + companion object { + fun verticalGradient(colors: List, startY: Float = 0f, endY: Float = Float.POSITIVE_INFINITY): Brush = + LinearGradient(colors, true) + + fun horizontalGradient(colors: List, startX: Float = 0f, endX: Float = Float.POSITIVE_INFINITY): Brush = + LinearGradient(colors, false) + + fun linearGradient(colors: List): Brush = LinearGradient(colors, true) + } +} + +class SolidColor(val value: Color) : Brush() + +internal class LinearGradient(val colors: List, val vertical: Boolean) : Brush() + +/// The outline a shape resolves to at a given size. +sealed class Outline { + class Rectangle(val width: Float, val height: Float) : Outline() + + /// A rounded rectangle; each radius is in pixels. + class Rounded( + val width: Float, val height: Float, + val topLeft: Float, val topRight: Float, val bottomRight: Float, val bottomLeft: Float + ) : Outline() +} + +interface Shape { + fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline +} + +val RectangleShape: Shape = object : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline = + Outline.Rectangle(size.width, size.height) + + override fun toString(): String = "RectangleShape" +} + +/// A decoded bitmap. Wraps a Codename One image. +class ImageBitmap internal constructor(internal val image: com.codename1.ui.Image) { + val width: Int get() = image.width + val height: Int get() = image.height + + companion object { + /// Wraps an image the application already has, for [androidx.compose.foundation.Image]. + fun fromCodenameOne(image: com.codename1.ui.Image): ImageBitmap = ImageBitmap(image) + } +} + +fun com.codename1.ui.Image.asImageBitmap(): ImageBitmap = ImageBitmap(this) + +enum class StrokeCap { Butt, Round, Square } + +object DefaultAlpha { + const val value: Float = 1f +} + +/// How a drawing combines with what is under it. Only normal source-over drawing +/// is supported; the other modes draw as [SrcOver]. +enum class BlendMode { SrcOver, Src, Multiply, Screen } + +class ColorFilter private constructor(internal val tint: Color) { + companion object { + fun tint(color: Color, blendMode: BlendMode = BlendMode.SrcOver): ColorFilter = ColorFilter(color) + } +} + +internal fun Offset.isFinite(): Boolean = !x.isInfinite() && !y.isInfinite() diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope.kt new file mode 100644 index 00000000000..501c64ebdde --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui.graphics.drawscope + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection + +/// How a shape is drawn: filled, or stroked with a width in pixels. +sealed class DrawStyle + +object Fill : DrawStyle() + +class Stroke(val width: Float = 0f, val cap: StrokeCap = StrokeCap.Butt) : DrawStyle() + +/// The surface a [androidx.compose.foundation.Canvas] or `drawBehind` draws on. +/// Coordinates are pixels, from the top left corner of the drawing's box. +interface DrawScope : Density { + val size: Size + val layoutDirection: LayoutDirection get() = LayoutDirection.Ltr + val center: Offset get() = Offset(size.width / 2f, size.height / 2f) + + fun drawRect(color: Color, topLeft: Offset = Offset.Zero, size: Size = this.size, alpha: Float = 1f, + style: DrawStyle = Fill) + + fun drawRoundRect(color: Color, topLeft: Offset = Offset.Zero, size: Size = this.size, + cornerRadius: CornerRadius = CornerRadius.Zero, alpha: Float = 1f, style: DrawStyle = Fill) + + fun drawCircle(color: Color, radius: Float = size.minDimension / 2f, center: Offset = this.center, + alpha: Float = 1f, style: DrawStyle = Fill) + + fun drawArc(color: Color, startAngle: Float, sweepAngle: Float, useCenter: Boolean, + topLeft: Offset = Offset.Zero, size: Size = this.size, alpha: Float = 1f, style: DrawStyle = Fill) + + fun drawLine(color: Color, start: Offset, end: Offset, strokeWidth: Float = 0f, + cap: StrokeCap = StrokeCap.Butt, alpha: Float = 1f) + + fun drawImage(image: ImageBitmap, topLeft: Offset = Offset.Zero, alpha: Float = 1f) + + /// Draws `points` joined by straight segments. + fun drawPolyline(color: Color, points: List, strokeWidth: Float, alpha: Float = 1f) + + /// Fills the polygon `points` describe. + fun drawPolygon(color: Color, points: List, alpha: Float = 1f) + + fun translate(left: Float, top: Float, block: DrawScope.() -> Unit) +} + +fun DrawScope.inset(horizontal: Float, vertical: Float, block: DrawScope.() -> Unit) { + translate(horizontal, vertical, block) +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/graphics/vector/ImageVector.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/graphics/vector/ImageVector.kt new file mode 100644 index 00000000000..7b820145851 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/graphics/vector/ImageVector.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui.graphics.vector + +import androidx.compose.runtime.Immutable + +/// A vector icon. The icons shipped here are glyphs of the material design font +/// every Codename One application already carries, drawn at any size. +@Immutable +class ImageVector internal constructor(val name: String, internal val glyph: Char) { + override fun equals(other: Any?): Boolean = other is ImageVector && other.glyph == glyph + + override fun hashCode(): Int = glyph.code + + override fun toString(): String = "ImageVector($name)" + + companion object { + /// An icon from any glyph of the material design font. + fun cn1Material(name: String, glyph: Char): ImageVector = ImageVector(name, glyph) + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/layout/ContentScale.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/layout/ContentScale.kt new file mode 100644 index 00000000000..70ff46358f4 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/layout/ContentScale.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui.layout + +/// How an image is scaled into the box it is given. +class ContentScale private constructor(internal val name: String) { + override fun toString(): String = "ContentScale.$name" + + companion object { + val Fit: ContentScale = ContentScale("Fit") + val Crop: ContentScale = ContentScale("Crop") + val FillBounds: ContentScale = ContentScale("FillBounds") + val FillWidth: ContentScale = ContentScale("FillWidth") + val Inside: ContentScale = ContentScale("Inside") + val None: ContentScale = ContentScale("None") + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/platform/Platform.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/platform/Platform.kt new file mode 100644 index 00000000000..0b67bac43ad --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/platform/Platform.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui.platform + +import androidx.compose.ui.Modifier +import com.codename1.compose.internal.TestTagElement + +/// Names the node, for tests: the Codename One component's name is the tag. +fun Modifier.testTag(tag: String): Modifier = this.then(TestTagElement(tag)) diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/text/TextStyle.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/text/TextStyle.kt new file mode 100644 index 00000000000..9b81453e08c --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/text/TextStyle.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui.text + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.TextUnit + +/// How text is drawn. Unspecified values fall back to the style it is merged +/// into, as in Compose. +@Immutable +data class TextStyle( + val color: Color = Color.Unspecified, + val fontSize: TextUnit = TextUnit.Unspecified, + val fontWeight: FontWeight? = null, + val fontStyle: FontStyle? = null, + val fontFamily: FontFamily? = null, + val letterSpacing: TextUnit = TextUnit.Unspecified, + val textDecoration: TextDecoration? = null, + val textAlign: TextAlign = TextAlign.Unspecified, + val lineHeight: TextUnit = TextUnit.Unspecified +) { + fun merge(other: TextStyle?): TextStyle { + if (other == null) return this + return TextStyle( + color = if (other.color != Color.Unspecified) other.color else color, + fontSize = if (other.fontSize.isSpecified) other.fontSize else fontSize, + fontWeight = other.fontWeight ?: fontWeight, + fontStyle = other.fontStyle ?: fontStyle, + fontFamily = other.fontFamily ?: fontFamily, + letterSpacing = if (other.letterSpacing.isSpecified) other.letterSpacing else letterSpacing, + textDecoration = other.textDecoration ?: textDecoration, + textAlign = if (other.textAlign != TextAlign.Unspecified) other.textAlign else textAlign, + lineHeight = if (other.lineHeight.isSpecified) other.lineHeight else lineHeight + ) + } + + operator fun plus(other: TextStyle): TextStyle = merge(other) + + companion object { + val Default: TextStyle = TextStyle() + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/text/font/FontWeight.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/text/font/FontWeight.kt new file mode 100644 index 00000000000..002543c7785 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/text/font/FontWeight.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui.text.font + +import androidx.compose.runtime.Immutable + +@Immutable +data class FontWeight(val weight: Int) : Comparable { + override fun compareTo(other: FontWeight): Int = weight.compareTo(other.weight) + + companion object { + val Thin: FontWeight = FontWeight(100) + val ExtraLight: FontWeight = FontWeight(200) + val Light: FontWeight = FontWeight(300) + val Normal: FontWeight = FontWeight(400) + val Medium: FontWeight = FontWeight(500) + val SemiBold: FontWeight = FontWeight(600) + val Bold: FontWeight = FontWeight(700) + val ExtraBold: FontWeight = FontWeight(800) + val Black: FontWeight = FontWeight(900) + } +} + +@JvmInline +value class FontStyle(val value: Int) { + companion object { + val Normal: FontStyle = FontStyle(0) + val Italic: FontStyle = FontStyle(1) + } +} + +/// A font family. Only the platform's own families exist here: text is drawn +/// with the device's native font. +@Immutable +class FontFamily private constructor(internal val name: String) { + override fun toString(): String = "FontFamily.$name" + + companion object { + val Default: FontFamily = FontFamily("Default") + val SansSerif: FontFamily = FontFamily("SansSerif") + val Serif: FontFamily = FontFamily("Serif") + val Monospace: FontFamily = FontFamily("Monospace") + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/text/style/TextStyles.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/text/style/TextStyles.kt new file mode 100644 index 00000000000..7cf2c5c1e3d --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/text/style/TextStyles.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui.text.style + +@JvmInline +value class TextAlign(val value: Int) { + companion object { + val Left: TextAlign = TextAlign(1) + val Right: TextAlign = TextAlign(2) + val Center: TextAlign = TextAlign(3) + val Justify: TextAlign = TextAlign(4) + val Start: TextAlign = TextAlign(5) + val End: TextAlign = TextAlign(6) + val Unspecified: TextAlign = TextAlign(0) + } +} + +@JvmInline +value class TextOverflow(val value: Int) { + companion object { + val Clip: TextOverflow = TextOverflow(1) + val Ellipsis: TextOverflow = TextOverflow(2) + val Visible: TextOverflow = TextOverflow(3) + } +} + +@JvmInline +value class TextDecoration(val mask: Int) { + companion object { + val None: TextDecoration = TextDecoration(0) + val Underline: TextDecoration = TextDecoration(1) + val LineThrough: TextDecoration = TextDecoration(2) + } +} diff --git a/maven/compose-compat/src/main/kotlin/androidx/compose/ui/unit/Units.kt b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/unit/Units.kt new file mode 100644 index 00000000000..a675221bf20 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/androidx/compose/ui/unit/Units.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package androidx.compose.ui.unit + +import com.codename1.compose.internal.Density as Cn1Density + +/// A density-independent length: 1/160 of an inch, the unit every size in a +/// Compose layout is written in. +@JvmInline +value class Dp(val value: Float) : Comparable { + operator fun plus(other: Dp): Dp = Dp(value + other.value) + operator fun minus(other: Dp): Dp = Dp(value - other.value) + operator fun unaryMinus(): Dp = Dp(-value) + operator fun div(other: Float): Dp = Dp(value / other) + operator fun div(other: Int): Dp = Dp(value / other) + operator fun div(other: Dp): Float = value / other.value + operator fun times(other: Float): Dp = Dp(value * other) + operator fun times(other: Int): Dp = Dp(value * other) + override fun compareTo(other: Dp): Int = value.compareTo(other.value) + override fun toString(): String = if (value.isNaN()) "Dp.Unspecified" else "$value.dp" + + companion object { + val Hairline: Dp = Dp(0f) + val Infinity: Dp = Dp(Float.POSITIVE_INFINITY) + val Unspecified: Dp = Dp(Float.NaN) + } +} + +inline val Int.dp: Dp get() = Dp(this.toFloat()) +inline val Float.dp: Dp get() = Dp(this) +inline val Double.dp: Dp get() = Dp(this.toFloat()) + +operator fun Int.times(other: Dp): Dp = Dp(this * other.value) +operator fun Float.times(other: Dp): Dp = Dp(this * other.value) + +fun min(a: Dp, b: Dp): Dp = if (a.value <= b.value) a else b +fun max(a: Dp, b: Dp): Dp = if (a.value >= b.value) a else b + +val Dp.isSpecified: Boolean get() = !value.isNaN() +val Dp.isUnspecified: Boolean get() = value.isNaN() + +/// A width and a height in [Dp]. +data class DpSize(val width: Dp, val height: Dp) + +/// A text size. Only scaled pixels are supported; `em` is treated as a multiple of +/// the default body size. +@JvmInline +value class TextUnit internal constructor(internal val packed: Long) { + val value: Float get() = Float.fromBits((packed and 0xffffffffL).toInt()) + val isSp: Boolean get() = (packed ushr 32) == 1L + val isEm: Boolean get() = (packed ushr 32) == 2L + val isUnspecified: Boolean get() = (packed ushr 32) == 0L + val isSpecified: Boolean get() = !isUnspecified + operator fun times(other: Float): TextUnit = textUnit(packed ushr 32, value * other) + override fun toString(): String = when { + isSp -> "$value.sp" + isEm -> "$value.em" + else -> "Unspecified" + } + + companion object { + val Unspecified: TextUnit = TextUnit(0L) + } +} + +internal fun textUnit(type: Long, value: Float): TextUnit = + TextUnit((type shl 32) or (value.toRawBits().toLong() and 0xffffffffL)) + +val Int.sp: TextUnit get() = textUnit(1L, this.toFloat()) +val Float.sp: TextUnit get() = textUnit(1L, this) +val Double.sp: TextUnit get() = textUnit(1L, this.toFloat()) +val Int.em: TextUnit get() = textUnit(2L, this.toFloat()) +val Float.em: TextUnit get() = textUnit(2L, this) +val Double.em: TextUnit get() = textUnit(2L, this.toFloat()) + +/// A size in pixels. +data class IntSize(val width: Int, val height: Int) { + companion object { + val Zero: IntSize = IntSize(0, 0) + } +} + +/// A position in pixels. +data class IntOffset(val x: Int, val y: Int) { + companion object { + val Zero: IntOffset = IntOffset(0, 0) + } +} + +enum class LayoutDirection { Ltr, Rtl } + +/// Converts between [Dp] and pixels on the current display. +interface Density { + val density: Float + val fontScale: Float + fun Dp.toPx(): Float = value * density + fun Dp.roundToPx(): Int = Math.round(toPx()) + fun TextUnit.toPx(): Float = value * density * fontScale + fun Int.toDp(): Dp = Dp(this / density) + fun Float.toDp(): Dp = Dp(this / density) +} + +fun Density(density: Float, fontScale: Float = 1f): Density = object : Density { + override val density: Float = density + override val fontScale: Float = fontScale +} + +/// The display's density, for code that converts by hand. +val currentDisplayDensity: Density get() = Cn1Density.current diff --git a/maven/compose-compat/src/main/kotlin/com/codename1/compose/Compose.kt b/maven/compose-compat/src/main/kotlin/com/codename1/compose/Compose.kt new file mode 100644 index 00000000000..6bd73cf9acf --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/com/codename1/compose/Compose.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose + +import androidx.compose.runtime.Composable +import com.codename1.compose.internal.ComposeHost +import com.codename1.ui.Container +import com.codename1.ui.Form +import com.codename1.ui.layouts.BorderLayout + +/// Embeds Compose content in a Codename One user interface. The content's nodes +/// are real Codename One components, so a composition can sit beside any other +/// component, and recomposes on the EDT. +/// +/// ```kotlin +/// val view = ComposeView { App() } +/// form.add(BorderLayout.CENTER, view) +/// ``` +class ComposeView(content: @Composable () -> Unit) : Container(BorderLayout()) { + private val host = ComposeHost() + + init { + allStyles.setPadding(0, 0, 0, 0) + allStyles.setMargin(0, 0, 0, 0) + add(BorderLayout.CENTER, host) + host.setContent(content) + } + + /// Replaces the content. + fun setContent(content: @Composable () -> Unit) { + host.setContent(content) + } + + /// Ends the composition: its effects are cancelled and its nodes removed. + fun dispose() { + host.dispose() + } + + /// Whether a recomposition or an animation frame is still pending; tests wait + /// for this to become false. + fun hasPendingWork(): Boolean = host.cn1HasPendingWork() + + /// Delivers an animation frame now, for tests that do not wait for the EDT's + /// animation loop. + fun cn1Frame() { + host.sendFrame() + } +} + +/// A form whose whole content is a composition: the usual way to start a +/// Compose application on Codename One. +/// +/// ```kotlin +/// override fun runApp() { +/// ComposeForm { App() }.show() +/// } +/// ``` +class ComposeForm(title: String = "", content: @Composable () -> Unit) : Form(title, BorderLayout()) { + val view: ComposeView = ComposeView(content) + + init { + contentPane.allStyles.setPadding(0, 0, 0, 0) + if (title.isEmpty()) { + toolbar?.isHidden = true + titleArea.isVisible = false + } + add(BorderLayout.CENTER, view) + } +} diff --git a/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/ComposeHost.kt b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/ComposeHost.kt new file mode 100644 index 00000000000..c2e686e75fe --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/ComposeHost.kt @@ -0,0 +1,328 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose.internal + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import com.codename1.ui.Component +import com.codename1.ui.Container +import com.codename1.ui.geom.Dimension +import com.codename1.ui.layouts.Layout +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/// Inserts, moves and removes the components a composition emits. +internal class NodeApplier(root: Container) : AbstractApplier(root) { + override fun insertTopDown(index: Int, instance: Component) { + // Children are attached bottom up, so a parent is complete when it is added. + } + + override fun insertBottomUp(index: Int, instance: Component) { + val parent = current as? Container ?: return + if (index >= parent.componentCount) { + parent.addComponent(instance) + } else { + parent.addComponent(index, instance) + } + invalidate(parent) + } + + override fun remove(index: Int, count: Int) { + val parent = current as? Container ?: return + for (i in 0 until count) { + parent.removeComponent(parent.getComponentAt(index)) + } + invalidate(parent) + } + + override fun move(from: Int, to: Int, count: Int) { + val parent = current as? Container ?: return + val moved = ArrayList(count) + for (i in 0 until count) { + moved.add(parent.getComponentAt(from)) + parent.removeComponent(parent.getComponentAt(from)) + } + val dest = if (from > to) to else to - count + for (i in 0 until count) { + parent.addComponent(dest + i, moved[i]) + } + invalidate(parent) + } + + override fun onClear() { + (root as? Container)?.removeAll() + } + + override fun onEndChanges() { + val r = root + if (r is ComposeHost) { + r.requestRemeasure() + } + } + + private fun invalidate(c: Container) { + if (c is UiNode) { + c.invalidateMeasure() + } + } +} + +/// The Codename One component a composition lives in. Its children are the +/// composition's top level nodes, stacked as a `Box` would stack them. +internal class ComposeHost : Container(HostLayout) { + private val clock = BroadcastFrameClock { scheduleFrame() } + // Immediate, so a frame resumes its awaiters inside sendFrame. Dispatched, + // every resumption waited for the end of the EDT cycle, and so did the + // state it wrote: a change reached the screen three cycles after it was + // made. Now the recomposer applies what a frame callback writes in that + // same frame (see scheduleFrame for when the frame runs). + private val scope = CoroutineScope(EdtDispatcher.Immediate + clock + Job()) + private val recomposer = Recomposer(scope.coroutineContext) + private var composition: Composition? = null + private var inFrame = false + private var sentInLayout = false + private var animatedOn: com.codename1.ui.Form? = null + private var frameRequested = false + private var remeasureInFrame = false + + init { + cn1StripStyle(this) + GlobalSnapshotManager.ensureStarted() + scope.launch(start = CoroutineStart.UNDISPATCHED) { + recomposer.runRecomposeAndApplyChanges() + } + } + + fun setContent(content: @Composable () -> Unit) { + val c = composition ?: Composition(NodeApplier(this), recomposer).also { composition = it } + c.setContent(content) + requestRemeasure() + } + + fun dispose() { + composition?.dispose() + composition = null + recomposer.cancel() + scope.cancel() + animatedOn?.deregisterAnimated(this) + animatedOn = null + } + + /// Whether a composition change, a state change or a frame is pending. + fun cn1HasPendingWork(): Boolean = recomposer.hasPendingWork || clock.hasAwaiters + + // --- frames ------------------------------------------------------------- + + /// A frame runs at the start of the next EDT cycle's layout pass, as + /// Android's Choreographer runs a frame's callbacks before traversal: the + /// frame, recomposition, layout and paint then share one cycle. Sent from + /// `animate()`, which runs after the cycle has painted, a change waited a + /// whole cycle more. The cycle's own wait paces continuous animation: + /// `revalidateLater` does not wake the EDT. `animate()`, after the paint, + /// is the fallback for a cycle whose layout pass did not reach this host + /// (the form defers revalidation while it animates). A host that is not + /// showing gets no frames: its request waits, and `initComponent` sends it + /// when the host is shown again, so a hidden infinite animation costs + /// nothing. + private fun scheduleFrame() { + frameRequested = true + val f = componentForm + if (f != null && f.isVisible) { + if (animatedOn !== f) { + animatedOn?.deregisterAnimated(this) + f.registerAnimated(this) + animatedOn = f + } + if (!inFrame) { + revalidateLater() + } + } + } + + internal fun sendFrame() { + if (frameRequested) { + frameRequested = false + clock.sendFrame(System.nanoTime()) + } + } + + + override fun animate(): Boolean { + if (sentInLayout) { + sentInLayout = false + } else { + sendFrame() + } + return false + } + + override fun deinitialize() { + animatedOn?.deregisterAnimated(this) + animatedOn = null + super.deinitialize() + } + + override fun paint(g: com.codename1.ui.Graphics) { + super.paint(g) + } + + override fun initComponent() { + super.initComponent() + if (frameRequested) { + scheduleFrame() + } + } + + // --- measuring ---------------------------------------------------------- + + private var remeasurePending = false + + fun requestRemeasure() { + if (inFrame) { + // This pass lays out what the frame applies, but the parent sized + // the host before it; layoutChildren asks the parent again. + remeasureInFrame = true + return + } + if (!remeasurePending) { + remeasurePending = true + setShouldCalcPreferredSize(true) + revalidateLater() + repaint() + } + } + + + /// The status bar and home indicator this host lies under, in pixels, as + /// of its last pass; read by its nodes' [SafeInsetsElement]s. + internal var insetTop = 0 + internal var insetBottom = 0 + private var insetsKnown = false + + internal fun layoutChildren() { + if (frameRequested) { + // What the frame applies is measured by this pass, so it must not + // queue another; a frame asked for during it goes to the next cycle. + inFrame = true + try { + sendFrame() + } finally { + inFrame = false + } + sentInLayout = true + if (remeasureInFrame) { + // Next cycle, never inline: the parent is mid-layout. + // Only when the size really moved: most frames change content, + // not size, and a parent pass per frame is what this avoids. + remeasureInFrame = false + val before = preferredSize + val w = before.width + val h = before.height + setShouldCalcPreferredSize(true) + val after = preferredSize + if (after.width != w || after.height != h) { + parent?.revalidateLater() + } + } + if (frameRequested) { + revalidateLater() + } + } + remeasurePending = false + val insets = SafeInsets.of(absoluteY, height) + val top = (insets ushr 32).toInt() + val bottom = insets.toInt() + val changed = !insetsKnown || top != insetTop || bottom != insetBottom + insetTop = top + insetBottom = bottom + insetsKnown = true + if (changed) { + // A node's measure is cached by constraints, and a safe area that + // arrives late (or a rotation) changes no constraint: forget the + // measures of the nodes that pad by it. Forgetting every node's + // rebuilt the lazy lists' items and cost a megabyte at start-up. + // This pass measures them; the nodes must not ask for another. + remeasurePending = true + forgetMeasures(this) + remeasurePending = false + } + val c = Constraints(0, width, 0, height) + for (k in 0 until componentCount) { + val child = getComponentAt(k) + val s = measureChild(child, c) + child.setX(if (child is UiNode) child.offsetX else 0) + child.setY(if (child is UiNode) child.offsetY else 0) + child.setWidth(s.w()) + child.setHeight(s.h()) + if (child is UiNode) { + child.applyPlacements() + } + } + } + + private fun forgetMeasures(c: Container) { + for (k in 0 until c.componentCount) { + val child = c.getComponentAt(k) + if (child is UiNode && child.usesSafeInsets) { + child.invalidateMeasure() + } + if (child is Container) { + forgetMeasures(child) + } + } + } + + internal fun preferred(): Dimension { + var w = 0 + var h = 0 + val insets = SafeInsets.of(absoluteY, cn1DisplayHeight()) + insetTop = (insets ushr 32).toInt() + insetBottom = insets.toInt() + // Measured with a guessed height: the next pass measures again. + insetsKnown = false + val c = Constraints(0, cn1DisplayWidth(), 0, Constraints.INF) + for (k in 0 until componentCount) { + val s = measureChild(getComponentAt(k), c) + w = Math.max(w, s.w()) + h = Math.max(h, s.h()) + } + return Dimension(w, h) + } +} + +internal object HostLayout : Layout() { + override fun layoutContainer(parent: Container) { + // Checked, never cast: a failed cast does not throw on iOS. + (parent as? ComposeHost)?.layoutChildren() + } + + override fun getPreferredSize(parent: Container): Dimension = + (parent as? ComposeHost)?.preferred() ?: Dimension(0, 0) +} diff --git a/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Constraints.kt b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Constraints.kt new file mode 100644 index 00000000000..5921f0239a0 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Constraints.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose.internal + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import com.codename1.ui.CN + +/// The display's density in Compose terms: pixels per dp (1/160 inch). Read from +/// the port once a display exists; one pixel per dp before that, which only a +/// test that never starts a display sees. +internal object Density : androidx.compose.ui.unit.Density { + private var cached = 0f + + override val density: Float + get() { + if (cached == 0f) { + val perHundredMm = try { + CN.convertToPixels(100f) + } catch (t: Throwable) { + 0 + } + // 1 dp = 1/160 in = 0.15875 mm. + cached = if (perHundredMm > 0) perHundredMm / 100f * 0.15875f else 1f + } + return cached + } + + override val fontScale: Float get() = 1f + + val current: androidx.compose.ui.unit.Density get() = this + + fun px(dp: Dp): Int = if (dp.value.isNaN()) 0 else Math.round(dp.value * density) + + fun pxF(dp: Dp): Float = if (dp.value.isNaN()) 0f else dp.value * density + + /// For tests that measure in a known unit. + fun cn1Override(pixelsPerDp: Float) { + cached = pixelsPerDp + } +} + +internal val LTR: LayoutDirection = LayoutDirection.Ltr + +/// What a parent allows a child to be, in pixels. [INF] marks an unbounded axis. +internal class Constraints(val minWidth: Int, val maxWidth: Int, val minHeight: Int, val maxHeight: Int) { + val hasBoundedWidth: Boolean get() = maxWidth != INF + val hasBoundedHeight: Boolean get() = maxHeight != INF + val hasFixedWidth: Boolean get() = minWidth == maxWidth + val hasFixedHeight: Boolean get() = minHeight == maxHeight + + fun constrainWidth(w: Int): Int = Math.max(minWidth, Math.min(maxWidth, w)) + fun constrainHeight(h: Int): Int = Math.max(minHeight, Math.min(maxHeight, h)) + + fun copy(minWidth: Int = this.minWidth, maxWidth: Int = this.maxWidth, + minHeight: Int = this.minHeight, maxHeight: Int = this.maxHeight): Constraints = + Constraints(minWidth, Math.max(minWidth, maxWidth), minHeight, Math.max(minHeight, maxHeight)) + + /// Shrinks every bound by `horizontal` and `vertical` pixels (padding). + fun offset(horizontal: Int, vertical: Int): Constraints { + fun sub(v: Int, d: Int): Int = if (v == INF) INF else Math.max(0, v - d) + return Constraints(Math.max(0, minWidth - horizontal), sub(maxWidth, horizontal), + Math.max(0, minHeight - vertical), sub(maxHeight, vertical)) + } + + fun loose(): Constraints = Constraints(0, maxWidth, 0, maxHeight) + + override fun toString(): String = "Constraints(w=$minWidth..${s(maxWidth)}, h=$minHeight..${s(maxHeight)})" + + private fun s(v: Int): String = if (v == INF) "inf" else v.toString() + + companion object { + const val INF: Int = Int.MAX_VALUE + + fun fixed(w: Int, h: Int): Constraints = Constraints(w, w, h, h) + } +} + +/// The parts of the display the system draws over (the status bar and the home +/// indicator on a notched phone) that a composition's host actually lies under, +/// in pixels. M3's top app bar and navigation bar pad themselves by these, as +/// Compose's window insets make them do; each host keeps its own. Measured against the host, not the +/// display: a host below a form's title area, in a dialog or mid-form is not +/// under the status bar, and padding it by the display's inset would leave a +/// band of empty colour. +internal object SafeInsets { + /// The top and bottom insets of a host at `absY` that is `height` tall, + /// packed as `top shl 32 or bottom`. + fun of(absY: Int, height: Int): Long { + val r = try { + com.codename1.ui.Display.getInstance().getDisplaySafeArea(com.codename1.ui.geom.Rectangle()) + } catch (e: Throwable) { + null + } + if (r == null || height <= 0) { + return 0L + } + val t = Math.max(0, Math.min(height, r.y - absY)) + val b = Math.max(0, Math.min(height, absY + height - (r.y + r.height))) + return (t.toLong() shl 32) or b.toLong() + } +} diff --git a/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Dispatch.kt b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Dispatch.kt new file mode 100644 index 00000000000..b756463879e --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Dispatch.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose.internal + +import androidx.compose.runtime.snapshots.Snapshot +import com.codename1.ui.CN +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.Delay +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.internal.MainDispatcherFactory +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext + +/// `Dispatchers.Main`: Codename One's event dispatch thread. +@OptIn(InternalCoroutinesApi::class) +internal open class EdtDispatcher(private val eager: Boolean) : MainCoroutineDispatcher(), Delay { + override val immediate: MainCoroutineDispatcher get() = if (eager) this else Immediate + + override fun isDispatchNeeded(context: CoroutineContext): Boolean = !eager || !isEdt() + + override fun dispatch(context: CoroutineContext, block: Runnable) { + CN.callSerially(block) + } + + override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation) { + val t = CN.setTimeout(Math.max(0L, Math.min(timeMillis, Int.MAX_VALUE.toLong())).toInt()) { + continuation.resumeWith(Result.success(Unit)) + } + continuation.invokeOnCancellation { t.cancel() } + } + + override fun toString(): String = if (eager) "Dispatchers.Main.immediate" else "Dispatchers.Main" + + companion object { + val Main: EdtDispatcher = EdtDispatcher(false) + val Immediate: EdtDispatcher = EdtDispatcher(true) + + fun isEdt(): Boolean = try { + CN.isEdt() + } catch (t: Throwable) { + false + } + } +} + +/// Found by kotlinx.coroutines through `META-INF/services`, which the translator +/// compiles into a registry; on the JVM the real service loader reads the file. +@OptIn(InternalCoroutinesApi::class) +internal class EdtDispatcherFactory : MainDispatcherFactory { + override val loadPriority: Int get() = Int.MAX_VALUE + + override fun createDispatcher(allFactories: List): MainCoroutineDispatcher = EdtDispatcher.Main + + override fun hintOnError(): String = "Codename One's main dispatcher" +} + +/// Sends snapshot apply notifications on the EDT, so that a state written from +/// any thread schedules a recomposition. +internal object GlobalSnapshotManager { + private val started = AtomicBoolean(false) + private val pending = AtomicBoolean(false) + + fun ensureStarted() { + if (started.compareAndSet(false, true)) { + Snapshot.registerGlobalWriteObserver { + if (pending.compareAndSet(false, true)) { + CN.callSerially { + pending.set(false) + Snapshot.sendApplyNotifications() + } + } + } + } + } +} diff --git a/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Elements.kt b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Elements.kt new file mode 100644 index 00000000000..ff7c27b1656 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Elements.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose.internal + +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.Dp + +// The modifier elements the public extension functions create. Each is a value +// (data class) where it can be, so that an unchanged chain compares equal and a +// recomposition that rebuilds it does not relayout. + +/// Changes how a node measures; applied in chain order. +internal sealed interface LayoutElement : Modifier.Element + +internal data class PaddingElement(val start: Dp, val top: Dp, val end: Dp, val bottom: Dp) : LayoutElement + +/// Padding by the system bars, read when the node is measured rather than when +/// it is composed: at the first composition the native view may not know its +/// safe area yet, and a value captured then would stay. The host re-measures +/// when the safe area changes (`ComposeHost.layoutChildren`). +internal data class SafeInsetsElement(val top: Boolean, val bottom: Boolean) : LayoutElement + +/// A size request. An unspecified bound leaves the incoming one alone; with +/// `enforceIncoming` the result still respects what the parent allows. +internal data class SizeElement( + val minWidth: Dp = Dp.Unspecified, val maxWidth: Dp = Dp.Unspecified, + val minHeight: Dp = Dp.Unspecified, val maxHeight: Dp = Dp.Unspecified, + val enforceIncoming: Boolean = true +) : LayoutElement + +internal data class FillElement(val horizontal: Boolean, val vertical: Boolean, val fraction: Float) : LayoutElement + +internal data class WrapElement(val horizontal: Boolean, val vertical: Boolean, val align: Alignment, + val unbounded: Boolean) : LayoutElement + +internal data class OffsetElement(val x: Dp, val y: Dp) : LayoutElement + +internal data class AspectRatioElement(val ratio: Float) : LayoutElement + +internal class ScrollElement(val state: ScrollStateHandle, val vertical: Boolean) : LayoutElement { + override fun equals(other: Any?): Boolean = + other is ScrollElement && other.state === state && other.vertical == vertical + + override fun hashCode(): Int = System.identityHashCode(state) * 2 + if (vertical) 1 else 0 +} + +/// Read by the parent's measure policy, not by the node. +internal sealed interface ParentDataElement : Modifier.Element + +internal data class WeightElement(val weight: Float, val fill: Boolean) : ParentDataElement + +internal data class AlignSelfElement(val horizontal: Alignment.Horizontal?, val vertical: Alignment.Vertical?, + val box: Alignment?) : ParentDataElement + +internal object MatchParentSizeElement : ParentDataElement + +/// Paints at the position it has in the chain. +internal sealed interface DrawElement : Modifier.Element + +internal data class BackgroundElement(val color: Color, val brush: Brush?, val shape: Shape) : DrawElement + +internal data class BorderElement(val width: Dp, val color: Color, val shape: Shape) : DrawElement + +internal data class ClipElement(val shape: Shape) : DrawElement + +internal class DrawBehindElement(val onDraw: DrawScope.() -> Unit) : DrawElement + +internal data class AlphaElement(val alpha: Float) : Modifier.Element + +internal class ClickableElement(val enabled: Boolean, val onClick: () -> Unit, val onLongClick: (() -> Unit)?, + val indication: Boolean) : Modifier.Element + +internal class PointerTapElement(val onTap: (Float, Float) -> Unit) : Modifier.Element + +internal data class TestTagElement(val tag: String) : Modifier.Element + +internal data class SemanticsElement(val contentDescription: String?) : Modifier.Element + +/// What a scroll modifier binds to; implemented by +/// [androidx.compose.foundation.ScrollState]. +internal interface ScrollStateHandle { + fun attach(node: UiNode, vertical: Boolean) + fun detach(node: UiNode) +} diff --git a/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Emit.kt b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Emit.kt new file mode 100644 index 00000000000..2beca85a9f3 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Emit.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import androidx.compose.ui.Modifier + +/// Emits a layout node with children. +@Composable +internal inline fun LayoutNode(modifier: Modifier, policy: MeasurePolicy, content: @Composable () -> Unit) { + ComposeNode( + factory = { UiNode() }, + update = { + set(modifier) { this.modifier = it } + set(policy) { this.policy = it } + }, + content = content + ) +} + +/// Emits a node with no children. +@Composable +internal fun LeafNode(modifier: Modifier, policy: MeasurePolicy) { + ComposeNode( + factory = { UiNode() }, + update = { + set(modifier) { this.modifier = it } + set(policy) { this.policy = it } + } + ) +} diff --git a/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Glyphs.kt b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Glyphs.kt new file mode 100644 index 00000000000..b94ea9e7d13 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Glyphs.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose.internal + +import androidx.compose.ui.graphics.vector.ImageVector + +internal object Glyphs { + fun of(v: ImageVector): Char = v.glyph +} diff --git a/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Leaves.kt b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Leaves.kt new file mode 100644 index 00000000000..8b9b4e3aaee --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Leaves.kt @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose.internal + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import com.codename1.ui.CN +import com.codename1.ui.Font +import com.codename1.ui.Graphics + +internal object Theme { + fun isDark(): Boolean = try { + CN.isDarkMode() == true + } catch (t: Throwable) { + false + } +} + +/// Native fonts by weight and pixel size, cached. +internal object Fonts { + private val cache = HashMap() + + fun get(sizePx: Int, weight: FontWeight?): Font { + val w = weight?.weight ?: 400 + val key = (sizePx.toLong() shl 16) or w.toLong() + cache[key]?.let { return it } + val f = create(sizePx, w) + cache[key] = f + return f + } + + private fun create(sizePx: Int, w: Int): Font { + try { + if (Font.isNativeFontSchemeSupported()) { + val name = when { + w <= 200 -> "native:MainThin" + w <= 300 -> "native:MainLight" + w <= 500 -> "native:MainRegular" + w <= 700 -> "native:MainBold" + else -> "native:MainBlack" + } + val base = Font.createTrueTypeFont(name, name) + if (base != null) { + return base.derive(sizePx.toFloat(), if (w >= 600) Font.STYLE_BOLD else Font.STYLE_PLAIN) + } + } + } catch (t: Throwable) { + // Fall through to a system font. + } + val size = when { + sizePx < Density.px(androidx.compose.ui.unit.Dp(13f)) -> Font.SIZE_SMALL + sizePx > Density.px(androidx.compose.ui.unit.Dp(18f)) -> Font.SIZE_LARGE + else -> Font.SIZE_MEDIUM + } + return Font.createSystemFont(Font.FACE_SYSTEM, if (w >= 600) Font.STYLE_BOLD else Font.STYLE_PLAIN, size) + } +} + +/// Text: wrapped at word boundaries to the width it is given, cut at +/// `maxLines` with an ellipsis when asked. +internal class TextNode : UiNode() { + var text: String = "" + set(v) { if (field != v) { field = v; invalidateMeasure(); repaint() } } + var style: TextStyle = TextStyle.Default + set(v) { if (field != v) { field = v; invalidateMeasure(); repaint() } } + var maxLines: Int = Int.MAX_VALUE + set(v) { if (field != v) { field = v; invalidateMeasure() } } + var softWrap: Boolean = true + set(v) { if (field != v) { field = v; invalidateMeasure() } } + var overflow: TextOverflow = TextOverflow.Clip + set(v) { if (field != v) { field = v; invalidateMeasure() } } + + private var lines: List = emptyList() + private var font: Font? = null + + init { + policy = LeafPolicy + } + + private fun font(): Font { + val sp = if (style.fontSize.isSpecified) style.fontSize.value else 14f + val f = Fonts.get(Math.max(1, Math.round(sp * Density.density)), style.fontWeight) + font = f + return f + } + + private fun lineHeight(f: Font): Int = + if (style.lineHeight.isSpecified && style.lineHeight.isSp) Math.max(f.height, Math.round(style.lineHeight.value * Density.density)) + else f.height + + override fun measureContent(c: Constraints): Long { + val f = font() + val maxW = c.maxWidth + val out = ArrayList() + for (para in text.split('\n')) { + if (!softWrap || maxW == Constraints.INF || f.stringWidth(para) <= maxW) { + out.add(para) + } else { + wrap(para, f, maxW, out) + } + if (out.size > maxLines) break + } + if (out.size > maxLines) { + while (out.size > maxLines) out.removeAt(out.size - 1) + if (overflow == TextOverflow.Ellipsis && out.isNotEmpty()) { + out[out.size - 1] = ellipsize(out[out.size - 1] + "\u2026", f, maxW) + } + } else if (overflow == TextOverflow.Ellipsis && maxW != Constraints.INF && out.size == 1 && f.stringWidth(out[0]) > maxW) { + out[0] = ellipsize(out[0], f, maxW) + } + lines = out + var w = 0 + for (l in out) w = Math.max(w, f.stringWidth(l)) + return size(Math.min(w, maxW), lineHeight(f) * out.size) + } + + private fun wrap(para: String, f: Font, maxW: Int, out: MutableList) { + var line = StringBuilder() + for (word in para.split(' ')) { + val candidate = if (line.isEmpty()) word else "$line $word" + if (f.stringWidth(candidate) <= maxW || line.isEmpty()) { + line = StringBuilder(candidate) + } else { + out.add(line.toString()) + line = StringBuilder(word) + } + } + out.add(line.toString()) + } + + private fun ellipsize(s: String, f: Font, maxW: Int): String { + val e = "\u2026" + var base = if (s.endsWith(e)) s.substring(0, s.length - 1) else s + if (maxW == Constraints.INF || f.stringWidth(base + e) <= maxW && s.endsWith(e)) return base + e + while (base.isNotEmpty() && f.stringWidth(base + e) > maxW) base = base.substring(0, base.length - 1) + return base.trimEnd() + e + } + + override fun paintContent(g: Graphics) { + val f = font ?: font() + val color = if (style.color == Color.Unspecified) Color.Black else style.color + if (color.alpha <= 0f) return + val old = g.alpha + g.font = f + g.color = color.argb and 0xffffff + if (color.alpha < 1f) g.concatenateAlpha(Math.round(color.alpha * 255)) + g.isAntiAliased = true + g.isAntiAliasedText = true + val lh = lineHeight(f) + var y = absY() + contentY + (lh - f.height) / 2 + val x0 = absX() + contentX + for (l in lines) { + val lw = f.stringWidth(l) + val dx = when (style.textAlign) { + TextAlign.Center -> (contentW - lw) / 2 + TextAlign.End, TextAlign.Right -> contentW - lw + else -> 0 + } + g.drawString(l, x0 + dx, y) + if (style.textDecoration == TextDecoration.Underline) { + g.drawLine(x0 + dx, y + f.ascent + 1, x0 + dx + lw, y + f.ascent + 1) + } else if (style.textDecoration == TextDecoration.LineThrough) { + g.drawLine(x0 + dx, y + f.height / 2, x0 + dx + lw, y + f.height / 2) + } + y += lh + } + g.alpha = old + } + + fun cn1Lines(): List = lines +} + +internal fun UiNode.absX(): Int = x +internal fun UiNode.absY(): Int = y + +/// A bitmap, scaled into its content box. +internal class ImageNode : UiNode() { + var bitmap: ImageBitmap? = null + set(v) { if (field !== v) { field = v; invalidateMeasure(); repaint() } } + var contentScale: ContentScale = ContentScale.Fit + var tint: Color = Color.Unspecified + + init { + policy = LeafPolicy + } + + override fun measureContent(c: Constraints): Long { + val b = bitmap ?: return size(c.minWidth, c.minHeight) + val iw = b.width + val ih = b.height + if (c.hasFixedWidth && c.hasFixedHeight) return size(c.maxWidth, c.maxHeight) + var w = c.constrainWidth(iw) + var h = c.constrainHeight(ih) + if (iw > 0 && ih > 0) { + val sw = w.toFloat() / iw + val sh = h.toFloat() / ih + val s = Math.min(sw, sh) + if (!c.hasFixedWidth) w = c.constrainWidth(Math.round(iw * s)) + if (!c.hasFixedHeight) h = c.constrainHeight(Math.round(ih * s)) + } + return size(w, h) + } + + override fun paintContent(g: Graphics) { + val b = bitmap ?: return + val bw = contentW + val bh = contentH + val iw = b.width.toFloat() + val ih = b.height.toFloat() + if (iw <= 0 || ih <= 0) return + val s = when (contentScale) { + ContentScale.Crop -> Math.max(bw / iw, bh / ih) + ContentScale.FillBounds -> -1f + ContentScale.None -> 1f + ContentScale.FillWidth -> bw / iw + else -> Math.min(bw / iw, bh / ih) + } + val x0 = x + contentX + val y0 = y + contentY + if (s < 0) { + g.drawImage(b.image, x0, y0, bw, bh) + return + } + val dw = Math.round(iw * s) + val dh = Math.round(ih * s) + g.pushClip() + g.clipRect(x0, y0, bw, bh) + g.drawImage(b.image, x0 + (bw - dw) / 2, y0 + (bh - dh) / 2, dw, dh) + g.popClip() + } +} + +/// A material icon glyph from the material design font. +internal class IconNode : UiNode() { + var glyph: Char = ' ' + set(v) { if (field != v) { field = v; repaint() } } + var tint: Color = Color.Black + set(v) { if (field != v) { field = v; repaint() } } + + init { + policy = LeafPolicy + } + + override fun measureContent(c: Constraints): Long { + val d = Density.px(androidx.compose.ui.unit.Dp(24f)) + return size(c.constrainWidth(d), c.constrainHeight(d)) + } + + override fun paintContent(g: Graphics) { + val s = Math.min(contentW, contentH) + if (s <= 0 || tint.alpha <= 0f) return + val f = com.codename1.ui.FontImage.getMaterialDesignFont().derive(s.toFloat(), Font.STYLE_PLAIN) + val old = g.alpha + g.font = f + g.color = tint.argb and 0xffffff + if (tint.alpha < 1f) g.concatenateAlpha(Math.round(tint.alpha * 255)) + g.isAntiAliasedText = true + val str = glyph.toString() + val gw = f.stringWidth(str) + g.drawString(str, x + contentX + (contentW - gw) / 2, y + contentY + (contentH - f.height) / 2) + g.alpha = old + } +} + +/// Hosts a Codename One component as a node's only child, sized to the node's +/// content box: how text input gets the platform's own editing. +internal class NativeNode(val component: T) : UiNode() { + init { + policy = BoxPolicy(androidx.compose.ui.Alignment.TopStart, true) + addComponent(component) + } +} + +/// A single line or multi line text input over Codename One's `TextArea`. +internal class TextInputNode : UiNode() { + val field: com.codename1.ui.TextArea + private var syncing = false + var onValueChange: (String) -> Unit = {} + var textColor: Color = Color.Black + set(v) { field = v; applyColors() } + var font: Font? = null + set(v) { field = v; if (v != null) this.field.allStyles.font = v; invalidateMeasure() } + + init { + policy = BoxPolicy(androidx.compose.ui.Alignment.TopStart, true) + val input = com.codename1.ui.TextField() + field = input + cn1StripStyle(input, allStates = true) + input.addDataChangedListener { _, _ -> + if (!syncing) onValueChange(input.text) + } + addComponent(field) + } + + fun setValue(v: String) { + if (field.text != v) { + syncing = true + try { + field.text = v + } finally { + syncing = false + } + } + } + + var singleLine: Boolean = true + set(v) { field = v; this.field.isSingleLineTextArea = v; invalidateMeasure() } + + var hint: String = "" + set(v) { field = v; this.field.hint = v } + + var enabledInput: Boolean = true + set(v) { field = v; this.field.isEnabled = v } + + private fun applyColors() { + field.allStyles.fgColor = textColor.argb and 0xffffff + field.hintLabel?.allStyles?.fgColor = textColor.argb and 0xffffff + field.hintLabel?.allStyles?.opacity = 140 + } +} diff --git a/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Painting.kt b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Painting.kt new file mode 100644 index 00000000000..e7b61c7dc09 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Painting.kt @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose.internal + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.LinearGradient +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.DrawStyle +import androidx.compose.ui.graphics.drawscope.Stroke +import com.codename1.ui.Graphics +import com.codename1.ui.geom.GeneralPath + +/// Fills and strokes shapes on Codename One graphics. +internal object Painting { + fun outline(shape: Shape, w: Int, h: Int): Outline = + shape.createOutline(Size(w.toFloat(), h.toFloat()), LTR, Density) + + fun fill(g: Graphics, shape: Shape, color: Color, brush: Brush?, x: Int, y: Int, w: Int, h: Int) { + if (w <= 0 || h <= 0) return + val o = outline(shape, w, h) + val b = brush + if (b is LinearGradient && b.colors.size >= 2 && o is Outline.Rectangle) { + val a = b.colors[0] + val e = b.colors[b.colors.size - 1] + val old = g.alpha + g.concatenateAlpha(Math.round(a.alpha * 255)) + g.fillLinearGradient(a.argb and 0xffffff, e.argb and 0xffffff, x, y, w, h, !b.vertical) + g.alpha = old + return + } + val c = when (b) { + is SolidColor -> b.value + is LinearGradient -> if (b.colors.isEmpty()) color else b.colors[0] + else -> color + } + if (c.alpha <= 0f) return + val old = g.alpha + g.color = c.argb and 0xffffff + if (c.alpha < 1f) g.concatenateAlpha(Math.round(c.alpha * 255)) + when (o) { + is Outline.Rounded -> { + if (uniform(o)) { + val r = Math.round(o.topLeft * 2) + g.fillRoundRect(x, y, w, h, r, r) + } else if (g.isShapeSupported) { + g.fillShape(roundedPath(o, x.toFloat(), y.toFloat())) + } else { + val r = o.cornerArc() + g.fillRoundRect(x, y, w, h, r, r) + } + } + else -> g.fillRect(x, y, w, h) + } + g.alpha = old + } + + fun stroke(g: Graphics, shape: Shape, color: Color, width: Int, x: Int, y: Int, w: Int, h: Int) { + if (w <= 0 || h <= 0 || width <= 0 || color.alpha <= 0f) return + val old = g.alpha + g.color = color.argb and 0xffffff + if (color.alpha < 1f) g.concatenateAlpha(Math.round(color.alpha * 255)) + val o = outline(shape, w, h) + val half = width / 2f + if (g.isShapeSupported) { + val path = when (o) { + is Outline.Rounded -> roundedPath(Outline.Rounded(w - width.toFloat(), h - width.toFloat(), + Math.max(0f, o.topLeft - half), Math.max(0f, o.topRight - half), + Math.max(0f, o.bottomRight - half), Math.max(0f, o.bottomLeft - half)), x + half, y + half) + else -> rectPath(x + half, y + half, w - width.toFloat(), h - width.toFloat()) + } + g.drawShape(path, com.codename1.ui.Stroke(width.toFloat(), com.codename1.ui.Stroke.CAP_BUTT, + com.codename1.ui.Stroke.JOIN_MITER, 4f)) + } else { + for (i in 0 until width) { + when (o) { + is Outline.Rounded -> { + val r = o.cornerArc() + g.drawRoundRect(x + i, y + i, w - 1 - 2 * i, h - 1 - 2 * i, r, r) + } + else -> g.drawRect(x + i, y + i, w - 1 - 2 * i, h - 1 - 2 * i) + } + } + } + g.alpha = old + } + + private fun uniform(o: Outline.Rounded): Boolean = + o.topLeft == o.topRight && o.topRight == o.bottomRight && o.bottomRight == o.bottomLeft + + fun rectPath(x: Float, y: Float, w: Float, h: Float): GeneralPath { + val p = GeneralPath() + p.moveTo(x, y) + p.lineTo(x + w, y) + p.lineTo(x + w, y + h) + p.lineTo(x, y + h) + p.closePath() + return p + } + + fun roundedPath(o: Outline.Rounded, x: Float, y: Float): GeneralPath { + val w = o.width + val h = o.height + val max = Math.min(w, h) / 2f + val tl = Math.min(o.topLeft, max) + val tr = Math.min(o.topRight, max) + val br = Math.min(o.bottomRight, max) + val bl = Math.min(o.bottomLeft, max) + val p = GeneralPath() + p.moveTo(x + tl, y) + p.lineTo(x + w - tr, y) + if (tr > 0) p.arc(x + w - 2 * tr, y, 2 * tr, 2 * tr, HALF_PI, -HALF_PI, true) + p.lineTo(x + w, y + h - br) + if (br > 0) p.arc(x + w - 2 * br, y + h - 2 * br, 2 * br, 2 * br, 0f, -HALF_PI, true) + p.lineTo(x + bl, y + h) + if (bl > 0) p.arc(x, y + h - 2 * bl, 2 * bl, 2 * bl, -HALF_PI, -HALF_PI, true) + p.lineTo(x, y + tl) + if (tl > 0) p.arc(x, y, 2 * tl, 2 * tl, PI, -HALF_PI, true) + p.closePath() + return p + } + + /// Clips `g` to the rounded rectangle `o` at (`x`, `y`), intersected with + /// the clip already in force, and answers false when nothing of it is + /// visible. `setClip(Shape)` replaces the clip, so the intersection is + /// ours to make. A rounded rectangle is convex: flattened to a polygon it + /// is cut by the clip rectangle edge by edge (Sutherland-Hodgman), with no + /// curve math and no allocation once the buffers have grown. Cutting the + /// curved path with `GeneralPath.intersect` was most of what a scrolling + /// list allocated; the iOS port flattens a curved clip to a polygon anyway. + fun clipRounded(g: Graphics, o: Outline.Rounded, x: Int, y: Int): Boolean { + var n = roundedPolygon(o, x.toFloat(), y.toFloat()) + val left = g.clipX.toFloat() + val top = g.clipY.toFloat() + val right = left + g.clipWidth + val bottom = top + g.clipHeight + n = cut(n, 0, left, true) + n = cut(n, 0, right, false) + n = cut(n, 1, top, true) + n = cut(n, 1, bottom, false) + if (n < 3) { + return false + } + val p = clipPath + p.reset() + p.moveTo(poly[0], poly[1]) + for (i in 1 until n) { + p.lineTo(poly[i * 2], poly[i * 2 + 1]) + } + p.closePath() + g.setClip(p) + return true + } + + private const val ARC_STEPS = 6 + private var poly = FloatArray(64) + private var scratch = FloatArray(64) + private val clipPath = GeneralPath() + + /// Writes `o` as a clockwise polygon into `poly`; answers its point count. + private fun roundedPolygon(o: Outline.Rounded, x: Float, y: Float): Int { + val max = Math.min(o.width, o.height) / 2f + val w = o.width + val h = o.height + ensure(4 * (ARC_STEPS + 1) + 8) + var n = 0 + n = corner(n, x, y, Math.min(o.topLeft, max), 1f, 1f, PI) + n = corner(n, x + w, y, Math.min(o.topRight, max), -1f, 1f, PI + HALF_PI) + n = corner(n, x + w, y + h, Math.min(o.bottomRight, max), -1f, -1f, 0f) + n = corner(n, x, y + h, Math.min(o.bottomLeft, max), 1f, -1f, HALF_PI) + return n + } + + private fun corner(n: Int, px: Float, py: Float, r: Float, sx: Float, sy: Float, start: Float): Int { + var k = n + if (r <= 0f) { + poly[k * 2] = px + poly[k * 2 + 1] = py + return k + 1 + } + val cx = px + sx * r + val cy = py + sy * r + for (i in 0..ARC_STEPS) { + val a = start + HALF_PI * i / ARC_STEPS + poly[k * 2] = cx + r * Math.cos(a.toDouble()).toFloat() + poly[k * 2 + 1] = cy + r * Math.sin(a.toDouble()).toFloat() + k++ + } + return k + } + + /// Keeps the part of the polygon in `poly` on the inner side of the line + /// `axis` (0 is x, 1 is y) = `v`; `low` keeps values at or above it. + private fun cut(n: Int, axis: Int, v: Float, low: Boolean): Int { + if (n == 0) { + return 0 + } + ensure(n + 4) + val src = poly + val dst = scratch + var m = 0 + for (i in 0 until n) { + val j = (i + 1) % n + val a = src[i * 2 + axis] + val b = src[j * 2 + axis] + val aIn = if (low) a >= v else a <= v + val bIn = if (low) b >= v else b <= v + if (aIn) { + dst[m * 2] = src[i * 2] + dst[m * 2 + 1] = src[i * 2 + 1] + m++ + } + if (aIn != bIn) { + val t = (v - a) / (b - a) + dst[m * 2] = src[i * 2] + t * (src[j * 2] - src[i * 2]) + dst[m * 2 + 1] = src[i * 2 + 1] + t * (src[j * 2 + 1] - src[i * 2 + 1]) + m++ + } + } + poly = dst + scratch = src + return m + } + + private fun ensure(points: Int) { + if (poly.size < points * 2) { + poly = poly.copyOf(points * 4) + } + if (scratch.size < points * 2 || scratch.size < poly.size) { + scratch = FloatArray(poly.size) + } + } + + private const val PI = Math.PI.toFloat() + private const val HALF_PI = (Math.PI / 2).toFloat() +} + +/// [DrawScope] over Codename One graphics, already translated to the box's +/// origin. +internal class Cn1DrawScope(private val g: Graphics, override val size: Size) : DrawScope { + override val density: Float get() = Density.density + override val fontScale: Float get() = 1f + + private inline fun paint(color: Color, alpha: Float, block: () -> Unit) { + val a = color.alpha * alpha + if (a <= 0f) return + val old = g.alpha + g.color = color.argb and 0xffffff + if (a < 1f) g.concatenateAlpha(Math.round(a * 255)) + g.isAntiAliased = true + try { + block() + } finally { + g.alpha = old + } + } + + private fun stroke(width: Float, cap: StrokeCap): com.codename1.ui.Stroke = + com.codename1.ui.Stroke(Math.max(1f, width), when (cap) { + StrokeCap.Round -> com.codename1.ui.Stroke.CAP_ROUND + StrokeCap.Square -> com.codename1.ui.Stroke.CAP_SQUARE + else -> com.codename1.ui.Stroke.CAP_BUTT + }, com.codename1.ui.Stroke.JOIN_ROUND, 4f) + + private fun r(v: Float): Int = Math.round(v) + + override fun drawRect(color: Color, topLeft: Offset, size: Size, alpha: Float, style: DrawStyle) = paint(color, alpha) { + if (style is Stroke) { + g.drawShape(Painting.rectPath(topLeft.x, topLeft.y, size.width, size.height), stroke(style.width, style.cap)) + } else { + g.fillRect(r(topLeft.x), r(topLeft.y), r(size.width), r(size.height)) + } + } + + override fun drawRoundRect(color: Color, topLeft: Offset, size: Size, cornerRadius: CornerRadius, alpha: Float, + style: DrawStyle) = paint(color, alpha) { + val rr = cornerRadius.x + if (style is Stroke) { + g.drawShape(Painting.roundedPath(Outline.Rounded(size.width, size.height, rr, rr, rr, rr), topLeft.x, topLeft.y), + stroke(style.width, style.cap)) + } else { + g.fillRoundRect(r(topLeft.x), r(topLeft.y), r(size.width), r(size.height), r(rr * 2), r(cornerRadius.y * 2)) + } + } + + override fun drawCircle(color: Color, radius: Float, center: Offset, alpha: Float, style: DrawStyle) = paint(color, alpha) { + if (style is Stroke) { + val p = GeneralPath() + p.arc(center.x - radius, center.y - radius, radius * 2, radius * 2, 0f, (Math.PI * 2).toFloat()) + g.drawShape(p, stroke(style.width, style.cap)) + } else { + g.fillArc(r(center.x - radius), r(center.y - radius), r(radius * 2), r(radius * 2), 0, 360) + } + } + + override fun drawArc(color: Color, startAngle: Float, sweepAngle: Float, useCenter: Boolean, topLeft: Offset, + size: Size, alpha: Float, style: DrawStyle) = paint(color, alpha) { + // Compose measures angles clockwise; Codename One counter-clockwise. + if (style is Stroke) { + val p = GeneralPath() + val rad = (Math.PI / 180).toFloat() + p.arc(topLeft.x, topLeft.y, size.width, size.height, -startAngle * rad, -sweepAngle * rad) + g.drawShape(p, stroke(style.width, style.cap)) + } else { + g.fillArc(r(topLeft.x), r(topLeft.y), r(size.width), r(size.height), r(-startAngle), r(-sweepAngle)) + } + } + + override fun drawLine(color: Color, start: Offset, end: Offset, strokeWidth: Float, cap: StrokeCap, alpha: Float) = + paint(color, alpha) { + if (strokeWidth <= 1f) { + g.drawLine(r(start.x), r(start.y), r(end.x), r(end.y)) + } else { + val p = GeneralPath() + p.moveTo(start.x, start.y) + p.lineTo(end.x, end.y) + g.drawShape(p, stroke(strokeWidth, cap)) + } + } + + override fun drawImage(image: ImageBitmap, topLeft: Offset, alpha: Float) = paint(Color.Black, alpha) { + g.drawImage(image.image, r(topLeft.x), r(topLeft.y)) + } + + override fun drawPolyline(color: Color, points: List, strokeWidth: Float, alpha: Float) = paint(color, alpha) { + if (points.size >= 2) { + val p = GeneralPath() + p.moveTo(points[0].x, points[0].y) + for (i in 1 until points.size) p.lineTo(points[i].x, points[i].y) + g.drawShape(p, stroke(strokeWidth, StrokeCap.Round)) + } + } + + override fun drawPolygon(color: Color, points: List, alpha: Float) = paint(color, alpha) { + if (points.size >= 3) { + val p = GeneralPath() + p.moveTo(points[0].x, points[0].y) + for (i in 1 until points.size) p.lineTo(points[i].x, points[i].y) + p.closePath() + g.fillShape(p) + } + } + + override fun translate(left: Float, top: Float, block: DrawScope.() -> Unit) { + val dx = r(left) + val dy = r(top) + g.translate(dx, dy) + try { + block() + } finally { + g.translate(-dx, -dy) + } + } +} diff --git a/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Policies.kt b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Policies.kt new file mode 100644 index 00000000000..de455f8258a --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/Policies.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose.internal + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.IntSize +import com.codename1.ui.Component + +private fun Component.weightOf(): WeightElement? = (this as? UiNode)?.weight +private fun Component.alignOf(): AlignSelfElement? = (this as? UiNode)?.alignSelf + +/// Stacks children, each aligned in the box. +internal class BoxPolicy(val alignment: Alignment, val propagateMinConstraints: Boolean) : MeasurePolicy { + override fun measure(node: UiNode, children: List, constraints: Constraints): Long { + val childC = if (propagateMinConstraints) constraints else constraints.loose() + var w = constraints.minWidth + var h = constraints.minHeight + val sizes = LongArray(children.size) + // Children that match the parent's size are measured once the others have + // decided it. + for (i in children.indices) { + val c = children[i] + if ((c as? UiNode)?.matchParentSize == true) continue + val s = measureChild(c, childC) + sizes[i] = s + w = Math.max(w, s.w()) + h = Math.max(h, s.h()) + } + w = constraints.constrainWidth(w) + h = constraints.constrainHeight(h) + for (i in children.indices) { + val c = children[i] + if ((c as? UiNode)?.matchParentSize == true) { + sizes[i] = measureChild(c, Constraints.fixed(w, h)) + } + val s = sizes[i] + val a = c.alignOf()?.box ?: alignment + val o = a.align(IntSize(s.w(), s.h()), IntSize(w, h), LTR) + node.place(i, o.x, o.y, s.w(), s.h()) + } + return size(w, h) + } + + override fun equals(other: Any?): Boolean = + other is BoxPolicy && other.alignment == alignment && other.propagateMinConstraints == propagateMinConstraints + + override fun hashCode(): Int = alignment.hashCode() * 2 + if (propagateMinConstraints) 1 else 0 + + companion object { + val TopStart: BoxPolicy = BoxPolicy(Alignment.TopStart, false) + } +} + +/// Row and Column: children along the main axis, with weights and an +/// arrangement, aligned on the cross axis. +internal class LinearPolicy( + val vertical: Boolean, + val horizontalArrangement: Arrangement.Horizontal?, + val verticalArrangement: Arrangement.Vertical?, + val horizontalAlignment: Alignment.Horizontal?, + val verticalAlignment: Alignment.Vertical? +) : MeasurePolicy { + override fun measure(node: UiNode, children: List, constraints: Constraints): Long { + val d = Density + val spacing = if (vertical) d.px(verticalArrangement!!.spacing) else d.px(horizontalArrangement!!.spacing) + val mainMax = if (vertical) constraints.maxHeight else constraints.maxWidth + val mainMin = if (vertical) constraints.minHeight else constraints.minWidth + val crossMax = if (vertical) constraints.maxWidth else constraints.maxHeight + val crossMin = if (vertical) constraints.minWidth else constraints.minHeight + val n = children.size + val mains = IntArray(n) + val crosses = IntArray(n) + var used = if (n > 1) spacing * (n - 1) else 0 + var totalWeight = 0f + var cross = 0 + for (i in 0 until n) { + val c = children[i] + val wt = c.weightOf() + if (wt != null && wt.weight > 0f) { + totalWeight += wt.weight + continue + } + val remaining = if (mainMax == Constraints.INF) Constraints.INF else Math.max(0, mainMax - used) + val cc = if (vertical) Constraints(0, crossMax, 0, remaining) else Constraints(0, remaining, 0, crossMax) + val s = measureChild(c, cc) + mains[i] = if (vertical) s.h() else s.w() + crosses[i] = if (vertical) s.w() else s.h() + used += mains[i] + cross = Math.max(cross, crosses[i]) + } + if (totalWeight > 0f) { + val space = if (mainMax == Constraints.INF) 0 else Math.max(0, mainMax - used) + var left = space + var lastWeighted = -1 + for (i in 0 until n) { + val wt = children[i].weightOf() + if (wt != null && wt.weight > 0f) lastWeighted = i + } + for (i in 0 until n) { + val c = children[i] + val wt = c.weightOf() ?: continue + if (wt.weight <= 0f) continue + val share = if (i == lastWeighted) left else Math.round(space * wt.weight / totalWeight) + left -= share + val min = if (wt.fill) share else 0 + val cc = if (vertical) Constraints(0, crossMax, min, share) else Constraints(min, share, 0, crossMax) + val s = measureChild(c, cc) + mains[i] = if (vertical) s.h() else s.w() + crosses[i] = if (vertical) s.w() else s.h() + used += mains[i] + cross = Math.max(cross, crosses[i]) + } + } + val mainSize = Math.max(mainMin, if (totalWeight > 0f && mainMax != Constraints.INF) mainMax else + Math.min(used, mainMax)) + val crossSize = Math.max(crossMin, Math.min(cross, crossMax)) + // Arrange along the main axis. + val positions = IntArray(n) + if (vertical) { + verticalArrangement!!.arrange(mainSize, mains, positions) + } else { + horizontalArrangement!!.arrange(mainSize, mains, LTR, positions) + } + for (i in 0 until n) { + val c = children[i] + val a = c.alignOf() + val crossPos = if (vertical) { + (a?.horizontal ?: horizontalAlignment!!).align(crosses[i], crossSize, LTR) + } else { + (a?.vertical ?: verticalAlignment!!).align(crosses[i], crossSize) + } + if (vertical) { + node.place(i, crossPos, positions[i], crosses[i], mains[i]) + } else { + node.place(i, positions[i], crossPos, mains[i], crosses[i]) + } + } + return if (vertical) size(crossSize, mainSize) else size(mainSize, crossSize) + } + + override fun equals(other: Any?): Boolean = other is LinearPolicy && other.vertical == vertical && + other.horizontalArrangement == horizontalArrangement && other.verticalArrangement == verticalArrangement && + other.horizontalAlignment == horizontalAlignment && other.verticalAlignment == verticalAlignment + + override fun hashCode(): Int = (if (vertical) 1 else 0) + 31 * (horizontalArrangement.hashCode() + + 31 * (verticalArrangement.hashCode() + 31 * (horizontalAlignment.hashCode() + 31 * verticalAlignment.hashCode()))) +} + +/// A node with no children whose content measures itself (text, an icon, a +/// canvas). +internal object LeafPolicy : MeasurePolicy { + override fun measure(node: UiNode, children: List, constraints: Constraints): Long = + size(constraints.minWidth, constraints.minHeight) +} diff --git a/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/UiNode.kt b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/UiNode.kt new file mode 100644 index 00000000000..9a1250696c3 --- /dev/null +++ b/maven/compose-compat/src/main/kotlin/com/codename1/compose/internal/UiNode.kt @@ -0,0 +1,736 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose.internal + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import com.codename1.ui.Component +import com.codename1.ui.Container +import com.codename1.ui.Graphics +import com.codename1.ui.geom.Dimension +import com.codename1.ui.layouts.Layout +import com.codename1.ui.plaf.Border + +/// Lays out the children of a node. `measure` returns the content size and +/// records, through [UiNode.place], where each child goes relative to the content +/// box's top left corner. +internal interface MeasurePolicy { + fun measure(node: UiNode, children: List, constraints: Constraints): Long +} + +internal fun size(w: Int, h: Int): Long = (w.toLong() shl 32) or (h.toLong() and 0xffffffffL) +internal fun Long.w(): Int = (this ushr 32).toInt() +internal fun Long.h(): Int = this.toInt() + +/// A Compose layout node, which is also a Codename One container: the node's +/// children are Codename One components, and so is everything a composable emits. +/// +/// Compose's layout model is reproduced here rather than mapped onto Codename One +/// layouts. A node is measured with [Constraints] and answers a size; its modifier +/// chain is walked outermost first, each layout element transforming the +/// constraints going in and the size coming out (padding shrinks one and grows the +/// other); the innermost box is where [policy] measures the children. Drawing +/// elements paint at the box their position in the chain gives them. +/// +/// Measuring happens from the root of a composition ([ComposeHost]) in one pass, +/// which also fixes every child's position. Codename One's own layout pass then +/// only copies those positions out ([NodeLayout]); a node it lays out on its own +/// re-measures at its current size. +internal open class UiNode : Container(NodeLayout) { + var policy: MeasurePolicy = BoxPolicy.TopStart + set(value) { + if (field != value) { + field = value + invalidateMeasure() + } + } + + var modifier: Modifier = Modifier + set(value) { + if (field != value) { + val old = field + field = value + resolveModifier(old) + invalidateMeasure() + repaint() + } + } + + // The resolved chain. + internal var chain: Array = EMPTY + internal var weight: WeightElement? = null + internal var alignSelf: AlignSelfElement? = null + internal var matchParentSize: Boolean = false + internal var alpha: Float = 1f + internal var clickable: ClickableElement? = null + internal var tap: PointerTapElement? = null + private var scroll: ScrollElement? = null + + // Measure results: the size at every chain position, and the box each one + // occupies once placed (x, y, w, h per element, then the content box). + private var sizes = LongArray(1) + private var boxes = IntArray(4) + internal var measured = false + + /// Whether a [SafeInsetsElement] is in the chain, so the host re-measures + /// this node (and only it, with its ancestors) when the safe area changes. + internal var usesSafeInsets = false + + /// The top inset this node was measured with, so placing it cannot read a + /// newer one. + private var placedInsetTop = 0 + private var lastConstraints: Constraints? = null + private var placements = IntArray(0) + internal var contentX = 0 + internal var contentY = 0 + internal var contentW = 0 + internal var contentH = 0 + + // Offset modifiers move the whole node when its parent places it. + internal var offsetX = 0 + internal var offsetY = 0 + + init { + cn1StripStyle(this) + } + + // --- modifier resolution ------------------------------------------------ + + private fun resolveModifier(old: Modifier) { + val list = ArrayList() + modifier.foldIn(list) { acc, e -> acc.add(e); acc } + var weight: WeightElement? = null + var align: AlignSelfElement? = null + var match = false + var alpha = 1f + var click: ClickableElement? = null + var tap: PointerTapElement? = null + var scroll: ScrollElement? = null + var tag: String? = null + var description: String? = null + val chain = ArrayList(list.size) + for (e in list) { + when (e) { + is WeightElement -> weight = e + is AlignSelfElement -> align = e + is MatchParentSizeElement -> match = true + is AlphaElement -> alpha *= e.alpha + is ClickableElement -> click = e + is PointerTapElement -> tap = e + is TestTagElement -> tag = e.tag + is SemanticsElement -> description = e.contentDescription + is ScrollElement -> { + scroll = e + chain.add(e) + } + is LayoutElement, is DrawElement -> chain.add(e) + else -> {} + } + } + this.chain = chain.toTypedArray() + var insets = false + for (k in 0 until chain.size) { + if (chain[k] is SafeInsetsElement) { + insets = true + } + } + usesSafeInsets = insets + this.weight = weight + this.alignSelf = align + this.matchParentSize = match + this.alpha = alpha + this.clickable = click + this.tap = tap + if (this.scroll?.state !== scroll?.state) { + this.scroll?.state?.detach(this) + scroll?.state?.attach(this, scroll.vertical) + } + this.scroll = scroll + isScrollableY = (scroll != null && scroll.vertical) || lazyAxis() == 1 + isScrollableX = (scroll != null && !scroll.vertical) || lazyAxis() == 2 + val interactive = (click != null && click.enabled) || tap != null + isGrabsPointerEvents = interactive + isFocusable = interactive + if (tag != null) { + name = tag + } + if (description != null) { + accessibilityText = description + } + sizes = LongArray(this.chain.size + 1) + boxes = IntArray((this.chain.size + 1) * 4) + } + + // --- measuring ---------------------------------------------------------- + + /// Marks this node and every ancestor up to the composition's root as needing + /// a new measure pass, and asks the host for one. + fun invalidateMeasure() { + var n: Container? = this + while (n != null) { + if (n is UiNode) { + if (!n.measured && n !== this) { + break + } + n.measured = false + n.setShouldCalcPreferredSize(true) + } else if (n is ComposeHost) { + n.requestRemeasure() + break + } + n = n.parent + } + } + + /// The host this node is composed into, or null while it is detached. + private fun composeHost(): ComposeHost? { + var n: Container? = parent + while (n != null) { + if (n is ComposeHost) { + return n + } + n = n.parent + } + return null + } + + /// Measures this node under `c` and answers its size (packed with [size]). + fun measure(c: Constraints): Long { + val last = lastConstraints + if (measured && last != null && last.minWidth == c.minWidth && last.maxWidth == c.maxWidth && + last.minHeight == c.minHeight && last.maxHeight == c.maxHeight) { + return sizes[0] + } + val result = measureChain(0, c) + lastConstraints = c + measured = true + placeChain() + return result + } + + val measuredWidth: Int get() = sizes[0].w() + val measuredHeight: Int get() = sizes[0].h() + + private fun measureChain(i: Int, c: Constraints): Long { + if (i == chain.size) { + val s = measureContent(c) + val r = size(c.constrainWidth(s.w()), c.constrainHeight(s.h())) + sizes[i] = r + return r + } + val d = Density + val r: Long = when (val e = chain[i]) { + is SafeInsetsElement -> { + val host = composeHost() + placedInsetTop = if (e.top && host != null) host.insetTop else 0 + val v = placedInsetTop + (if (e.bottom && host != null) host.insetBottom else 0) + val inner = measureChain(i + 1, c.offset(0, v)) + size(c.constrainWidth(inner.w()), c.constrainHeight(inner.h() + v)) + } + is PaddingElement -> { + val l = d.px(e.start) + val t = d.px(e.top) + val h = l + d.px(e.end) + val v = t + d.px(e.bottom) + val inner = measureChain(i + 1, c.offset(h, v)) + size(c.constrainWidth(inner.w() + h), c.constrainHeight(inner.h() + v)) + } + is SizeElement -> { + val inner = measureChain(i + 1, sizeConstraints(e, c)) + size(c.constrainWidth(inner.w()), c.constrainHeight(inner.h())) + } + is FillElement -> { + var minW = c.minWidth + var maxW = c.maxWidth + var minH = c.minHeight + var maxH = c.maxHeight + if (e.horizontal && c.hasBoundedWidth) { + val w = c.constrainWidth(Math.round(c.maxWidth * e.fraction)) + minW = w + maxW = w + } + if (e.vertical && c.hasBoundedHeight) { + val h = c.constrainHeight(Math.round(c.maxHeight * e.fraction)) + minH = h + maxH = h + } + val inner = measureChain(i + 1, Constraints(minW, maxW, minH, maxH)) + size(c.constrainWidth(inner.w()), c.constrainHeight(inner.h())) + } + is WrapElement -> { + val inner = measureChain(i + 1, Constraints( + if (e.horizontal) 0 else c.minWidth, + if (e.horizontal && e.unbounded) Constraints.INF else c.maxWidth, + if (e.vertical) 0 else c.minHeight, + if (e.vertical && e.unbounded) Constraints.INF else c.maxHeight)) + size(c.constrainWidth(inner.w()), c.constrainHeight(inner.h())) + } + is AspectRatioElement -> { + var cc = c + if (c.hasBoundedWidth && !c.hasFixedHeight) { + val h = c.constrainHeight(Math.round(c.maxWidth / e.ratio)) + cc = Constraints(c.maxWidth, c.maxWidth, h, h) + } else if (c.hasBoundedHeight) { + val w = c.constrainWidth(Math.round(c.maxHeight * e.ratio)) + cc = Constraints(w, w, c.maxHeight, c.maxHeight) + } + val inner = measureChain(i + 1, cc) + size(c.constrainWidth(inner.w()), c.constrainHeight(inner.h())) + } + is ScrollElement -> { + val inner = if (e.vertical) { + measureChain(i + 1, Constraints(c.minWidth, c.maxWidth, 0, Constraints.INF)) + } else { + measureChain(i + 1, Constraints(0, Constraints.INF, c.minHeight, c.maxHeight)) + } + size(c.constrainWidth(inner.w()), c.constrainHeight(inner.h())) + } + else -> measureChain(i + 1, c) + } + sizes[i] = r + return r + } + + private fun sizeConstraints(e: SizeElement, c: Constraints): Constraints { + val d = Density + var minW = if (e.minWidth.value.isNaN()) -1 else d.px(e.minWidth) + var maxW = if (e.maxWidth.value.isNaN()) -1 else if (e.maxWidth.value.isInfinite()) Constraints.INF else d.px(e.maxWidth) + var minH = if (e.minHeight.value.isNaN()) -1 else d.px(e.minHeight) + var maxH = if (e.maxHeight.value.isNaN()) -1 else if (e.maxHeight.value.isInfinite()) Constraints.INF else d.px(e.maxHeight) + if (e.enforceIncoming) { + if (minW >= 0) minW = c.constrainWidth(minW) + if (maxW >= 0) maxW = c.constrainWidth(maxW) + if (minH >= 0) minH = c.constrainHeight(minH) + if (maxH >= 0) maxH = c.constrainHeight(maxH) + } + val rMinW = if (minW >= 0) minW else if (maxW >= 0) Math.min(c.minWidth, maxW) else c.minWidth + val rMaxW = if (maxW >= 0) Math.max(maxW, rMinW) else Math.max(c.maxWidth, rMinW) + val rMinH = if (minH >= 0) minH else if (maxH >= 0) Math.min(c.minHeight, maxH) else c.minHeight + val rMaxH = if (maxH >= 0) Math.max(maxH, rMinH) else Math.max(c.maxHeight, rMinH) + return Constraints(rMinW, rMaxW, rMinH, rMaxH) + } + + /// Measures what is inside the innermost box. Containers run [policy] over + /// their children; leaves override this. + protected open fun measureContent(c: Constraints): Long { + val kids = ArrayList(componentCount) + for (k in 0 until componentCount) { + kids.add(getComponentAt(k)) + } + if (placements.size != kids.size * 4) { + placements = IntArray(kids.size * 4) + } + return policy.measure(this, kids, c) + } + + /// Records where a child goes, relative to the content box. + fun place(index: Int, x: Int, y: Int, w: Int, h: Int) { + if (placements.size < (index + 1) * 4) { + placements = placements.copyOf((index + 1) * 4) + } + placements[index * 4] = x + placements[index * 4 + 1] = y + placements[index * 4 + 2] = w + placements[index * 4 + 3] = h + } + + /// Fixes each chain position's box, outermost first. + private fun placeChain() { + val d = Density + var x = 0 + var y = 0 + offsetX = 0 + offsetY = 0 + var leading = true + for (i in 0..chain.size) { + val w = sizes[i].w() + val h = sizes[i].h() + boxes[i * 4] = x + boxes[i * 4 + 1] = y + boxes[i * 4 + 2] = w + boxes[i * 4 + 3] = h + if (i == chain.size) { + break + } + val e = chain[i] + val nw = sizes[i + 1].w() + val nh = sizes[i + 1].h() + when (e) { + is SafeInsetsElement -> { + y += placedInsetTop + leading = false + } + is PaddingElement -> { + x += d.px(e.start) + y += d.px(e.top) + leading = false + } + is OffsetElement -> { + if (leading) { + offsetX += d.px(e.x) + offsetY += d.px(e.y) + } else { + x += d.px(e.x) + y += d.px(e.y) + } + } + is WrapElement -> { + val o = e.align.align(androidx.compose.ui.unit.IntSize(nw, nh), + androidx.compose.ui.unit.IntSize(w, h), LTR) + x += o.x + y += o.y + leading = false + } + is ScrollElement -> leading = false + is LayoutElement -> { + // A box forced larger than its content centers it, as Compose does. + x += (w - nw) / 2 + y += (h - nh) / 2 + leading = false + } + else -> leading = false + } + } + val n = chain.size * 4 + contentX = boxes[n] + contentY = boxes[n + 1] + contentW = boxes[n + 2] + contentH = boxes[n + 3] + } + + /// Copies the positions the last measure recorded onto the children. + internal fun applyPlacements() { + val count = componentCount + for (k in 0 until count) { + val child = getComponentAt(k) + if (placements.size < (k + 1) * 4) { + break + } + var x = contentX + placements[k * 4] + var y = contentY + placements[k * 4 + 1] + if (child is UiNode) { + x += child.offsetX + y += child.offsetY + } + child.setX(x) + child.setY(y) + child.setWidth(placements[k * 4 + 2]) + child.setHeight(placements[k * 4 + 3]) + } + } + + /// The axis a node scrolls along by itself, without a scroll modifier: 1 for + /// vertical, 2 for horizontal. Lazy lists do. + internal open fun lazyAxis(): Int = 0 + + /// Measures again under the last constraints, at the same size, and places + /// the whole subtree now rather than on the next layout pass. + internal fun remeasureInPlace() { + val lc = lastConstraints ?: return + val w = measuredWidth + val h = measuredHeight + measured = false + measure(lc) + if (measuredWidth != w || measuredHeight != h) { + // The parent placed this node at the old size; only a full pass can move its siblings. + invalidateMeasure() + return + } + applyTree() + repaint() + } + + private fun applyTree() { + applyPlacements() + for (k in 0 until componentCount) { + val child = getComponentAt(k) + if (child is UiNode) { + child.applyTree() + } else if (child is Container) { + child.layoutContainer() + } + } + } + + internal fun layoutFromCn1() { + val w = width + val h = height + if (!measured || sizes[0].w() != w || sizes[0].h() != h) { + measure(Constraints.fixed(w, h)) + } + applyPlacements() + if (pendingScroll >= 0) { + val v = pendingScroll + pendingScroll = -1 + if (isScrollableY) cn1ScrollY(v) else cn1ScrollX(v) + } + } + + override fun calcScrollSize(): Dimension { + // The scrolled extent is the box just inside the scroll modifier. + var n = chain.size * 4 + for (i in chain.indices) { + if (chain[i] is ScrollElement) { + n = (i + 1) * 4 + break + } + } + return Dimension(Math.max(width, boxes[n] + boxes[n + 2]), Math.max(height, boxes[n + 1] + boxes[n + 3])) + } + + override fun calcPreferredSize(): Dimension { + val s = measure(Constraints(0, cn1DisplayWidth(), 0, Constraints.INF)) + return Dimension(s.w(), s.h()) + } + + // --- painting ----------------------------------------------------------- + + override fun paintBackground(g: Graphics) { + val old = g.alpha + if (alpha < 1f) { + g.concatenateAlpha(Math.round(alpha * 255)) + } + g.isAntiAliased = true + val ax = x + val ay = y + for (i in chain.indices) { + val e = chain[i] + if (e !is DrawElement) { + continue + } + val bx = ax + boxes[i * 4] + val by = ay + boxes[i * 4 + 1] + val bw = boxes[i * 4 + 2] + val bh = boxes[i * 4 + 3] + when (e) { + is BackgroundElement -> Painting.fill(g, e.shape, e.color, e.brush, bx, by, bw, bh) + is BorderElement -> Painting.stroke(g, e.shape, e.color, Density.px(e.width), bx, by, bw, bh) + is DrawBehindElement -> { + g.translate(bx, by) + try { + e.onDraw(Cn1DrawScope(g, Size(bw.toFloat(), bh.toFloat()))) + } finally { + g.translate(-bx, -by) + } + } + else -> {} + } + } + g.alpha = old + } + + override fun paint(g: Graphics) { + val old = g.alpha + if (alpha < 1f) { + g.concatenateAlpha(Math.round(alpha * 255)) + } + val clip = clipBox() + if (clip >= 0) { + g.pushClip() + clipTo(g, (chain[clip] as ClipElement).shape, x + boxes[clip * 4], y + boxes[clip * 4 + 1], + boxes[clip * 4 + 2], boxes[clip * 4 + 3]) + } + paintContent(g) + if (clip >= 0) { + g.popClip() + } + val click = clickable + if (pressed && click != null && click.indication) { + g.color = 0x000000 + g.concatenateAlpha(30) + val shape = indicationShape() + Painting.fill(g, shape, androidx.compose.ui.graphics.Color.Black, null, x, y, width, height) + } + g.alpha = old + } + + /// Paints what is inside the content box; containers paint their children. + protected open fun paintContent(g: Graphics) { + super.paint(g) + } + + /// Clips to `shape`, as Compose does: a card's children stay inside its + /// rounded corners. `setClip(Shape)` replaces the clip, so the path is + /// first cut to the clip in force, or a card scrolled under a bar would + /// paint over it. + private fun clipTo(g: Graphics, shape: Shape, cx: Int, cy: Int, cw: Int, ch: Int) { + val o = Painting.outline(shape, cw, ch) + if (o is androidx.compose.ui.graphics.Outline.Rounded && g.isShapeClipSupported) { + if (!Painting.clipRounded(g, o, cx, cy)) { + // The clip in force misses the outline (it may still meet a + // corner the rounding cuts off): nothing is visible. + g.clipRect(0, 0, 0, 0) + } + return + } + g.clipRect(cx, cy, cw, ch) + } + + private fun clipBox(): Int { + for (i in chain.indices) { + if (chain[i] is ClipElement) { + return i + } + } + return -1 + } + + private fun indicationShape(): Shape { + for (e in chain) { + if (e is ClipElement) return e.shape + if (e is BackgroundElement) return e.shape + } + return androidx.compose.ui.graphics.RectangleShape + } + + // --- input -------------------------------------------------------------- + + private var pressed = false + private var pressX = 0 + private var pressY = 0 + private var moved = false + + override fun pointerPressed(x: Int, y: Int) { + super.pointerPressed(x, y) + if (clickable?.enabled == true || tap != null) { + pressed = true + moved = false + pressX = x + pressY = y + repaint() + } + } + + override fun pointerDragged(x: Int, y: Int) { + if (pressed && !moved) { + val slop = Density.px(androidx.compose.ui.unit.Dp(8f)) + if (Math.abs(x - pressX) > slop || Math.abs(y - pressY) > slop) { + moved = true + pressed = false + repaint() + } + } + super.pointerDragged(x, y) + } + + override fun pointerReleased(x: Int, y: Int) { + super.pointerReleased(x, y) + if (pressed) { + pressed = false + repaint() + if (!moved && contains(x, y)) { + tap?.onTap?.invoke((x - absoluteX).toFloat(), (y - absoluteY).toFloat()) + val c = clickable + if (c != null && c.enabled) { + c.onClick() + } + } + } + } + + /// Clicks the node as a tap would, for tests and automation. + fun cn1PerformClick(): Boolean { + val c = clickable ?: return false + if (!c.enabled) return false + c.onClick() + return true + } + + internal fun cn1ScrollY(v: Int) { + scrollY = Math.max(0, Math.min(v, Math.max(0, scrollDimension.height - height))) + } + + private var pendingScroll = -1 + + /// A scroll position to restore once the node has been measured. + internal fun cn1ScrollPending(v: Int, vertical: Boolean) { + pendingScroll = v + } + + internal fun cn1ScrollX(v: Int) { + scrollX = Math.max(0, Math.min(v, Math.max(0, scrollDimension.width - width))) + } + + companion object { + private val EMPTY = arrayOf() + } +} + +/// Codename One's layout pass for a node: copies out what measuring decided. +internal object NodeLayout : Layout() { + override fun layoutContainer(parent: Container) { + // Checked, never cast: a failed cast does not throw on iOS. + (parent as? UiNode)?.layoutFromCn1() + } + + override fun getPreferredSize(parent: Container): Dimension { + val node = parent as? UiNode ?: return Dimension(0, 0) + val s = node.measure(Constraints(0, cn1DisplayWidth(), 0, Constraints.INF)) + return Dimension(s.w(), s.h()) + } +} + +internal fun cn1DisplayWidth(): Int = try { + com.codename1.ui.CN.getDisplayWidth().let { if (it > 0) it else 1080 } +} catch (t: Throwable) { + 1080 +} + +internal fun cn1DisplayHeight(): Int = try { + com.codename1.ui.CN.getDisplayHeight().let { if (it > 0) it else 1920 } +} catch (t: Throwable) { + 1920 +} + +/// Removes every theme decoration from a component that a node paints itself. +/// +/// A node is never focused, pressed or disabled, so only its unselected style is +/// ever read. Asking for `allStyles` created the other three, each with its own +/// background painter, for every node: 3,335 styles and 1 MB of a 150-card +/// screen's heap. A real input that takes focus passes `allStates`. +internal fun cn1StripStyle(c: Component, allStates: Boolean = false) { + val s = if (allStates) c.allStyles else c.unselectedStyle + s.setPadding(0, 0, 0, 0) + s.setMargin(0, 0, 0, 0) + s.setBgTransparency(0) + s.setBorder(Border.getEmpty()) +} + +/// Measures any child: a node by its own rules, any other Codename One component +/// by its preferred size, constrained. +internal fun measureChild(child: Component, c: Constraints): Long { + if (child is UiNode) { + return child.measure(c) + } + val pw = child.preferredW + val ph = child.preferredH + return size(c.constrainWidth(pw), c.constrainHeight(ph)) +} + +internal fun Outline.cornerArc(): Int = when (this) { + is Outline.Rounded -> Math.round(Math.max(Math.max(topLeft, topRight), Math.max(bottomLeft, bottomRight)) * 2) + else -> 0 +} diff --git a/maven/compose-compat/src/main/resources/META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory b/maven/compose-compat/src/main/resources/META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory new file mode 100644 index 00000000000..faf6dc9d6d0 --- /dev/null +++ b/maven/compose-compat/src/main/resources/META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory @@ -0,0 +1 @@ +com.codename1.compose.internal.EdtDispatcherFactory diff --git a/maven/compose-compat/src/test/kotlin/com/codename1/compose/KeynoteAppTest.kt b/maven/compose-compat/src/test/kotlin/com/codename1/compose/KeynoteAppTest.kt new file mode 100644 index 00000000000..262fe0eb87b --- /dev/null +++ b/maven/compose-compat/src/test/kotlin/com/codename1/compose/KeynoteAppTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose + +import com.codename1.compat.testing.HeadlessImplementation +import com.codename1.compat.testing.MainThreadRule +import androidx.compose.foundation.lazy.LazyListNode +import com.codename1.compose.internal.TextNode +import com.codename1.compose.internal.TextInputNode +import com.codename1.compose.internal.UiNode +import com.codename1.ui.Component +import com.codename1.ui.Container +import com.codename1.ui.Display +import keynote.App +import keynote.AppState +import keynote.Programme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test + +/// Drives the benchmark's hero application through every screen, headlessly. +class KeynoteAppTest { + @get:Rule + val mainThread = MainThreadRule() + + companion object { + @BeforeClass + @JvmStatic + fun install() { + HeadlessImplementation.install() + } + } + + private fun waitUntil(form: ComposeForm, what: String, cond: () -> Boolean) { + val end = System.currentTimeMillis() + 5000 + while (System.currentTimeMillis() < end) { + ComposeTesting.settle(form) + if (cond()) return + Display.getInstance().invokeAndBlock { Thread.sleep(10) } + } + throw AssertionError("timed out waiting for $what") + } + + private fun cards(root: Container): Int = count(root) { it.name?.startsWith("session-") == true } + + /// How many items the session list holds; only the ones in view are composed. + private fun items(form: ComposeForm): Int = (ComposeTesting.find(form, "list") as LazyListNode).cn1ItemCount() + + private fun count(c: Container, p: (Component) -> Boolean): Int { + var n = 0 + for (i in 0 until c.componentCount) { + val k = c.getComponentAt(i) + if (p(k)) n++ + if (k is Container) n += count(k, p) + } + return n + } + + private fun texts(c: Container, out: MutableList = ArrayList()): List { + for (i in 0 until c.componentCount) { + val k = c.getComponentAt(i) + if (k is TextNode) out.addAll(k.cn1Lines()) + if (k is Container) texts(k, out) + } + return out + } + + private fun click(form: ComposeForm, tag: String) { + val node = ComposeTesting.find(form, tag) as UiNode + assertTrue("$tag is not clickable", node.cn1PerformClick()) + ComposeTesting.settle(form) + } + + @Test + fun everyScreen() { + val state = AppState() + val form = ComposeTesting.show { App(state) } + waitUntil(form, "the programme to load") { cards(form) > 0 } + val day1 = Programme.sessions.filter { it.day == 1 } + assertEquals(day1.size, items(form)) + // Virtualized: the cards in view are composed, not the whole day. + val list = ComposeTesting.find(form, "list") as LazyListNode + assertTrue("composed ${cards(form)} of ${day1.size}", cards(form) < day1.size) + assertEquals(cards(form), list.cn1ComposedCount()) + // Scrolling to the end composes the last card and disposes of the first. + list.cn1ScrollY(Int.MAX_VALUE) + ComposeTesting.settle(form) + list.cn1ScrollY(Int.MAX_VALUE) + ComposeTesting.settle(form) + assertEquals(1, count(form) { it.name == "session-${day1.last().id}" }) + assertEquals(0, count(form) { it.name == "session-${day1.first().id}" }) + list.cn1ScrollY(0) + ComposeTesting.settle(form) + + // Favorite a session; it shows on the favorites tab. + click(form, "fav-0") + assertEquals(listOf(0), state.favorites.toList()) + click(form, "tab-favorites") + assertEquals(1, items(form)) + assertEquals(1, cards(form)) + click(form, "tab-schedule") + + // Day chips and search filter the list. + click(form, "day-2") + assertEquals(Programme.sessions.count { it.day == 2 }, items(form)) + val input = ComposeTesting.find(form, "search") + val field = findInput(input as Container)!! + field.field.text = "Compose" + ComposeTesting.settle(form) + assertEquals("Compose", state.query) + val expected = Programme.sessions.count { it.day == 2 && (it.title.contains("Compose", true) || + it.speaker.name.contains("Compose", true)) } + assertEquals(expected, items(form)) + + // Open a session and come back. + val first = Programme.sessions.first { it.day == 2 && it.title.contains("Compose", true) } + click(form, "session-${first.id}") + assertTrue(texts(form.contentPane).joinToString(" ").contains(first.speaker.name)) + click(form, "back") + assertNull(state.selected) + + // Insights: the chart animates in. + click(form, "tab-insights") + waitUntil(form, "the chart") { count(form) { it.name == "chart" } == 1 } + assertTrue(texts(form.contentPane).contains("You have 1 favorite sessions.")) + + // Paint every screen state once, to catch a painting failure. + val img = com.codename1.ui.Image.createImage(form.width, form.height) + form.paintComponent(img.graphics, true) + } + + private fun findInput(c: Container): TextInputNode? { + for (i in 0 until c.componentCount) { + val k = c.getComponentAt(i) + if (k is TextInputNode) return k + if (k is Container) findInput(k)?.let { return it } + } + return null + } +} diff --git a/maven/compose-compat/src/test/kotlin/com/codename1/compose/LayoutTest.kt b/maven/compose-compat/src/test/kotlin/com/codename1/compose/LayoutTest.kt new file mode 100644 index 00000000000..a87432c193b --- /dev/null +++ b/maven/compose-compat/src/test/kotlin/com/codename1/compose/LayoutTest.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.codename1.compat.testing.HeadlessImplementation +import com.codename1.compat.testing.MainThreadRule +import com.codename1.ui.Component +import com.codename1.ui.Container +import com.codename1.ui.Display +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test + +class LayoutTest { + @get:Rule + val mainThread = MainThreadRule() + + companion object { + @BeforeClass + @JvmStatic + fun install() { + HeadlessImplementation.install() + } + } + + @Test + fun columnRowAndWeights() { + val form = ComposeTesting.show { + Column(Modifier.fillMaxSize().padding(10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Box(Modifier.size(50.dp).background(Color.Red).testTag("a")) + Row(Modifier.fillMaxWidth().height(20.dp)) { + Box(Modifier.weight(1f).fillMaxSize().testTag("w1")) + Box(Modifier.width(30.dp).fillMaxSize().testTag("fixed")) + Box(Modifier.weight(2f).fillMaxSize().testTag("w2")) + } + Box(Modifier.fillMaxWidth().height(10.dp), contentAlignment = Alignment.Center) { + Spacer(Modifier.size(4.dp).testTag("centered")) + } + } + } + // Two pixels per dp under the headless implementation. + val a = ComposeTesting.find(form, "a") + assertEquals(20, a.absoluteX - form.contentPane.absoluteX) + assertEquals(100, a.width) + val w1 = ComposeTesting.find(form, "w1") + val fixed = ComposeTesting.find(form, "fixed") + val w2 = ComposeTesting.find(form, "w2") + assertEquals(60, fixed.width) + val total = form.contentPane.width - 40 + assertEquals(total - 60, w1.width + w2.width) + org.junit.Assert.assertTrue(Math.abs(w1.width * 2 - w2.width) <= 1) + assertEquals(40, w1.height) + assertEquals(a.absoluteY + 100 + 8, w1.absoluteY) + val c = ComposeTesting.find(form, "centered") + assertEquals(8, c.width) + assertEquals(w1.absoluteY + 40 + 8 + 6, c.absoluteY) + } + + @Test + fun clickRecomposes() { + var count by mutableIntStateOf(0) + val form = ComposeTesting.show { + Column { + BasicText("Count $count", Modifier.testTag("label")) + Box(Modifier.size(40.dp).clickable { count++ }.testTag("button")) + } + } + val label = ComposeTesting.find(form, "label") as com.codename1.compose.internal.TextNode + assertEquals(listOf("Count 0"), label.cn1Lines()) + val button = ComposeTesting.find(form, "button") as com.codename1.compose.internal.UiNode + button.cn1PerformClick() + ComposeTesting.settle(form) + assertEquals(listOf("Count 1"), label.cn1Lines()) + assertEquals(7 * HeadlessImplementation.CHAR_WIDTH, label.width) + } + + @Test + fun textWraps() { + val form = ComposeTesting.show { + Box(Modifier.width(50.dp)) { + BasicText("aaa bbb ccc", Modifier.testTag("t")) + } + } + val t = ComposeTesting.find(form, "t") as com.codename1.compose.internal.TextNode + assertEquals(listOf("aaa", "bbb", "ccc"), t.cn1Lines()) + assertEquals(3 * HeadlessImplementation.FONT_HEIGHT, t.height) + } +} + +/// Shows compositions and waits for them to settle. +object ComposeTesting { + fun show(content: @androidx.compose.runtime.Composable () -> Unit): ComposeForm { + val f = ComposeForm(content = content) + f.show() + settle(f) + return f + } + + fun settle(f: ComposeForm) { + for (i in 0 until 50) { + MainThreadRule.drain() + f.view.cn1Frame() + f.revalidate() + if (!f.view.hasPendingWork()) { + MainThreadRule.drain() + f.revalidate() + return + } + } + throw AssertionError("composition did not settle") + } + + fun find(root: Container, tag: String): Component { + val r = search(root, tag) + assertNotNull("no node tagged $tag", r) + return r!! + } + + private fun search(c: Container, tag: String): Component? { + for (i in 0 until c.componentCount) { + val k = c.getComponentAt(i) + if (tag == k.name) return k + if (k is Container) search(k, tag)?.let { return it } + } + return null + } +} diff --git a/maven/compose-compat/src/test/kotlin/com/codename1/compose/LazyListTest.kt b/maven/compose-compat/src/test/kotlin/com/codename1/compose/LazyListTest.kt new file mode 100644 index 00000000000..6f9dae6c451 --- /dev/null +++ b/maven/compose-compat/src/test/kotlin/com/codename1/compose/LazyListTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListNode +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.codename1.compat.testing.HeadlessImplementation +import com.codename1.compat.testing.MainThreadRule +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test + +class LazyListTest { + @get:Rule + val mainThread = MainThreadRule() + + companion object { + @BeforeClass + @JvmStatic + fun install() { + HeadlessImplementation.install() + } + + var itemRuns = 0 + } + + private fun list(form: ComposeForm): LazyListNode = ComposeTesting.find(form, "list") as LazyListNode + + @Test + fun parentRecompositionDoesNotRecomposeItems() { + var count by mutableIntStateOf(0) + itemRuns = 0 + val form = ComposeTesting.show { + Column { + BasicText("Count $count", Modifier.testTag("label")) + LazyColumn(Modifier.height(300.dp).testTag("list")) { + items(100, key = { it }) { + itemRuns++ + BasicText("item $it", Modifier.height(40.dp)) + } + } + } + } + val composed = list(form).cn1ComposedCount() + assertTrue("only the items in view are composed", composed in 1 until 100) + assertEquals(composed, itemRuns) + count++ + ComposeTesting.settle(form) + val label = ComposeTesting.find(form, "label") as com.codename1.compose.internal.TextNode + assertEquals(listOf("Count 1"), label.cn1Lines()) + assertEquals("a recomposition above the list must not recompose its items", composed, itemRuns) + } + + @Test + fun duplicateKeysAreRefused() { + try { + ComposeTesting.show { + LazyColumn(Modifier.height(300.dp).testTag("list")) { + items(5, key = { "same" }) { BasicText("item $it", Modifier.height(40.dp)) } + } + } + fail("two items with one key must be refused") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("same")) + } + } + + @Test + fun scrollToItemLandsOnTheMeasuredItem() { + var state: LazyListState? = null + val form = ComposeTesting.show { + val s = rememberLazyListState() + state = s + // Sizes nothing like the estimate taken from the first items, so the target's + // position is only right once its neighbours have been measured. + LazyColumn(Modifier.height(300.dp).testTag("list"), state = s) { + items(200, key = { it }) { + BasicText("item $it", Modifier.fillMaxWidth().height(if (it < 10) 20.dp else (30 + (it % 7) * 15).dp) + .testTag("item-$it")) + } + } + } + val node = list(form) + for (target in intArrayOf(150, 60, 199, 3)) { + runBlocking { state!!.scrollToItem(target) } + ComposeTesting.settle(form) + val item = ComposeTesting.find(form, "item-$target") + // A container's absolute Y has its own scroll taken off; the view's top is this. + val top = node.absoluteY + node.scrollY + val want = if (target == 199) top + node.height - item.height else top + assertEquals("item $target starts where scrollToItem put it", want, item.absoluteY) + if (target != 199) assertEquals(target, state!!.firstVisibleItemIndex) + } + } + + @Test + fun aLongListCostsTheWindow() { + var state: LazyListState? = null + val form = ComposeTesting.show { + val s = rememberLazyListState() + state = s + LazyColumn(Modifier.height(300.dp).testTag("list"), state = s) { + items(100000, key = { it }) { + BasicText("item $it", Modifier.height((20 + (it % 5) * 10).dp).testTag("item-$it")) + } + } + } + val node = list(form) + var target = 0 + while (target < 100000) { + runBlocking { state!!.scrollToItem(target) } + ComposeTesting.settle(form) + assertTrue("composed ${node.cn1ComposedCount()}", node.cn1ComposedCount() < 40) + val item = ComposeTesting.find(form, "item-$target") + if (target < 99990) assertEquals(node.absoluteY + node.scrollY, item.absoluteY) + target += 9973 + } + } + + @Test + fun userScrollCanBeTurnedOff() { + val form = ComposeTesting.show { + LazyColumn(Modifier.height(300.dp).testTag("list"), userScrollEnabled = false) { + items(100, key = { it }) { BasicText("item $it", Modifier.height(40.dp)) } + } + } + assertFalse(list(form).isScrollableY) + } +} diff --git a/maven/core-unittests/spotbugs-exclude.xml b/maven/core-unittests/spotbugs-exclude.xml index 2177df3765d..6e4de2f489a 100644 --- a/maven/core-unittests/spotbugs-exclude.xml +++ b/maven/core-unittests/spotbugs-exclude.xml @@ -379,6 +379,27 @@ + + + + + + + + + + + + + + + + + + + diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaRevalidateLoopTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaRevalidateLoopTest.java index bf785e1f75b..c5915e4e48c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaRevalidateLoopTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaRevalidateLoopTest.java @@ -33,12 +33,15 @@ import java.util.ArrayList; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** * A safe-area container writes inset padding onto its own style, measures or lays out * with it, and hands the padding straight back. That round trip must be silent. * + *

The comment below is kept as the history of why.

+ * *

It used to announce the setting half as a PADDING style change, and * {@link Component#styleChanged} answers a PADDING change by queueing a * {@code revalidateLater()} on the parent. So laying out a Form with a safe-area @@ -80,7 +83,7 @@ private Container notchedSafeAreaForm(Form f) { } @FormTest - void measuringASafeAreaContainerAnnouncesItsPadding() { + void measuringASafeAreaContainerIsSilent() { Form f = Display.getInstance().getCurrent(); Container inner = notchedSafeAreaForm(f); @@ -88,18 +91,33 @@ void measuringASafeAreaContainerAnnouncesItsPadding() { inner.getStyle().addStyleListener(rec); inner.setShouldCalcPreferredSize(true); inner.getPreferredSize(); + inner.layoutContainer(); inner.getStyle().removeStyleListener(rec); - // It DOES announce, and that is load-bearing -- see the comment on the - // snapToSafeAreaInternal call in Container. Suppressing it removes the - // revalidate treadmill, and with it the repaints that peer components - // depend on: the JavaSE video peer fills its buffer from the AWT side - // and never requests a repaint itself, so with nothing else repainting - // the form its frames decode correctly and never reach the screen. - // Pinned so the optimisation is not reinstated without first making - // peers drive their own repaints. - assertTrue(rec.properties.size() > 0, - "measuring a safe-area container must still announce its padding"); + assertTrue(rec.properties.isEmpty(), + "the safe-area round trip announced " + rec.properties); + } + + /** + * What the announcement used to hide: a component painted by its parent + * kept the flag its own full repaint() set, so each later region repaint -- + * a peer's new video frame -- was dropped as already covered. + */ + @FormTest + void aRegionRepaintAfterAParentPaintIsKept() { + Form f = Display.getInstance().getCurrent(); + f.setLayout(new BorderLayout()); + Label child = new Label("peer"); + f.add(BorderLayout.CENTER, child); + f.show(); + f.layoutContainer(); + + child.repaint(); + Image img = Image.createImage(f.getWidth(), f.getHeight()); + f.paintComponent(img.getGraphics(), true); + + child.repaint(child.getAbsoluteX(), child.getAbsoluteY(), 4, 4); + assertNotNull(child.getDirtyRegion(), "a region repaint after the parent painted was dropped"); } } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/geom/GeneralPathTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/geom/GeneralPathTest.java index c7389150fdf..9f1ad248463 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/geom/GeneralPathTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/geom/GeneralPathTest.java @@ -191,6 +191,29 @@ void testContainsAndIntersectsRectangle() { assertFalse(path.intersect(noOverlap)); } + /// A rounded rectangle cut by a clip that crosses its corners: the curves + /// running right to left made the crossing count -1, which segmenting + /// mistook for an unsupported curve order and threw. + @Test + void testIntersectRoundedRectangleAcrossCorners() { + GeneralPath path = new GeneralPath(); + path.moveTo(10, 0); + path.lineTo(90, 0); + path.quadTo(100, 0, 100, 10); + path.lineTo(100, 40); + path.quadTo(100, 50, 90, 50); + path.lineTo(10, 50); + path.quadTo(0, 50, 0, 40); + path.lineTo(0, 10); + path.quadTo(0, 0, 10, 0); + path.closePath(); + assertTrue(path.intersect(new Rectangle(-5, 5, 110, 30))); + Rectangle bounds = path.getBounds(); + assertEquals(0, bounds.getX()); + assertEquals(100, bounds.getX() + bounds.getWidth()); + assertTrue(bounds.getY() >= 5 && bounds.getY() + bounds.getHeight() <= 35); + } + @Test void testEqualsWithTransform() { GeneralPath original = new GeneralPath(); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/util/ThemeStringsTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/util/ThemeStringsTest.java new file mode 100644 index 00000000000..2d101ffb81d --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/util/ThemeStringsTest.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.ui.util; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ThemeStringsTest { + + @Test + void readUtfRejectsBadContinuationBytes() { + final byte[][] bad = {{0, 2, (byte) 0xc3, 0x41}, {0, 3, (byte) 0xe2, (byte) 0x82, 0x41}, {0, 1, (byte) 0x80}}; + for (final byte[] b : bad) { + assertThrows(IOException.class, () -> new ThemeStrings(1).readUTF(new DataInputStream(new ByteArrayInputStream(b)))); + } + } + + @Test + void hexAndDecimalMatchTheJdk() { + ThemeStrings s = new ThemeStrings(0); + int[] values = {0, 1, 9, 10, 15, 16, 0xff, 0x7aff, 0xffffff, 0x1000000, -1, Integer.MIN_VALUE, Integer.MAX_VALUE}; + for (int v : values) { + assertEquals(Integer.toHexString(v), s.hex(v)); + } + for (int v = 0; v < 256; v++) { + assertEquals(String.valueOf(v), s.decimal(v)); + } + } + + @Test + void readUtfRoundTripsAndSharesRepeats() throws IOException { + String[] values = {"", "ffffff", "native:MainRegular", "caf\u00e9", "\u05e9\u05dc\u05d5\u05dd", "\u0000x", "ffffff"}; + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bo); + for (String v : values) { + out.writeUTF(v); + } + DataInputStream in = new DataInputStream(new ByteArrayInputStream(bo.toByteArray())); + ThemeStrings s = new ThemeStrings(0); + String[] read = new String[values.length]; + for (int i = 0; i < values.length; i++) { + read[i] = s.readUTF(in); + assertEquals(values[i], read[i]); + } + assertSame(read[1], read[6]); + assertSame(read[1], s.hex(0xffffff)); + assertSame(read[2], s.share(new String("native:MainRegular"))); + } + + @Test + void manyDistinctValuesSurviveGrowth() { + ThemeStrings s = new ThemeStrings(0); + String[] first = new String[5000]; + for (int i = 0; i < first.length; i++) { + first[i] = s.share("k" + i); + } + for (int i = 0; i < first.length; i++) { + assertSame(first[i], s.share("k" + i)); + } + } + + @Test + void floatsAndIntsMatchConcatenation() { + ThemeStrings s = new ThemeStrings(16); + for (int k = -20000; k <= 20000; k++) { + float f = (float) (k / 10.0); + assertEquals("" + f + "," + f + "," + f + "," + f, s.floats(f, f, f, f)); + } + float[] odd = {0.25f, 1.125f, 1e7f, 3.4e38f, -0.0f, 1e-5f, 0.001f, Float.NaN, Float.POSITIVE_INFINITY, 123456.7f, 999999.9f}; + for (float f : odd) { + assertEquals("" + f + "," + 0f + "," + f + "," + 1f, s.floats(f, 0f, f, 1f)); + } + for (int v = 0; v < 256; v++) { + assertEquals("" + v + "," + 255 + "," + 0 + "," + v, s.ints(v, 255, 0, v)); + } + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java index f2dd663e152..91f48ba7cec 100644 --- a/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java +++ b/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java @@ -1724,14 +1724,14 @@ public long getDataSize(String id) { /** * Adapts loaded themes to the new resource selected/unselected modes */ - Hashtable loadTheme(String id, boolean newerVersion) throws IOException { + HashMap loadThemeMap(String id, boolean newerVersion) throws IOException { if(overrideResource != null) { - Hashtable h = overrideResource.loadTheme(id, newerVersion); + HashMap h = overrideResource.loadThemeMap(id, newerVersion); if(h != null) { return h; } } - Hashtable h = super.loadTheme(id, newerVersion); + HashMap h = super.loadThemeMap(id, newerVersion); Iterator keyIter = h.keySet().iterator(); while(keyIter.hasNext()) { diff --git a/maven/integration-tests/kmp-compat-test.sh b/maven/integration-tests/kmp-compat-test.sh new file mode 100755 index 00000000000..117c2297544 --- /dev/null +++ b/maven/integration-tests/kmp-compat-test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. +# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +# +# This code is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 2 only, as +# published by the Free Software Foundation. Codename One designates this +# particular file as subject to the "Classpath" exception as provided +# by Oracle in the LICENSE file that accompanied this code. +# +# This code is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# version 2 for more details (a copy is included in the LICENSE file that +# accompanied this code). +# +# You should have received a copy of the GNU General Public License version +# 2 along with this work; if not, write to the Free Software Foundation, +# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Please contact Codename One through http://www.codenameone.com/ if you +# need additional information or have any questions. +# +# Kotlin Multiplatform sources as a Codename One application, end to end, for +# every sample under scripts/kmp-compat-samples: +# - cn1:import-kmp-project copies commonMain (and the JVM source set, minus +# its desktop window) into common/src/main/kmp and writes the main class; +# - the Maven build compiles them with the Compose compiler against +# compose-compat and passes the bytecode compliance check; +# - the Gradle conversion applies the Compose plugin, and both tools stage +# the same upload for every target, carrying the Compose runtime and +# compose-compat as they are -- androidx.compose is never relocated. +# +# Needs the reactor installed (mvn install) and a JDK 17+; see inc/gradle.sh. +SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" +set -e +SAMPLES="$SCRIPTPATH/../../scripts/kmp-compat-samples" +source "$SCRIPTPATH/inc/env.sh" +source "$SCRIPTPATH/inc/gradle.sh" +source "$SCRIPTPATH/inc/compat.sh" + +WORKDIR="${CN1_COMPAT_WORKDIR:-$SCRIPTPATH/build}/kmp-compat" +rm -rf "$WORKDIR" +mkdir -p "$WORKDIR" +FAILED=0 + +check_kmp_upload() { + local jar="$1" + assert_zip_has "$jar" "(^|/)$MAIN_ENTRY\\.class\$" + assert_zip_has "$jar" "^androidx/compose/runtime/Recomposer\\.class\$" + assert_zip_has "$jar" "^com/codename1/compose/ComposeView\\.class\$" + assert_zip_has "$jar" "^kotlinx/coroutines/.*\\.class\$" + # The dispatcher kotlinx.coroutines finds for Dispatchers.Main. + assert_zip_has "$jar" "^META-INF/services/kotlinx\\.coroutines\\.internal\\.MainDispatcherFactory\$" + assert_zip_lacks "$jar" "^com/codename1/androidcompat/androidx/compose/" +} + +for sample_dir in "$SAMPLES"/*/; do + sample=$(basename "$sample_dir") + echo "== $sample" + W="$WORKDIR/$sample" + mkdir -p "$W" + PKG=com.acme.kmp + compat_generate_app "$sample" "$W" $PKG KmpApp + MAPP="$W/app" + (cd "$MAPP" && mvn_local "com.codenameone:codenameone-maven-plugin:$CN1_VERSION:import-kmp-project" \ + "-Dcn1.kmp.import=$sample_dir") > "$W/import.log" 2>&1 \ + || { tail -40 "$W/import.log"; fail "$sample: import-kmp-project"; } + kmp="$MAPP/common/src/main/kmp" + [ -n "$(find "$kmp/commonMain" -name '*.kt' 2>/dev/null | head -1)" ] || fail "$sample: the import copied no commonMain sources" + [ -z "$(grep -rl 'androidx\.compose\.ui\.window' "$kmp" 2>/dev/null)" ] \ + || fail "$sample: the import kept a source that opens a desktop window" + grep -q 'left out: iosMain' "$W/import.log" || fail "$sample: the import did not report the iosMain source set" + MAIN_ENTRY=com/acme/kmp/KmpApp + + compat_build_common "$sample" "$W" "$MAPP" + grep -q 'bytecode-compliance' "$W/mvn-common.log" || fail "$sample: the compliance check did not run" + classes="$MAPP/common/target/classes" + [ -f "$classes/$MAIN_ENTRY.class" ] || fail "$sample: the build produced no $MAIN_ENTRY.class" + # The Compose compiler ran: a composable takes the Composer it added. + find "$classes" -name 'AppKt.class' -exec javap -p {} \; 2>/dev/null | grep -q 'androidx.compose.runtime.Composer' \ + || fail "$sample: App() was not compiled by the Compose compiler" + + compat_convert_to_gradle "$sample" "$W" "$MAPP" + GAPP="$W/app-gradle" + [ -d "$GAPP/src/main/kmp" ] || fail "$sample: the Gradle conversion dropped src/main/kmp" + grep -q 'org.jetbrains.kotlin.plugin.compose' "$GAPP/build.gradle.kts" \ + || fail "$sample: the Gradle conversion did not apply the Compose compiler plugin" + + compat_stage_all "$sample" "$W" "$MAPP" "$GAPP" check_kmp_upload +done + +[ "$FAILED" = 0 ] || { echo "kmp-compat-test: FAILED"; exit 1; } +echo "kmp-compat-test: OK" diff --git a/maven/pom.xml b/maven/pom.xml index 0e9749d93bf..7c3bea5cf4c 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -97,6 +97,8 @@ android-compat swing-compat javafx-compat + + compose-compat fxml-compiler lottie-transcoder + + compose + + + ${basedir}/src/main/kmp + + + + + + google + https://maven.google.com + + + + + com.codenameone + codenameone-compose-compat + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-compose-compiler-plugin-embeddable + ${kotlin.version} + + + + + + + @@ -148,7 +192,9 @@ - 1.6.0 + + 2.2.10 true @@ -163,7 +209,7 @@ org.jetbrains annotations - 13.0 + ${kotlin.annotations.version} com.codenameone @@ -212,6 +258,9 @@ ${android.gallery.dir}/kotlin ${android.gallery.dir}/java ${project.build.directory}/generated-sources/android + + ${project.basedir}/src/main/kmp/commonMain/kotlin + ${project.basedir}/src/main/kmp/jvmMain/kotlin -no-reflect @@ -517,8 +566,7 @@ build that does not used to name the libraries one by one, and the one nobody had named was left out of the translation - the application then failed in C, on the header of a class no - source directory supplied. Kotlin's runtime is the exception: - those builds stage it themselves. --> + source directory supplied. --> org.apache.maven.plugins maven-dependency-plugin @@ -532,7 +580,13 @@ runtime - org.jetbrains.kotlin,org.jetbrains + META-INF/** ${project.build.directory}/translation-libs diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index aba2a1209c4..c80ccc5d16b 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -58,6 +58,10 @@ import com.codenameone.examples.hellocodenameone.tests.desktopcompat.DesktopCompatSwingTreesScreenshotTest; import com.codenameone.examples.hellocodenameone.tests.desktopcompat.DesktopCompatSwingPaintingScreenshotTest; import com.codenameone.examples.hellocodenameone.tests.desktopcompat.DesktopCompatSwingContainersScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.kmpcompat.KmpCompatLayoutScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.kmpcompat.KmpCompatTypographyScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.kmpcompat.KmpCompatListScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.kmpcompat.KmpCompatStateScreenshotTest; import com.codenameone.examples.hellocodenameone.tests.desktopcompat.DesktopCompatSwingOptionPaneScreenshotTest; import com.codenameone.examples.hellocodenameone.tests.desktopcompat.DesktopCompatSwingXScreenshotTest; import com.codenameone.examples.hellocodenameone.tests.desktopcompat.DesktopCompatFxControlsScreenshotTest; @@ -707,6 +711,11 @@ private static int testTimeoutMs(BaseTest testClass) { new DesktopCompatFxFxmlScreenshotTest(), new DesktopCompatFxPanesScreenshotTest(), new DesktopCompatFxAlertScreenshotTest(), + // Compose (common/src/main/kmp), embedded through ComposeView. + new KmpCompatLayoutScreenshotTest(), + new KmpCompatTypographyScreenshotTest(), + new KmpCompatListScreenshotTest(), + new KmpCompatStateScreenshotTest(), // After every test that shows a dialog. It asks for the notification // permission, and on iOS the system alert nobody answers leaves the // application inactive for the rest of the run: Display.isMinimized() diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatLayoutScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatLayoutScreenshotTest.java new file mode 100644 index 00000000000..db42bbcb5fe --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatLayoutScreenshotTest.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests.kmpcompat; + +import com.codename1.ui.Component; +import com.codenameone.examples.hellocodenameone.kmp.KmpScenes; +import com.codenameone.examples.hellocodenameone.tests.desktopcompat.DesktopCompatScreenshotTest; + +/// A Compose screen of common/src/main/kmp, embedded as a component. +public class KmpCompatLayoutScreenshotTest extends DesktopCompatScreenshotTest { + public KmpCompatLayoutScreenshotTest() { + super("Compose layout", "KmpCompatLayout"); + } + + @Override + protected Component scene() { + return KmpScenes.layout(); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatListScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatListScreenshotTest.java new file mode 100644 index 00000000000..a9c411b8e7d --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatListScreenshotTest.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests.kmpcompat; + +import com.codename1.ui.Component; +import com.codenameone.examples.hellocodenameone.kmp.KmpScenes; +import com.codenameone.examples.hellocodenameone.tests.desktopcompat.DesktopCompatScreenshotTest; + +/// A Compose screen of common/src/main/kmp, embedded as a component. +public class KmpCompatListScreenshotTest extends DesktopCompatScreenshotTest { + public KmpCompatListScreenshotTest() { + super("Compose list", "KmpCompatList"); + } + + @Override + protected Component scene() { + return KmpScenes.list(); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatStateScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatStateScreenshotTest.java new file mode 100644 index 00000000000..0831fa13bd3 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatStateScreenshotTest.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests.kmpcompat; + +import com.codename1.ui.Component; +import com.codenameone.examples.hellocodenameone.kmp.KmpScenes; +import com.codenameone.examples.hellocodenameone.tests.desktopcompat.DesktopCompatScreenshotTest; + +/// A Compose screen of common/src/main/kmp, embedded as a component. +public class KmpCompatStateScreenshotTest extends DesktopCompatScreenshotTest { + public KmpCompatStateScreenshotTest() { + super("Compose state", "KmpCompatState"); + } + + @Override + protected Component scene() { + return KmpScenes.state(); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatTypographyScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatTypographyScreenshotTest.java new file mode 100644 index 00000000000..5288d27e210 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/kmpcompat/KmpCompatTypographyScreenshotTest.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests.kmpcompat; + +import com.codename1.ui.Component; +import com.codenameone.examples.hellocodenameone.kmp.KmpScenes; +import com.codenameone.examples.hellocodenameone.tests.desktopcompat.DesktopCompatScreenshotTest; + +/// A Compose screen of common/src/main/kmp, embedded as a component. +public class KmpCompatTypographyScreenshotTest extends DesktopCompatScreenshotTest { + public KmpCompatTypographyScreenshotTest() { + super("Compose typography", "KmpCompatTypography"); + } + + @Override + protected Component scene() { + return KmpScenes.typography(); + } +} diff --git a/scripts/hellocodenameone/common/src/main/kmp/commonMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/Screens.kt b/scripts/hellocodenameone/common/src/main/kmp/commonMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/Screens.kt new file mode 100644 index 00000000000..fa9b30f203e --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/kmp/commonMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/Screens.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.kmp + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/// Box, Row and Column under padding, size, background, border and weight. +@Composable +fun LayoutScreen() { + MaterialTheme { + Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.weight(1f).height(48.dp).background(Color(0xFFE53935))) + Box(Modifier.weight(2f).height(48.dp).background(Color(0xFF43A047))) + Box(Modifier.size(48.dp).background(Color(0xFF1E88E5), RoundedCornerShape(12.dp))) + } + Box(Modifier.fillMaxWidth().height(96.dp).border(2.dp, Color(0xFF6750A4), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center) { + Text("Centered in a bordered box") + } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(32.dp).background(Color(0xFFFFB300))) + Spacer(Modifier.size(12.dp)) + Column { + Text("Row with a spacer", fontWeight = FontWeight.Bold) + Text("and a nested column") + } + } + } + } +} + +/// The Material 3 type scale and buttons. +@Composable +fun TypographyScreen() { + MaterialTheme { + Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Headline large", style = MaterialTheme.typography.headlineLarge) + Text("Title medium", style = MaterialTheme.typography.titleMedium) + Text("Body medium text that is long enough to wrap onto a second line in a phone width column.", + style = MaterialTheme.typography.bodyMedium) + Text("Label small", style = MaterialTheme.typography.labelSmall) + HorizontalDivider() + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = {}) { Text("Filled") } + OutlinedButton(onClick = {}) { Text("Outlined") } + TextButton(onClick = {}) { Text("Text") } + } + } + } +} + +/// A Scaffold with a top app bar over a LazyColumn of cards. +@Composable +fun ListScreen() { + MaterialTheme { + Scaffold(topBar = { TopAppBar(title = { Text("Compose list") }) }) { padding -> + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(30) { index -> + Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { + Column(Modifier.padding(12.dp)) { + Text("Item $index", style = MaterialTheme.typography.titleMedium) + Text("Supporting text for row $index") + } + } + } + } + } + } +} + +/// State and recomposition: a counter bumped from an effect, and an +/// AnimatedVisibility captured at its end state. +@Composable +fun StateScreen() { + MaterialTheme { + var count by remember { mutableIntStateOf(0) } + var shown by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + count = 3 + shown = true + } + Column(Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Clicked $count times", style = MaterialTheme.typography.headlineSmall) + Button(onClick = { count++ }) { Text("Click me") } + AnimatedVisibility(visible = shown) { + Card(Modifier.fillMaxWidth()) { + Text("Revealed by AnimatedVisibility", Modifier.padding(16.dp)) + } + } + AnimatedVisibility(visible = !shown) { + Text("This text is hidden at the end state") + } + } + } +} diff --git a/scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/GuideSessionCard.kt b/scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/GuideSessionCard.kt new file mode 100644 index 00000000000..109c595d5e7 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/GuideSessionCard.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.kmp + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +// The developer guide shows this card beside its Codename One twin, +// docs/demos/.../kmp/SessionCard.java; keep the two doing the same thing. +// tag::kotlin-multiplatform-interop-session-card-compose[] +@Composable +fun SessionCard(time: String, title: String, speaker: String, + favorite: Boolean, onFavorite: () -> Unit) { + Card(Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f).padding(12.dp)) { + Text(time, style = MaterialTheme.typography.labelMedium) + Text(title, style = MaterialTheme.typography.titleMedium) + Text(speaker, style = MaterialTheme.typography.bodySmall) + } + IconButton(onFavorite) { + Icon(if (favorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, null) + } + } + } +} +// end::kotlin-multiplatform-interop-session-card-compose[] diff --git a/scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/GuideSnippets.kt b/scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/GuideSnippets.kt new file mode 100644 index 00000000000..6a9d58d0abf --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/GuideSnippets.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.kmp + +import com.codename1.compose.ComposeForm +import com.codename1.compose.ComposeView +import com.codename1.system.Lifecycle +import com.codename1.ui.Form +import com.codename1.ui.Label +import com.codename1.ui.layouts.BorderLayout + +// The developer guide's entry-point examples (Kotlin-Multiplatform-Interop), +// kept here so they compile with the rest of the Compose screens. Nothing +// calls them. + +// tag::kotlin-multiplatform-interop-kotlin-002[] +class MyApp : Lifecycle() { + override fun runApp() { + ComposeForm { LayoutScreen() }.show() + } +} + +// end::kotlin-multiplatform-interop-kotlin-002[] +internal fun guideMixedForm() { + // tag::kotlin-multiplatform-interop-kotlin-003[] + val form = Form("Mixed", BorderLayout()) + form.add(BorderLayout.NORTH, Label("Codename One")) + form.add(BorderLayout.CENTER, ComposeView { ListScreen() }) + form.show() + // end::kotlin-multiplatform-interop-kotlin-003[] +} diff --git a/scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/KmpScenes.kt b/scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/KmpScenes.kt new file mode 100644 index 00000000000..ff40f45cedd --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/kmp/jvmMain/kotlin/com/codenameone/examples/hellocodenameone/kmp/KmpScenes.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.kmp + +import com.codename1.compose.ComposeView +import com.codename1.ui.Component + +/// The Compose screens as Codename One components, for the KmpCompat +/// screenshot tests, which are Java. +object KmpScenes { + @JvmStatic fun layout(): Component = ComposeView { LayoutScreen() } + @JvmStatic fun typography(): Component = ComposeView { TypographyScreen() } + @JvmStatic fun list(): Component = ComposeView { ListScreen() } + @JvmStatic fun state(): Component = ComposeView { StateScreen() } +} diff --git a/scripts/hellocodenameone/pom.xml b/scripts/hellocodenameone/pom.xml index 77cc3585098..8910a17c372 100644 --- a/scripts/hellocodenameone/pom.xml +++ b/scripts/hellocodenameone/pom.xml @@ -31,6 +31,8 @@ 17 17 17 + + 23.0.0 hellocodenameone @@ -70,6 +72,13 @@ ${cn1.version} provided + + + com.codenameone + codenameone-compose-compat + ${cn1.version} + com.codenameone codenameone-unity-compat diff --git a/scripts/javascript/screenshots/KmpCompatLayout.png b/scripts/javascript/screenshots/KmpCompatLayout.png new file mode 100644 index 00000000000..88262c0d2d2 Binary files /dev/null and b/scripts/javascript/screenshots/KmpCompatLayout.png differ diff --git a/scripts/javascript/screenshots/KmpCompatList.png b/scripts/javascript/screenshots/KmpCompatList.png new file mode 100644 index 00000000000..31960f0f3da Binary files /dev/null and b/scripts/javascript/screenshots/KmpCompatList.png differ diff --git a/scripts/javascript/screenshots/KmpCompatState.png b/scripts/javascript/screenshots/KmpCompatState.png new file mode 100644 index 00000000000..21609c8c0e9 Binary files /dev/null and b/scripts/javascript/screenshots/KmpCompatState.png differ diff --git a/scripts/javascript/screenshots/KmpCompatTypography.png b/scripts/javascript/screenshots/KmpCompatTypography.png new file mode 100644 index 00000000000..e5a78b09639 Binary files /dev/null and b/scripts/javascript/screenshots/KmpCompatTypography.png differ diff --git a/scripts/kmp-bench/README.md b/scripts/kmp-bench/README.md new file mode 100644 index 00000000000..c209b26dacd --- /dev/null +++ b/scripts/kmp-bench/README.md @@ -0,0 +1,155 @@ +# Codename One vs. Compose Multiplatform + +One Compose application, **Keynote** (a conference schedule: search, day +filters, a 150-session list of cards, favorites, a detail screen and an animated +chart), built twice from the **same `commonMain` source** and run side by side: + +- **Codename One**: the source imported into an archetype application + (`common/src/main/kmp`), compiled by kotlinc with the Compose compiler against + `maven/compose-compat`, translated by ParparVM to C, built by Xcode. +- **Compose Multiplatform**: the same source as a CMP module + (`org.jetbrains.compose`, Kotlin/Native and Skia), linked as an iOS + executable. + +The shared code is in `app/shared/commonMain`. The only per-side files are each +side's entry point (`app/cn1/BenchEntry.kt`, `app/cmp/composeApp/src/iosMain`). +The CMP build adds one compiler opt-in, `ExperimentalMaterial3Api`, which the +real material3 demands for the top app bar. That keeps the shared source +identical on both sides. Keynote is written for this benchmark. It is not a +JetBrains sample. + +## Running it + +```bash +export JAVA17_HOME=... # JDK 17: Gradle, kotlinc, the CN1 translation +scripts/kmp-bench/run_bench.sh --work /tmp/kbench --runs 5 \ + --maven-repo "$PWD/.m2-repo" # the repository the checkout installed into +``` + +The script needs Xcode, Gradle 8.5 or newer on `PATH`, and the reactor installed +into the repository it is given. It boots one iPhone simulator, shuts it down +when it finishes, and writes `results.json`, `results.md` and a screenshot of +each app into the work directory. + +## What is measured + +| | | +|---|---| +| Start-up | From `simctl launch` to the app's `KMPBENCH first-frame` line. Both print it on the first main-thread turn after their UI is installed. CMP's marker fires before Skia has drawn anything, so the comparison favours CMP. Run 1 is the first launch after install (cold); the median of the rest is reported as warm. | +| Executable | The app binary after `strip`, which is what an App Store build ships. | +| Bundle | A copy of the `.app` with its executable stripped, plus the bytes of everything in it except the executable. | +| Recomposition | `KMPBENCH_PERF=1` launches only (`Perf.kt`, shared code). After 30 discarded changes, the day filter is changed 300 times. Each change is timed until a 1 dp marker drawn after the list has been painted, so compose, layout and draw are all included. | +| Scroll | After one discarded pass, all 150 cards (three days of 50) are scrolled one card per frame with `scrollToItem`. compose-compat has no `scrollBy`. Work is the time from the scroll call to the marker drawn. Interval is the time between consecutive markers. Jank is frames longer than twice the median interval. | +| Memory | Physical footprint (`footprint `) five seconds after launch: the dirty memory the process owns, which is what iOS charges an app and kills it for. Resident size is not used: on the simulator it counts the shared dyld cache, which every process maps, so an empty app already shows 184 MB. | + +## Results + +iPhone 17 Pro simulator, Xcode 27, Apple silicon, Release builds, 5 runs. +Start-up here is from process start (the kernel's start time) to the marker, so +`simctl`'s own overhead is left out of both. + +| | Codename One, Compose | Codename One, Java | Compose Multiplatform | +|---|---|---|---| +| Cold start | 961 ms | 805 ms | 1008 ms | +| Warm start (median) | 580 ms | 572 ms | 604 ms | +| Executable, stripped | 16.8 MB | 8.9 MB | 22.9 MB | +| App bundle, executable stripped | 18.3 MB | 10.2 MB | 22.9 MB | +| Bundle without executable | 1.3 MB | 1.3 MB | 2.5 KB | +| Physical footprint | 47.0 MB | 47.0 MB | 48.0 MB | +| Recomposition, day change to drawn (median) | 4.58 ms | 1.49 ms | 19.24 ms | +| Recomposition, day change to drawn (p95) | 6.17 ms | 2.01 ms | 23.02 ms | +| Scroll, work per frame (median) | 4.04 ms | 1.76 ms | 17.03 ms | +| Scroll, work per frame (p95) | 6.60 ms | 2.68 ms | 18.21 ms | +| Scroll, frame interval (median) | 35.45 ms | 34.79 ms | 33.35 ms | +| Scroll, frame interval (p95) | 38.12 ms | 37.94 ms | 34.74 ms | +| Scroll, frames over 2x median | 0 of 144 | 0 of 144 | 0 of 144 | + +Runtime values are the median over 5 launches of each launch's median or p95. +An empty UIKit app measured 640-750 ms start-up and a 34 MB footprint on the +same simulator. + +**The bundles differ mostly in resources.** The CMP bundle holds only the +executable, `Info.plist` and its signature, because Skia and the fonts it uses +are linked in. The CN1 bundle holds 1.3 MB besides its executable: +`Assets.car` (570 KB), `material-design-font.ttf` (357 KB), +`iOSModernTheme.res` (213 KB), `default.metallib` (92 KB) and +`kotlin.kotlin_builtins` (29 KB). + +**Compose Multiplatform is the reference point, and a good one.** It is the +closest thing to Codename One's own model -- one codebase, one UI, shared +logic -- and it keeps pace with the display on every step here. The question +is how much CPU each runtime spends to get there. + +**Codename One runs the same Compose code in a quarter of the time.** A day +change takes 4.6 ms from state change to drawn on Codename One and 19.2 ms on +CMP; one card of scrolling takes 4.0 ms against 17.0 ms. Every side then waits +for the display: `scrollToItem` is issued once per drawn frame, so the frame +interval is about two display frames on each, and none dropped a frame against +its own median. These are simulator numbers on an Apple-silicon Mac. They show +where each runtime spends CPU in identical code. They do not show device frame +rates, GPU cost, or how a finger-driven fling feels. + +**The Java column is the same app without Kotlin, and it is faster again.** +`app/java/JavaBench.java` is the Keynote screens written against Codename One +directly -- the same programme generator, the same cards, the same steps timed +the same way, a 1 px marker painted after the list -- so it ships no Kotlin +standard library, no Compose runtime and no coroutines. The list is an +`InfiniteContainer`: cards are made a batch at a time as the scroll nears the +end, as `LazyColumn` composes only what comes into view, and a card once made +is rebound rather than rebuilt. A day change is 1.5 ms, thirteen times faster +than CMP; a scroll step is 1.8 ms, nearly ten times faster; the executable is +8.9 MB, 39% of CMP's; memory is level with both. + +The time gap is mostly the cost of the reactive model. A composition records +what it read, diffs what it emits against its slot table and hands the changes +to an applier. An imperative update sets three labels on a card that already +exists. The size gap is Kotlin: the Java build carries neither the Kotlin +standard library nor the Compose runtime. The Java card also reads as plainly: the components are made +once, `bind` fills them, and a test makes a card and presses its button +without a test rule or semantics tree. The developer guide's Kotlin +Multiplatform chapter shows the card both ways and a test for it. + +Building it found a framework bug every Codename One app on a notched device +paid: a Form with a Toolbar laid itself out again on every EDT cycle, forever, +because the safe-area padding it sets for one measurement announced a style +change that queued another layout. The idle app burned 14-26% CPU and 10-20 MB +of garbage. `Container.snapToSafeAreaInternal` now sets that padding silently. + +The first version of compose-compat took 42 ms per day change, and almost none +of it was work: the EDT sat idle for 83% of it. A state change waited for a +dispatched resumption, then for the snapshot to apply, then for frame +callbacks sent after paint -- three EDT cycles. Frames now run at the start of +the layout pass, as Android's Choreographer runs them before traversal, and +the recomposer resumes inline on the EDT, so change, layout and paint share +one cycle. + +**Start-up is the simulator's floor.** An empty UIKit application, with no +code of its own, takes as long to show its first frame as either side, so +neither has much start-up of its own left to lose there. Measure on a device +before quoting a difference. + +The Codename One executable includes the whole iOS port and the full Material +icon font, so the 6.1 MB difference is a floor. + +**Memory is level**, 47 MB on both Codename One builds against 48 MB. The Java heap holds +3.2 MB live after start-up, in about 57,000 objects. Loading the theme used to +leave 24,000 strings behind -- one per key and value, plus every colour and +padding built by concatenation, and a cache key per `Font.derive` call. The +theme loader now decodes into reused buffers and shares repeated values +(`ThemeStrings`), and a derived font is found without building a key, which +took 0.6 MB of live heap and about 2 MB of footprint off. Freed pages stay +resident on iOS, so collecting sooner does not help: garbage that is never +created is the only saving. Most of what remains is per thread (two +`Dispatchers.Default` workers that `flowOn` starts) and text rendering (the +glyph atlas and CoreText). + +Codename One also targets platforms CMP does not: watchOS, tvOS, and native +Windows and Linux binaries with no JVM. Keynote has not been built for those +here yet. + +## Not yet covered + +- `LazyColumn` and `LazyRow` with `reverseLayout = true` are refused at run + time. The lists compose only the items in view. +- Scrolling is measured as programmatic `scrollToItem` steps on the simulator, + not a touch fling on a device. diff --git a/scripts/kmp-bench/app/cmp/Info.plist b/scripts/kmp-bench/app/cmp/Info.plist new file mode 100644 index 00000000000..8b860c21dbd --- /dev/null +++ b/scripts/kmp-bench/app/cmp/Info.plist @@ -0,0 +1,14 @@ + + + +CFBundleExecutableKeynote +CFBundleIdentifiercom.example.kmpbench.cmp +CFBundleNameKeynote +CFBundlePackageTypeAPPL +CFBundleShortVersionString1.0 +CFBundleVersion1 +MinimumOSVersion15.0 +UIDeviceFamily1 +UILaunchScreen +CADisableMinimumFrameDurationOnPhone + diff --git a/scripts/kmp-bench/app/cmp/build.gradle.kts b/scripts/kmp-bench/app/cmp/build.gradle.kts new file mode 100644 index 00000000000..a1e59554fcf --- /dev/null +++ b/scripts/kmp-bench/app/cmp/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +plugins { + kotlin("multiplatform") version "2.2.10" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false + id("org.jetbrains.compose") version "1.9.0" apply false +} diff --git a/scripts/kmp-bench/app/cmp/composeApp/build.gradle.kts b/scripts/kmp-bench/app/cmp/composeApp/build.gradle.kts new file mode 100644 index 00000000000..83278771e26 --- /dev/null +++ b/scripts/kmp-bench/app/cmp/composeApp/build.gradle.kts @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +// The Compose Multiplatform build of the same commonMain the Codename One +// project compiles. Release builds only: the benchmark measures what ships. +import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType + +plugins { + kotlin("multiplatform") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose") +} + +kotlin { + jvm("desktop") + listOf(iosSimulatorArm64(), iosArm64()).forEach { target -> + target.binaries.executable(listOf(NativeBuildType.RELEASE)) { + entryPoint = "keynote.ios.main" + } + } + // The shared code is unchanged from the Codename One side, which needs no + // opt-in for the material3 top bar. + compilerOptions { + optIn.add("androidx.compose.material3.ExperimentalMaterial3Api") + } + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + } + val desktopMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.10.2") + } + } + } +} + +compose.desktop { + application { + mainClass = "keynote.desktop.MainKt" + } +} diff --git a/scripts/kmp-bench/app/cmp/composeApp/src/desktopMain/kotlin/Main.kt b/scripts/kmp-bench/app/cmp/composeApp/src/desktopMain/kotlin/Main.kt new file mode 100644 index 00000000000..e61e93ebf84 --- /dev/null +++ b/scripts/kmp-bench/app/cmp/composeApp/src/desktopMain/kotlin/Main.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package keynote.desktop + +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 keynote.App + +fun main() = application { + Window(onCloseRequest = ::exitApplication, title = "Keynote", + state = rememberWindowState(width = 412.dp, height = 892.dp)) { + App() + } +} diff --git a/scripts/kmp-bench/app/cmp/composeApp/src/iosMain/kotlin/Main.kt b/scripts/kmp-bench/app/cmp/composeApp/src/iosMain/kotlin/Main.kt new file mode 100644 index 00000000000..684342717ff --- /dev/null +++ b/scripts/kmp-bench/app/cmp/composeApp/src/iosMain/kotlin/Main.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package keynote.ios + +import androidx.compose.ui.window.ComposeUIViewController +import keynote.App +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDate +import platform.Foundation.NSStringFromClass +import platform.Foundation.timeIntervalSince1970 +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationDelegateProtocol +import platform.UIKit.UIApplicationDelegateProtocolMeta +import platform.UIKit.UIApplicationMain +import platform.UIKit.UIResponder +import platform.UIKit.UIResponderMeta +import platform.UIKit.UIScreen +import platform.UIKit.UIWindow +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue +import kotlinx.cinterop.autoreleasepool +import kotlinx.cinterop.cstr +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.toCValues +import kotlinx.cinterop.toKString +import platform.posix.getenv + +/// The whole iOS application: a delegate whose window shows the composition. +/// An executable rather than a framework inside an Xcode project, so the build +/// needs no project file; the .app is assembled by build_apps.sh. +@OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) +class AppDelegate @OverrideInit constructor() : UIResponder(), UIApplicationDelegateProtocol { + companion object : UIResponderMeta(), UIApplicationDelegateProtocolMeta + + private var mainWindow: UIWindow? = null + + override fun application(application: UIApplication, didFinishLaunchingWithOptions: Map?): Boolean { + // run_bench.sh launches with SIMCTL_CHILD_KMPBENCH_PERF=1 for the runtime measurements. + val perf = getenv("KMPBENCH_PERF")?.toKString() == "1" + val w = UIWindow(frame = UIScreen.mainScreen.bounds) + w.rootViewController = ComposeUIViewController { App(perf = perf) } + w.makeKeyAndVisible() + mainWindow = w + // After the launch method returns, as the Codename One side prints its + // marker from callSerially once its form is shown. + dispatch_async(dispatch_get_main_queue()) { + println("KMPBENCH first-frame " + (NSDate().timeIntervalSince1970 * 1000.0).toLong()) + } + return true + } +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +fun main(args: Array) { + memScoped { + val argv = (arrayOf("keynote") + args).map { it.cstr.ptr }.toCValues() + autoreleasepool { + UIApplicationMain(args.size + 1, argv, null, NSStringFromClass(AppDelegate)) + } + } +} diff --git a/scripts/kmp-bench/app/cmp/gradle.properties b/scripts/kmp-bench/app/cmp/gradle.properties new file mode 100644 index 00000000000..e36d638b6e3 --- /dev/null +++ b/scripts/kmp-bench/app/cmp/gradle.properties @@ -0,0 +1,3 @@ +kotlin.code.style=official +org.gradle.jvmargs=-Xmx3g +kotlin.native.ignoreDisabledTargets=true diff --git a/scripts/kmp-bench/app/cmp/settings.gradle.kts b/scripts/kmp-bench/app/cmp/settings.gradle.kts new file mode 100644 index 00000000000..cd383448492 --- /dev/null +++ b/scripts/kmp-bench/app/cmp/settings.gradle.kts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +rootProject.name = "keynote-cmp" +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } +} +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } +} +include(":composeApp") diff --git a/scripts/kmp-bench/app/cn1/Bench.java b/scripts/kmp-bench/app/cn1/Bench.java new file mode 100644 index 00000000000..810cc3b0c9e --- /dev/null +++ b/scripts/kmp-bench/app/cn1/Bench.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.example.kmpbench; + +import com.codename1.system.Lifecycle; + +/** + * Boots the Keynote hero application, the same Compose source the Compose + * Multiplatform side builds. All the work is in BenchEntry.kt: Compose content + * has to be created from Kotlin, where the compiler plugin makes the lambda + * composable. + */ +public class Bench extends Lifecycle { + @Override + public void runApp() { + BenchEntryKt.start(); + } +} diff --git a/scripts/kmp-bench/app/cn1/BenchEntry.kt b/scripts/kmp-bench/app/cn1/BenchEntry.kt new file mode 100644 index 00000000000..fccf31403b8 --- /dev/null +++ b/scripts/kmp-bench/app/cn1/BenchEntry.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.example.kmpbench + +import com.codename1.compose.ComposeForm +import com.codename1.ui.CN +import keynote.App + +/// Shows the application and prints the marker the harness times start-up +/// against, once the first frame with content has been painted. +fun start() { + // run_bench.sh launches with SIMCTL_CHILD_KMPBENCH_PERF=1 for the runtime measurements. + val perf = System.getenv("KMPBENCH_PERF") == "1" + val form = ComposeForm { App(perf = perf) } + form.show() + CN.callSerially { + println("KMPBENCH first-frame " + System.currentTimeMillis()) + } +} diff --git a/scripts/kmp-bench/app/java/JavaBench.java b/scripts/kmp-bench/app/java/JavaBench.java new file mode 100644 index 00000000000..5436a4d851f --- /dev/null +++ b/scripts/kmp-bench/app/java/JavaBench.java @@ -0,0 +1,771 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.example.javabench; + +import com.codename1.system.Lifecycle; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Font; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.InfiniteContainer; +import com.codename1.ui.Label; +import com.codename1.ui.Tabs; +import com.codename1.ui.TextField; +import com.codename1.ui.Toolbar; +import com.codename1.ui.animations.Animation; +import com.codename1.ui.animations.Motion; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.plaf.Border; +import com.codename1.ui.plaf.RoundRectBorder; +import com.codename1.ui.plaf.Style; +import java.util.ArrayList; +import java.util.List; + +/** + * The Keynote application written directly against Codename One: the same + * screens, data and benchmark steps as the Compose source in app/shared, with + * no Kotlin, no Compose runtime and no coroutines in the binary. run_bench.sh + * builds it as the third side of the comparison. + */ +public class JavaBench extends Lifecycle { + static final int SESSIONS_PER_DAY = 50; + + final List sessions = Programme.generate(); + final List favorites = new ArrayList(); + int day = 1; + String query = ""; + + Form form; + Tabs tabs; + SessionList schedule; + SessionList favoriteList; + Insights insights; + Marker marker; + Container stepHost; + Perf perf; + + @Override + public void runApp() { + form = new Form("KotlinConf-ish 2026", new BorderLayout()); + form.getToolbar().setTitleCentered(true); + // Material's top app bar title: 22 sp, where the iOS theme sets a large title. + form.getToolbar().getTitleComponent().getAllStyles().setFont(font("native:MainRegular", 22)); + tabs = new Tabs(Component.BOTTOM); + tabs.setSwipeActivated(false); + tabs.addTab("Schedule", FontImage.MATERIAL_DATE_RANGE, 4, scheduleTab()); + favoriteList = new SessionList(this); + tabs.addTab("Favorites", FontImage.MATERIAL_FAVORITE, 4, favoriteList); + insights = new Insights(this); + tabs.addTab("Insights", FontImage.MATERIAL_INFO, 4, insights); + tabs.addSelectionListener((old, now) -> { + form.setTitle(now == 2 ? "Insights" : "KotlinConf-ish 2026"); + if (now == 1) { + favoriteList.show(favoriteSessions(), "Tap the heart on a session to keep it here."); + } else if (now == 2) { + insights.open(); + } + }); + form.add(BorderLayout.CENTER, tabs); + form.show(); + // A Form focuses its first focusable component; the search field's + // blinking caret would then repaint the transparent field, and with it + // the whole tab, every half second. Compose does not focus it either. + form.setFocused(null); + CN.callSerially(() -> System.out.println("KMPBENCH first-frame " + System.currentTimeMillis())); + if ("1".equals(System.getenv("KMPBENCH_PERF"))) { + perf = new Perf(this); + perf.start(); + } + } + + private Container scheduleTab() { + TextField search = new TextField("", "Search talks and speakers", 20, TextField.ANY); + search.setHint("Search talks and speakers", FontImage.createMaterial(FontImage.MATERIAL_SEARCH, search.getHintLabel().getUnselectedStyle())); + search.getAllStyles().setBorder(RoundRectBorder.create().cornerRadius(5f).strokeColor(0x79747e).strokeOpacity(255).stroke(1, false)); + search.getAllStyles().setBgTransparency(0); + search.getAllStyles().setPaddingUnit(Style.UNIT_TYPE_DIPS); + search.getAllStyles().setPadding(0.6f, 0.6f, 2.5f, 2.5f); + search.getAllStyles().setMarginUnit(Style.UNIT_TYPE_DIPS); + search.getAllStyles().setMargin(1.3f, 1.3f, 2.5f, 2.5f); + // Material's outlined field is 56 dp; the iOS theme's field is taller. + search.setPreferredH(dp(56)); + search.addDataChangedListener((type, index) -> { + query = search.getText().trim(); + refreshSchedule(); + }); + Container days = new Container(new FlowLayout()); + days.getAllStyles().setPaddingUnit(Style.UNIT_TYPE_DIPS); + days.getAllStyles().setPadding(0, 0, 2.5f, 2.5f); + for (int d = 1; d <= 3; d++) { + days.add(new DayChip(this, d)); + } + schedule = new SessionList(this); + marker = new Marker(); + Container top = BoxLayout.encloseY(search, days); + Container tab = new Container(new BorderLayout()) { + @Override + public void layoutContainer() { + if (perf != null) { + perf.layoutStep(); + } + super.layoutContainer(); + } + }; + stepHost = tab; + tab.add(BorderLayout.NORTH, top); + tab.add(BorderLayout.CENTER, schedule); + tab.add(BorderLayout.SOUTH, marker); + refreshSchedule(); + return tab; + } + + void setDay(int d) { + day = d; + refreshSchedule(); + schedule.getParent().getComponentAt(0).repaint(); + } + + void refreshSchedule() { + List visible = new ArrayList(); + String q = query.toLowerCase(); + for (Session s : sessions) { + if (s.day == day && (q.length() == 0 || s.title.toLowerCase().indexOf(q) >= 0 + || s.speaker.toLowerCase().indexOf(q) >= 0)) { + visible.add(s); + } + } + schedule.show(visible, "No session matches \"" + query + "\"."); + } + + List favoriteSessions() { + List out = new ArrayList(); + for (Session s : sessions) { + if (favorites.contains(Integer.valueOf(s.id))) { + out.add(s); + } + } + return out; + } + + void toggleFavorite(Session s) { + Integer id = Integer.valueOf(s.id); + if (!favorites.remove(id)) { + favorites.add(id); + } + } + + static int dp(float v) { + return CN.convertToPixels(v * 0.15875f); + } + + private static final java.util.Map FONTS = new java.util.HashMap(); + + /** One native font per face and size, shared by every card. */ + static Font font(String face, float sp) { + String key = face + sp; + Font f = FONTS.get(key); + if (f == null) { + f = Font.createTrueTypeFont(face, sp * 0.15875f); + FONTS.put(key, f); + } + return f; + } + + static boolean dark() { + Boolean d = CN.isDarkMode(); + return d != null && d.booleanValue(); + } + + // --- data --------------------------------------------------------------- + + static final String[] TRACK_LABELS = {"Language", "Multiplatform", "Server", "Tooling", "UI"}; + static final int[] TRACK_COLORS = {0x7F52FF, 0x0095D5, 0x2E7D32, 0xE65100, 0xC2185B}; + + static final class Session { + int id; + String title; + String speaker; + String company; + int track; + int day; + int start; + int length; + String room; + String description; + + String timeLabel() { + return clock(start) + " - " + clock(start + length); + } + } + + static String clock(int minutes) { + int h = minutes / 60; + int m = minutes % 60; + return (h < 10 ? "0" : "") + h + ":" + (m < 10 ? "0" : "") + m; + } + + /** The same generator, seed and order as Data.kt, so both sides show the same 150 sessions. */ + static final class Programme { + static final String[] FIRST = {"Ada", "Linus", "Grace", "Alan", "Barbara", "Ken", "Margaret", "Dennis", "Frances", "Edsger", + "Radia", "Bjarne", "Sophie", "Guido", "Hedy", "James"}; + static final String[] LAST = {"Lovelace", "Hopper", "Turing", "Liskov", "Thompson", "Hamilton", "Ritchie", "Allen", + "Dijkstra", "Perlman", "Wilson", "Lamarr", "Gosling", "Kay", "Knuth", "Backus"}; + static final String[] COMPANIES = {"JetBrains", "Google", "Square", "Netflix", "Touchlab", "Cash App", "Shopify", "Zalando"}; + static final String[] TOPICS = {"Coroutines", "Flows", "Compose", "K2", "Ktor", "Gradle", "Serialization", "Wasm", + "Native interop", "Context parameters", "Value classes", "Multiplatform libraries", "Testing", "Performance", + "Accessibility", "Animations", "State hoisting", "Server-side Kotlin", "Build speed", "Code sharing"}; + static final String[] SHAPES = {"Deep dive into %s", "%s in production", "What's new in %s", "%s: lessons learned", + "Mastering %s", "%s from scratch", "The hidden cost of %s", "%s at scale"}; + static final String[] ROOMS = {"Auditorium", "Room 1", "Room 2", "Workshop A", "Lightning stage"}; + + private static long seed; + + private static int next(int n) { + seed = seed * 6364136223846793005L + 1442695040888963407L; + return (int) ((seed >>> 33) % n); + } + + static List generate() { + seed = 20261025L; + List out = new ArrayList(); + int id = 0; + for (int day = 1; day <= 3; day++) { + int start = 9 * 60; + int count = 0; + while (count < SESSIONS_PER_DAY) { + for (String room : ROOMS) { + if (count >= SESSIONS_PER_DAY) { + break; + } + String topic = TOPICS[next(TOPICS.length)]; + String title = replace(SHAPES[next(SHAPES.length)], topic); + String speaker = FIRST[next(FIRST.length)] + " " + LAST[next(LAST.length)]; + String company = COMPANIES[next(COMPANIES.length)]; + Session s = new Session(); + s.id = id++; + s.title = title; + s.speaker = speaker; + s.company = company; + s.track = next(TRACK_LABELS.length); + s.day = day; + s.start = start; + s.length = "Lightning stage".equals(room) ? 15 : room.startsWith("Workshop") ? 90 : 45; + s.room = room; + s.description = title + ". " + speaker + " of " + company + " walks through " + topic + + ": the model behind it, the mistakes teams make with it, and the patterns that hold up in real applications. " + + "Bring questions; the last ten minutes are open Q&A."; + out.add(s); + count++; + } + start += 60; + } + } + return out; + } + + private static String replace(String shape, String topic) { + int i = shape.indexOf("%s"); + return shape.substring(0, i) + topic + shape.substring(i + 2); + } + } + + // --- components --------------------------------------------------------- + + /** A Material filter chip: outlined, filled when selected. */ + static final class DayChip extends Button { + private final JavaBench app; + private final int day; + + DayChip(JavaBench app, int day) { + super("Day " + day); + this.app = app; + this.day = day; + Style s = getAllStyles(); + s.setFont(font("native:MainRegular", 14)); + s.setPaddingUnit(Style.UNIT_TYPE_DIPS); + s.setPadding(1, 1, 2.5f, 2.5f); + s.setMarginUnit(Style.UNIT_TYPE_DIPS); + s.setMargin(0, 0, 0, 1.3f); + s.setBorder(Border.createEmpty()); + s.setBgTransparency(0); + s.setFgColor(dark() ? 0xE6E1E5 : 0x1D1B20); + addActionListener(e -> app.setDay(day)); + } + + @Override + public void paintBackground(Graphics g) { + boolean on = app.day == day; + int r = dp(8); + g.setAntiAliased(true); + if (on) { + g.setColor(dark() ? 0x3A2D6B : 0xE9E1FF); + g.fillRoundRect(getX(), getY(), getWidth(), getHeight(), r * 2, r * 2); + } else { + g.setColor(0x79747E); + g.drawRoundRect(getX(), getY(), getWidth() - 1, getHeight() - 1, r * 2, r * 2); + } + } + } + + /** + * The scrolling list of cards: an InfiniteContainer, so cards are made a + * batch at a time as the scroll nears the end, as LazyColumn composes only + * what comes into view. A card, once made, is kept in a pool and rebound + * when the day or the query changes, as a lazy list reuses its item nodes. + */ + static final class SessionList extends InfiniteContainer { + private static final int BATCH = 8; + private final JavaBench app; + private final List pool = new ArrayList(); + private final Label empty = new Label(); + private List items = new ArrayList(); + + SessionList(JavaBench app) { + super(BATCH); + this.app = app; + getAllStyles().setPaddingUnit(Style.UNIT_TYPE_DIPS); + getAllStyles().setPadding(1.3f, 1.3f, 2.5f, 2.5f); + empty.getAllStyles().setAlignment(Component.CENTER); + empty.getAllStyles().setFgColor(0x49454F); + } + + @Override + public Component[] fetchComponents(int index, int amount) { + if (items.isEmpty()) { + return index == 0 ? new Component[] {empty} : new Component[0]; + } + int n = Math.max(0, Math.min(amount, items.size() - index)); + Component[] out = new Component[n]; + for (int i = 0; i < n; i++) { + while (pool.size() <= index + i) { + pool.add(new Card(app)); + } + Card c = pool.get(index + i); + c.bind(items.get(index + i)); + out[i] = c; + } + return out; + } + + void show(List list, String emptyText) { + items = list; + empty.setText(emptyText); + refresh(); + } + + /** The list's scrollToItem: card i at the top, made first if needed. */ + void scrollToCard(int i) { + int y = 0; + if (i < pool.size() && pool.get(i).getParent() == this) { + y = pool.get(i).getY() - getStyle().getPaddingTop(); + } else { + y = Integer.MAX_VALUE; + } + int max = Math.max(0, getScrollDimension().getHeight() - getHeight()); + setScrollY(Math.max(0, Math.min(y, max))); + } + } + + /** One session: track stripe, time and room, title, speaker, favorite heart. */ + static final class Card extends Container { + private final JavaBench app; + private final Label time = new Label(); + private final Label title = new Label(); + private final Label speaker = new Label(); + private final Button heart = new Button(); + private Session session; + private boolean shownFavorite; + + Card(JavaBench app) { + super(new BorderLayout()); + this.app = app; + Style s = getAllStyles(); + s.setBgTransparency(0); + s.setBorder(Border.createEmpty()); + s.setMarginUnit(Style.UNIT_TYPE_DIPS); + s.setMargin(0, 1.9f, 0, 0); + s.setPaddingUnit(Style.UNIT_TYPE_DIPS); + s.setPadding(1.9f, 1.9f, 2.9f, 0.6f); + style(time, "native:MainRegular", 12, 0x49454F); + style(title, "native:MainRegular", 16, 0x1D1B20); + style(speaker, "native:MainRegular", 12, 0x49454F); + title.getAllStyles().setMarginUnit(Style.UNIT_TYPE_DIPS); + title.getAllStyles().setMargin(0.6f, 0.3f, 0, 0); + heart.getAllStyles().setBgTransparency(0); + heart.getAllStyles().setBorder(Border.createEmpty()); + heart.addActionListener(e -> { + app.toggleFavorite(session); + updateHeart(); + }); + add(BorderLayout.CENTER, BoxLayout.encloseY(time, title, speaker)); + add(BorderLayout.EAST, BorderLayout.centerAbsolute(heart)); + addPointerReleasedListener(e -> { + if (session != null && !heart.contains(e.getX(), e.getY())) { + new Detail(app, session).show(); + } + }); + } + + private static void style(Label l, String face, float sp, int color) { + Style s = l.getAllStyles(); + s.setFont(font(face, sp)); + s.setFgColor(color); + s.setPadding(0, 0, 0, 0); + s.setMargin(0, 0, 0, 0); + s.setBgTransparency(0); + l.setEndsWith3Points(true); + } + + void bind(Session s) { + session = s; + time.setText(s.timeLabel() + " - " + s.room); + title.setText(s.title); + speaker.setText(s.speaker + ", " + s.company); + updateHeart(); + } + + private void updateHeart() { + boolean fav = app.favorites.contains(Integer.valueOf(session.id)); + Style s = heart.getAllStyles(); + s.setFgColor(fav ? 0xE91E63 : 0x49454F); + if (fav != shownFavorite || heart.getIcon() == null) { + shownFavorite = fav; + FontImage.setMaterialIcon(heart, fav ? FontImage.MATERIAL_FAVORITE : FontImage.MATERIAL_FAVORITE_BORDER, 3.8f); + } + } + + @Override + public void paintBackground(Graphics g) { + // The rounded card in the track colour, then the card colour over + // all of it but a 6 dp stripe, whose inner edge stays straight. + int x = getX(); + int y = getY(); + int w = getWidth(); + int h = getHeight(); + int r = dp(12); + int stripe = dp(6); + g.setAntiAliased(true); + g.setColor(TRACK_COLORS[session == null ? 0 : session.track]); + g.fillRoundRect(x, y, w, h, r * 2, r * 2); + g.setColor(dark() ? 0x1D1B20 : 0xF7F2FA); + g.fillRect(x + stripe, y, w - stripe - r, h); + g.fillRoundRect(x + w - r * 2, y, r * 2, h, r * 2, r * 2); + } + } + + /** Drawn after the list in every frame; the benchmark times a step until it paints. */ + static final class Marker extends Component { + int seq; + int drawnSeq; + long drawnAt; + + @Override + public void paint(Graphics g) { + drawnAt = System.nanoTime(); + drawnSeq = seq; + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(1, 1); + } + } + + /** Sessions per track for the selected day, as bars that grow in. */ + static final class Insights extends Container { + private final JavaBench app; + private final Label caption = new Label(); + private final Container legend = new Container(BoxLayout.y()); + private final Chart chart = new Chart(); + + Insights(JavaBench app) { + super(BoxLayout.y()); + this.app = app; + getAllStyles().setPaddingUnit(Style.UNIT_TYPE_DIPS); + getAllStyles().setPadding(3.2f, 3.2f, 3.2f, 3.2f); + caption.getAllStyles().setFont(font("native:MainBold", 16)); + add(caption); + add(chart); + add(legend); + } + + void open() { + int[] counts = new int[TRACK_LABELS.length]; + for (Session s : app.sessions) { + if (s.day == app.day) { + counts[s.track]++; + } + } + caption.setText("Sessions per track, day " + app.day); + legend.removeAll(); + for (int t = 0; t < counts.length; t++) { + Label name = new Label(TRACK_LABELS[t]); + Label n = new Label(String.valueOf(counts[t])); + n.getAllStyles().setFont(font("native:MainBold", 14)); + legend.add(BorderLayout.centerEastWest(name, n, null)); + } + if (!app.favorites.isEmpty()) { + Label fav = new Label("You have " + app.favorites.size() + " favorite sessions."); + fav.getAllStyles().setFgColor(0x7F52FF); + legend.add(fav); + } + chart.start(counts); + revalidate(); + } + } + + static final class Chart extends Component implements Animation { + private int[] counts = new int[0]; + private Motion grow; + + void start(int[] c) { + counts = c; + grow = Motion.createEaseInOutMotion(0, 1000, 700); + grow.start(); + getComponentForm().registerAnimated(this); + } + + @Override + public boolean animate() { + if (grow != null && grow.isFinished()) { + getComponentForm().deregisterAnimated(this); + } + return true; + } + + @Override + public void paint(Graphics g) { + int max = 1; + for (int n : counts) { + max = Math.max(max, n); + } + int gap = dp(16); + int r = dp(8) * 2; + int w = counts.length == 0 ? 0 : (getWidth() - gap * (counts.length - 1)) / counts.length; + float f = grow == null ? 1 : grow.getValue() / 1000f; + g.setAntiAliased(true); + for (int i = 0; i < counts.length; i++) { + int x = getX() + i * (w + gap); + g.setColor(0xE6E0E9); + g.fillRoundRect(x, getY(), w, getHeight(), r, r); + int h = (int) (getHeight() * counts[i] / (float) max * f); + g.setColor(TRACK_COLORS[i]); + g.fillRoundRect(x, getY() + getHeight() - h, w, h, r, r); + } + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.getDisplayWidth(), dp(220)); + } + } + + /** A session's own screen. */ + static final class Detail extends Form { + Detail(JavaBench app, Session s) { + super(TRACK_LABELS[s.track], BoxLayout.y()); + Form back = app.form; + getToolbar().setTitleCentered(true); + getToolbar().setBackCommand("", Toolbar.BackCommandPolicy.AS_ARROW, e -> back.showBack()); + getContentPane().getAllStyles().setPaddingUnit(Style.UNIT_TYPE_DIPS); + getContentPane().getAllStyles().setPadding(3.2f, 3.2f, 3.2f, 3.2f); + Label title = new Label(s.title); + title.getAllStyles().setFont(font("native:MainRegular", 24)); + add(title); + add(new Label(s.speaker + ", " + s.company)); + add(new Label("Day " + s.day + ", " + s.timeLabel(), FontImage.createMaterial(FontImage.MATERIAL_SCHEDULE, "Label", 3.5f))); + add(new Label(s.room, FontImage.createMaterial(FontImage.MATERIAL_PLACE, "Label", 3.5f))); + com.codename1.components.SpanLabel description = new com.codename1.components.SpanLabel(s.description); + add(description); + } + } + + /** + * The steps of Perf.kt, timed the same way: from the state change to the + * marker's paint, one frame between steps. + */ + static final class Perf implements Animation { + private final JavaBench app; + private final List recompose = new ArrayList(); + private final List work = new ArrayList(); + private final List frames = new ArrayList(); + private int phase; + private int step; + private int gap; + private boolean waiting; + private boolean requested; + private long t0; + private long last; + + Perf(JavaBench app) { + this.app = app; + } + + void start() { + System.out.println("KMPBENCH perf-start 0"); + gap = 10; + app.form.registerAnimated(this); + } + + @Override + public boolean animate() { + Marker m = app.marker; + if (waiting) { + if (m.drawnSeq < m.seq) { + return false; + } + waiting = false; + record(m.drawnAt); + // Perf.kt notices the paint in the next frame and steps in the + // one after; returning here keeps the same two frames per step. + return false; + } + if (gap > 0) { + gap--; + return false; + } + if (!requested) { + // The step runs at the start of the next cycle's layout pass, + // where a Compose frame runs: change, layout and paint then + // share one cycle on both sides. + requested = true; + app.stepHost.revalidateLater(); + } + return false; + } + + /** Called from the schedule tab's layout. */ + void layoutStep() { + if (requested && !waiting) { + requested = false; + next(); + } + } + + private void record(long drawnAt) { + long us = (drawnAt - t0) / 1000; + if (phase == 0) { + if (step > 30) { + recompose.add(Long.valueOf(us)); + } + } else if (phase >= 2) { + work.add(Long.valueOf(us)); + if (last != 0) { + frames.add(Long.valueOf((drawnAt - last) / 1000)); + } + last = drawnAt; + } + } + + private void mark() { + Marker m = app.marker; + m.seq++; + m.repaint(); + waiting = true; + } + + private void next() { + if (phase == 0) { + if (step == 330) { + report("recompose-median-us", pct(recompose, 50)); + report("recompose-p95-us", pct(recompose, 95)); + phase = 1; + step = 0; + app.setDay(1); + gap = 2; + return; + } + step++; + t0 = System.nanoTime(); + app.setDay(app.day % 3 + 1); + mark(); + return; + } + // phase 1 scrolls day 1 as a warm-up; phases 2..4 scroll days 1..3. + if (step == 0) { + app.schedule.scrollToCard(0); + app.schedule.repaint(); + last = 0; + step = 1; + gap = 1; + return; + } + if (step < SESSIONS_PER_DAY) { + t0 = System.nanoTime(); + app.schedule.scrollToCard(step); + app.schedule.repaint(); + step++; + mark(); + return; + } + if (phase == 4) { + app.form.deregisterAnimated(this); + long fm = pct(frames, 50); + report("scroll-frames", frames.size()); + report("scroll-frame-median-us", fm); + report("scroll-frame-p95-us", pct(frames, 95)); + int jank = 0; + for (Long f : frames) { + if (f.longValue() > 2 * fm) { + jank++; + } + } + report("scroll-jank-frames", jank); + report("scroll-work-median-us", pct(work, 50)); + report("scroll-work-p95-us", pct(work, 95)); + report("perf-done", 0); + return; + } + if (phase == 1) { + work.clear(); + frames.clear(); + } + phase++; + step = 0; + app.setDay(phase - 1); + gap = 2; + } + + @Override + public void paint(Graphics g) { + } + + private static long pct(List v, int p) { + Long[] s = v.toArray(new Long[v.size()]); + java.util.Arrays.sort(s); + return s[Math.min(s.length - 1, (s.length * p) / 100)].longValue(); + } + + private static void report(String name, long v) { + System.out.println("KMPBENCH " + name + " " + v); + } + } +} diff --git a/scripts/kmp-bench/app/prepare.sh b/scripts/kmp-bench/app/prepare.sh new file mode 100755 index 00000000000..0b366585132 --- /dev/null +++ b/scripts/kmp-bench/app/prepare.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Materialises all three sides of the Kotlin Multiplatform benchmark into a work +# directory, from ONE copy of the application. +# +# scripts/kmp-bench/app/prepare.sh --work /tmp/kbench [--maven-repo DIR] +# +# shared/commonMain is the application: plain Compose Multiplatform code with +# no Codename One import in it. Both projects compile that directory as their +# commonMain, so an edit cannot land on one side only. +# +# Outputs: +# /cn1/ a Codename One project generated from the shipping archetype, +# with the source in common/src/main/kmp (the compose profile) +# /cmp/ a JetBrains Compose Multiplatform project (desktop and iOS) +set -Eeuo pipefail +trap 'rc=$?; echo "prepare.sh: FAILED at line $LINENO (exit $rc)" >&2' ERR + +WORK="" +CN1_VERSION="${CN1_VERSION:-}" +MAVEN_REPO_LOCAL="${MAVEN_REPO_LOCAL:-}" +while [ $# -gt 0 ]; do + case "$1" in + --work) WORK="$2"; shift 2 ;; + --cn1-version) CN1_VERSION="$2"; shift 2 ;; + --maven-repo) MAVEN_REPO_LOCAL="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$WORK" ] || { echo "usage: prepare.sh --work

" >&2; exit 2; } +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../../.." && pwd)" +if [ -z "$CN1_VERSION" ]; then + CN1_VERSION="$(sed -n 's:.*\(.*-SNAPSHOT\).*:\1:p' "$REPO/maven/pom.xml" | head -1)" +fi +MVN_REPO_ARG="" +[ -n "$MAVEN_REPO_LOCAL" ] && MVN_REPO_ARG="-Dmaven.repo.local=$MAVEN_REPO_LOCAL" + +rm -rf "$WORK/cn1" "$WORK/cmp" "$WORK/java" +mkdir -p "$WORK" + +# --- Codename One ----------------------------------------------------------- + +( cd "$WORK" && mvn -B -q $MVN_REPO_ARG archetype:generate \ + -DarchetypeArtifactId=cn1app-archetype -DarchetypeGroupId=com.codenameone \ + -DarchetypeVersion="$CN1_VERSION" -DgroupId=com.example.kmpbench -DartifactId=cn1 \ + -Dversion=1.0-SNAPSHOT -DmainName=Bench -DinteractiveMode=false ) +COMMON="$WORK/cn1/common" +cp "$HERE/cn1/Bench.java" "$COMMON/src/main/java/com/example/kmpbench/Bench.java" +mkdir -p "$COMMON/src/main/kotlin/com/example/kmpbench" "$COMMON/src/main/kmp/commonMain" +cp "$HERE/cn1/BenchEntry.kt" "$COMMON/src/main/kotlin/com/example/kmpbench/" +cp -R "$HERE/shared/commonMain/kotlin" "$COMMON/src/main/kmp/commonMain/kotlin" +echo "codename1.arg.ios.newStorageLocation=true" >> "$COMMON/codenameone_settings.properties" + +# --- Codename One, written in Java ------------------------------------------ +# The same screens over Codename One directly: no Kotlin sources, so neither +# the Kotlin standard library nor the Compose runtime is in the application. + +( cd "$WORK" && mvn -B -q $MVN_REPO_ARG archetype:generate \ + -DarchetypeArtifactId=cn1app-archetype -DarchetypeGroupId=com.codenameone \ + -DarchetypeVersion="$CN1_VERSION" -DgroupId=com.example.javabench -DartifactId=java \ + -Dversion=1.0-SNAPSHOT -DmainName=JavaBench -DinteractiveMode=false ) +cp "$HERE/java/JavaBench.java" "$WORK/java/common/src/main/java/com/example/javabench/JavaBench.java" +echo "codename1.arg.ios.newStorageLocation=true" >> "$WORK/java/common/codenameone_settings.properties" + +# --- Compose Multiplatform -------------------------------------------------- + +cp -R "$HERE/cmp/." "$WORK/cmp/" +mkdir -p "$WORK/cmp/composeApp/src" +cp -R "$HERE/shared/commonMain" "$WORK/cmp/composeApp/src/commonMain" +echo "prepared $WORK (cn1 $CN1_VERSION)" diff --git a/scripts/kmp-bench/app/shared/commonMain/kotlin/keynote/App.kt b/scripts/kmp-bench/app/shared/commonMain/kotlin/keynote/App.kt new file mode 100644 index 00000000000..02185b5a036 --- /dev/null +++ b/scripts/kmp-bench/app/shared/commonMain/kotlin/keynote/App.kt @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package keynote + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.ui.draw.drawBehind +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/// What the application shows, hoisted so the benchmark can drive it. +class AppState { + var tab by mutableIntStateOf(0) + var day by mutableIntStateOf(1) + var query by mutableStateOf("") + var selected by mutableStateOf(null) + val favorites = mutableStateListOf() + + // Benchmark hooks (Perf.kt); unused in a normal launch. + var list: LazyListState? = null + var tick by mutableIntStateOf(0) + var drawnSeq = 0 + var drawnAt = 0L + + fun toggleFavorite(id: Int) { + if (!favorites.remove(id)) favorites.add(id) + } +} + +private val Brand = Color(0xFF7F52FF) + +@Composable +fun App(state: AppState = remember { AppState() }, perf: Boolean = false) { + val dark = isSystemInDarkTheme() + val scheme = if (dark) darkColorScheme(primary = Color(0xFFB9A4FF), secondaryContainer = Color(0xFF3A2D6B)) + else lightColorScheme(primary = Brand, secondaryContainer = Color(0xFFE9E1FF)) + MaterialTheme(colorScheme = scheme) { + val sessions by remember { Programme.load() }.collectAsState(emptyList()) + if (perf && sessions.isNotEmpty()) LaunchedEffect(Unit) { runPerf(state) } + val selected = state.selected + if (selected != null) { + SessionDetail(selected, state.favorites.contains(selected.id), { state.toggleFavorite(selected.id) }) { + state.selected = null + } + return@MaterialTheme + } + Scaffold( + topBar = { + CenterAlignedTopAppBar(title = { Text(if (state.tab == 2) "Insights" else "KotlinConf-ish 2026", + fontWeight = FontWeight.SemiBold) }) + }, + bottomBar = { + NavigationBar { + NavigationBarItem(state.tab == 0, { state.tab = 0 }, { Icon(Icons.Default.DateRange, null) }, + label = { Text("Schedule") }, modifier = Modifier.testTag("tab-schedule")) + NavigationBarItem(state.tab == 1, { state.tab = 1 }, { Icon(Icons.Default.Favorite, null) }, + label = { Text("Favorites") }, modifier = Modifier.testTag("tab-favorites")) + NavigationBarItem(state.tab == 2, { state.tab = 2 }, { Icon(Icons.Default.Info, null) }, + label = { Text("Insights") }, modifier = Modifier.testTag("tab-insights")) + } + } + ) { padding -> + Box(Modifier.padding(padding).fillMaxSize()) { + if (sessions.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + } else when (state.tab) { + 0 -> Schedule(sessions, state) + 1 -> SessionList(sessions.filter { state.favorites.contains(it.id) }, state, + empty = "Tap the heart on a session to keep it here.") + else -> Insights(sessions, state) + } + } + } + } +} + +@Composable +private fun Schedule(sessions: List, state: AppState) { + Column(Modifier.fillMaxSize()) { + OutlinedTextField(state.query, { state.query = it }, Modifier.fillMaxWidth().padding(16.dp, 8.dp).testTag("search"), + placeholder = { Text("Search talks and speakers") }, leadingIcon = { Icon(Icons.Default.Search, null) }, + singleLine = true, shape = RoundedCornerShape(28.dp)) + LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(listOf(1, 2, 3)) { d -> + FilterChip(state.day == d, { state.day = d }, { Text("Day $d") }, Modifier.testTag("day-$d")) + } + } + val q = state.query.trim() + val visible = sessions.filter { + it.day == state.day && (q.isEmpty() || it.title.contains(q, ignoreCase = true) || + it.speaker.name.contains(q, ignoreCase = true)) + } + val listState = rememberLazyListState() + state.list = listState + Box(Modifier.weight(1f)) { + SessionList(visible, state, empty = "No session matches \"$q\".", listState = listState) + } + // Drawn after the list in every frame that follows a benchmark step. + val seq = state.tick + Box(Modifier.size(1.dp).drawBehind { state.drawnAt = nowNanos(); state.drawnSeq = seq }) + } +} + +@Composable +private fun SessionList(sessions: List, state: AppState, empty: String, + listState: LazyListState = rememberLazyListState()) { + if (sessions.isEmpty()) { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text(empty, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + return + } + LazyColumn(Modifier.fillMaxSize().testTag("list"), state = listState, contentPadding = PaddingValues(16.dp, 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(sessions, key = { it.id }) { s -> + SessionCard(s, state.favorites.contains(s.id), { state.toggleFavorite(s.id) }) { state.selected = s } + } + } +} + +@Composable +fun SessionCard(s: Session, favorite: Boolean, onFavorite: () -> Unit, onOpen: () -> Unit) { + Card(onOpen, Modifier.fillMaxWidth().testTag("session-${s.id}"), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow)) { + Row(Modifier.padding(start = 0.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.width(6.dp).height(84.dp).background(Color(s.track.color))) + Column(Modifier.weight(1f).padding(12.dp)) { + Text(s.timeLabel + " - " + s.room, style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + Text(s.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.height(2.dp)) + Text(s.speaker.name + ", " + s.speaker.company, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + FavoriteButton(favorite, onFavorite, Modifier.testTag("fav-${s.id}")) + } + } +} + +@Composable +private fun FavoriteButton(favorite: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val scale by animateFloatAsState(if (favorite) 1f else 0.8f) + IconButton(onClick, modifier) { + Icon(if (favorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + if (favorite) "Remove from favorites" else "Add to favorites", + Modifier.size((24 * scale).dp), + tint = if (favorite) Color(0xFFE91E63) else MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun SessionDetail(s: Session, favorite: Boolean, onFavorite: () -> Unit, onBack: () -> Unit) { + Scaffold(topBar = { + CenterAlignedTopAppBar(title = { Text(s.track.label) }, + navigationIcon = { IconButton(onBack, Modifier.testTag("back")) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + actions = { FavoriteButton(favorite, onFavorite) }) + }) { padding -> + Column(Modifier.padding(padding).padding(20.dp).fillMaxWidth()) { + Text(s.title, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(48.dp).background(Color(s.track.color), CircleShape), contentAlignment = Alignment.Center) { + Text(s.speaker.name.split(" ").joinToString("") { it.take(1) }, color = Color.White, + fontWeight = FontWeight.Bold) + } + Spacer(Modifier.width(12.dp)) + Column { + Text(s.speaker.name, style = MaterialTheme.typography.titleMedium) + Text(s.speaker.company, style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + DetailLine(Icons.Default.Schedule, "Day ${s.day}, ${s.timeLabel}") + DetailLine(Icons.Default.Place, s.room) + Spacer(Modifier.height(16.dp)) + Text(s.description, style = MaterialTheme.typography.bodyLarge) + } + } +} + +@Composable +private fun DetailLine(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) { + Row(Modifier.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(12.dp)) + Text(text, style = MaterialTheme.typography.bodyLarge) + } +} + +/// Sessions per track, as bars that grow in when the tab opens. +@Composable +private fun Insights(sessions: List, state: AppState) { + var shown by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { shown = true } + val grow by animateFloatAsState(if (shown) 1f else 0f, tween(700)) + val counts = Track.entries.map { t -> t to sessions.count { it.track == t && it.day == state.day } } + val max = (counts.maxOfOrNull { it.second } ?: 1).coerceAtLeast(1) + Column(Modifier.fillMaxSize().padding(20.dp)) { + Text("Sessions per track, day ${state.day}", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(16.dp)) + val bar = MaterialTheme.colorScheme.surfaceContainerHighest + Canvas(Modifier.fillMaxWidth().height(220.dp).testTag("chart")) { + val gap = 16.dp.toPx() + val w = (size.width - gap * (counts.size - 1)) / counts.size + counts.forEachIndexed { i, (track, n) -> + val x = i * (w + gap) + drawRoundRect(bar, Offset(x, 0f), Size(w, size.height), CornerRadius(8.dp.toPx())) + val h = size.height * n / max * grow + drawRoundRect(Color(track.color), Offset(x, size.height - h), Size(w, h), CornerRadius(8.dp.toPx())) + } + } + Spacer(Modifier.height(12.dp)) + counts.forEach { (track, n) -> + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(12.dp).background(Color(track.color), CircleShape)) + Spacer(Modifier.width(8.dp)) + Text(track.label, Modifier.weight(1f)) + Text("$n", fontWeight = FontWeight.SemiBold) + } + } + AnimatedVisibility(state.favorites.isNotEmpty()) { + Text("You have ${state.favorites.size} favorite sessions.", Modifier.padding(top = 12.dp), + color = MaterialTheme.colorScheme.primary) + } + } +} diff --git a/scripts/kmp-bench/app/shared/commonMain/kotlin/keynote/Data.kt b/scripts/kmp-bench/app/shared/commonMain/kotlin/keynote/Data.kt new file mode 100644 index 00000000000..c061c7bbf57 --- /dev/null +++ b/scripts/kmp-bench/app/shared/commonMain/kotlin/keynote/Data.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package keynote + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn + +enum class Track(val label: String, val color: Long) { + Language("Language", 0xFF7F52FF), + Multiplatform("Multiplatform", 0xFF0095D5), + Server("Server", 0xFF2E7D32), + Tooling("Tooling", 0xFFE65100), + UI("UI", 0xFFC2185B) +} + +data class Speaker(val name: String, val company: String) + +data class Session( + val id: Int, + val title: String, + val speaker: Speaker, + val track: Track, + val day: Int, + val startMinutes: Int, + val lengthMinutes: Int, + val room: String, + val description: String +) { + val timeLabel: String get() = clock(startMinutes) + " - " + clock(startMinutes + lengthMinutes) +} + +fun clock(minutes: Int): String { + val h = minutes / 60 + val m = minutes % 60 + return (if (h < 10) "0" else "") + h + ":" + (if (m < 10) "0" else "") + m +} + +/// The conference programme. Generated from a fixed seed, so every build and +/// every run of the benchmark sees the same 150 sessions. +object Programme { + private val first = listOf("Ada", "Linus", "Grace", "Alan", "Barbara", "Ken", "Margaret", "Dennis", "Frances", "Edsger", + "Radia", "Bjarne", "Sophie", "Guido", "Hedy", "James") + private val last = listOf("Lovelace", "Hopper", "Turing", "Liskov", "Thompson", "Hamilton", "Ritchie", "Allen", + "Dijkstra", "Perlman", "Wilson", "Lamarr", "Gosling", "Kay", "Knuth", "Backus") + private val companies = listOf("JetBrains", "Google", "Square", "Netflix", "Touchlab", "Cash App", "Shopify", "Zalando") + private val topics = listOf("Coroutines", "Flows", "Compose", "K2", "Ktor", "Gradle", "Serialization", "Wasm", + "Native interop", "Context parameters", "Value classes", "Multiplatform libraries", "Testing", "Performance", + "Accessibility", "Animations", "State hoisting", "Server-side Kotlin", "Build speed", "Code sharing") + private val shapes = listOf("Deep dive into %s", "%s in production", "What's new in %s", "%s: lessons learned", + "Mastering %s", "%s from scratch", "The hidden cost of %s", "%s at scale") + private val rooms = listOf("Auditorium", "Room 1", "Room 2", "Workshop A", "Lightning stage") + + val sessions: List by lazy { generate() } + + private fun generate(): List { + var seed = 20261025L + fun next(n: Int): Int { + seed = (seed * 6364136223846793005L + 1442695040888963407L) + return ((seed ushr 33) % n).toInt() + } + val out = ArrayList() + var id = 0 + for (day in 1..3) { + var start = 9 * 60 + while (out.count { it.day == day } < 50) { + for (room in rooms) { + if (out.count { it.day == day } >= 50) break + val topic = topics[next(topics.size)] + val title = shapes[next(shapes.size)].replace("%s", topic) + val speaker = Speaker(first[next(first.size)] + " " + last[next(last.size)], companies[next(companies.size)]) + val len = if (room == "Lightning stage") 15 else if (room.startsWith("Workshop")) 90 else 45 + out.add(Session(id++, title, speaker, Track.entries[next(Track.entries.size)], day, start, len, room, + "$title. ${speaker.name} of ${speaker.company} walks through $topic: the model behind it, " + + "the mistakes teams make with it, and the patterns that hold up in real applications. " + + "Bring questions; the last ten minutes are open Q&A.")) + } + start += 60 + } + } + return out + } + + /// The programme as a feed would deliver it: off the main thread, after a + /// network-like delay. + fun load(): Flow> = flow { + delay(150) + emit(sessions) + }.flowOn(Dispatchers.Default) +} diff --git a/scripts/kmp-bench/app/shared/commonMain/kotlin/keynote/Perf.kt b/scripts/kmp-bench/app/shared/commonMain/kotlin/keynote/Perf.kt new file mode 100644 index 00000000000..ba03c737cba --- /dev/null +++ b/scripts/kmp-bench/app/shared/commonMain/kotlin/keynote/Perf.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package keynote + +import androidx.compose.runtime.withFrameNanos +import kotlin.time.TimeSource + +/// CPU-bound measurements run inside the app, identical on both sides. Each +/// result is printed as "KMPBENCH " for run_bench.sh. +/// +/// Time is taken from a state change to the moment a 1 dp marker after the list +/// is drawn (`AppState.drawnAt`), so it covers recomposition, layout and drawing +/// of that frame. The simulator paces frames to its display, so frame interval +/// alone mostly shows vsync; the change-to-drawn time is the work itself. +private val origin = TimeSource.Monotonic.markNow() +internal fun nowNanos(): Long = origin.elapsedNow().inWholeNanoseconds + +private suspend fun awaitDrawn(state: AppState, seq: Int) { + var guard = 0 + while (state.drawnSeq < seq) { + if (guard++ == 60) { println("KMPBENCH perf-stall " + seq); throw IllegalStateException("marker not drawn") } + withFrameNanos { } + } +} + +private fun pct(v: List, p: Int): Long { + val s = v.sorted() + return s[minOf(s.size - 1, (s.size * p) / 100)] +} + +private fun report(name: String, v: Long) = println("KMPBENCH $name $v") + +private suspend fun recompose(state: AppState, n: Int): List { + val out = ArrayList() + for (i in 0 until n) { + withFrameNanos { } + val t0 = nowNanos() + state.day = state.day % 3 + 1 + val seq = ++state.tick + awaitDrawn(state, seq) + out.add((state.drawnAt - t0) / 1000) + } + return out +} + +/// Scrolls the list of the current day one card per frame, to the end. +private suspend fun scrollDay(state: AppState, work: MutableList, frames: MutableList) { + val list = state.list ?: return + list.scrollToItem(0) + withFrameNanos { } + val count = 50 // sessions per day, Data.kt + var last = 0L + for (i in 1 until count) { + withFrameNanos { } + val t0 = nowNanos() + list.scrollToItem(i) + val seq = ++state.tick + awaitDrawn(state, seq) + work.add((state.drawnAt - t0) / 1000) + if (last != 0L) frames.add((state.drawnAt - last) / 1000) + last = state.drawnAt + } +} + +suspend fun runPerf(state: AppState) { + println("KMPBENCH perf-start 0") + repeat(10) { withFrameNanos { } } + recompose(state, 30) // warm-up, discarded + val r = recompose(state, 300) + report("recompose-median-us", pct(r, 50)); report("recompose-p95-us", pct(r, 95)) + state.day = 1; withFrameNanos { }; withFrameNanos { } + scrollDay(state, ArrayList(), ArrayList()) // warm-up, discarded + val work = ArrayList(); val frames = ArrayList() + for (d in 1..3) { + state.day = d; withFrameNanos { }; withFrameNanos { } + scrollDay(state, work, frames) + } + val fm = pct(frames, 50) + report("scroll-frames", frames.size.toLong()) + report("scroll-frame-median-us", fm); report("scroll-frame-p95-us", pct(frames, 95)) + report("scroll-jank-frames", frames.count { it > 2 * fm }.toLong()) + report("scroll-work-median-us", pct(work, 50)); report("scroll-work-p95-us", pct(work, 95)) + report("perf-done", 0) +} diff --git a/scripts/kmp-bench/chart.py b/scripts/kmp-bench/chart.py new file mode 100755 index 00000000000..24dcef69059 --- /dev/null +++ b/scripts/kmp-bench/chart.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. +# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +# +# This code is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 2 only, as +# published by the Free Software Foundation. Codename One designates this +# particular file as subject to the "Classpath" exception as provided +# by Oracle in the LICENSE file that accompanied this code. +# +# This code is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# version 2 for more details (a copy is included in the LICENSE file that +# accompanied this code). +# +# You should have received a copy of the GNU General Public License version +# 2 along with this work; if not, write to the Free Software Foundation, +# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Please contact Codename One through http://www.codenameone.com/ if you +# need additional information or have any questions. +# +"""Draws results.json as the guide's chart, every metric relative to CMP. + +Each metric is one group on the same axis: Compose Multiplatform is the dashed +100% line, and the two bars are Codename One running the same Compose source +and Codename One running the same screens written in Java, each as a share of +it. A bar half as long means half the size or half the time. The absolute +values are printed beside each bar. + + chart.py results.json out.svg +""" +import json +import sys + +FONT = 'font-family="Arial, sans-serif"' +SERIES = [("codename_one", "Codename One, same Compose source", "#b5651d"), + ("codename_one_java", "Codename One, the same screens in Java", "#2e7d5b")] + + +def rows(r): + """(label, cn1 value, cmp value, formatter) for every metric present.""" + mb = lambda b: "%.1f MB" % (b / 1048576.0) + ms = lambda v: "%.1f ms" % v if v < 10 else "%d ms" % round(v) + us = lambda k: (lambda x: x["perf_median_of_runs_us"].get(k, 0) / 1000.0) + key = lambda k: (lambda x: x.get(k)) + spec = [ + ("Executable, stripped", key("executable_stripped_bytes"), mb), + ("App bundle", key("bundle_bytes"), mb), + ("Physical footprint", key("footprint_bytes"), mb), + ("Cold start", key("cold_start_ms"), ms), + ("Warm start, median", key("warm_start_median_ms"), ms), + ("Recomposition, median", us("recompose-median-us"), ms), + ("Recomposition, p95", us("recompose-p95-us"), ms), + ("Scroll work per frame, median", us("scroll-work-median-us"), ms), + ("Scroll work per frame, p95", us("scroll-work-p95-us"), ms), + ("Scroll frame interval, median", us("scroll-frame-median-us"), ms), + ] + base = r["compose_multiplatform"] + out = [] + for label, g, f in spec: + vals = [g(r[k]) if k in r else None for k, _, _ in SERIES] + if g(base) and all(vals): + out.append((label, vals, g(base), f)) + return out + + +def svg(r): + data = rows(r) + left, axis_w, bar_h, group_h, top = 250, 400, 13, 36, 92 + scale_max = max(1.25, max(c / m for _, vals, m, _ in data for c in vals) * 1.1) + px = lambda share: axis_w * share / scale_max + h = top + group_h * len(data) + 40 + w = left + axis_w + 220 + out = ['' % (w, h, w, h), + ' ' % (w, h), + ' ' + 'Keynote on an iPhone simulator, relative to Compose Multiplatform' % (w / 2, FONT), + ' ' + 'The dashed line is Compose Multiplatform (100%%); each bar is a share of it. ' + 'Shorter is better on every row.' % (w / 2, FONT)] + lx = 30 + for _, name, color in SERIES: + out.append(' ' % (lx, color)) + out.append(' %s' % (lx + 18, FONT, name)) + lx += 18 + 7 * len(name) + 30 + for i, (label, vals, m, f) in enumerate(data): + y = top + i * group_h + out.append(' %s' + % (y + 17, FONT, label)) + for j, ((_, _, color), c) in enumerate(zip(SERIES, vals)): + by = y + j * (bar_h + 2) + share = c / m + out.append(' ' % (left, by, px(share), bar_h, color)) + out.append(' %d%% (%s vs %s)' + % (left + max(px(share), px(1)) + 8, by + 10, FONT, round(share * 100), f(c), f(m))) + x100 = left + px(1) + out.append(' ' % (x100, top - 6, x100, top + group_h * len(data) - 6)) + out.append(' ' + 'Compose Multiplatform = 100%%' % (x100, top + group_h * len(data) + 10, FONT)) + out.append('') + return "\n".join(out) + "\n" + + +if __name__ == "__main__": + with open(sys.argv[1]) as f: + results = json.load(f) + with open(sys.argv[2], "w") as f: + f.write(svg(results)) diff --git a/scripts/kmp-bench/results.json b/scripts/kmp-bench/results.json new file mode 100644 index 00000000000..3e9bb61fec7 --- /dev/null +++ b/scripts/kmp-bench/results.json @@ -0,0 +1,77 @@ +{ + "codename_one": { + "cold_start_ms": 961, + "warm_start_median_ms": 580.0, + "starts_ms": [ + 961, + 621, + 592, + 566, + 568 + ], + "executable_stripped_bytes": 17617384, + "bundle_bytes": 19152896, + "bundle_without_executable_bytes": 1405874, + "footprint_bytes": 49283072, + "perf_median_of_runs_us": { + "recompose-p95-us": 6173, + "scroll-work-p95-us": 6601, + "recompose-median-us": 4576, + "scroll-frames": 144, + "scroll-work-median-us": 4039, + "scroll-frame-p95-us": 38122, + "scroll-frame-median-us": 35447, + "scroll-jank-frames": 0 + } + }, + "codename_one_java": { + "cold_start_ms": 805, + "warm_start_median_ms": 572.0, + "starts_ms": [ + 805, + 663, + 567, + 573, + 571 + ], + "executable_stripped_bytes": 9354120, + "bundle_bytes": 10731520, + "bundle_without_executable_bytes": 1324542, + "footprint_bytes": 49283072, + "perf_median_of_runs_us": { + "recompose-p95-us": 2013, + "scroll-work-p95-us": 2677, + "recompose-median-us": 1487, + "scroll-frames": 144, + "scroll-work-median-us": 1759, + "scroll-frame-p95-us": 37944, + "scroll-frame-median-us": 34789, + "scroll-jank-frames": 0 + } + }, + "compose_multiplatform": { + "cold_start_ms": 1008, + "warm_start_median_ms": 604.0, + "starts_ms": [ + 1008, + 713, + 597, + 603, + 605 + ], + "executable_stripped_bytes": 24041184, + "bundle_bytes": 24051712, + "bundle_without_executable_bytes": 2510, + "footprint_bytes": 50331648, + "perf_median_of_runs_us": { + "recompose-p95-us": 23015, + "scroll-work-p95-us": 18207, + "recompose-median-us": 19238, + "scroll-frames": 144, + "scroll-work-median-us": 17030, + "scroll-frame-p95-us": 34740, + "scroll-frame-median-us": 33349, + "scroll-jank-frames": 0 + } + } +} \ No newline at end of file diff --git a/scripts/kmp-bench/run_bench.sh b/scripts/kmp-bench/run_bench.sh new file mode 100755 index 00000000000..dbd062df04d --- /dev/null +++ b/scripts/kmp-bench/run_bench.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. +# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +# +# This code is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 2 only, as +# published by the Free Software Foundation. Codename One designates this +# particular file as subject to the "Classpath" exception as provided +# by Oracle in the LICENSE file that accompanied this code. +# +# This code is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# version 2 for more details (a copy is included in the LICENSE file that +# accompanied this code). +# +# You should have received a copy of the GNU General Public License version +# 2 along with this work; if not, write to the Free Software Foundation, +# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Please contact Codename One through http://www.codenameone.com/ if you +# need additional information or have any questions. +# +# Builds the Keynote app twice from the same Compose source -- once with +# Codename One (compose-compat, ParparVM) and once with Compose Multiplatform +# (Kotlin/Native + Skia) -- and a third time from app/java, the same screens +# written in Java over Codename One directly. Runs all three on one iOS +# simulator and writes results.json and results.md into the work directory. +# +# run_bench.sh --work [--runs N] [--device ] [--maven-repo ] +# +# Start-up is process start to marker: each app prints "KMPBENCH first-frame +# " from the first main-thread turn after its UI is installed, and the +# start is the kernel's start time for the process, so simctl's own latency is +# in neither. Compose Multiplatform's marker runs before Skia has drawn, which +# favours it. Memory is physical footprint (footprint(1)) a few seconds after +# launch; resident size counts the shared dyld cache on the simulator. Run 1 of +# each is cold (first launch after install) and reported separately. +set -Eeuo pipefail +trap 'rc=$?; echo "run_bench.sh: FAILED at line $LINENO (exit $rc)" >&2' ERR + +WORK=""; RUNS=5; DEVICE=""; MAVEN_REPO_LOCAL="${MAVEN_REPO_LOCAL:-}" +while [ $# -gt 0 ]; do + case "$1" in + --work) WORK="$2"; shift 2 ;; + --runs) RUNS="$2"; shift 2 ;; + --device) DEVICE="$2"; shift 2 ;; + --maven-repo) MAVEN_REPO_LOCAL="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$WORK" ] || { echo "usage: run_bench.sh --work [--runs N]" >&2; exit 2; } +HERE="$(cd "$(dirname "$0")" && pwd)" +mkdir -p "$WORK"; WORK="$(cd "$WORK" && pwd)" +MVN_REPO_ARG="" +[ -n "$MAVEN_REPO_LOCAL" ] && MVN_REPO_ARG="-Dmaven.repo.local=$MAVEN_REPO_LOCAL" +: "${JAVA17_HOME:?JAVA17_HOME must name a JDK 17}" + +"$HERE/app/prepare.sh" --work "$WORK" ${MAVEN_REPO_LOCAL:+--maven-repo "$MAVEN_REPO_LOCAL"} + +echo "== Codename One: translate and build" +( cd "$WORK/cn1" && JAVA_HOME="$JAVA17_HOME" mvn -B -q $MVN_REPO_ARG package -DskipTests \ + -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source -Dopen=false ) > "$WORK/cn1-build.log" 2>&1 +SRC="$(dirname "$(find "$WORK/cn1/common/target" -maxdepth 2 -name Bench.xcodeproj | head -1)")" +( cd "$SRC" && xcodebuild -project Bench.xcodeproj -scheme Bench -configuration Release \ + -sdk iphonesimulator -arch arm64 -derivedDataPath "$WORK/dd" CODE_SIGNING_ALLOWED=NO build ) \ + > "$WORK/cn1-xcode.log" 2>&1 +CN1_APP="$WORK/dd/Build/Products/Release-iphonesimulator/Bench.app" +CN1_EXE="$CN1_APP/Bench" +CN1_ID=com.example.kmpbench + +echo "== Codename One, Java: translate and build" +( cd "$WORK/java" && JAVA_HOME="$JAVA17_HOME" mvn -B -q $MVN_REPO_ARG package -DskipTests \ + -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source -Dopen=false ) > "$WORK/java-build.log" 2>&1 +JSRC="$(dirname "$(find "$WORK/java/common/target" -maxdepth 2 -name JavaBench.xcodeproj | head -1)")" +( cd "$JSRC" && xcodebuild -project JavaBench.xcodeproj -scheme JavaBench -configuration Release \ + -sdk iphonesimulator -arch arm64 -derivedDataPath "$WORK/dd-java" CODE_SIGNING_ALLOWED=NO build ) \ + > "$WORK/java-xcode.log" 2>&1 +JAVA_APP="$WORK/dd-java/Build/Products/Release-iphonesimulator/JavaBench.app" +JAVA_EXE="$JAVA_APP/JavaBench" +JAVA_ID=com.example.javabench + +echo "== Compose Multiplatform: build" +( cd "$WORK/cmp" && JAVA_HOME="$JAVA17_HOME" gradle --no-daemon -q \ + :composeApp:linkReleaseExecutableIosSimulatorArm64 ) > "$WORK/cmp-build.log" 2>&1 +# An executable, not a framework in an Xcode project: the bundle is the +# executable plus an Info.plist, which is all a simulator app needs. +CMP_APP="$WORK/Keynote.app"; CMP_EXE="$CMP_APP/Keynote"; CMP_ID=com.example.kmpbench.cmp +rm -rf "$CMP_APP"; mkdir -p "$CMP_APP" +cp "$WORK/cmp/composeApp/build/bin/iosSimulatorArm64/releaseExecutable/composeApp.kexe" "$CMP_EXE" +cp "$HERE/app/cmp/Info.plist" "$CMP_APP/Info.plist" +codesign -s - --force "$CMP_APP" > /dev/null 2>&1 + +stripped() { cp "$1" "$WORK/strip.tmp"; strip "$WORK/strip.tmp" 2>/dev/null || true; stat -f %z "$WORK/strip.tmp"; } +# The bundle as shipped: a copy of the .app with its executable stripped. +bundle() { + rm -rf "$WORK/bundle.tmp"; cp -R "$1" "$WORK/bundle.tmp" + strip "$WORK/bundle.tmp/$2" 2>/dev/null || true + du -sk "$WORK/bundle.tmp" | awk '{print $1 * 1024}' +} +# Bytes of everything in the bundle but the executable (fonts, theme, plists). +resources() { find "$1" -type f ! -path "$1/$2" -exec stat -f %z {} + | awk '{s += $1} END {print s + 0}'; } +largest() { find "$1" -type f ! -path "$1/$2" -exec stat -f '%z %N' {} + | sort -rn | head -5 | sed "s|$1/||"; } + +if [ -z "$DEVICE" ]; then + DEVICE="$(xcrun simctl list devices available | sed -n 's/.*iPhone 17 Pro (\([0-9A-F-]*\)).*/\1/p' | head -1)" +fi +[ -n "$DEVICE" ] || { echo "no iPhone simulator found; pass --device" >&2; exit 1; } +xcrun simctl boot "$DEVICE" 2>/dev/null || true +trap 'xcrun simctl shutdown "$DEVICE" 2>/dev/null || true' EXIT +xcrun simctl bootstatus "$DEVICE" > /dev/null + +# The kernel's start time of a process, epoch milliseconds. +started() { + python3 - "$1" <<'PY' +import ctypes, ctypes.util, struct, sys +libc = ctypes.CDLL(ctypes.util.find_library("c")) +mib = (ctypes.c_int * 4)(1, 14, 1, int(sys.argv[1])) # CTL_KERN, KERN_PROC, KERN_PROC_PID +buf = ctypes.create_string_buffer(1024); size = ctypes.c_size_t(1024) +libc.sysctl(mib, 4, buf, ctypes.byref(size), None, 0) +sec, usec = struct.unpack_from("qi", buf.raw, 0) # kp_proc.p_starttime +print(sec * 1000 + usec // 1000) +PY +} +# Prints one start-up time per run, milliseconds, space separated. +measure() { + local id="$1" name="$2" exe="$3" out="" + for i in $(seq 1 "$RUNS"); do + xcrun simctl terminate "$DEVICE" "$id" 2>/dev/null || true + local log="$WORK/$name-run$i.log" t0 t + ( timeout 20 xcrun simctl launch --console-pty "$DEVICE" "$id" > "$log" 2>&1 || true ) & + for _ in $(seq 1 100); do grep -q "KMPBENCH first-frame" "$log" 2>/dev/null && break; sleep 0.1; done + t="$(grep -o 'KMPBENCH first-frame [0-9]*' "$log" | awk '{print $3}' | head -1)" + [ -n "$t" ] || { echo "$name run $i printed no marker; see $log" >&2; exit 1; } + t0="$(started "$(pgrep -f "$exe" | head -1)")" + out="$out $((t - t0))" + sleep 3 + # While the app is still up: a launch is killed by its timeout after 20s. + [ "$i" = "$RUNS" ] && xcrun simctl io "$DEVICE" screenshot "$WORK/$name.png" > /dev/null 2>&1 || true + done + echo "$out" +} +footprint_bytes() { + xcrun simctl launch "$DEVICE" "$1" > /dev/null 2>&1; sleep 5 + footprint "$(pgrep -f "$2" | head -1)" 2>&1 \ + | awk '/Footprint:/ { for (i = 1; i <= NF; i++) if ($i == "Footprint:") { + v = $(i + 1); u = $(i + 2); print int(v * (u == "GB" ? 1073741824 : u == "MB" ? 1048576 : 1024)); exit } }' +} + +for side in cn1 java cmp; do + if [ $side = cn1 ]; then app="$CN1_APP"; id=$CN1_ID; exe=/Bench.app/Bench + elif [ $side = java ]; then app="$JAVA_APP"; id=$JAVA_ID; exe=JavaBench.app/JavaBench + else app="$CMP_APP"; id=$CMP_ID; exe=Keynote.app/Keynote; fi + xcrun simctl uninstall "$DEVICE" "$id" 2>/dev/null || true + xcrun simctl install "$DEVICE" "$app" + eval "STARTS_$side=\"\$(measure \"\$id\" \"\$side\" \"\$exe\")\"" +done +# Runtime measurements (app/shared/commonMain/kotlin/keynote/Perf.kt): one +# launch per run with KMPBENCH_PERF=1, values in microseconds, collected as one +# "name=v1,v2,..." line per measure. +perf() { + local id="$1" name="$2" + for i in $(seq 1 "$RUNS"); do + xcrun simctl terminate "$DEVICE" "$id" 2>/dev/null || true + local log="$WORK/$name-perf$i.log" + ( SIMCTL_CHILD_KMPBENCH_PERF=1 timeout 180 xcrun simctl launch --console-pty "$DEVICE" "$id" > "$log" 2>&1 || true ) & + for _ in $(seq 1 1700); do grep -q "KMPBENCH perf-done" "$log" 2>/dev/null && break; sleep 0.1; done + grep -q "KMPBENCH perf-done" "$log" || { echo "$name perf run $i did not finish; see $log" >&2; exit 1; } + xcrun simctl terminate "$DEVICE" "$id" 2>/dev/null || true + done + cat "$WORK/$name"-perf[0-9]*.log | tr -d '\r' | awk '{ for (j = 1; j < NF; j++) if ($j == "KMPBENCH") break } + j < NF && $(j + 1) !~ /^(perf-done|perf-start|first-frame)$/ { + v[$(j + 1)] = v[$(j + 1)] (v[$(j + 1)] == "" ? "" : ",") $(j + 2) } END { for (k in v) print k "=" v[k] }' > "$WORK/$name-perf.txt" +} +perf $CN1_ID cn1; perf $JAVA_ID java; perf $CMP_ID cmp +CN1_RSS="$(footprint_bytes $CN1_ID /Bench.app/Bench)"; JAVA_RSS="$(footprint_bytes $JAVA_ID JavaBench.app/JavaBench)"; CMP_RSS="$(footprint_bytes $CMP_ID Keynote.app/Keynote)" + +python3 - "$WORK" < /* _beginthreadex */ #include #include +#include +#include +#include #include "cn1_win_compat.h" @@ -565,4 +568,69 @@ int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offset return 1; } +FILE* cn1_win_fopen_utf8(const char* path, const char* mode) { + wchar_t wmode[8]; + wchar_t* wide; + wchar_t* full; + wchar_t* target; + DWORD fullLen; + int wideLen; + int i; + FILE* f; + for(i = 0; mode[i] != 0 && i < 7; i++) { + wmode[i] = (wchar_t)(unsigned char)mode[i]; + } + wmode[i] = 0; + wideLen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, NULL, 0); + if(wideLen <= 0) { + errno = EINVAL; + return NULL; + } + wide = (wchar_t*)malloc(sizeof(wchar_t) * (size_t)wideLen); + if(wide == NULL) { + errno = ENOMEM; + return NULL; + } + MultiByteToWideChar(CP_UTF8, 0, path, -1, wide, wideLen); + /* Already prefixed, or short enough for the classic API: as given. */ + if(wideLen - 1 < MAX_PATH - 12 || wcsncmp(wide, L"\\\\?\\", 4) == 0) { + f = _wfopen(wide, wmode); + free(wide); + return f; + } + fullLen = GetFullPathNameW(wide, 0, NULL, NULL); + if(fullLen == 0) { + f = _wfopen(wide, wmode); + free(wide); + return f; + } + /* Room for \\?\ or \\?\UNC\ in front of the resolved path. */ + full = (wchar_t*)malloc(sizeof(wchar_t) * ((size_t)fullLen + 8)); + if(full == NULL) { + free(wide); + errno = ENOMEM; + return NULL; + } + target = full + 8; + if(GetFullPathNameW(wide, fullLen, target, NULL) == 0) { + f = _wfopen(wide, wmode); + free(full); + free(wide); + return f; + } + if(target[0] == L'\\' && target[1] == L'\\') { + /* \\server\share\x becomes \\?\UNC\server\share\x */ + target += 2; + target -= 8; + memcpy(target, L"\\\\?\\UNC\\", 8 * sizeof(wchar_t)); + } else { + target -= 4; + memcpy(target, L"\\\\?\\", 4 * sizeof(wchar_t)); + } + f = _wfopen(target, wmode); + free(full); + free(wide); + return f; +} + #endif /* _WIN32 */ diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.h b/vm/ByteCodeTranslator/src/cn1_win_compat.h index 4ada0eb600d..7ad7d1dc49e 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.h +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.h @@ -45,6 +45,7 @@ #include /* struct timespec (C11), localtime_s, _mkgmtime */ #include #include /* _putenv_s */ +#include /* FILE, for cn1_win_fopen_utf8 */ #ifdef __cplusplus extern "C" { @@ -181,6 +182,16 @@ void cn1_win_resume_thread(void* handle); int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, int* dstOut, int* rawOut); +/* --- opening a file by a UTF-8 path --- + fopen on Windows reads its argument in the ANSI code page and stops at + MAX_PATH (260) characters, so a UTF-8 path with a non-ASCII name, or any + path longer than that, failed to open although the file was there. This + converts to UTF-16, resolves the path (which is also what removes "..", + since the long-path prefix turns normalization off), adds the \\?\ prefix + when the result is too long for the classic API, and calls _wfopen. mode + is an ASCII fopen mode. Returns NULL with errno set, as fopen does. */ +FILE* cn1_win_fopen_utf8(const char* path, const char* mode); + /* --- environment / time.h POSIX helpers absent from MSVC --- Thin static-inline wrappers over the MSVC equivalents; used by the date / timezone runtime in nativeMethods. */ diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java index 96c1f2ad432..fa8a3e8f2c5 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java @@ -41,6 +41,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -1393,9 +1394,7 @@ public String generateCCode(List allClasses) { } if(isInterface) { - b.append("int **classToInterfaceMap_"); - b.append(clsName); - b.append(";\n"); + appendInterfaceMap(b, allClasses); } fullFieldList = new ArrayList(); @@ -1963,8 +1962,7 @@ public String generateCCode(List allClasses) { // we pretend to have a virtual method here but the optimizer says its not really needed if(!m.isVirtualOverriden()) { m.setThunkCases(thunkCasesFor(m)); - m.appendVirtualMethodC(clsName, b, "classToInterfaceMap_" + clsName + - "[cn1__cls->classId][" + offset + "]", true); + m.appendVirtualMethodC(clsName, b, interfaceSlot(offset), true); m.setThunkCases(null); } offset++; @@ -2034,13 +2032,13 @@ public String generateCCode(List allClasses) { // ACQUIRE, not a plain load. This is the fast path of a double-checked // initialisation: the completing store below is a RELEASE, and the two // together are what make the writes this function performed -- the - // vtable, and every classToInterfaceMap_[classId] row -- visible - // to a thread that observes the flag set. + // vtable -- visible to a thread that observes the flag set. // // With plain accesses on arm64 a second thread could see LOADED==1 while // those table stores were still invisible, then index a row that read as - // NULL. OBSERVED: three identical SIGSEGVs at - // classToInterfaceMap_java_util_NavigableMap[classId] + 0x8, reached from + // NULL. OBSERVED: three identical SIGSEGVs at what was then the run-time + // built classToInterfaceMap_java_util_NavigableMap[classId] + 0x8 (the map + // is constant data now, see appendInterfaceMap), reached from // TreeSet.clear -> the interface dispatch for NavigableMap.clear, in a // translator that is single-threaded in its own code but shares the // process with the GC thread, which also runs Java and so also runs @@ -2081,57 +2079,20 @@ public String generateCCode(List allClasses) { b.append(".vtable = initVtableForInterface();\n "); } - // create the vtable - b.append(" class__"); - b.append(clsName); - b.append(".vtable = malloc(sizeof(void*) *"); - b.append(virtualMethodList.size()); - b.append(");\n"); if(isInterface) { // special case, java_lang_object calls on interfaces should - // act like standard virtual method calls + // act like standard virtual method calls. The dispatch map is constant + // data (appendInterfaceMap), so there is nothing else to build here. b.append(" class__"); b.append(clsName); b.append(".vtable = initVtableForInterface();\n"); - b.append(" classToInterfaceMap_"); - b.append(clsName); - // calloc, not malloc: rows are filled only for classes that implement - // this interface, so an id that does not read as a registered row must - // read as NULL rather than as whatever the allocator last left there. - b.append(" = calloc(cn1_array_start_offset, sizeof(int*));\n"); - for(ByteCodeClass cls : allClasses) { - if(!cls.isInterface) { - if(cls.doesImplement(this)) { - b.append(" classToInterfaceMap_"); - b.append(clsName); - b.append("[cn1_class_id_"); - b.append(cls.clsName); - // sizeof(int), not sizeof(int*): a row is an int[] of vtable - // slot indices -- the map is int**, so the ROW is what is - // int-sized. Asking for pointer size allocated twice what the - // row can ever hold, on every interface/implementor pair. - b.append("] = malloc(sizeof(int) * "); - b.append(getMethodCountIncludingBase()); - b.append(");\n"); - offset = 0; - for(BytecodeMethod m : virtualMethodList) { - if(!m.getClsName().equals("java_lang_Object")) { - b.append(" classToInterfaceMap_"); - b.append(clsName); - b.append("[cn1_class_id_"); - b.append(cls.clsName); - b.append("]["); - b.append(offset); - b.append("] = "); - b.append(cls.virtualMethodList.indexOf(m)); - b.append(";\n"); - offset++; - } - } - } - } - } } else { + // create the vtable + b.append(" class__"); + b.append(clsName); + b.append(".vtable = malloc(sizeof(void*) *"); + b.append(virtualMethodList.size()); + b.append(");\n"); b.append(" __INIT_VTABLE_"); b.append(clsName); b.append("(threadStateData, class__"); @@ -2151,8 +2112,8 @@ public String generateCCode(List allClasses) { // that recursion guard. Nothing outside this function may treat it as // "safe to use the class" in the JLS sense -- a class under initialization // is not finished. The release is what the INLINE GUARDS acquire against: - // they test this flag, and it is what publishes the vtable and the - // classToInterfaceMap rows written just above. Guarding them on + // they test this flag, and it is what publishes the vtable written just + // above. Guarding them on // __X_LOADED__ instead would also be correct about the vtable and would // additionally hold other threads until __CLINIT__ returned -- a strictly // later gate than master opens, which moved layout on four native ports @@ -2313,6 +2274,143 @@ private static char onDeviceDebugTypeCharFor(ByteCodeField bf) { * @param interfaceObj the interface to test * @return true when this class is assignable to it */ + /// The interface dispatch map, emitted as constant data. + /// + /// Dispatching a method of an interface needs, for the receiver's class, the + /// vtable slot that implements it. That used to be a table built by the + /// interface's static initializer: one `calloc` of a pointer per class in the + /// program, plus one `malloc`ed row per implementing class. With 3,500 classes + /// that was 28 KB per interface whatever its implementers, and a Compose + /// application initializes ninety of them -- 2.5 MB of dirty heap, written once + /// and never changed, describing nothing the translator did not already know. + /// + /// It is now two `static const` arrays, which the linker puts in read-only + /// pages that are mapped from the binary and never dirtied: + /// + /// - `cn1_itrows_`: the rows, one slot per method that is not + /// Object's. Classes that inherit one layout share one row, so the + /// hundred-odd implementers of `Iterator` take a handful. + /// - `cn1_itix_`: for every class id between the lowest and highest + /// implementer, where its row starts. Only implementers ever index it -- + /// the thunk is only reached with a receiver of the interface's type -- so + /// ids outside that range need no entry. + /// + /// The lookup is the same two dependent loads as before, and needs no + /// publication: nothing is written at run time, so no thread can read it half + /// built. It adds no branch either: see appendRowLookup. + private void appendInterfaceMap(StringBuilder b, List allClasses) { + List slots = new ArrayList(); + for(BytecodeMethod m : virtualMethodList) { + if(!m.getClsName().equals("java_lang_Object")) { + slots.add(m); + } + } + interfaceMapBase = 0; + if(slots.isEmpty()) { + return; + } + List implementers = new ArrayList(); + int min = Integer.MAX_VALUE; + int max = Integer.MIN_VALUE; + for(ByteCodeClass cls : allClasses) { + // An abstract class is never the class of an object, so no dispatch reads its row. + if(!cls.isInterface && !cls.isAbstract && cls.doesImplement(this)) { + implementers.add(cls); + min = Math.min(min, cls.getClassOffset()); + max = Math.max(max, cls.getClassOffset()); + } + } + if(implementers.isEmpty()) { + // Never dispatched through: no object of this type can exist. The thunks + // still compile against an empty map. + b.append("static const unsigned short cn1_itrows_").append(clsName).append("[1] __attribute__((unused));\n"); + b.append("static inline JAVA_INT cn1_itrow_").append(clsName).append("(JAVA_INT id) __attribute__((unused));\n"); + b.append("static inline JAVA_INT cn1_itrow_").append(clsName).append("(JAVA_INT id) {\n"); + b.append(" __builtin_trap();\n return 0;\n}\n"); + return; + } + Map rowOffsets = new HashMap(); + StringBuilder rows = new StringBuilder(); + int[] starts = new int[implementers.size()]; + int rowCount = 0; + int maxValue = 0; + boolean missing = false; + for(int k = 0 ; k < implementers.size() ; k++) { + ByteCodeClass cls = implementers.get(k); + StringBuilder row = new StringBuilder(); + for(BytecodeMethod m : slots) { + int idx = cls.virtualMethodList.indexOf(m); + maxValue = Math.max(maxValue, idx); + missing |= idx < 0; + row.append(idx).append(','); + } + String key = row.toString(); + Integer at = rowOffsets.get(key); + if(at == null) { + at = rowCount * slots.size(); + rowOffsets.put(key, at); + rows.append("\n ").append(key); + rowCount++; + } + starts[k] = at; + } + int lastStart = (rowCount - 1) * slots.size() + 1; + // A concrete class without the method (a stale or mismatched class file) gets -1, as it + // did when the rows were written at start-up: a call through it fails as it always has, + // and a class that is never called through still links. An unsigned row would turn the + // -1 into 65535, an index past any vtable, so such a table is stored signed. + String rowType = missing ? (maxValue < 32768 ? "short" : "JAVA_INT") + : (maxValue < 65536 ? "unsigned short" : "JAVA_INT"); + String ixType = lastStart < 65536 ? "unsigned short" : "JAVA_INT"; + // Dense, every entry a real row: a gap (a class in the range that does not implement + // the interface) takes the row of the lowest implementer; see appendRowLookup. + int[] dense = new int[max - min + 1]; + int lowest = 0; + for(int k = 0 ; k < implementers.size() ; k++) { + if(implementers.get(k).getClassOffset() == min) { + lowest = starts[k]; + } + } + java.util.Arrays.fill(dense, lowest); + for(int k = 0 ; k < implementers.size() ; k++) { + dense[implementers.get(k).getClassOffset() - min] = starts[k]; + } + b.append("static const ").append(rowType).append(" cn1_itrows_").append(clsName) + .append("[] __attribute__((unused)) = {"); + b.append(rows).append("\n};\n"); + b.append("static const ").append(ixType).append(" cn1_itix_").append(clsName) + .append("[").append(max - min + 1).append("] __attribute__((unused)) = {"); + for(int k = 0 ; k < dense.length ; k++) { + b.append(k == 0 ? "" : ",").append(k % 32 == 0 ? "\n " : "").append(dense[k]); + } + b.append("\n};\n"); + interfaceMapBase = min; + appendRowLookup(b, String.valueOf(max - min + 1), "cn1_itix_" + clsName + "[d]"); + } + + /// The row of the receiver's class, with no branch: this runs on every interface call, and a + /// range test plus a trap here cost the backend benchmark about 1% of its throughput. + /// + /// A receiver that does not implement the interface can only get here through a cast that + /// did not hold, since ParparVM's CHECKCAST is unchecked. Its id is clamped into the table + /// (a conditional move, not a branch), and every entry is a real row, so such a call reaches + /// a real implementation of the same method with the wrong receiver -- what an unchecked cast + /// does everywhere else on ParparVM -- and never a row read from past the table. + private void appendRowLookup(StringBuilder b, String len, String read) { + b.append("static inline JAVA_INT cn1_itrow_").append(clsName).append("(JAVA_INT id) __attribute__((unused));\n"); + b.append("static inline JAVA_INT cn1_itrow_").append(clsName).append("(JAVA_INT id) {\n"); + b.append(" unsigned int d = (unsigned int)(id - ").append(interfaceMapBase).append(");\n"); + b.append(" return ").append(read.replace("[d]", "[d < " + len + "u ? d : 0]")).append(";\n}\n"); + } + + /// The lowest class id cn1_itix_ covers; see appendInterfaceMap. + private int interfaceMapBase; + + /// The vtable slot of the receiver's class for the interface method at `slot`. + private String interfaceSlot(int slot) { + return "cn1_itrows_" + clsName + "[cn1_itrow_" + clsName + "(cn1__cls->classId) + " + slot + "]"; + } + boolean doesImplement(ByteCodeClass interfaceObj) { if(baseInterfacesObject != null) { if(baseInterfacesObject.contains(interfaceObj)) { @@ -3160,7 +3258,7 @@ public boolean hasMarkWork() { /// from then on it is always false. Two kinds qualify: /// /// - A class with NO . Its __STATIC_INITIALIZER only mallocs and fills its - /// vtable, or for an interface its classToInterfaceMap rows. These run first, in + /// vtable, or for an interface points its vtable at Object's. These run first, in /// cn1EagerInitClasses, at the very start of initConstantPool. /// - A class whose is PURE (see computePureClinits): it reads and writes /// only its own static fields, constants, string literals and arrays it creates, diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeField.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeField.java index a37abe13c45..21764cac66f 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeField.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeField.java @@ -42,6 +42,7 @@ public class ByteCodeField { private Object value; private boolean privateField; private final boolean volatileField; + private final String descriptor; public ByteCodeField(String clsName, int access, String name, String desc, String signature, Object value) { this.clsName = clsName; @@ -54,6 +55,7 @@ public ByteCodeField(String clsName, int access, String name, String desc, Strin finalField = (access & Opcodes.ACC_FINAL) == Opcodes.ACC_FINAL; volatileField = (access & Opcodes.ACC_VOLATILE) == Opcodes.ACC_VOLATILE; fieldName = name.replace('$', '_').replace('-', '_'); + descriptor = desc; arrayDimensions = 0; while(desc.startsWith("[")) { @@ -103,6 +105,11 @@ public ByteCodeField(String clsName, int access, String name, String desc, Strin } } + /// The JVM descriptor the field was declared with, e.g. `Ljava/lang/Object;`. + public String getDescriptor() { + return descriptor; + } + public String getFieldName() { return fieldName; } diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index b2342aa0630..4cd33a985e4 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -1079,21 +1079,24 @@ private static void handleAppleOutput(ByteCodeTranslator b, File[] sources, File } } optionalFrameworks.add("UserNotifications.framework"); - includeFrameworks.add("libiconv.dylib"); + // Only what the port's natives call. A linked framework is loaded at launch whether or not + // anything calls it, with everything it links in turn, so one listed here "in case" costs + // every application memory and start-up time. MapKit, GameKit, QuickLook, AddressBook, + // libxml2, libz and libiconv were listed here and called by nothing, and loading them cost + // a Compose application 11 MB of dyld memory on the simulator, a fifth of its footprint. + // A feature that needs a framework adds it when it is used -- the maps provider brings + // MapKit (MapsProviderInjector), contacts bring AddressBook, App Attest brings + // DeviceCheck -- and a cn1lib declares its own through ios.add_libs. //includeFrameworks.add("AdSupport.framework"); // AddressBookUI and AddressBook are iOS-only. On macOS the contacts API - // is Contacts.framework, and linking the iOS ones fails outright rather - // than merely going unused. - if (platform.hasIosDeviceIdioms()) { - includeFrameworks.add("AddressBookUI.framework"); - includeFrameworks.add("AddressBook.framework"); - } else { + // is Contacts.framework, and linking the iOS ones fails outright. + // On iOS the builder links them with INCLUDE_CONTACTS_USAGE, the define their natives + // compile under (IPhoneBuilder, beside ios.add_libs). + if (!platform.hasIosDeviceIdioms()) { includeFrameworks.add("Contacts.framework"); } includeFrameworks.add("SystemConfiguration.framework"); - includeFrameworks.add("MapKit.framework"); includeFrameworks.add("AudioToolbox.framework"); - includeFrameworks.add("libxml2.dylib"); includeFrameworks.add("QuartzCore.framework"); if (!isBundledSqliteCipherEnabled()) { // The system libsqlite3 has no cipher support, so an application that encrypts @@ -1103,7 +1106,6 @@ private static void handleAppleOutput(ByteCodeTranslator b, File[] sources, File includeFrameworks.add("libsqlite3.dylib"); includeFrameworks.add("libsqlite3.0.dylib"); } - includeFrameworks.add("GameKit.framework"); includeFrameworks.add("Security.framework"); //includeFrameworks.add("StoreKit.framework"); // CoreMotion has no macOS implementation -- a Mac has no accelerometer to @@ -1136,10 +1138,8 @@ private static void handleAppleOutput(ByteCodeTranslator b, File[] sources, File includeFrameworks.add("AVFoundation.framework"); includeFrameworks.add("CoreText.framework"); includeFrameworks.add("CoreVideo.framework"); - includeFrameworks.add("QuickLook.framework"); //includeFrameworks.add("iAd.framework"); includeFrameworks.add("CoreMedia.framework"); - includeFrameworks.add("libz.dylib"); includeFrameworks.add("AVKit.framework"); if(!addFrameworks.equalsIgnoreCase("none")) { includeFrameworks.addAll(Arrays.asList(Util.splitLiteral(addFrameworks, ';'))); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java index 69be2e92342..0c78cfdaa60 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java @@ -2413,8 +2413,8 @@ public void appendVirtualMethodC(String cls, StringBuilder b, String offset, boo b.append("\n#if CN1_TAGGED_ACTIVE\n if(CN1_TAG_CODE(__cn1ThisObject) == CN1_TAG_INTEGER && CN1_TAG_CODE(__cn1Arg1) == CN1_TAG_INTEGER) { return (__cn1ThisObject == __cn1Arg1) ? JAVA_TRUE : JAVA_FALSE; }\n#endif\n "); } - // An eagerly initialized interface (no ) had its classToInterfaceMap - // rows written in initConstantPool before any Java thread existed, so there is + // An eagerly initialized interface (no ) had its vtable written in + // initConstantPool before any Java thread existed, so there is // nothing left for this guard to publish -- see // ByteCodeClass.isEagerInitEligible. if(includeStaticInitializer && !ByteCodeClass.eagerInit(cls)) { @@ -2432,9 +2432,9 @@ public void appendVirtualMethodC(String cls, StringBuilder b, String offset, boo // self time doing nothing but returning. // // The flag is the right thing to test and the ordering is not incidental: - // class__X.initialized is RELEASE-stored after the vtable and the - // classToInterfaceMap rows are written, and this thunk is about to index - // exactly those rows -- so the ACQUIRE load here is what makes them visible. + // class__X.initialized is RELEASE-stored after the interface's vtable is + // written, and this thunk may read it -- so the ACQUIRE load here is what + // makes it visible. (The dispatch map itself is constant data.) // Testing __X_LOADED__ instead would be a weaker gate that publishes // nothing; that distinction is why the inline guards were moved onto this // flag in the first place. @@ -2450,8 +2450,8 @@ public void appendVirtualMethodC(String cls, StringBuilder b, String offset, boo // CN1_CLASS_OF is not a field load when tagged values are compiled in: it // masks the pointer, tests the tag and selects between a proxy and the object // before it loads anything. The interface form of this thunk used it twice -- - // once for the vtable and once for the classId that indexes - // classToInterfaceMap -- so resolving it once here removes that work from + // once for the vtable and once for the classId that indexes the + // interface map -- so resolving it once here removes that work from // every interface dispatch, which is the hottest indirect call the VM makes. // Skip the tagged resolve where a tagged value cannot arrive: this thunk only // ever sees objects whose class is its owner or below, and a boxed immediate @@ -2467,7 +2467,7 @@ public void appendVirtualMethodC(String cls, StringBuilder b, String offset, boo /* A SWITCH ON THE CLASS ID, ahead of the indirect dispatch below. * * The indirect form is six dependent loads -- class pointer, class id, the - * classToInterfaceMap row, the offset within it, the vtable, the slot -- and + * interface map row, the offset within it, the vtable, the slot -- and * then a branch the C compiler cannot see through, so nothing on either side * of the call is optimised across it. The switch is ONE load and a jump table, * and every arm is a named callee that ThinLTO can inline. diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java index b8607cf4680..77b58f23010 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java @@ -1286,6 +1286,18 @@ private static void appendClassRegistration(StringBuilder out, ByteCodeClass cls } if (cls.isIsInterface()) { out.append(" I: 1,\n"); + // ``D`` marks an interface that declares a default (non-abstract, + // non-static) method. JVMS 5.5 initializes such an interface along + // with any class implementing it, and the runtime relies on this + // flag to do so: the default method's body reads the interface's + // own statics without an init guard (see + // defaultMethodInterfacesToInit in parparvm_runtime.js). + for (BytecodeMethod m : cls.getMethods()) { + if (!m.isAbstract() && !m.isStatic() && !m.isEliminated()) { + out.append(" D: 1,\n"); + break; + } + } } if (cls.isIsAbstract()) { out.append(" A: 1,\n"); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java index 07e522c1d4f..4b015e95b47 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java @@ -492,6 +492,7 @@ public static void cleanup() { + " refused=" + BytecodeMethod.stackIterRefused); } LocalReceiverTypes.clear(); + RuntimeLowering.cleanup(); cn1SubclassIndex = null; cn1SubclassIndexSize = -1; nativeSources = null; @@ -560,19 +561,44 @@ public static void parse(File sourceFile) throws Exception { try (InputStream in = new FileInputStream(sourceFile)) { r = new ClassReader(in); } + if (RuntimeLowering.isReplacedStub(r.getClassName())) { + // Regenerated from the inputs' service files once everything is parsed. + return; + } Parser p = new Parser(); p.clsName = r.getClassName().replace('/', '_').replace('$', '_').replace('-', '_'); p.cls = new ByteCodeClass(p.clsName, r.getClassName()); readingClassDepth++; try { - r.accept(p, ClassReader.EXPAND_FRAMES); + r.accept(RuntimeLowering.adapt(p), ClassReader.EXPAND_FRAMES); } finally { readingClassDepth--; } classes.add(p.cls); } + + /// Writes a class the translator generates itself into a [ClassVisitor], as a + /// class file would be read into one. + interface SyntheticEmitter { + void emit(ClassVisitor cv); + } + + /// Parses a generated class exactly as [#parse(File)] parses one read from disk. + static void parseSynthetic(String internalName, SyntheticEmitter emitter) { + BytecodeMethod.setDependencyGraph(dependencyGraph); + Parser p = new Parser(); + p.clsName = internalName.replace('/', '_').replace('$', '_').replace('-', '_'); + p.cls = new ByteCodeClass(p.clsName, internalName); + readingClassDepth++; + try { + emitter.emit(p); + } finally { + readingClassDepth--; + } + classes.add(p.cls); + } private static ByteCodeClass getClassByName(String name) { return classIndex().get(name.replace('/', '_').replace('$', '_').replace('-', '_')); @@ -1390,6 +1416,9 @@ private static String fourChars(String s) { } public static void writeOutput(File outputDirectory) throws Exception { + // Field updaters and service providers: classes generated from what the parse + // found, which every later pass has to see as if they had been read. + RuntimeLowering.writeSynthetics(outputDirectory); // Must run before anything is emitted: the instanceof call sites bake the // dense bit index in, so the assignment has to exist before the first one. collectTypeTests(); @@ -1643,6 +1672,7 @@ public static void writeOutput(File outputDirectory) throws Exception { verifyNativeMethods(handWrittenNativeSources); generateClassAndMethodIndexHeader(outputDirectory); + RuntimeLowering.pruneCulledSynthetics(outputDirectory); boolean concatenate = "true".equals(Util.getProperty("concatenateFiles", "false")); ConcatenatingFileOutputStream cos = concatenate ? new ConcatenatingFileOutputStream(outputDirectory) : null; diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/RuntimeLowering.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/RuntimeLowering.java new file mode 100644 index 00000000000..48fe43b5ca4 --- /dev/null +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/RuntimeLowering.java @@ -0,0 +1,602 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import com.codename1.tools.translator.classfile.ClassVisitor; +import com.codename1.tools.translator.classfile.Label; +import com.codename1.tools.translator.classfile.MethodVisitor; +import com.codename1.tools.translator.classfile.Opcodes; +import com.codename1.tools.translator.classfile.Type; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/// The two JDK facilities a device cannot provide at run time, provided at build time. +/// +/// Both are reflection in disguise, and both are everywhere in Kotlin libraries: +/// kotlinx.coroutines, the Compose runtime, atomicfu and Ktor are built on them. +/// +/// #### Field updaters +/// +/// `AtomicReferenceFieldUpdater.newUpdater(Owner.class, V.class, "field")` (and the +/// `Integer` and `Long` forms) finds a field by name. Every compiler emits the +/// arguments as constants right before the call, so [#adapt] recognises the three (or +/// two) `ldc` instructions and the `invokestatic`, and replaces them with +/// `new Owner$cn1fu$field()`: a subclass of the updater that [#writeSynthetics] +/// generates for that one field. Its `get` and `set` are a plain `getfield` and +/// `putfield`, which on a volatile field are already atomic. Its `compareAndSet` +/// calls a static `cas0`, which is: +/// +/// - on a native target, a native whose C body is generated here -- a C11 compare and +/// exchange on the struct member, behind the same SATB and generational barriers +/// the generated setter applies. A lock would not do: code that uses an updater +/// also writes the field directly (atomicfu lowers `value = x` to a volatile +/// store), and only a hardware CAS is atomic with respect to that store; +/// - on JavaScript, branch-free bytecode: every thread there runs on one host thread, +/// so nothing can run between the read and the write. +/// +/// A call whose arguments are not constants is left alone, and reaches the JavaAPI +/// method, which throws. +/// +/// #### Services +/// +/// `java.util.ServiceLoader` cannot search a class path. [#recordResource] collects +/// every `META-INF/services` file among the translated inputs, and the generated +/// `java.util.ServiceLoader$Registry.providers(Class)` instantiates the providers each +/// names, in order. The JavaAPI stub of that class is skipped when parsing +/// ([#isReplacedStub]) and always regenerated, empty for an application that has no +/// service files. +final class RuntimeLowering { + static final String REGISTRY = "java/util/ServiceLoader$Registry"; + private static final String REF_UPDATER = "java/util/concurrent/atomic/AtomicReferenceFieldUpdater"; + private static final String INT_UPDATER = "java/util/concurrent/atomic/AtomicIntegerFieldUpdater"; + private static final String LONG_UPDATER = "java/util/concurrent/atomic/AtomicLongFieldUpdater"; + private static final String GENERATED_C = "cn1_runtime_lowering"; + private static final int CLASS_VERSION_8 = 52; + + /// Every field an updater was resolved for, keyed by the generated class name. + private static final Map UPDATERS = new TreeMap(); + /// Service interface (binary name) to provider class names, in file order. + private static final Map> SERVICES = new TreeMap>(); + + private RuntimeLowering() { + } + + static void cleanup() { + UPDATERS.clear(); + SERVICES.clear(); + CAS_BODIES.clear(); + } + + static boolean isReplacedStub(String internalName) { + return REGISTRY.equals(internalName); + } + + /// One resolved updater: the generated class, the class named as the owner, and + /// the field. + private static final class Updater { + final String generated; + final String owner; + final String field; + final String updaterClass; + /// `I`, `J` or `L` -- the updater's kind, not the field's declared type. + final char kind; + + Updater(String generated, String owner, String field, String updaterClass, char kind) { + this.generated = generated; + this.owner = owner; + this.field = field; + this.updaterClass = updaterClass; + this.kind = kind; + } + } + + /// Wraps the parser's visitor so updater call sites are rewritten as they are read. + static ClassVisitor adapt(ClassVisitor parser) { + return new ClassVisitor(parser) { + @Override + public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { + MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); + return mv == null ? null : new CallSites(mv); + } + }; + } + + /// Holds back trailing `ldc` instructions until it knows whether a `newUpdater` + /// follows them. + private static final class CallSites extends MethodVisitor { + private final List pending = new ArrayList(); + + CallSites(MethodVisitor mv) { + super(mv); + } + + private void flush() { + for (Object o : pending) { + mv.visitLdcInsn(o); + } + pending.clear(); + } + + @Override + public void visitLdcInsn(Object value) { + pending.add(value); + if (pending.size() > 3) { + mv.visitLdcInsn(pending.remove(0)); + } + } + + @Override + public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) { + if (opcode == Opcodes.INVOKESTATIC && "newUpdater".equals(name) && tryResolve(owner)) { + return; + } + flush(); + mv.visitMethodInsn(opcode, owner, name, desc, itf); + } + + private boolean tryResolve(String updaterClass) { + char kind; + int args; + if (REF_UPDATER.equals(updaterClass)) { + kind = 'L'; + args = 3; + } else if (INT_UPDATER.equals(updaterClass)) { + kind = 'I'; + args = 2; + } else if (LONG_UPDATER.equals(updaterClass)) { + kind = 'J'; + args = 2; + } else { + return false; + } + if (pending.size() < args) { + return false; + } + int base = pending.size() - args; + Object ownerConst = pending.get(base); + Object nameConst = pending.get(pending.size() - 1); + if (!(ownerConst instanceof Type) || ((Type) ownerConst).getSort() != Type.OBJECT + || !(nameConst instanceof String) + || (args == 3 && !(pending.get(base + 1) instanceof Type))) { + return false; + } + String owner = ((Type) ownerConst).getInternalName(); + String field = (String) nameConst; + String generated = owner + "$cn1fu$" + field; + synchronized (UPDATERS) { + if (!UPDATERS.containsKey(generated)) { + UPDATERS.put(generated, new Updater(generated, owner, field, updaterClass, kind)); + } + } + for (int i = 0; i < base; i++) { + mv.visitLdcInsn(pending.get(i)); + } + pending.clear(); + mv.visitTypeInsn(Opcodes.NEW, generated); + mv.visitInsn(Opcodes.DUP); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, generated, "", "()V", false); + return true; + } + + @Override public void visitInsn(int opcode) { flush(); mv.visitInsn(opcode); } + @Override public void visitIntInsn(int opcode, int operand) { flush(); mv.visitIntInsn(opcode, operand); } + @Override public void visitVarInsn(int opcode, int var) { flush(); mv.visitVarInsn(opcode, var); } + @Override public void visitTypeInsn(int opcode, String type) { flush(); mv.visitTypeInsn(opcode, type); } + @Override public void visitFieldInsn(int opcode, String owner, String name, String desc) { + flush(); + mv.visitFieldInsn(opcode, owner, name, desc); + } + @Override public void visitInvokeDynamicInsn(String name, String desc, + com.codename1.tools.translator.classfile.Handle bsm, Object... bsmArgs) { + flush(); + mv.visitInvokeDynamicInsn(name, desc, bsm, bsmArgs); + } + @Override public void visitJumpInsn(int opcode, Label label) { flush(); mv.visitJumpInsn(opcode, label); } + @Override public void visitLabel(Label label) { flush(); mv.visitLabel(label); } + @Override public void visitIincInsn(int var, int increment) { flush(); mv.visitIincInsn(var, increment); } + @Override public void visitTableSwitchInsn(int min, int max, Label dflt, Label... labels) { + flush(); + mv.visitTableSwitchInsn(min, max, dflt, labels); + } + @Override public void visitLookupSwitchInsn(Label dflt, int[] keys, Label[] labels) { + flush(); + mv.visitLookupSwitchInsn(dflt, keys, labels); + } + @Override public void visitMultiANewArrayInsn(String desc, int dims) { flush(); mv.visitMultiANewArrayInsn(desc, dims); } + @Override public void visitTryCatchBlock(Label start, Label end, Label handler, String type) { + flush(); + mv.visitTryCatchBlock(start, end, handler, type); + } + @Override public void visitLineNumber(int line, Label start) { flush(); mv.visitLineNumber(line, start); } + @Override public void visitMaxs(int maxStack, int maxLocals) { flush(); mv.visitMaxs(maxStack, maxLocals); } + @Override public void visitEnd() { flush(); mv.visitEnd(); } + } + + /// Records a `META-INF/services` file met among the inputs. Returns true when `f` is + /// one. + static boolean recordResource(File f) throws IOException { + File parent = f.getParentFile(); + if (parent == null || !"services".equals(parent.getName()) || parent.getParentFile() == null + || !"META-INF".equals(parent.getParentFile().getName())) { + return false; + } + String text; + try (InputStream in = new FileInputStream(f)) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + text = new String(out.toByteArray(), StandardCharsets.UTF_8); + } + Set providers; + synchronized (SERVICES) { + providers = SERVICES.get(f.getName()); + if (providers == null) { + providers = new LinkedHashSet(); + SERVICES.put(f.getName(), providers); + } + } + int start = 0; + while (start <= text.length()) { + int end = text.indexOf('\n', start); + if (end < 0) { + end = text.length(); + } + String line = text.substring(start, end); + start = end + 1; + int hash = line.indexOf('#'); + String name = (hash >= 0 ? line.substring(0, hash) : line).trim(); + if (!name.isEmpty()) { + providers.add(name); + } + } + return true; + } + + /// Parses the generated classes and writes the C bodies of their natives. Runs once + /// every input is parsed and before anything is emitted. + static void writeSynthetics(File outputDirectory) throws IOException { + boolean javascript = ByteCodeTranslator.output == ByteCodeTranslator.OutputType.OUTPUT_TYPE_JAVASCRIPT; + StringBuilder c = new StringBuilder(); + Set headers = new LinkedHashSet(); + CAS_BODIES.clear(); + for (Updater u : UPDATERS.values()) { + ByteCodeField field = findField(u.owner, u.field); + if (field == null || field.isStaticField()) { + System.out.println("WARNING: " + u.updaterClass.substring(u.updaterClass.lastIndexOf('/') + 1) + + " names " + u.owner.replace('/', '.') + "." + u.field + + ", which is not an instance field of a translated class"); + continue; + } + String desc = field.getDescriptor(); + emitUpdaterClass(u, desc, !javascript); + if (!javascript) { + headers.add(Util.mangle(u.owner)); + headers.add(Util.mangle(u.generated)); + int before = c.length(); + appendCasBody(c, u, field); + CAS_BODIES.add(new String[] {Util.mangle(u.owner), Util.mangle(u.generated), + c.substring(before)}); + } + } + emitRegistry(); + File out = new File(outputDirectory, GENERATED_C + "." + ByteCodeTranslator.output.extension()); + if (javascript || c.length() == 0) { + if (out.exists() && !out.delete()) { + throw new IOException("Could not remove " + out); + } + return; + } + writeCasFile(out, headers, c); + } + + /// Each CAS body written by writeSynthetics: the owner class, the generated + /// updater class and the C text. + private static final List CAS_BODIES = new ArrayList(); + + /// Rewrites the CAS file once the dead-code passes have run. It is written before + /// them so the natives it calls count as used, but an application that never + /// reaches an updater loses the owner or the updater class, and with it the header + /// the file includes: a Linux build without coroutines failed on a missing + /// kotlinx_coroutines_CancelledContinuation.h. Bodies whose classes are gone are + /// dropped, and the file with them when none is left. + static void pruneCulledSynthetics(File outputDirectory) throws IOException { + File out = new File(outputDirectory, GENERATED_C + "." + ByteCodeTranslator.output.extension()); + if (!out.exists() || CAS_BODIES.isEmpty()) { + return; + } + Set headers = new LinkedHashSet(); + StringBuilder c = new StringBuilder(); + for (String[] body : CAS_BODIES) { + if (Parser.getClassObject(body[0]) == null || Parser.getClassObject(body[1]) == null) { + continue; + } + headers.add(body[0]); + headers.add(body[1]); + c.append(body[2]); + } + if (c.length() == 0) { + if (!out.delete()) { + throw new IOException("Could not remove " + out); + } + return; + } + writeCasFile(out, headers, c); + } + + private static void writeCasFile(File out, Set headers, StringBuilder c) throws IOException { + StringBuilder file = new StringBuilder(); + file.append("// Generated by the translator: compare-and-set for the field updaters.\n"); + file.append("#include \"cn1_globals.h\"\n#include \n"); + for (String h : headers) { + file.append("#include \"").append(h).append(".h\"\n"); + } + file.append(c); + try (OutputStream o = new FileOutputStream(out)) { + o.write(file.toString().getBytes(StandardCharsets.UTF_8)); + } + } + + private static ByteCodeField findField(String owner, String name) { + // ByteCodeField keeps the mangled name ($ and - folded to _). + String mangled = name.replace('$', '_').replace('-', '_'); + ByteCodeClass bc = Parser.getClassObject(Util.mangle(owner)); + while (bc != null) { + for (ByteCodeField f : bc.getFields()) { + if (f.getFieldName().equals(mangled)) { + return f; + } + } + String base = bc.getBaseClass(); + bc = base == null ? null : Parser.getClassObject(Util.mangle(base)); + } + return null; + } + + private static String valueDesc(Updater u) { + return u.kind == 'L' ? "Ljava/lang/Object;" : String.valueOf(u.kind); + } + + private static void emitUpdaterClass(final Updater u, final String fieldDesc, final boolean nativeCas) { + Parser.parseSynthetic(u.generated, new Parser.SyntheticEmitter() { + @Override + public void emit(ClassVisitor cv) { + String v = valueDesc(u); + cv.visit(CLASS_VERSION_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL | Opcodes.ACC_SYNTHETIC, + u.generated, null, u.updaterClass, new String[0]); + + MethodVisitor m = cv.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + m.visitCode(); + m.visitVarInsn(Opcodes.ALOAD, 0); + m.visitMethodInsn(Opcodes.INVOKESPECIAL, u.updaterClass, "", "()V", false); + m.visitInsn(Opcodes.RETURN); + m.visitMaxs(1, 1); + m.visitEnd(); + + m = cv.visitMethod(Opcodes.ACC_PUBLIC, "get", "(Ljava/lang/Object;)" + v, null, null); + m.visitCode(); + m.visitVarInsn(Opcodes.ALOAD, 1); + m.visitTypeInsn(Opcodes.CHECKCAST, u.owner); + m.visitFieldInsn(Opcodes.GETFIELD, u.owner, u.field, fieldDesc); + m.visitInsn(returnOp(u.kind)); + m.visitMaxs(2, 2); + m.visitEnd(); + + m = cv.visitMethod(Opcodes.ACC_PUBLIC, "set", "(Ljava/lang/Object;" + v + ")V", null, null); + m.visitCode(); + m.visitVarInsn(Opcodes.ALOAD, 1); + m.visitTypeInsn(Opcodes.CHECKCAST, u.owner); + m.visitVarInsn(loadOp(u.kind), 2); + if (u.kind == 'L') { + m.visitTypeInsn(Opcodes.CHECKCAST, Type.getType(fieldDesc).getInternalName()); + } + m.visitFieldInsn(Opcodes.PUTFIELD, u.owner, u.field, fieldDesc); + m.visitInsn(Opcodes.RETURN); + m.visitMaxs(4, 4); + m.visitEnd(); + + // compareAndSet(obj, expect, update): null-check, then cas0. + int wide = u.kind == 'J' ? 2 : 1; + String casDesc = "(Ljava/lang/Object;" + v + v + ")Z"; + m = cv.visitMethod(Opcodes.ACC_PUBLIC, "compareAndSet", casDesc, null, null); + m.visitCode(); + m.visitVarInsn(Opcodes.ALOAD, 1); + m.visitMethodInsn(Opcodes.INVOKESTATIC, "java/util/Objects", "requireNonNull", + "(Ljava/lang/Object;)Ljava/lang/Object;", false); + m.visitVarInsn(loadOp(u.kind), 2); + m.visitVarInsn(loadOp(u.kind), 2 + wide); + m.visitMethodInsn(Opcodes.INVOKESTATIC, u.generated, "cas0", casDesc, false); + m.visitInsn(Opcodes.IRETURN); + m.visitMaxs(6, 2 + 2 * wide); + m.visitEnd(); + + if (nativeCas) { + m = cv.visitMethod(Opcodes.ACC_STATIC | Opcodes.ACC_NATIVE | Opcodes.ACC_PRIVATE, "cas0", casDesc, null, null); + m.visitEnd(); + } else { + // One host thread: read, compare, and write back either the update or + // what was there, with nothing able to run in between. + int same = 1 + 2 * wide; + String selDesc = "(Z" + v + v + ")" + v; + m = cv.visitMethod(Opcodes.ACC_STATIC | Opcodes.ACC_PRIVATE, "cas0", casDesc, null, null); + m.visitCode(); + m.visitVarInsn(Opcodes.ALOAD, 0); + m.visitTypeInsn(Opcodes.CHECKCAST, u.owner); + m.visitFieldInsn(Opcodes.GETFIELD, u.owner, u.field, fieldDesc); + m.visitVarInsn(loadOp(u.kind), 1); + m.visitMethodInsn(Opcodes.INVOKESTATIC, u.updaterClass, "cn1Same", "(" + v + v + ")Z", false); + m.visitVarInsn(Opcodes.ISTORE, same); + m.visitVarInsn(Opcodes.ALOAD, 0); + m.visitTypeInsn(Opcodes.CHECKCAST, u.owner); + m.visitVarInsn(Opcodes.ILOAD, same); + m.visitVarInsn(loadOp(u.kind), 1 + wide); + m.visitVarInsn(Opcodes.ALOAD, 0); + m.visitTypeInsn(Opcodes.CHECKCAST, u.owner); + m.visitFieldInsn(Opcodes.GETFIELD, u.owner, u.field, fieldDesc); + m.visitMethodInsn(Opcodes.INVOKESTATIC, u.updaterClass, "cn1Select", selDesc, false); + if (u.kind == 'L') { + m.visitTypeInsn(Opcodes.CHECKCAST, Type.getType(fieldDesc).getInternalName()); + } + m.visitFieldInsn(Opcodes.PUTFIELD, u.owner, u.field, fieldDesc); + m.visitVarInsn(Opcodes.ILOAD, same); + m.visitInsn(Opcodes.IRETURN); + m.visitMaxs(8, same + 1); + m.visitEnd(); + } + cv.visitEnd(); + } + }); + } + + private static int loadOp(char kind) { + return kind == 'L' ? Opcodes.ALOAD : kind == 'J' ? Opcodes.LLOAD : Opcodes.ILOAD; + } + + private static int returnOp(char kind) { + return kind == 'L' ? Opcodes.ARETURN : kind == 'J' ? Opcodes.LRETURN : Opcodes.IRETURN; + } + + /// The C body of one updater's `cas0`. + private static void appendCasBody(StringBuilder c, Updater u, ByteCodeField field) { + ByteCodeClass gen = Parser.getClassObject(Util.mangle(u.generated)); + BytecodeMethod cas = null; + for (BytecodeMethod m : gen.getMethods()) { + if ("cas0".equals(m.getMethodName())) { + cas = m; + } + } + StringBuilder header = new StringBuilder(); + cas.appendMethodHeader(header, Util.mangle(u.generated)); + String proto = header.toString().trim(); + proto = proto.substring(0, proto.length() - 1); + String ctype = u.kind == 'L' ? "JAVA_OBJECT" : u.kind == 'J' ? "JAVA_LONG" : "JAVA_INT"; + String member = "((struct obj__" + Util.mangle(u.owner) + "*)__cn1Arg1)->" + + field.getClsName() + "_" + field.getFieldName(); + c.append('\n').append(proto).append(" {\n"); + c.append(" ").append(ctype).append(" __cn1Expect = __cn1Arg2;\n"); + if (u.kind == 'L') { + c.append(" CN1_WRITE_BARRIER(__cn1Arg1, __cn1Arg3);\n"); + c.append(" CN1_SATB_DELETE(&").append(member).append(");\n"); + } + if (field.isVolatile()) { + c.append(" return atomic_compare_exchange_strong(&").append(member) + .append(", &__cn1Expect, __cn1Arg3) ? JAVA_TRUE : JAVA_FALSE;\n"); + } else { + c.append(" return __atomic_compare_exchange_n(&").append(member) + .append(", &__cn1Expect, __cn1Arg3, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST) ? JAVA_TRUE : JAVA_FALSE;\n"); + } + c.append("}\n"); + } + + /// `ServiceLoader$Registry.providers(Class)`: compares the class against each + /// service and returns a fresh array of its providers. + private static void emitRegistry() { + final Map> table = new LinkedHashMap>(); + for (Map.Entry> e : SERVICES.entrySet()) { + String service = e.getKey().replace('.', '/'); + if (Parser.getClassObject(Util.mangle(service)) == null) { + continue; + } + List impls = new ArrayList(); + for (String p : e.getValue()) { + String internal = p.replace('.', '/'); + ByteCodeClass bc = Parser.getClassObject(Util.mangle(internal)); + if (bc == null) { + // A provider whose class was not translated: the JDK would fail to + // load it too, and the loader skips it. + continue; + } + impls.add(internal); + } + if (!impls.isEmpty()) { + table.put(service, impls); + } + } + Parser.parseSynthetic(REGISTRY, new Parser.SyntheticEmitter() { + @Override + public void emit(ClassVisitor cv) { + cv.visit(CLASS_VERSION_8, Opcodes.ACC_FINAL | Opcodes.ACC_SYNTHETIC, REGISTRY, null, + "java/lang/Object", new String[0]); + MethodVisitor m = cv.visitMethod(Opcodes.ACC_PRIVATE, "", "()V", null, null); + m.visitCode(); + m.visitVarInsn(Opcodes.ALOAD, 0); + m.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + m.visitInsn(Opcodes.RETURN); + m.visitMaxs(1, 1); + m.visitEnd(); + + m = cv.visitMethod(Opcodes.ACC_STATIC, "providers", "(Ljava/lang/Class;)[Ljava/lang/Object;", null, null); + m.visitCode(); + for (Map.Entry> e : table.entrySet()) { + Label next = new Label(); + m.visitVarInsn(Opcodes.ALOAD, 0); + m.visitLdcInsn(Type.getObjectType(e.getKey())); + m.visitJumpInsn(Opcodes.IF_ACMPNE, next); + List impls = e.getValue(); + pushInt(m, impls.size()); + m.visitTypeInsn(Opcodes.ANEWARRAY, "java/lang/Object"); + for (int i = 0; i < impls.size(); i++) { + m.visitInsn(Opcodes.DUP); + pushInt(m, i); + m.visitTypeInsn(Opcodes.NEW, impls.get(i)); + m.visitInsn(Opcodes.DUP); + m.visitMethodInsn(Opcodes.INVOKESPECIAL, impls.get(i), "", "()V", false); + m.visitInsn(Opcodes.AASTORE); + } + m.visitInsn(Opcodes.ARETURN); + m.visitLabel(next); + } + m.visitInsn(Opcodes.ICONST_0); + m.visitTypeInsn(Opcodes.ANEWARRAY, "java/lang/Object"); + m.visitInsn(Opcodes.ARETURN); + m.visitMaxs(6, 1); + m.visitEnd(); + cv.visitEnd(); + } + }); + } + + private static void pushInt(MethodVisitor m, int v) { + if (v <= 5) { + m.visitInsn(Opcodes.ICONST_0 + v); + } else { + m.visitIntInsn(Opcodes.SIPUSH, v); + } + } +} diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java index 7634cd71a55..90fbba4839f 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java @@ -492,7 +492,7 @@ public void appendInstruction(StringBuilder b) { // CLOSED-WORLD DEVIRT FOR INTERFACES. This was not attempted at all -- // the block above is gated on INVOKEVIRTUAL -- and interface dispatch is // the most expensive call this VM makes: a class id load, a - // classToInterfaceMap row, an offset, a vtable slot, then an indirect + // interface map row (cn1_itix_), an offset, a vtable slot, then an indirect // branch, none of which the C compiler can see through. // // A closed world does not have to guess. If exactly one reachable class diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/TypeInstruction.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/TypeInstruction.java index 2f6a69ba945..08334f84964 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/TypeInstruction.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/TypeInstruction.java @@ -303,8 +303,8 @@ public void appendInstruction(StringBuilder b, List l) { // set, so it never takes the class monitor and cannot rely on // the monitor's release. Pairs with the __ATOMIC_RELEASE store // in ByteCodeClass. A plain load here let a thread see the flag - // set while the vtable / classToInterfaceMap rows it describes - // were still invisible. + // set while the vtable it describes was still invisible. (Interface + // dispatch maps were once built here too; they are constant data now.) if(stackBuilderBytes > 0) { b.append("cn1StackBufferReset(&__cn1sbscope_").append(stackAllocId).append("); "); } diff --git a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js index 833715d51e7..c38925ee2a8 100644 --- a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js +++ b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js @@ -1128,6 +1128,52 @@ const jvm = { } return cls.classObject; }, + // JVMS 5.5 step 7: initializing a class also initializes every + // superinterface (direct or inherited through other interfaces; the superclass chain's own are initialized by the baseClass call before this walk) + // that declares a non-abstract, non-static method -- a default method. Such + // a method runs against an instance whose class never named the interface + // in a GETSTATIC, and the translator elides the init guard on a static read + // of the method's own class, so without this a Kotlin interface default + // like Compose's ``MonotonicFrameClock.getKey()`` (``= Key``) read the + // companion before the interface's ran and answered null. + // The translator flags an interface declaring a default method with ``D`` + // (its method map is no test: a devirtualized default is called directly + // and never listed). Only interfaces with a clinit matter. + // Computed once per class. + defaultMethodInterfacesToInit(cls) { + let out = cls.__defaultIfaceInit; + if (out !== undefined) { + return out; + } + out = []; + const seen = Object.create(null); + const visit = (ifaceName) => { + if (seen[ifaceName]) { + return; + } + seen[ifaceName] = 1; + const def = this.classes[ifaceName]; + if (!def) { + return; + } + const supers = def.interfaces || []; + for (let i = 0; i < supers.length; i++) { + visit(supers[i]); + } + if (def.isInterface && def.D + && (def.clinit || this.nativeMethods["cn1_" + ifaceName + "___CLINIT__"])) { + out.push(ifaceName); + } + }; + if (!cls.isInterface) { + const own = cls.interfaces || []; + for (let i = 0; i < own.length; i++) { + visit(own[i]); + } + } + cls.__defaultIfaceInit = out; + return out; + }, ensureClassInitialized(className) { const cls = this.classes[className]; if (!cls) { @@ -1144,6 +1190,10 @@ const jvm = { if (cls.baseClass) { this.ensureClassInitialized(cls.baseClass); } + const defaultIfaces = this.defaultMethodInterfacesToInit(cls); + for (let i = 0; i < defaultIfaces.length; i++) { + this.ensureClassInitialized(defaultIfaces[i]); + } // NOTE: ``initialized`` is only set after the clinit completes // successfully (see end of this function). Re-entrant reads from // within the clinit early-return on ``initializing`` above. If the @@ -1286,6 +1336,10 @@ const jvm = { if (cls.baseClass) { yield* this.ensureClassInitializedSuspending(cls.baseClass); } + const defaultIfaces = this.defaultMethodInterfacesToInit(cls); + for (let i = 0; i < defaultIfaces.length; i++) { + yield* this.ensureClassInitializedSuspending(defaultIfaces[i]); + } const clinitMethodId = "cn1_" + className + "___CLINIT__"; const clinit = this.nativeMethods[clinitMethodId] || cls.clinit; if (clinit) { diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 4b58d969d4b..88606bcbc8f 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -1275,7 +1275,11 @@ JAVA_LONG java_io_FileInputStream_openImpl___java_lang_String_R_long(CODENAME_ON valid across the safepoint. */ FILE* f; CN1_YIELD_THREAD; +#if defined(_WIN32) + f = cn1_win_fopen_utf8(path, "rb"); +#else f = fopen(path, "rb"); +#endif if(f != NULL) { /* Same reasoning, and the same 16KB ceiling for the same reason: it is * dirty memory held per open stream. */ @@ -1301,7 +1305,9 @@ JAVA_LONG java_io_FileInputStream_openImpl___java_lang_String_R_long(CODENAME_ON * * 2. PATHS ARE PASSED TO THE NARROW CRT. stringToUTF8 produces UTF-8, and the * Windows CRT's fopen reads it in the active ANSI code page, so a path holding - * a non-ASCII user or file name fails to open. The same mismatch runs through + * a non-ASCII user or file name fails to open. The two stream opens below no + * longer do (cn1_win_fopen_utf8, which also lifts MAX_PATH: the self-hosted + * translator failed on a 270-character Kotlin class path); the rest still do. The same mismatch runs through * java_io_File.m's stat/access/FindFirstFile calls. cn1_db_sqlite_impl.h around * line 196 already documents this exact problem and converts UTF-8 to UTF-16 * before calling the wide API; the file layer needs the same treatment applied @@ -1459,7 +1465,11 @@ JAVA_LONG java_io_FileOutputStream_openImpl___java_lang_String_boolean_R_long(CO /* The mirror of the read side: opening a FIFO for write waits for a reader. */ FILE* f; CN1_YIELD_THREAD; +#if defined(_WIN32) + f = cn1_win_fopen_utf8(path, append ? "ab" : "wb"); +#else f = fopen(path, append ? "ab" : "wb"); +#endif if(f != NULL) { /* stdio's default buffer is a few kilobytes, so writing a file of any * size becomes that many write(2) calls. A bigger one cuts them by the diff --git a/vm/JavaAPI/src/java/lang/Class.java b/vm/JavaAPI/src/java/lang/Class.java index b679094dae9..1a352e2d5e9 100644 --- a/vm/JavaAPI/src/java/lang/Class.java +++ b/vm/JavaAPI/src/java/lang/Class.java @@ -559,6 +559,56 @@ public java.lang.reflect.Type[] getGenericInterfaces() { public native boolean isPrimitive(); + /// Reflective lookups find nothing on this platform: the translator keeps no member + /// tables, and strips what nothing calls. + public Method getDeclaredMethod(String name, Class... parameterTypes) throws NoSuchMethodException { + throw new NoSuchMethodException(name); + } + + public Method getMethod(String name, Class... parameterTypes) throws NoSuchMethodException { + throw new NoSuchMethodException(name); + } + + public Constructor[] getConstructors() { + return new Constructor[0]; + } + + public Constructor[] getDeclaredConstructors() { + return new Constructor[0]; + } + + public Constructor getDeclaredConstructor(Class... parameterTypes) throws NoSuchMethodException { + throw new NoSuchMethodException(""); + } + + public Constructor getConstructor(Class... parameterTypes) throws NoSuchMethodException { + throw new NoSuchMethodException(""); + } + + public Method[] getMethods() { + return new Method[0]; + } + + public Method[] getDeclaredMethods() { + return new Method[0]; + } + + public java.lang.reflect.Field[] getFields() { + return new java.lang.reflect.Field[0]; + } + + public java.lang.reflect.Field[] getDeclaredFields() { + return new java.lang.reflect.Field[0]; + } + + public java.lang.reflect.Field getDeclaredField(String name) throws NoSuchFieldException { + throw new NoSuchFieldException(name); + } + + public java.lang.reflect.Field getField(String name) throws NoSuchFieldException { + throw new NoSuchFieldException(name); + } + public Method getEnclosingMethod() { return null; } diff --git a/vm/JavaAPI/src/java/lang/ClassLoader.java b/vm/JavaAPI/src/java/lang/ClassLoader.java index 8c5dd8b4520..e132f8d25e8 100644 --- a/vm/JavaAPI/src/java/lang/ClassLoader.java +++ b/vm/JavaAPI/src/java/lang/ClassLoader.java @@ -49,4 +49,19 @@ public static InputStream getSystemResourceAsStream(String name) { throw new UnsupportedOperationException(); } + /// Always empty: nothing on a device can be listed by name the way a class path's + /// entries can. Code that discovers services this way (kotlinx.coroutines' + /// fast loader) then falls back to [java.util.ServiceLoader], which the translator + /// fills from the `META-INF/services` files it was given. + public Class loadClass(String name) throws ClassNotFoundException { + throw new ClassNotFoundException(name); + } + + /// There is no class path to search. Fails rather than answering an empty + /// enumeration, so that a service lookup scanning `META-INF/services` falls back to + /// [java.util.ServiceLoader], which the translator fills in at build time. + public java.util.Enumeration getResources(String name) throws java.io.IOException { + throw new java.io.IOException("no class path on this platform: " + name); + } + } \ No newline at end of file diff --git a/vm/JavaAPI/src/java/lang/ClassValue.java b/vm/JavaAPI/src/java/lang/ClassValue.java new file mode 100644 index 00000000000..2debf0a6ba6 --- /dev/null +++ b/vm/JavaAPI/src/java/lang/ClassValue.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2018, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.lang; + +import java.util.HashMap; + +/// Lazily computes one value per class and keeps it until [#remove(Class)]. +public abstract class ClassValue { + private final HashMap, Object> values = new HashMap, Object>(); + private static final Object NULL = new Object(); + + protected ClassValue() { + } + + protected abstract T computeValue(Class type); + + @SuppressWarnings("unchecked") + public T get(Class type) { + Object v; + synchronized (values) { + v = values.get(type); + } + if (v == null) { + T computed = computeValue(type); + synchronized (values) { + Object raced = values.get(type); + if (raced != null) { + v = raced; + } else { + v = computed == null ? NULL : computed; + values.put(type, v); + } + } + } + return v == NULL ? null : (T) v; + } + + public void remove(Class type) { + synchronized (values) { + values.remove(type); + } + } +} diff --git a/vm/JavaAPI/src/java/lang/NoSuchFieldException.java b/vm/JavaAPI/src/java/lang/NoSuchFieldException.java new file mode 100644 index 00000000000..cf1085ae615 --- /dev/null +++ b/vm/JavaAPI/src/java/lang/NoSuchFieldException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2018, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.lang; + +/// Thrown by the reflective lookups, which find nothing on this platform. +public class NoSuchFieldException extends Exception { + public NoSuchFieldException() { + } + + public NoSuchFieldException(String message) { + super(message); + } +} diff --git a/vm/JavaAPI/src/java/lang/NoSuchMethodException.java b/vm/JavaAPI/src/java/lang/NoSuchMethodException.java new file mode 100644 index 00000000000..88442db9a3c --- /dev/null +++ b/vm/JavaAPI/src/java/lang/NoSuchMethodException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2018, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.lang; + +/// Thrown by the reflective lookups, which find nothing on this platform. +public class NoSuchMethodException extends Exception { + public NoSuchMethodException() { + } + + public NoSuchMethodException(String message) { + super(message); + } +} diff --git a/vm/JavaAPI/src/java/lang/Thread.java b/vm/JavaAPI/src/java/lang/Thread.java index a3c1b0d302f..181660cf5d2 100644 --- a/vm/JavaAPI/src/java/lang/Thread.java +++ b/vm/JavaAPI/src/java/lang/Thread.java @@ -66,6 +66,8 @@ public class Thread implements java.lang.Runnable{ private Runnable target; private boolean alive; + private boolean terminated; + private ClassLoader contextClassLoader; private String name; private int priority = NORM_PRIORITY; private long nativeThreadId; @@ -249,6 +251,28 @@ public boolean isInterrupted() { private native boolean isInterrupted(boolean clearInterrupted); + /// A thread's state, as far as this runtime tracks it: a thread that is alive is + /// reported RUNNABLE whether or not it is blocked. + public enum State { + NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED + } + + /// Kept for code that installs one; nothing here loads classes through it. + public ClassLoader getContextClassLoader() { + return contextClassLoader; + } + + public void setContextClassLoader(ClassLoader cl) { + contextClassLoader = cl; + } + + public State getState() { + if (alive) { + return State.RUNNABLE; + } + return terminated ? State.TERMINATED : State.NEW; + } + /** * Tests if this thread is alive. A thread is alive if it has been started and has not yet died. */ @@ -332,6 +356,7 @@ private void runImpl(long tid) { threadLocalValues = null; synchronized(this) { alive = false; + terminated = true; notifyAll(); } } diff --git a/vm/JavaAPI/src/java/lang/ref/ReferenceQueue.java b/vm/JavaAPI/src/java/lang/ref/ReferenceQueue.java new file mode 100644 index 00000000000..3bbcb22f3ea --- /dev/null +++ b/vm/JavaAPI/src/java/lang/ref/ReferenceQueue.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2018, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.lang.ref; + +/// A queue the collector never enqueues into: this collector clears references but +/// does not post them. [#poll()] therefore always answers null and [#remove()] blocks +/// until the thread is interrupted, which is what a cleaning loop waiting on it expects +/// of a queue that stays empty. +public class ReferenceQueue { + public ReferenceQueue() { + } + + public Reference poll() { + return null; + } + + public Reference remove() throws InterruptedException { + synchronized (this) { + while (true) { + wait(); + } + } + } + + public Reference remove(long timeout) throws InterruptedException { + if (timeout < 0) { + throw new IllegalArgumentException("negative timeout"); + } + synchronized (this) { + if (timeout == 0) { + while (true) { + wait(); + } + } + wait(timeout); + } + return null; + } +} diff --git a/vm/JavaAPI/src/java/lang/ref/WeakReference.java b/vm/JavaAPI/src/java/lang/ref/WeakReference.java index a1af9be37e1..10c425b4e07 100644 --- a/vm/JavaAPI/src/java/lang/ref/WeakReference.java +++ b/vm/JavaAPI/src/java/lang/ref/WeakReference.java @@ -58,4 +58,12 @@ public class WeakReference extends java.lang.ref.Reference{ public WeakReference(java.lang.Object ref){ super(ref, STRENGTH_WEAK); } + + /** + * Creates a weak reference registered with a queue. The collector never posts to + * a {@link ReferenceQueue} here, so the queue is accepted and not kept. + */ + public WeakReference(java.lang.Object ref, ReferenceQueue q){ + super(ref, STRENGTH_WEAK); + } } diff --git a/vm/JavaAPI/src/java/lang/reflect/Constructor.java b/vm/JavaAPI/src/java/lang/reflect/Constructor.java index d7968c78c60..ea490f5aaf9 100644 --- a/vm/JavaAPI/src/java/lang/reflect/Constructor.java +++ b/vm/JavaAPI/src/java/lang/reflect/Constructor.java @@ -23,6 +23,19 @@ package java.lang.reflect; public class Constructor { + public Class[] getParameterTypes() { + return new Class[0]; + } + + public int getModifiers() { + return 0; + } + + public Object newInstance(Object... args) throws InstantiationException, IllegalAccessException, + InvocationTargetException { + throw new InstantiationException("reflection is not supported on this platform"); + } + public String getName() { return null; } diff --git a/vm/JavaAPI/src/java/lang/reflect/Field.java b/vm/JavaAPI/src/java/lang/reflect/Field.java new file mode 100644 index 00000000000..57b69cf01ce --- /dev/null +++ b/vm/JavaAPI/src/java/lang/reflect/Field.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.lang.reflect; + +/// Declared so that code which reflects on a field as a fallback links. No lookup ever +/// answers one: [Class#getDeclaredField(String)] throws `NoSuchFieldException`. +public class Field { + Field() { + } + + public String getName() { + return null; + } + + public Class getType() { + return null; + } + + public int getModifiers() { + return 0; + } + + public void setAccessible(boolean flag) { + } + + public Object get(Object obj) throws IllegalAccessException { + throw new IllegalAccessException("reflection is not supported on this platform"); + } + + public void set(Object obj, Object value) throws IllegalAccessException { + throw new IllegalAccessException("reflection is not supported on this platform"); + } +} diff --git a/vm/JavaAPI/src/java/lang/reflect/GenericDeclaration.java b/vm/JavaAPI/src/java/lang/reflect/GenericDeclaration.java new file mode 100644 index 00000000000..26a2ec7c7b6 --- /dev/null +++ b/vm/JavaAPI/src/java/lang/reflect/GenericDeclaration.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2018, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.lang.reflect; + +/// Something that declares type variables. Nothing on this platform reports any. +public interface GenericDeclaration { + TypeVariable[] getTypeParameters(); +} diff --git a/vm/JavaAPI/src/java/lang/reflect/Method.java b/vm/JavaAPI/src/java/lang/reflect/Method.java index 3d292b37f21..adb6d89a510 100644 --- a/vm/JavaAPI/src/java/lang/reflect/Method.java +++ b/vm/JavaAPI/src/java/lang/reflect/Method.java @@ -23,6 +23,25 @@ package java.lang.reflect; public class Method { + public Object invoke(Object obj, Object... args) throws IllegalAccessException, InvocationTargetException { + throw new IllegalAccessException("reflection is not supported on this platform"); + } + + public void setAccessible(boolean flag) { + } + + public Class[] getParameterTypes() { + return new Class[0]; + } + + public Class getReturnType() { + return null; + } + + public int getModifiers() { + return 0; + } + public String getName() { return null; } diff --git a/vm/JavaAPI/src/java/lang/reflect/Modifier.java b/vm/JavaAPI/src/java/lang/reflect/Modifier.java new file mode 100644 index 00000000000..713472cc605 --- /dev/null +++ b/vm/JavaAPI/src/java/lang/reflect/Modifier.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2018, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.lang.reflect; + +/// The JVM access flags, for code that decodes a member's modifiers. +public class Modifier { + public static final int PUBLIC = 0x0001; + public static final int PRIVATE = 0x0002; + public static final int PROTECTED = 0x0004; + public static final int STATIC = 0x0008; + public static final int FINAL = 0x0010; + public static final int SYNCHRONIZED = 0x0020; + public static final int VOLATILE = 0x0040; + public static final int TRANSIENT = 0x0080; + public static final int NATIVE = 0x0100; + public static final int INTERFACE = 0x0200; + public static final int ABSTRACT = 0x0400; + public static final int STRICT = 0x0800; + + public Modifier() { + } + + public static boolean isPublic(int mod) { return (mod & PUBLIC) != 0; } + public static boolean isPrivate(int mod) { return (mod & PRIVATE) != 0; } + public static boolean isProtected(int mod) { return (mod & PROTECTED) != 0; } + public static boolean isStatic(int mod) { return (mod & STATIC) != 0; } + public static boolean isFinal(int mod) { return (mod & FINAL) != 0; } + public static boolean isSynchronized(int mod) { return (mod & SYNCHRONIZED) != 0; } + public static boolean isVolatile(int mod) { return (mod & VOLATILE) != 0; } + public static boolean isTransient(int mod) { return (mod & TRANSIENT) != 0; } + public static boolean isNative(int mod) { return (mod & NATIVE) != 0; } + public static boolean isInterface(int mod) { return (mod & INTERFACE) != 0; } + public static boolean isAbstract(int mod) { return (mod & ABSTRACT) != 0; } + public static boolean isStrict(int mod) { return (mod & STRICT) != 0; } +} diff --git a/vm/JavaAPI/src/java/net/URLStreamHandler.java b/vm/JavaAPI/src/java/net/URLStreamHandler.java new file mode 100644 index 00000000000..c6c642ed2e3 --- /dev/null +++ b/vm/JavaAPI/src/java/net/URLStreamHandler.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2018, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.net; + +/// Declared so that the [URL] constructor taking a handler links. No handler is +/// ever installed on this platform. +public abstract class URLStreamHandler { + public URLStreamHandler() { + } +} diff --git a/vm/JavaAPI/src/java/util/HashMap.java b/vm/JavaAPI/src/java/util/HashMap.java index 45235d0c6b6..4744bd653a4 100644 --- a/vm/JavaAPI/src/java/util/HashMap.java +++ b/vm/JavaAPI/src/java/util/HashMap.java @@ -566,7 +566,18 @@ public void putAll(Map map) { } } + @SuppressWarnings("unchecked") private void putAllImpl(Map map) { + if (map instanceof HashMap) { + // Straight from the source's slots: the entry iterator below builds + // an Entry per mapping, which copying a 5,000-key theme made garbage. + HashMap source = (HashMap) map; + for (int i = source.cn1FirstIndex(); i >= 0; i = source.cn1NextIndex(i)) { + put((K) NativeStorage.get(source.cn1KeysBlock, i), + (V) NativeStorage.get(NativeStorage.part(source.cn1KeysBlock, VALS), i)); + } + return; + } Iterator it = map.entrySet().iterator(); while (it.hasNext()) { Map.Entry entry = (Map.Entry) it.next(); diff --git a/vm/JavaAPI/src/java/util/Hashtable.java b/vm/JavaAPI/src/java/util/Hashtable.java index b4edc1bdd09..da37414a0ac 100644 --- a/vm/JavaAPI/src/java/util/Hashtable.java +++ b/vm/JavaAPI/src/java/util/Hashtable.java @@ -87,9 +87,27 @@ public void remove() { *

There are no node objects in this representation, so unlike the * chained layout this replaced -- where the Entry WAS the storage and cost * an allocation per mapping -- an Entry is built only when an iterator - * hands one out, and only for {@code entrySet()}. It keeps the table and + * hands one out from {@code entrySet()}. It keeps the table and * slot so {@link #setValue} still writes through. */ + /** + * Markers for key and value iterators, which {@link HashIterator#at} reads + * from the slot without building an Entry; never called. + */ + @SuppressWarnings("rawtypes") + private static final MapEntry.Type KEYS = new MapEntry.Type() { + public Object get(MapEntry entry) { + return entry.key; + } + }; + + @SuppressWarnings("rawtypes") + private static final MapEntry.Type VALUES = new MapEntry.Type() { + public Object get(MapEntry entry) { + return entry.value; + } + }; + private static class Entry extends MapEntry { private final Hashtable table; @@ -240,6 +258,23 @@ private class HashIterator implements Iterator { expectedModCount = modCount; } + /** + * The element at an occupied slot. A key or value iterator reads the + * slot directly; only an entry iterator builds an {@link Entry}. A + * theme load walks its tables' keys several times, and an Entry per + * key was 16,000 objects of garbage per launch. + */ + @SuppressWarnings("unchecked") + final E at(int idx) { + if (type == KEYS) { + return (E) NativeStorage.get(cn1Keys, idx); + } + if (type == VALUES) { + return (E) NativeStorage.get(NativeStorage.part(cn1Keys, VALS), idx); + } + return type.get(new Entry(Hashtable.this, idx)); + } + /** Advance past empty and tombstoned slots. */ final boolean advance() { long meta = NativeStorage.part(cn1Keys, META); @@ -266,7 +301,7 @@ public E next() { lastPosition = position; position++; canRemove = true; - return type.get(new Entry(Hashtable.this, lastPosition)); + return at(lastPosition); } public void remove() { @@ -448,11 +483,7 @@ public synchronized java.util.Enumeration elements() { if (elementCount == 0) { return (java.util.Enumeration) EMPTY_ENUMERATION; } - return new HashEnumIterator(new MapEntry.Type() { - public V get(MapEntry entry) { - return entry.value; - } - }, true); + return new HashEnumIterator((MapEntry.Type) VALUES, true); } /** @@ -614,11 +645,7 @@ public synchronized java.util.Enumeration keys() { if (elementCount == 0) { return (java.util.Enumeration) EMPTY_ENUMERATION; } - return new HashEnumIterator(new MapEntry.Type() { - public K get(MapEntry entry) { - return entry.key; - } - }, true); + return new HashEnumIterator((MapEntry.Type) KEYS, true); } /** @@ -659,11 +686,7 @@ public Iterator iterator() { if (this.size() == 0) { return (Iterator) EMPTY_ITERATOR; } - return new HashEnumIterator(new MapEntry.Type() { - public K get(MapEntry entry) { - return entry.key; - } - }); + return new HashEnumIterator((MapEntry.Type) KEYS); } }, this); } @@ -715,7 +738,7 @@ public E nextElement() { } int idx = position; position++; - return type.get(new Entry(Hashtable.this, idx)); + return at(idx); } return super.next(); } @@ -925,11 +948,7 @@ public void clear() { @Override public Iterator iterator() { return new HashIterator( - new MapEntry.Type() { - public V get(MapEntry entry) { - return entry.value; - } - }); + (MapEntry.Type) VALUES); } }, this); } diff --git a/vm/JavaAPI/src/java/util/ServiceLoader.java b/vm/JavaAPI/src/java/util/ServiceLoader.java new file mode 100644 index 00000000000..104a916d34e --- /dev/null +++ b/vm/JavaAPI/src/java/util/ServiceLoader.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util; + +/// The providers of a service, as in the JDK -- declared at build time. +/// +/// A device loads no classes at run time, so there is no class path to search. The +/// translator reads every `META-INF/services` file among the classes it translates and +/// generates [Registry#providers(Class)], which instantiates the providers a file +/// names, in the order it names them; a provider that is not among the translated +/// classes is left out. A service with no file has no providers. The class loader +/// argument is accepted and ignored: there is only one. +public final class ServiceLoader implements Iterable { + private final Class service; + private List providers; + + private ServiceLoader(Class service) { + this.service = service; + } + + public static ServiceLoader load(Class service) { + return new ServiceLoader(service); + } + + public static ServiceLoader load(Class service, ClassLoader loader) { + return new ServiceLoader(service); + } + + public static ServiceLoader loadInstalled(Class service) { + return new ServiceLoader(service); + } + + public void reload() { + providers = null; + } + + @SuppressWarnings("unchecked") + public Iterator iterator() { + if (providers == null) { + Object[] found = Registry.providers(service); + List l = new ArrayList(found.length); + for (int i = 0; i < found.length; i++) { + l.add((S) found[i]); + } + providers = l; + } + return Collections.unmodifiableList(providers).iterator(); + } + + public String toString() { + return "java.util.ServiceLoader[" + service.getName() + "]"; + } + + /// Replaced by the translator with the providers of every `META-INF/services` + /// file it was given; this body is what an application with none of them gets. + static final class Registry { + private Registry() { + } + + static Object[] providers(Class service) { + return new Object[0]; + } + } +} diff --git a/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicIntegerArray.java b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicIntegerArray.java new file mode 100644 index 00000000000..7e3108a1596 --- /dev/null +++ b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicIntegerArray.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util.concurrent.atomic; + +/// An array of `int` updated atomically, as in the JDK. +/// +/// Every access goes through this class, so one lock per array makes each operation +/// atomic, the way [AtomicInteger] does it. +public class AtomicIntegerArray { + private final Object lock = new Object(); + private final int[] array; + + public AtomicIntegerArray(int length) { + array = new int[length]; + } + + public AtomicIntegerArray(int[] source) { + array = new int[source.length]; + System.arraycopy(source, 0, array, 0, source.length); + } + + public final int length() { + return array.length; + } + + public final int get(int i) { + synchronized (lock) { + return array[i]; + } + } + + public final void set(int i, int newValue) { + synchronized (lock) { + array[i] = newValue; + } + } + + public final void lazySet(int i, int newValue) { + set(i, newValue); + } + + public final int getAndSet(int i, int newValue) { + synchronized (lock) { + int old = array[i]; + array[i] = newValue; + return old; + } + } + + public final boolean compareAndSet(int i, int expect, int update) { + synchronized (lock) { + if (array[i] == expect) { + array[i] = update; + return true; + } + return false; + } + } + + public final boolean weakCompareAndSet(int i, int expect, int update) { + return compareAndSet(i, expect, update); + } + + public final int getAndAdd(int i, int delta) { + synchronized (lock) { + int old = array[i]; + array[i] = old + delta; + return old; + } + } + + public final int addAndGet(int i, int delta) { + return getAndAdd(i, delta) + delta; + } + + public final int getAndIncrement(int i) { + return getAndAdd(i, 1); + } + + public final int getAndDecrement(int i) { + return getAndAdd(i, -1); + } + + public final int incrementAndGet(int i) { + return getAndAdd(i, 1) + 1; + } + + public final int decrementAndGet(int i) { + return getAndAdd(i, -1) - 1; + } + + public String toString() { + StringBuilder b = new StringBuilder("["); + for (int i = 0; i < array.length; i++) { + if (i > 0) { + b.append(", "); + } + b.append(get(i)); + } + return b.append(']').toString(); + } +} diff --git a/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicIntegerFieldUpdater.java b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicIntegerFieldUpdater.java new file mode 100644 index 00000000000..6b60ab3dbba --- /dev/null +++ b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicIntegerFieldUpdater.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util.concurrent.atomic; + +/// Atomic access to a volatile `int` field of a class, as in the JDK. +/// +/// Resolved by the translator exactly as [AtomicReferenceFieldUpdater] is: see that +/// class for how, and for what a call with non-constant arguments does. Only +/// [#get], [#set] and [#compareAndSet] are generated per field; every other operation +/// is a compare-and-set loop over them. +public abstract class AtomicIntegerFieldUpdater { + /// For the subclasses the translator generates. + protected AtomicIntegerFieldUpdater() { + } + + /// Resolved by the translator when every argument is a constant; reaching this + /// method means it could not be. + public static AtomicIntegerFieldUpdater newUpdater(Class tclass, String fieldName) { + throw new UnsupportedOperationException("Field updaters must name their class and field as constants: " + fieldName); + } + + public abstract boolean compareAndSet(T obj, int expect, int update); + + public boolean weakCompareAndSet(T obj, int expect, int update) { + return compareAndSet(obj, expect, update); + } + + public abstract void set(T obj, int newValue); + + public void lazySet(T obj, int newValue) { + set(obj, newValue); + } + + public abstract int get(T obj); + + public int getAndSet(T obj, int newValue) { + int prev; + do { + prev = get(obj); + } while (!compareAndSet(obj, prev, newValue)); + return prev; + } + + public int getAndAdd(T obj, int delta) { + int prev; + do { + prev = get(obj); + } while (!compareAndSet(obj, prev, prev + delta)); + return prev; + } + + public int addAndGet(T obj, int delta) { + return getAndAdd(obj, delta) + delta; + } + + public int getAndIncrement(T obj) { + return getAndAdd(obj, 1); + } + + public int getAndDecrement(T obj) { + return getAndAdd(obj, -1); + } + + public int incrementAndGet(T obj) { + return getAndAdd(obj, 1) + 1; + } + + public int decrementAndGet(T obj) { + return getAndAdd(obj, -1) - 1; + } + + /// Equality, for the branch-free compare-and-set the translator generates where + /// every thread shares one host thread (JavaScript). + protected static boolean cn1Same(int a, int b) { + return a == b; + } + + /// `c ? a : b`, for the same generated code. + protected static int cn1Select(boolean c, int a, int b) { + return c ? a : b; + } +} diff --git a/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicLongArray.java b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicLongArray.java new file mode 100644 index 00000000000..1d07a8b3100 --- /dev/null +++ b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicLongArray.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util.concurrent.atomic; + +/// An array of `long` updated atomically, as in the JDK. +/// +/// Every access goes through this class, so one lock per array makes each operation +/// atomic, the way [AtomicLong] does it. +public class AtomicLongArray { + private final Object lock = new Object(); + private final long[] array; + + public AtomicLongArray(int length) { + array = new long[length]; + } + + public AtomicLongArray(long[] source) { + array = new long[source.length]; + System.arraycopy(source, 0, array, 0, source.length); + } + + public final int length() { + return array.length; + } + + public final long get(int i) { + synchronized (lock) { + return array[i]; + } + } + + public final void set(int i, long newValue) { + synchronized (lock) { + array[i] = newValue; + } + } + + public final void lazySet(int i, long newValue) { + set(i, newValue); + } + + public final long getAndSet(int i, long newValue) { + synchronized (lock) { + long old = array[i]; + array[i] = newValue; + return old; + } + } + + public final boolean compareAndSet(int i, long expect, long update) { + synchronized (lock) { + if (array[i] == expect) { + array[i] = update; + return true; + } + return false; + } + } + + public final boolean weakCompareAndSet(int i, long expect, long update) { + return compareAndSet(i, expect, update); + } + + public final long getAndAdd(int i, long delta) { + synchronized (lock) { + long old = array[i]; + array[i] = old + delta; + return old; + } + } + + public final long addAndGet(int i, long delta) { + return getAndAdd(i, delta) + delta; + } + + public final long getAndIncrement(int i) { + return getAndAdd(i, 1); + } + + public final long getAndDecrement(int i) { + return getAndAdd(i, -1); + } + + public final long incrementAndGet(int i) { + return getAndAdd(i, 1) + 1; + } + + public final long decrementAndGet(int i) { + return getAndAdd(i, -1) - 1; + } + + public String toString() { + StringBuilder b = new StringBuilder("["); + for (int i = 0; i < array.length; i++) { + if (i > 0) { + b.append(", "); + } + b.append(get(i)); + } + return b.append(']').toString(); + } +} diff --git a/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicLongFieldUpdater.java b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicLongFieldUpdater.java new file mode 100644 index 00000000000..1ae2d627944 --- /dev/null +++ b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicLongFieldUpdater.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util.concurrent.atomic; + +/// Atomic access to a volatile `long` field of a class, as in the JDK. +/// +/// Resolved by the translator exactly as [AtomicReferenceFieldUpdater] is: see that +/// class for how, and for what a call with non-constant arguments does. Only +/// [#get], [#set] and [#compareAndSet] are generated per field; every other operation +/// is a compare-and-set loop over them. +public abstract class AtomicLongFieldUpdater { + /// For the subclasses the translator generates. + protected AtomicLongFieldUpdater() { + } + + /// Resolved by the translator when every argument is a constant; reaching this + /// method means it could not be. + public static AtomicLongFieldUpdater newUpdater(Class tclass, String fieldName) { + throw new UnsupportedOperationException("Field updaters must name their class and field as constants: " + fieldName); + } + + public abstract boolean compareAndSet(T obj, long expect, long update); + + public boolean weakCompareAndSet(T obj, long expect, long update) { + return compareAndSet(obj, expect, update); + } + + public abstract void set(T obj, long newValue); + + public void lazySet(T obj, long newValue) { + set(obj, newValue); + } + + public abstract long get(T obj); + + public long getAndSet(T obj, long newValue) { + long prev; + do { + prev = get(obj); + } while (!compareAndSet(obj, prev, newValue)); + return prev; + } + + public long getAndAdd(T obj, long delta) { + long prev; + do { + prev = get(obj); + } while (!compareAndSet(obj, prev, prev + delta)); + return prev; + } + + public long addAndGet(T obj, long delta) { + return getAndAdd(obj, delta) + delta; + } + + public long getAndIncrement(T obj) { + return getAndAdd(obj, 1); + } + + public long getAndDecrement(T obj) { + return getAndAdd(obj, -1); + } + + public long incrementAndGet(T obj) { + return getAndAdd(obj, 1) + 1; + } + + public long decrementAndGet(T obj) { + return getAndAdd(obj, -1) - 1; + } + + /// Equality, for the branch-free compare-and-set the translator generates where + /// every thread shares one host thread (JavaScript). + protected static boolean cn1Same(long a, long b) { + return a == b; + } + + /// `c ? a : b`, for the same generated code. + protected static long cn1Select(boolean c, long a, long b) { + return c ? a : b; + } +} diff --git a/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicReferenceArray.java b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicReferenceArray.java new file mode 100644 index 00000000000..e2a8b3494f3 --- /dev/null +++ b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicReferenceArray.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util.concurrent.atomic; + +/// An array of references updated atomically, as in the JDK. +/// +/// Every access goes through this class, so one lock per array makes each operation +/// atomic, the way [AtomicReference] does it. +public class AtomicReferenceArray { + private final Object lock = new Object(); + private final Object[] array; + + public AtomicReferenceArray(int length) { + array = new Object[length]; + } + + public AtomicReferenceArray(E[] source) { + array = new Object[source.length]; + System.arraycopy(source, 0, array, 0, source.length); + } + + public final int length() { + return array.length; + } + + @SuppressWarnings("unchecked") + public final E get(int i) { + synchronized (lock) { + return (E) array[i]; + } + } + + public final void set(int i, E newValue) { + synchronized (lock) { + array[i] = newValue; + } + } + + public final void lazySet(int i, E newValue) { + set(i, newValue); + } + + @SuppressWarnings("unchecked") + public final E getAndSet(int i, E newValue) { + synchronized (lock) { + Object old = array[i]; + array[i] = newValue; + return (E) old; + } + } + + public final boolean compareAndSet(int i, E expect, E update) { + synchronized (lock) { + if (array[i] == expect) { + array[i] = update; + return true; + } + return false; + } + } + + public final boolean weakCompareAndSet(int i, E expect, E update) { + return compareAndSet(i, expect, update); + } + + public String toString() { + StringBuilder b = new StringBuilder("["); + for (int i = 0; i < array.length; i++) { + if (i > 0) { + b.append(", "); + } + b.append(get(i)); + } + return b.append(']').toString(); + } +} diff --git a/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicReferenceFieldUpdater.java b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicReferenceFieldUpdater.java new file mode 100644 index 00000000000..46d44a197cd --- /dev/null +++ b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicReferenceFieldUpdater.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util.concurrent.atomic; + +/// Atomic access to a volatile reference field of a class, as in the JDK. +/// +/// The device has no reflection, so [#newUpdater(Class, Class, String)] cannot find a +/// field by name at run time. The translator resolves the call instead: wherever the +/// class, the value class and the field name are constants -- which is how javac, +/// kotlinc and atomicfu all emit it, in a static initializer -- it replaces the call +/// with an instance of a subclass it generates for that one field. The generated +/// [#compareAndSet] is a hardware compare-and-swap on the field, so it is atomic with +/// respect to plain volatile reads and writes of the field as well as to other +/// updaters. A call whose arguments are not constants reaches the method below and +/// throws. +public abstract class AtomicReferenceFieldUpdater { + /// For the subclasses the translator generates. + protected AtomicReferenceFieldUpdater() { + } + + /// Resolved by the translator when every argument is a constant; see the class + /// description. Reaching this method means it could not be. + public static AtomicReferenceFieldUpdater newUpdater(Class tclass, Class vclass, String fieldName) { + throw new UnsupportedOperationException("Field updaters must name their class and field as constants: " + fieldName); + } + + public abstract boolean compareAndSet(T obj, V expect, V update); + + public boolean weakCompareAndSet(T obj, V expect, V update) { + return compareAndSet(obj, expect, update); + } + + public abstract void set(T obj, V newValue); + + public void lazySet(T obj, V newValue) { + set(obj, newValue); + } + + public abstract V get(T obj); + + public V getAndSet(T obj, V newValue) { + V prev; + do { + prev = get(obj); + } while (!compareAndSet(obj, prev, newValue)); + return prev; + } + + /// Identity comparison, for the branch-free compare-and-set the translator + /// generates where every thread shares one host thread (JavaScript). + protected static boolean cn1Same(Object a, Object b) { + return a == b; + } + + /// , for the same generated code. + protected static Object cn1Select(boolean c, Object a, Object b) { + return c ? a : b; + } +} diff --git a/vm/JavaAPI/src/java/util/concurrent/locks/LockSupport.java b/vm/JavaAPI/src/java/util/concurrent/locks/LockSupport.java new file mode 100644 index 00000000000..6b0c1c32594 --- /dev/null +++ b/vm/JavaAPI/src/java/util/concurrent/locks/LockSupport.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2018, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util.concurrent.locks; + +import java.util.WeakHashMap; + +/// Thread parking, with the JDK's permit semantics: [#unpark(Thread)] before +/// [#park()] makes that park return at once, and a park may also return spuriously +/// or because the thread was interrupted, whose status it leaves set. +/// +/// Each thread gets a monitor of its own the first time it parks or is unparked, +/// kept in a weak map so a finished thread does not hold it. +public class LockSupport { + private static final WeakHashMap PARKERS = new WeakHashMap(); + + private LockSupport() { + } + + private static final class Parker { + boolean permit; + } + + private static Parker parker(Thread t) { + synchronized (PARKERS) { + Parker p = PARKERS.get(t); + if (p == null) { + p = new Parker(); + PARKERS.put(t, p); + } + return p; + } + } + + public static void unpark(Thread thread) { + if (thread == null) { + return; + } + Parker p = parker(thread); + synchronized (p) { + p.permit = true; + p.notifyAll(); + } + } + + public static void park() { + block(0, true); + } + + public static void park(Object blocker) { + park(); + } + + public static void parkNanos(long nanos) { + if (nanos > 0) { + // Object.wait has millisecond resolution here; round up so a short park + // still waits rather than spinning. + block((nanos + 999999L) / 1000000L, false); + } + } + + public static void parkNanos(Object blocker, long nanos) { + parkNanos(nanos); + } + + public static void parkUntil(long deadline) { + long millis = deadline - System.currentTimeMillis(); + if (millis > 0) { + block(millis, false); + } + } + + public static void parkUntil(Object blocker, long deadline) { + parkUntil(deadline); + } + + public static Object getBlocker(Thread t) { + return null; + } + + private static void block(long millis, boolean forever) { + Thread self = Thread.currentThread(); + Parker p = parker(self); + synchronized (p) { + if (!p.permit && !self.isInterrupted()) { + try { + if (forever) { + p.wait(); + } else { + p.wait(millis); + } + } catch (InterruptedException e) { + // park reports an interrupt by returning with the status still set. + self.interrupt(); + } + } + p.permit = false; + } + } +} diff --git a/vm/JavaAPI/src/java/util/jar/JarFile.java b/vm/JavaAPI/src/java/util/jar/JarFile.java new file mode 100644 index 00000000000..f54ee185ae4 --- /dev/null +++ b/vm/JavaAPI/src/java/util/jar/JarFile.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.util.jar; + +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.ZipEntry; + +/// Declared so that code reading a jar from its class path links. An application has no +/// class path here, so opening one always fails. +public class JarFile { + public JarFile(String name) throws IOException { + this(name, true); + } + + public JarFile(String name, boolean verify) throws IOException { + throw new IOException("no jar files on this platform: " + name); + } + + public InputStream getInputStream(ZipEntry entry) throws IOException { + throw new IOException("no jar files on this platform"); + } + + public void close() throws IOException { + } +} diff --git a/vm/tests/pom.xml b/vm/tests/pom.xml index fd3537d3773..d74b0e780e0 100644 --- a/vm/tests/pom.xml +++ b/vm/tests/pom.xml @@ -89,6 +89,15 @@ + + + + google + https://maven.google.com + + + @@ -107,6 +116,53 @@ test + + + copy-kmp-runtime + generate-test-resources + + copy + + + ${project.build.directory}/kmp-runtime + + org.jetbrains.kotlinkotlin-stdlib2.4.21 + org.jetbrains.kotlinxkotlinx-coroutines-core-jvm1.11.0 + androidx.compose.runtimeruntime-desktop1.12.1 + androidx.compose.runtimeruntime-annotation-jvm1.12.1 + androidx.collectioncollection-jvm1.5.0 + androidx.annotationannotation-jvm1.9.1 + org.jetbrainsannotations23.0.0 + + + + + copy-kmp-compiler + generate-test-resources + + copy + + + ${project.build.directory}/kmp-compiler + + org.jetbrains.kotlinkotlin-compiler-embeddable2.4.21 + org.jetbrains.kotlinkotlin-compose-compiler-plugin-embeddable2.4.21 + org.jetbrains.kotlinkotlin-stdlib2.4.21 + org.jetbrains.kotlinkotlin-script-runtime2.4.21 + org.jetbrains.kotlinkotlin-daemon-embeddable2.4.21 + org.jetbrains.kotlinkotlin-build-tools-api2.4.21 + org.jetbrains.kotlinxkotlinx-coroutines-core-jvm1.8.0 + org.jetbrainsannotations13.0 + org.jetbrains.kotlinkotlin-reflect1.6.10 + + +