diff --git a/.github/workflows/android-compat.yml b/.github/workflows/android-compat.yml index 05f704d2a51..b314a9f31e5 100644 --- a/.github/workflows/android-compat.yml +++ b/.github/workflows/android-compat.yml @@ -18,6 +18,7 @@ on: branches: [ master ] paths: - 'maven/android-compat/**' + - 'maven/compat-jdk/**' - 'maven/android-res-compiler/**' - 'maven/build-engine/**' - 'maven/codenameone-maven-plugin/**' @@ -34,6 +35,7 @@ on: branches: [ master ] paths: - 'maven/android-compat/**' + - 'maven/compat-jdk/**' - 'maven/android-res-compiler/**' - 'maven/build-engine/**' - 'maven/codenameone-maven-plugin/**' @@ -85,7 +87,7 @@ jobs: bash ../scripts/ci/retry.sh xvfb-run -a mvn install -Plocal-dev-javase -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true - name: Unit tests working-directory: maven - run: xvfb-run -a mvn -B -Plocal-dev-javase -pl android-res-compiler,android-compat,build-engine test + run: xvfb-run -a mvn -B -Plocal-dev-javase -pl compat-jdk,android-res-compiler,android-compat,build-engine test - name: Refresh archetype catalog run: | cd maven diff --git a/.github/workflows/ant.yml b/.github/workflows/ant.yml index bbad13942c3..01c3c36e125 100644 --- a/.github/workflows/ant.yml +++ b/.github/workflows/ant.yml @@ -22,7 +22,10 @@ jobs: build-linux-jdk8: runs-on: ubuntu-24.04 - timeout-minutes: 45 + # The job ran 32 to 42 minutes before the Swing and JavaFX compatibility + # modules joined the reactor it builds and tests, and was cancelled at 45 + # while still making progress once they had. + timeout-minutes: 60 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/desktop-compat-benchmarks.yml b/.github/workflows/desktop-compat-benchmarks.yml new file mode 100644 index 00000000000..9adba7beb27 --- /dev/null +++ b/.github/workflows/desktop-compat-benchmarks.yml @@ -0,0 +1,293 @@ +name: Desktop compatibility benchmarks + +# The two in-tree desktop samples (scripts/desktop-compat-samples) built as what +# their developer ships today -- jar, jlink runtime image, jpackage application +# image -- and as native Codename One executables, then measured side by side: +# distributable size, memory, start-up and idle CPU. +# +# This is a measurement, not a gate: a job fails when the harness breaks, never +# because a number moved. Each leg uploads benchmarks-.json and the same +# figures as a markdown table. scripts/desktop-compat-benchmarks/README.md says +# what every column means and how it was taken. +# +# linux the reference leg: the same install script, driver and input +# scripts as the container the committed numbers came from +# macos sizes, and the runtime figures run-macos-bench.sh takes (start-up +# to first window, idle memory and CPU; no scripted input) +# windows sizes, and the same reduced runtime figures through +# win-measure.ps1. NOTHING on this leg has run outside CI: nobody +# had a Windows machine when it was written. +# +# Shared runners are noisy and have few cores. Compare the columns of one run +# with each other, not one run with another. +on: + workflow_dispatch: + pull_request: + branches: [ master ] + paths: + - 'scripts/desktop-compat-benchmarks/**' + - '.github/workflows/desktop-compat-benchmarks.yml' + push: + branches: [ master ] + paths: + - 'scripts/desktop-compat-benchmarks/**' + - '.github/workflows/desktop-compat-benchmarks.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + # The modules a native build of an imported desktop project resolves, plus + # everything they depend on (-am). The port module is added per leg. + BENCH_MODULES: parparvm,compat-jdk,swing-compat,javafx-compat,fxml-compiler,codenameone-maven-plugin,cn1app-archetype + +jobs: + linux: + name: Linux (x64, Xvfb) + runs-on: ubuntu-latest + timeout-minutes: 240 + steps: + - uses: actions/checkout@v6 + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + java-version: '8' + distribution: 'temurin' + cache: 'maven' + - run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + # The same script the benchmark container is built from: the GTK stack, + # Xvfb and a window manager, JDK 17 and 21, zig, the OpenJFX jmods. + - name: Install the toolchain and the measuring tools + run: sudo bash scripts/desktop-compat-benchmarks/install-linux-deps.sh /opt/bench/tools + - name: Build Codename One (JDK 8) + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + cd maven + JAVA_HOME="$JDK_8_HOME" bash ../scripts/ci/retry.sh xvfb-run -a mvn -B \ + -pl "linux,$BENCH_MODULES" -am -Plocal-dev-javase -DskipTests \ + -Darchetype.test.skip=true -Dmaven.javadoc.skip=true install + - name: Build and measure the samples + env: + BENCH_JAVAFX_JMODS: /opt/bench/tools/javafx-jmods + CN1_CC: /opt/bench/tools/cn1-zig-cc + BENCH_XVFB: '1' + BENCH_IMAGE_NAME: GitHub-hosted ubuntu-latest runner + BENCH_COMMIT: ${{ github.sha }} + run: | + ARCH="$(dpkg --print-architecture)" + export JAVA17_HOME="/usr/lib/jvm/java-17-openjdk-$ARCH" + export BENCH_JDK21_HOME="/usr/lib/jvm/java-21-openjdk-$ARCH" + export PATH="/opt/bench/tools/zig:$PATH" + bash scripts/desktop-compat-benchmarks/bench-samples.sh \ + --work "$RUNNER_TEMP/bench" --out "$GITHUB_WORKSPACE/benchmark-results" + cat benchmark-results/benchmarks-linux.md >> "$GITHUB_STEP_SUMMARY" + - name: Upload the results + if: always() + uses: actions/upload-artifact@v7 + with: + name: benchmarks-linux + path: benchmark-results/ + if-no-files-found: warn + - name: Upload the logs and screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: benchmarks-linux-logs + path: ${{ runner.temp }}/bench/*/logs/ + if-no-files-found: ignore + + macos: + name: macOS (arm64) + runs-on: macos-latest + timeout-minutes: 240 + steps: + - uses: actions/checkout@v6 + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - run: echo "BENCH_JDK21_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + - run: echo "JAVA17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + # Zulu: Temurin publishes no JDK 8 for Apple silicon. + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + java-version: '8' + distribution: 'zulu' + cache: 'maven' + - run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + - name: Build Codename One (JDK 8) + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + cd maven + JAVA_HOME="$JDK_8_HOME" bash ../scripts/ci/retry.sh mvn -B \ + -pl "mac,ios,$BENCH_MODULES" -am -Plocal-dev-javase -DskipTests \ + -Darchetype.test.skip=true -Dmaven.javadoc.skip=true -Djava.awt.headless=true install + - name: OpenJFX jmods + run: | + bash scripts/desktop-compat-benchmarks/javafx-jmods.sh "$BENCH_JDK21_HOME" "$RUNNER_TEMP/javafx-jmods" + echo "BENCH_JAVAFX_JMODS=$RUNNER_TEMP/javafx-jmods" >> "$GITHUB_ENV" + - name: Build the samples (sizes) + env: + BENCH_COMMIT: ${{ github.sha }} + run: | + bash scripts/desktop-compat-benchmarks/bench-samples.sh --stage build \ + --work "$RUNNER_TEMP/bench" --out "$GITHUB_WORKSPACE/benchmark-results" + # The runner has a logged-in desktop session, which is what this needs: + # it opens each application's window in turn. + - name: Measure the samples (start-up, idle memory and CPU) + run: | + for NAME in swing-gallery javafx-gallery; do + bash scripts/desktop-compat-benchmarks/run-macos-bench.sh --work "$RUNNER_TEMP/bench" --name "$NAME" \ + || echo "::warning::run-macos-bench.sh failed for $NAME" + done + python3 scripts/desktop-compat-benchmarks/benchutil.py collect benchmark-results/benchmarks-macos.json \ + "$RUNNER_TEMP"/bench/*/result.json + python3 scripts/desktop-compat-benchmarks/report.py --out benchmark-results/benchmarks-macos.md \ + "$RUNNER_TEMP"/bench/*/result.json + cat benchmark-results/benchmarks-macos.md >> "$GITHUB_STEP_SUMMARY" + - name: Upload the results + if: always() + uses: actions/upload-artifact@v7 + with: + name: benchmarks-macos + path: benchmark-results/ + if-no-files-found: warn + - name: Upload the logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: benchmarks-macos-logs + path: ${{ runner.temp }}/bench/*/logs/ + if-no-files-found: ignore + + windows: + name: Windows (x64) + runs-on: windows-latest + timeout-minutes: 240 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + # clang-cl uses the MSVC headers, CRT and linker, so the MSVC developer + # environment has to be on PATH for the cmake the builder spawns. + - name: Set up the MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: amd64 + - name: Install Ninja + run: | + python -m pip install --upgrade ninja + ninja --version + clang-cl --version + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - run: echo "BENCH_JDK21_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + - run: echo "JAVA17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + java-version: '8' + distribution: 'temurin' + cache: 'maven' + - run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + # The scripts call python3; the runner's interpreter is "python". + - name: Provide python3 + run: | + mkdir -p "$RUNNER_TEMP/bin" + printf '#!/bin/sh\nexec python "$@"\n' > "$RUNNER_TEMP/bin/python3" + chmod +x "$RUNNER_TEMP/bin/python3" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Build Codename One (JDK 8) + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + cd maven + JAVA_HOME="$JDK_8_HOME" bash ../scripts/ci/retry.sh mvn -B \ + -pl "windows,$BENCH_MODULES" -am -Plocal-dev-javase -DskipTests \ + -Darchetype.test.skip=true -Dmaven.javadoc.skip=true install + - name: OpenJFX jmods + run: | + bash scripts/desktop-compat-benchmarks/javafx-jmods.sh "$BENCH_JDK21_HOME" "$RUNNER_TEMP/javafx-jmods" + echo "BENCH_JAVAFX_JMODS=$RUNNER_TEMP/javafx-jmods" >> "$GITHUB_ENV" + - name: Build the samples (sizes) + env: + BENCH_COMMIT: ${{ github.sha }} + run: | + bash scripts/desktop-compat-benchmarks/bench-samples.sh --stage build \ + --work "$RUNNER_TEMP/bench" --out "$GITHUB_WORKSPACE/benchmark-results" + - name: Measure the samples (start-up, idle memory and CPU) + shell: pwsh + run: | + $bench = Join-Path $env:RUNNER_TEMP 'bench' + foreach ($name in 'swing-gallery', 'javafx-gallery') { + $dir = Join-Path $bench $name + $parts = Join-Path $dir 'parts' + $java = Join-Path $dir 'baseline\runtime\bin\java.exe' + $baseline = Join-Path $parts 'baseline.json' + if ((Test-Path $java) -and (Test-Path $baseline)) { + $main = (Get-Content $baseline -Raw | ConvertFrom-Json).main_class + $cp = (Join-Path $dir 'baseline\app') + '\*' + & scripts/desktop-compat-benchmarks/win-measure.ps1 -Label jvm_default ` + -Out (Join-Path $parts 'run.jvm_default.json') -Exe $java -Arguments '-cp', $cp, $main + & scripts/desktop-compat-benchmarks/win-measure.ps1 -Label jvm_tuned ` + -Out (Join-Path $parts 'run.jvm_tuned.json') -Exe $java ` + -Arguments '-XX:+UseSerialGC', '-Xss256k', '-XX:TieredStopAtLevel=1', '-Xmx64m', '-cp', $cp, $main + } else { + Write-Host "::warning::no baseline runtime image for $name" + } + $marker = Join-Path $dir 'cn1\executable.txt' + if (Test-Path $marker) { + $exe = Join-Path $dir ('cn1\dist\' + (Get-Content $marker -Raw).Trim()) + & scripts/desktop-compat-benchmarks/win-measure.ps1 -Label cn1_native ` + -Out (Join-Path $parts 'run.cn1_native.json') -Exe $exe + } else { + Write-Host "::warning::no native Codename One build for $name" + } + python scripts/desktop-compat-benchmarks/benchutil.py assemble $parts (Join-Path $dir 'result.json') + } + $results = @(Get-ChildItem -Path $bench -Filter result.json -Recurse -Depth 1 | ForEach-Object { $_.FullName }) + python scripts/desktop-compat-benchmarks/benchutil.py collect benchmark-results/benchmarks-windows.json @results + python scripts/desktop-compat-benchmarks/report.py --out benchmark-results/benchmarks-windows.md @results + Get-Content benchmark-results/benchmarks-windows.md | Add-Content $env:GITHUB_STEP_SUMMARY + - name: Upload the results + if: always() + uses: actions/upload-artifact@v7 + with: + name: benchmarks-windows + path: benchmark-results/ + if-no-files-found: warn + - name: Upload the logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: benchmarks-windows-logs + path: ${{ runner.temp }}/bench/*/logs/ + if-no-files-found: ignore diff --git a/.github/workflows/desktop-compat.yml b/.github/workflows/desktop-compat.yml new file mode 100644 index 00000000000..16e46b90854 --- /dev/null +++ b/.github/workflows/desktop-compat.yml @@ -0,0 +1,147 @@ +name: Desktop compatibility + +# Swing and JavaFX applications built as Codename One applications: the +# relocated toolkits (maven/swing-compat, maven/javafx-compat), the JDK classes +# they share with the Android layer (maven/compat-jdk), the headless harness +# their tests run on (maven/compat-testing) and the build steps that relocate +# and package them (build-engine, the Maven and Gradle plugins). +# +# unit tests the runtimes against the JDK's own behaviour, +# the remapper, the importer, and the sample +# loaded remapped and painted headless +# desktop-compat-test.sh every sample under scripts/desktop-compat-samples +# imported unmodified, built by Maven and Gradle, +# checked for unrelocated toolkit references, +# staged for iOS, Android, JavaScript and the +# desktop with identical uploads from both +# tools, and built once in a project that also +# holds an Android application +# desktop-compat-realport the two galleries that script staged, run on +# the real JavaSE port under xvfb and driven +# with the display server's pointer and keys; +# fails on anything thrown and on a click, a +# drag or a key that changes no pixel +on: + pull_request: + branches: [ master ] + paths: + - 'maven/swing-compat/**' + - 'maven/javafx-compat/**' + - 'maven/fxml-compiler/**' + - 'maven/compat-jdk/**' + - '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/desktop-compat-test.sh' + - 'maven/integration-tests/desktop-compat-realport/**' + - 'maven/integration-tests/inc/**' + - 'scripts/desktop-compat-samples/**' + # The script builds one project holding both layers from this sample. + - 'scripts/android-compat-samples/gallery/**' + - '.github/workflows/desktop-compat.yml' + push: + branches: [ master ] + paths: + - 'maven/swing-compat/**' + - 'maven/javafx-compat/**' + - 'maven/fxml-compiler/**' + - 'maven/compat-jdk/**' + - '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/desktop-compat-test.sh' + - 'maven/integration-tests/desktop-compat-realport/**' + - 'maven/integration-tests/inc/**' + - 'scripts/desktop-compat-samples/**' + - 'scripts/android-compat-samples/gallery/**' + - '.github/workflows/desktop-compat.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + desktop-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 + 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' + # xvfb for the CSS compiler, which opens an AWT frame. Nothing in the + # compatibility layers opens a window: their tests run on the headless + # implementation in maven/compat-testing. + - name: Install xvfb + run: bash scripts/ci/apt-get-install.sh xvfb + - 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 xvfb-run -a mvn install -Plocal-dev-javase -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true + # A module added to the layer (an FXML compiler, say) joins this list. + - name: Unit tests + working-directory: maven + run: xvfb-run -a mvn -B -Plocal-dev-javase -pl compat-jdk,compat-testing,swing-compat,javafx-compat,fxml-compiler,build-engine test + # The JavaFX sample has to stay what it claims to be: a JavaFX project + # that builds on its own, against OpenJFX, with nothing of the layer on + # its class path. Everything else here compiles it against the layer, + # which would also accept a sample written to the layer's shape. JDK 17 + # because OpenJFX 17 is; the Swing sample needs no such step, the JDK + # that compiles it in the unit tests is the toolkit. + - name: The JavaFX sample compiles against OpenJFX + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: JAVA_HOME="$GRADLE_JAVA_HOME" bash scripts/ci/retry.sh mvn -B -f scripts/desktop-compat-samples/javafx-gallery/pom.xml compile + - name: Refresh archetype catalog + run: | + cd maven + xvfb-run -a mvn archetype:update-local-catalog -Plocal-dev-javase + xvfb-run -a mvn archetype:crawl -Plocal-dev-javase + - name: Desktop samples, imported and built by Maven and Gradle + working-directory: maven/integration-tests + run: xvfb-run -a bash desktop-compat-test.sh + # The galleries the step above staged, on the real port, under the + # display server's own pointer and keys. The unit tests fire events at + # the node they are meant for; this is the only place a dead button or a + # key the port never delivers can fail. It runs on GRADLE_JAVA_HOME, + # the JDK 17: the port needs 11 or newer at run time. The screen is + # named because xvfb-run's default is smaller than the application. + - name: The galleries on the real JavaSE port, under real input + working-directory: maven/integration-tests + run: xvfb-run -a -s "-screen 0 1400x1000x24" bash desktop-compat-realport/run.sh + - name: Upload the test projects' logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: desktop-compat-logs + path: | + maven/integration-tests/build/desktop-compat/**/*.log + maven/integration-tests/build/desktop-compat-realport/**/realport.log + maven/integration-tests/build/desktop-compat-realport/**/frames/*.png + if-no-files-found: ignore diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d943bba4775..d14b09fe604 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -391,7 +391,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 -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,compat-jdk,swing-compat,javafx-compat,fxml-compiler -am verify - name: Type-check the SQLCipher-backed database package if: ${{ matrix.java-version == 8 }} env: @@ -609,7 +609,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 + 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/compat-jdk/target:maven/swing-compat/target:maven/javafx-compat/target:maven/fxml-compiler/target QUALITY_REPORT_SERVER_URL: ${{ github.server_url }} QUALITY_REPORT_REPOSITORY: ${{ github.repository }} QUALITY_REPORT_REF: ${{ github.event.pull_request.head.sha || github.sha }} @@ -631,6 +631,10 @@ jobs: "build-engine:maven/build-engine/target" \ "android-res-compiler:maven/android-res-compiler/target" \ "android-compat:maven/android-compat/target" \ + "compat-jdk:maven/compat-jdk/target" \ + "swing-compat:maven/swing-compat/target" \ + "javafx-compat:maven/javafx-compat/target" \ + "fxml-compiler:maven/fxml-compiler/target" \ "project-model:maven/project-model/target" \ "gradle-plugin:maven/codenameone-gradle-plugin/target" \ "backend:maven/backend/target" \ @@ -761,11 +765,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 + 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/compat-jdk/target:maven/swing-compat/target:maven/javafx-compat/target:maven/fxml-compiler/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 + 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:compat-jdk:swing-compat:javafx-compat:fxml-compiler # PMD and Checkstyle run on the core and on the backend runtime, and each # must produce its report: without one, the forbidden-rule gate below it # has nothing to read and passes. diff --git a/CLAUDE.md b/CLAUDE.md index fc26e59b1ab..53418845f8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -351,7 +351,9 @@ recognized and never reported. Note the rule is about the *cast*: a The scope is what a translation actually sees -- `maven/core`, `maven/ios`, `vm/JavaAPI` and `maven/android-compat`, whose runtime an application with -Android sources ships relocated inside its own classes. **The Android port is not covered**, and adding it back would be a +Android sources ships relocated inside its own classes, and `maven/compat-jdk`, +`maven/swing-compat` and `maven/javafx-compat`, which are relocated into an +application with desktop sources the same way. **The Android port is not covered**, and adding it back would be a mistake: ART implements `CHECKCAST` to spec, so a `catch (Throwable)` around a `(NotificationManager) getSystemService(...)` there is live, correct code, and `Ports/Android` is never translated. That is the same reason `Ports/CLDC11` is @@ -797,13 +799,22 @@ An Android Studio module's `src/main` dropped into `common/src/main/android` (views are peers, layouts run Android's measure/layout). Apps compile against it under the real names; its own framework resources live in `src/main/framework-res`. -- **The remap step** (`build-engine/.../AndroidRemapper`, goal `remap-android`) - relocates `android/`, `androidx/` and `com/google/android/material/` to +- **The remap step** (goal `remap-compat`; `remap-android` is the same goal + under its old name, kept so existing poms need no edit) relocates `android/`, + `androidx/` and `com/google/android/material/` to `com/codename1/androidcompat/...`, copies the relocated runtime into the app's classes, maps the JDK classes CLDC lacks (`java.io.File`, the file streams, - `BufferedReader`, `PrintWriter`, `Closeable`) to `com.codename1.androidcompat.jdk`, + `BufferedReader`, `PrintWriter`, `Closeable`) to `com.codename1.compat.jdk`, and generates the `android:onClick`, fragment-factory and WebView JS-bridge - dispatchers from the compiled classes. + dispatchers from the compiled classes. The JDK shims are their own module, + `maven/compat-jdk`, because the desktop layers use them too. +- **The remap is one pass over every layer**, split in `build-engine`: + `CompatLayers` declares each layer (artifact, source prefixes, target + package), `Relocation` holds the package and JDK-shim tables, + `ClassRelocator` rewrites a class against all active layers at once, + `CompatRemapper` drives the step and calls `AndroidRemapper` for what is + Android's own. The archetype binds `remap-compat` and then + `bytecode-compliance` in `process-classes`, in that order. Traps that have already cost a fix: @@ -828,6 +839,162 @@ Traps that have already cost a fix: (`.github/workflows/android-compat.yml`); it fails if any shipped class still names `android/`. +### Desktop compatibility: Swing and JavaFX apps as Codename One apps + +A desktop project's `src/main` dropped into `common/src/main/desktop` (or +imported with `cn1:import-desktop-project`) builds unmodified, for every target: + +- **`maven/swing-compat`** is `java.awt`, `javax.swing`, `java.beans`, + `javax.imageio` and a SwingX subset (`org.jdesktop`) written over Codename + One. **It is authored already relocated**, under + `com.codename1.desktopcompat.java.awt...`, because the JDK owns `java.awt` and + no module can declare it. Applications therefore compile against the *JDK's* + Swing, and the remap points them at this one. +- **`maven/javafx-compat`** is the `javafx.*` subset. Nothing owns that name, so + it is authored under the real names like `android-compat`, applications + compile against its jar, and the remap moves it to `com/codename1/fxcompat/`. + Its internals live in `com/codename1/fxcompat/runtime/`, which the remap maps + to `com/codename1/fxcompat/rt/`. +- **`maven/compat-jdk`** (`com.codename1.compat.jdk`) holds the JDK classes a + device lacks. `CompatRewrites` additionally redirects single missing + *members* of `Class`, `ClassLoader`, `System`, `Locale`, `String` and the + like, for the desktop layers only. +- **`maven/fxml-compiler`** turns each `.fxml` into + `com.codename1.generated.fxml.Fxml_` (in the remap step, after javac) + and each `.css` into a `.cn1css` table (from `prepare-desktop-sources`); + `FxmlDispatchGenerator` then runs in the remap, on the compiled classes, to + widen and reach the controllers' private `@FXML` members. The CSS value + classes are compiled twice: `fxcompat/runtime/css` is copied into the compiler + under `com.codename1.fxml.css`, so build and device share one parser. +- **`maven/compat-testing`** is the headless implementation and + `MainThreadRule` both layers' tests run on. + +What holds it together: + +- **Unsupported API is a build error, and that is the coverage gate.** + `bytecode-compliance` runs *after* the remap, so a member the layer lacks is + a reference to something that does not exist; `BytecodeCompliance` reports it + under the original name with the source line. What it cannot see: an + application *override* of a method the layer's type lacks passes, and is + simply never called. +- **The API shape is tested, not trusted.** `ApiShapeTest` compares every + public class of `swing-compat` with the JDK class (SwingX with the real + `swingx-all`), and `NoJdkDesktopReferenceTest` fails if a shipped class names + the real `java/awt`. In `javafx-compat`, `ApiNamingTest` requires every + public member that is not JavaFX API to be named `cn1...`/`CN1_...` and listed + in `src/test/resources/cn1-hooks.txt`. A new hook means a new line there. +- **Coordinates are logical pixels, 1/96 inch**, scaled by the display density + (Display property `desktopcompat.scale` overrides; both layers read it) and + snapped edge by edge. Never hand a Swing or JavaFX coordinate to Codename One + unscaled. +- **The entry point is generated into the classes directory by the remap** + (`DesktopEntryPoints`), since only compiled classes say whether the recorded + class is an `Application` or holds a `main`. `main`/`start` run on the EDT, + which is the Swing EDT and the JavaFX application thread. +- **Registries are generated over placeholders.** `CompatRegistry` (resources + and bundles, in `compat-jdk`) and the FXML dispatch class ship as empty + placeholders with the right shape and are replaced per application; a new + method on one needs the generator and the placeholder changed together. +- A desktop layer is active only when its jar is present **and** the compiled + classes name its API, so a Swing app does not ship the JavaFX runtime. + Compile- and runtime-scope dependency jars that name a layer are unpacked and relocated as + application code (`CompatLibraries`). + +- **Library mode: desktop sources inside a Codename One app.** No + `cn1-desktop.properties` beside a compiled main class of the project's own, + or `kind=library`, generates no entry point; the classes ship relocated. + `SwingInterop.asComponent` / `FxInterop.asComponent` wrap a component, a + parent or a scene as a `com.codename1.ui.Component`, and a `JFrame` or + `Stage` shown with no generated entry point is a form over the host's -- + closing the last one returns there and never exits. Both `asComponent` + parameters are `Object` **on purpose**: the remap copies the relocated + runtime into `target/classes`, which shadows the jar on the next non-clean + compile, so a non-relocated public class naming a toolkit type in a + signature stops compiling against application source. `hellocodenameone` + is the library-mode project (`common/src/main/desktop`, the + `DesktopCompat*` screenshot tests); `DesktopCompatScenesTest` in + `build-engine` runs the same scenes headless from the remapped classes. + +`compat-jdk`, `swing-compat`, `javafx-compat` and `fxml-compiler` are in the +SpotBugs zero-findings gate, each with its own `spotbugs-exclude.xml`. + +Traps that have already cost a fix: + +- **SpotBugs XML uses single-quoted attributes.** Grepping the report for + `type="` finds nothing and reads as zero findings. Match either quote, or + count `.txt` beside it against what + `desktop-compat-test.sh` staged (`desktop-compat.yml` does, under + `xvfb-run`). It opens a window: run it under xvfb or in a container. The + port turns Space and Enter into the same key code, and drops every + Ctrl/Alt/Meta combination before the form sees it. + ### Integration Tests Located in `maven/integration-tests/`: diff --git a/CodenameOne/src/com/codename1/ui/Image.java b/CodenameOne/src/com/codename1/ui/Image.java index e5824e00cf9..6a6f041a264 100644 --- a/CodenameOne/src/com/codename1/ui/Image.java +++ b/CodenameOne/src/com/codename1/ui/Image.java @@ -1469,6 +1469,13 @@ public Image modifyAlpha(byte alpha, int removeColor) { public Graphics getGraphics() { Graphics g = new Graphics(Display.impl.getNativeGraphics(image)); rgbCache = null; // the cache will become invalid + // So do the scaled copies. A port that cannot draw an image at a size + // draws scaled(w, h) instead, which is a copy this image keeps -- at + // the image's own size too. An image drawn on again after it had been + // shown once went on showing the copy made the first time: on the + // native Linux port a picture that was drawn in steps stayed as it was + // after the first one, until the collector happened to drop the copy. + scaleCache = null; return g; } diff --git a/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java b/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java index ee90cc09155..e0c43d89c6c 100644 --- a/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java +++ b/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java @@ -1971,8 +1971,23 @@ private static Shape intersection(Rectangle r, Shape s, GeneralPath out) { maxY = (float) (boundingRect.getY() + boundingRect.getHeight()); } if (rect2D.contains(curve.x(0.5), curve.y(0.5))) { - // It is fully inside, we can just add it to the out path - curve.addToPath(out, false); + // It is fully inside, we can just add it to the out path. + // It CONTINUES the subpath: passing join == false here + // made every curve open a subpath of its own, so a shape + // with a curved corner came back as one sliver per corner + // (the corner and the edge after it, closed across the + // shape) and its inside was no longer inside. Every port + // that narrows a shape clip with clipRect goes through + // this, which is how a gradient filled through a rounded + // rectangle was drawn as a hollow, skewed outline. The + // segment before may have ended outside and been clamped + // to the rectangle, in which case the outline first runs + // from there to where the curve really starts. + if (started && (Math.abs(prevX - origPrevX) > epsilon + || Math.abs(prevY - origPrevY) > epsilon)) { + out.lineTo((float) origPrevX, (float) origPrevY); + } + curve.addToPath(out, started); prevX = (float) curveEnd.getX(); prevY = (float) curveEnd.getY(); origPrevX = curveEnd.getX(); diff --git a/NOTICE b/NOTICE index dc50ab27df2..d87bb1344df 100644 --- a/NOTICE +++ b/NOTICE @@ -129,3 +129,22 @@ ASM is distributed under the BSD 3-Clause license: CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +================================================================================ +GroupLayout +================================================================================ + +The class com.codename1.desktopcompat.javax.swing.GroupLayout in +maven/swing-compat is adapted from Codename One's own port of the group layout, +CodenameOne/src/com/codename1/ui/layouts/GroupLayout.java, which is derived from +the GroupLayout Oracle published for the Java platform. The adaptation gives the +class the javax.swing API -- its method names, its Alignment enumeration and the +LayoutStyle.ComponentPlacement gap types -- reads minimum and maximum sizes from +the components, and retargets the layout at the Swing compatibility layer's own +java.awt component classes. The original copyright and license notice is +retained in the source file. + +The file is distributed under the GNU General Public License version 2 with the +"Classpath" exception, the license of Codename One itself (see LICENSE): + + Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 3f5bfb49eca..7eaa416f3a3 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -12435,7 +12435,60 @@ public void run() { //} checkEDT(); - + + // The editor is a Swing component, so it is built, added and focused on the + // AWT event thread and nowhere else. Built here, on the Codename One EDT, it + // raced the AWT thread that was already painting it: setCaretPosition off the + // AWT thread makes DefaultCaret queue its repaint there, and that repaint read + // the text view while setFont on this thread was replacing it -- an + // intermittent NullPointerException under DefaultCaret.repaintNewCaret. + // + // The hop waits, as flushGraphics and requestFullScreen do, so the editor is + // in place and isEditingText() answers true by the time this method returns, + // exactly as before. Nothing in the body waits for this thread in return: + // what the editor sends back goes through callSerially. + // + // One line of the body is not Swing's: the field's own text is set from the + // editor's when editing starts with a key. That fires the application's + // listeners, so it is handed back and done here, on the thread they expect. + final String[] initialText = new String[1]; + if (EventQueue.isDispatchThread()) { + editStringOnAwtThread(cmp, constraint, text, keyCode, initialText); + } else { + final String fText = text; + try { + EventQueue.invokeAndWait(new Runnable() { + public void run() { + editStringOnAwtThread(cmp, constraint, fText, keyCode, initialText); + } + }); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return; + } catch (java.lang.reflect.InvocationTargetException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw new RuntimeException(cause); + } + } + if (initialText[0] != null) { + ((com.codename1.ui.TextField) cmp).setText(initialText[0]); + } + } + + /** + * The Swing half of editString: builds the native editor over the field and + * starts editing. Runs on the AWT event thread only. + * + * @param initialText receives the text the field itself is to be given, when + * editing starts with a key; the caller sets it on the Codename One EDT + */ + private void editStringOnAwtThread(final Component cmp, final int constraint, String text, final int keyCode, String[] initialText) { class Repainter { JComponent jcmp; javax.swing.border.Border origBorder; @@ -12646,7 +12699,7 @@ public void keyReleased(KeyEvent e) { setText(tf, text); setCaretPosition(tf, text.length()); if(cmp instanceof com.codename1.ui.TextField) { - ((com.codename1.ui.TextField) cmp).setText(getText(tf)); + initialText[0] = getText(tf); } } else { setText(tf, text); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c b/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c index 95526c2f3ef..acc232e49ef 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c @@ -1181,6 +1181,136 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_mainWindowGetBounds___int_1ARR return JAVA_TRUE; } +/* + * Resizing the application's own window. + * + * The main window opens at the size initDisplay is given and, until this existed, + * could only be resized by the user: Display.setWindowSize reached the default + * implementation, which does nothing, so an application that asked for a 350x550 + * window drew into the 800x600 one it was born with. + * + * The size is the OUTER one, frame included, which is what the JavaSE port's + * setWindowSize (JFrame.setSize) and this port's secondary windows both mean. GTK + * resizes the client area, so the chrome has to be subtracted, and the chrome is + * not measurable until the window manager has reparented the window. The request + * is therefore kept pending and applied from configure-event, exactly as + * cn1DesktopApplyPendingOuterSize does for a secondary window. + */ +static int cn1MainOuterPending; +static int cn1MainRequestedOuterW; +static int cn1MainRequestedOuterH; +static int cn1MainConfigureHooked; + +static void cn1MainApplyPendingOuterSize(GtkWidget* main) { + int chromeW; + int chromeH; + int clientW; + int clientH; + if (main == 0 || !cn1MainOuterPending) { + return; + } + if (!cn1DesktopChromeInsets(main, &chromeW, &chromeH)) { + return; + } + clientW = cn1MainRequestedOuterW - chromeW; + clientH = cn1MainRequestedOuterH - chromeH; + if (clientW < 1) { + clientW = 1; + } + if (clientH < 1) { + clientH = 1; + } + /* Cleared before the resize: the resize comes back through configure-event. */ + cn1MainOuterPending = 0; + gtk_window_resize(GTK_WINDOW(main), clientW, clientH); +} + +static gboolean cn1MainOnWindowConfigure(GtkWidget* widget, GdkEventConfigure* e, gpointer data) { + (void) e; + (void) data; + cn1MainApplyPendingOuterSize(widget); + return FALSE; +} + +static void cn1MainWindowSetSizeOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + GtkWidget* main = cn1LinuxWindowWidget(); + if (main == 0) { + /* Headless: the surface size is the screenshot's, and is not ours to change. */ + return; + } + if (!cn1MainConfigureHooked) { + cn1MainConfigureHooked = 1; + g_signal_connect(main, "configure-event", G_CALLBACK(cn1MainOnWindowConfigure), 0); + } + cn1MainRequestedOuterW = op->a > 0 ? op->a : 1; + cn1MainRequestedOuterH = op->b > 0 ? op->b : 1; + cn1MainOuterPending = 1; + cn1MainApplyPendingOuterSize(main); + if (cn1MainOuterPending) { + /* No frame to measure yet -- or never, with no window manager, where the + * client area is the outer size. Resize to the figure asked for and let + * configure-event correct it once there is chrome. */ + gtk_window_resize(GTK_WINDOW(main), cn1MainRequestedOuterW, cn1MainRequestedOuterH); + } +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_mainWindowSetSize___int_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT width, JAVA_INT height) { + CN1DesktopOp op; + memset(&op, 0, sizeof(op)); + op.a = width; + op.b = height; + cn1LinuxRunOnMainAndWait(cn1MainWindowSetSizeOnMain, &op); +} + +/* The main window's outer rectangle: what mainWindowSetSize accepts, so that a + * size read back is the size that was set. mainWindowGetBounds answers the client + * area, which is what centring over a Form wants and is smaller by the chrome. */ +static void cn1MainWindowGetFrameOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + GtkWidget* main = cn1LinuxWindowWidget(); + int chromeW; + int chromeH; + op->a = 0; + if (main == 0) { + return; + } + gtk_window_get_position(GTK_WINDOW(main), &op->out[0], &op->out[1]); + gtk_window_get_size(GTK_WINDOW(main), &op->out[2], &op->out[3]); + if (cn1DesktopChromeInsets(main, &chromeW, &chromeH)) { + op->out[2] += chromeW; + op->out[3] += chromeH; + } else if (cn1MainOuterPending) { + op->out[2] = cn1MainRequestedOuterW; + op->out[3] = cn1MainRequestedOuterH; + } + op->a = 1; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_mainWindowGetFrame___int_1ARRAY_R_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { + CN1DesktopOp op; + JAVA_INT* arr; + if (out == JAVA_NULL) { + return JAVA_FALSE; + } + if ((int) (*(JAVA_ARRAY) out).length < 4) { + return JAVA_FALSE; + } + memset(&op, 0, sizeof(op)); + cn1LinuxRunOnMainAndWait(cn1MainWindowGetFrameOnMain, &op); + if (!op.a) { + return JAVA_FALSE; + } + arr = (JAVA_INT*) CN1_ARRAY_DATA(out); + arr[0] = op.out[0]; + arr[1] = op.out[1]; + arr[2] = op.out[2]; + arr[3] = op.out[3]; + return JAVA_TRUE; +} + JAVA_INT com_codename1_impl_linux_LinuxNative_desktopWindowGetWidth___int_R_int( CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { CN1LinuxWindow* w = slotAt(slot); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_edit.c b/Ports/LinuxPort/nativeSources/cn1_linux_edit.c index 25b73c87035..5ec2fcdb305 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_edit.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_edit.c @@ -44,6 +44,8 @@ typedef struct { GtkTextBuffer* buffer; /* GtkTextView buffer, or NULL for single-line */ int singleLine; volatile int done; + /* How the edit ended when Tab ended it: 1 forwards, 2 backwards, else 0. */ + volatile int tab; char* text; /* latest committed/changed text (heap) */ /* The window whose overlay hosts this editor, so teardown removes it from the * same place it was added. */ @@ -102,6 +104,28 @@ static gboolean cn1EditFocusOut(GtkWidget* w, GdkEvent* ev, gpointer data) { return FALSE; } +/* + * Tab in a single-line field ends the edit and is reported, so the port hands the + * key to the form and the focus moves on. Left to GTK it moved GTK's own focus: + * the entry is the only focusable widget of the window, so the edit ended on the + * focus-out and the focus of the form stayed where it was -- the next field was + * out of the keyboard's reach. + */ +static gboolean cn1EditKeyPress(GtkWidget* w, GdkEventKey* ev, gpointer data) { + CN1Edit* e = (CN1Edit*) data; + (void) w; + if ((ev->state & (GDK_CONTROL_MASK | GDK_MOD1_MASK)) != 0) { + return FALSE; + } + if (ev->keyval == GDK_KEY_Tab || ev->keyval == GDK_KEY_KP_Tab || ev->keyval == GDK_KEY_ISO_Left_Tab) { + cn1EditCaptureText(e); + e->tab = (ev->keyval == GDK_KEY_ISO_Left_Tab || (ev->state & GDK_SHIFT_MASK) != 0) ? 2 : 1; + e->done = 1; + return TRUE; + } + return FALSE; +} + static void cn1EditApplyStyle(GtkWidget* w, CN1Font* font, int fg, int bg) { if (font && font->desc) { gtk_widget_override_font(w, font->desc); @@ -143,6 +167,7 @@ static void cn1EditCreateOnMain(void* p) { cn1EditApplyStyle(e->entry, req->font, req->fg, req->bg); g_signal_connect(e->entry, "changed", G_CALLBACK(cn1EditChanged), e); g_signal_connect(e->entry, "activate", G_CALLBACK(cn1EditActivate), e); + g_signal_connect(e->entry, "key-press-event", G_CALLBACK(cn1EditKeyPress), e); g_signal_connect(e->entry, "focus-out-event", G_CALLBACK(cn1EditFocusOut), e); } else { /* Use the GtkTextView directly as the overlay widget rather than wrapping it @@ -171,7 +196,15 @@ static void cn1EditCreateOnMain(void* p) { g_object_ref_sink(e->container); cn1LinuxOverlayAdd(req->slot, e->container, req->x, req->y, req->w, req->h); - gtk_widget_grab_focus(e->container); + if (e->entry) { + /* An entry given the focus selects all of its text, so the first key typed + * replaced what the field held. The caret goes after the text instead, as + * it does in the field the editor stands in for. */ + gtk_entry_grab_focus_without_selecting(GTK_ENTRY(e->entry)); + gtk_editable_set_position(GTK_EDITABLE(e->entry), -1); + } else { + gtk_widget_grab_focus(e->container); + } req->result = e; } @@ -202,6 +235,11 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_editIsDone___long_R_boolean(CO return (e && e->done) ? JAVA_TRUE : JAVA_FALSE; } +JAVA_INT com_codename1_impl_linux_LinuxNative_editTabbed___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { + CN1Edit* e = (CN1Edit*) (intptr_t) peer; + return e ? (JAVA_INT) e->tab : 0; +} + JAVA_OBJECT com_codename1_impl_linux_LinuxNative_editGetText___long_R_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { CN1Edit* e = (CN1Edit*) (intptr_t) peer; JAVA_OBJECT result; diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_image.c b/Ports/LinuxPort/nativeSources/cn1_linux_image.c index 6b8eea1d168..543b4ab4a45 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_image.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_image.c @@ -34,8 +34,15 @@ */ #include "cn1_linux_gfx.h" +#include +#include +#include #include #include +#include +#ifdef __GLIBC__ +#include +#endif extern const char* stringToUTF8(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT str); extern JAVA_OBJECT allocArray(CODENAME_ONE_THREAD_STATE, int length, struct clazz* type, int primitiveSize, int dim); @@ -44,11 +51,61 @@ int cn1LinuxSurfaceToPng(cairo_surface_t* surface, unsigned char** outData, int* #define CN1I(p) ((CN1Image*) (intptr_t) (p)) +/* + * How many images are alive and how many bytes their surfaces hold. An image is + * made on whatever thread asks for one and destroyed on the drawing thread, hence + * atomics. The numbers answer the question a resident-set figure cannot: whether + * memory is in pictures the port still holds, or somewhere else. + * + * CN1_LOG_IMAGES in the environment prints them to stderr, at most every 50 ms, + * as "[IMAGES] live= bytes= made= freed=" -- the same + * kind of switch as the collector's CN1_GC_LOG_CYCLES, and like it one cached + * getenv when it is off. + */ +static _Atomic long cn1ImagesLive = 0; +static _Atomic long cn1ImageBytesLive = 0; +static _Atomic long cn1ImagesMade = 0; +static _Atomic long cn1ImagesFreed = 0; + +static void cn1LogImages(void) { + static _Atomic int logImages = -1; + static _Atomic long long lastLogged = 0; + struct timespec now; + long long millis; + int on = atomic_load_explicit(&logImages, memory_order_relaxed); + if (on < 0) { + on = getenv("CN1_LOG_IMAGES") ? 1 : 0; + atomic_store_explicit(&logImages, on, memory_order_relaxed); + } + if (!on) { + return; + } + clock_gettime(CLOCK_MONOTONIC, &now); + millis = (long long) now.tv_sec * 1000 + now.tv_nsec / 1000000; + if (millis - atomic_load_explicit(&lastLogged, memory_order_relaxed) < 50) { + return; + } + atomic_store_explicit(&lastLogged, millis, memory_order_relaxed); + fprintf(stderr, "[IMAGES] live=%ld bytes=%ld made=%ld freed=%ld\n", + atomic_load_explicit(&cn1ImagesLive, memory_order_relaxed), + atomic_load_explicit(&cn1ImageBytesLive, memory_order_relaxed), + atomic_load_explicit(&cn1ImagesMade, memory_order_relaxed), + atomic_load_explicit(&cn1ImagesFreed, memory_order_relaxed)); +} + +static long cn1SurfaceBytes(cairo_surface_t* surface) { + return (long) cairo_image_surface_get_stride(surface) * cairo_image_surface_get_height(surface); +} + static CN1Image* cn1WrapSurface(cairo_surface_t* surface) { CN1Image* img = (CN1Image*) calloc(1, sizeof(CN1Image)); img->surface = surface; img->width = cairo_image_surface_get_width(surface); img->height = cairo_image_surface_get_height(surface); + atomic_fetch_add_explicit(&cn1ImagesLive, 1, memory_order_relaxed); + atomic_fetch_add_explicit(&cn1ImageBytesLive, cn1SurfaceBytes(surface), memory_order_relaxed); + atomic_fetch_add_explicit(&cn1ImagesMade, 1, memory_order_relaxed); + cn1LogImages(); return img; } @@ -235,6 +292,98 @@ JAVA_LONG com_codename1_impl_linux_LinuxNative_getImageGraphics___long_R_long(CO return (JAVA_LONG) (intptr_t) g; } +/* + * Disposal. + * + * An image handle's finalizer runs on the collector's thread while the event + * dispatch thread may be inside cairo with the very surface, so the finalizer only + * queues the pointer (releaseImage) and the drawing thread frees what is queued + * between frames (nextReleasedImage / disposeImage). Until these existed nothing + * in the port destroyed an image surface at all. + */ +/* + * Freeing is not giving back. glibc keeps what free() hands it for the next + * malloc, and a surface is 1.9 MB at 800x600 -- below the size it maps on its + * own once the threshold has adapted, so the pages stay in the process. An + * application that draws a picture per frame freed every one of them and was + * still measured at 340 MB resident with 58 MB in use; malloc_trim(0) took it to + * 188 MB. The pages are returned once this many bytes of surfaces were freed + * since the last time, which is about once per collection. Other C libraries + * have no such call and keep their own counsel. + */ +#define CN1_TRIM_AFTER_BYTES (32L * 1024 * 1024) +static long cn1FreedSinceTrim = 0; + +static pthread_mutex_t cn1ReleasedLock = PTHREAD_MUTEX_INITIALIZER; +static CN1Image** cn1Released = 0; +static int cn1ReleasedCount = 0; +static int cn1ReleasedCapacity = 0; + +JAVA_VOID com_codename1_impl_linux_LinuxNative_releaseImage___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG image) { + CN1Image* img = CN1I(image); + if (!img) { + return; + } + pthread_mutex_lock(&cn1ReleasedLock); + if (cn1ReleasedCount == cn1ReleasedCapacity) { + int capacity = cn1ReleasedCapacity == 0 ? 64 : cn1ReleasedCapacity * 2; + CN1Image** grown = (CN1Image**) realloc(cn1Released, sizeof(CN1Image*) * (size_t) capacity); + if (grown == 0) { + /* Out of memory for the queue itself: the image stays allocated, which + * is what happened to every image before this queue existed. */ + pthread_mutex_unlock(&cn1ReleasedLock); + return; + } + cn1Released = grown; + cn1ReleasedCapacity = capacity; + } + cn1Released[cn1ReleasedCount++] = img; + pthread_mutex_unlock(&cn1ReleasedLock); +} + +JAVA_LONG com_codename1_impl_linux_LinuxNative_nextReleasedImage___R_long(CODENAME_ONE_THREAD_STATE) { + CN1Image* img = 0; + pthread_mutex_lock(&cn1ReleasedLock); + if (cn1ReleasedCount > 0) { + img = cn1Released[--cn1ReleasedCount]; + } + pthread_mutex_unlock(&cn1ReleasedLock); + return (JAVA_LONG) (intptr_t) img; +} + +JAVA_LONG com_codename1_impl_linux_LinuxNative_disposeImage___long_R_long(CODENAME_ONE_THREAD_STATE, JAVA_LONG image) { + CN1Image* img = CN1I(image); + CN1Graphics* g; + if (!img) { + return 0; + } + g = img->mutableGraphics; + if (g != 0) { + if (g->cr != 0) { + cairo_destroy(g->cr); + } + cn1LinuxFreeClipShape(g); + free(g); + } + if (img->surface != 0) { + long bytes = cn1SurfaceBytes(img->surface); + cn1FreedSinceTrim += bytes; + atomic_fetch_sub_explicit(&cn1ImageBytesLive, bytes, memory_order_relaxed); + cairo_surface_destroy(img->surface); + } + free(img); + atomic_fetch_sub_explicit(&cn1ImagesLive, 1, memory_order_relaxed); + atomic_fetch_add_explicit(&cn1ImagesFreed, 1, memory_order_relaxed); + cn1LogImages(); +#ifdef __GLIBC__ + if (cn1FreedSinceTrim >= CN1_TRIM_AFTER_BYTES) { + cn1FreedSinceTrim = 0; + malloc_trim(0); + } +#endif + return (JAVA_LONG) (intptr_t) g; +} + /* drawImage / drawImageScaled live here (image ownership unit). They blit the * CN1Image surface onto the target graphics honouring its clip, affine and * alpha. */ diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_io.c b/Ports/LinuxPort/nativeSources/cn1_linux_io.c index e703ba70f8b..4a0ea775634 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_io.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_io.c @@ -467,6 +467,32 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_storageDir___R_java_lang_String return newStringFromCString(threadStateData, cn1StorageDir()); } +/* + * A moment written the way the user's locale writes a date (what == 0, strftime's %x) + * or a time of day (what == 1, %X), in the local time zone. An empty string when the + * moment cannot be broken down. + * + * The locale is the one GTK selected from the environment when it started + * (setlocale(LC_ALL, "")), so LC_TIME decides: 02/29/2024 for en_US, 29.02.2024 for + * de_DE, 02/29/24 with no locale set at all. + */ +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_formatLocalTime___long_int_R_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG millis, JAVA_INT what) { + char buf[256]; + struct tm parts; + time_t seconds = (time_t) (millis / 1000); + if (millis < 0 && millis % 1000 != 0) { + /* Division truncates towards zero; a moment before 1970 belongs to the second below. */ + seconds--; + } + buf[0] = 0; + if (localtime_r(&seconds, &parts) != 0) { + if (strftime(buf, sizeof(buf), what == 1 ? "%X" : "%x", &parts) == 0) { + buf[0] = 0; + } + } + return newStringFromCString(threadStateData, buf); +} + JAVA_OBJECT com_codename1_impl_linux_LinuxNative_executableDir___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { char buf[4096]; ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_text.c b/Ports/LinuxPort/nativeSources/cn1_linux_text.c index 1817e317cd9..cdd0629dd58 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_text.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_text.c @@ -86,11 +86,66 @@ static CN1Font* cn1MakeFont(const char* family, int weight, int italic, int pixe pango_font_description_set_weight(f->desc, weight); pango_font_description_set_style(f->desc, italic ? PANGO_STYLE_ITALIC : PANGO_STYLE_NORMAL); pango_font_description_set_absolute_size(f->desc, pixelSize * PANGO_SCALE); + /* + * A weight under regular is kept only when the family has a face near it. + * Pango answers with the nearest face it has, and for a family with no Light + * that is its ExtraLight when it has one (DejaVu Sans does): the titles and + * buttons a theme styles with native:MainLight came out as hairlines that read + * as disabled. Regular is what the other ports fall back to. + */ + if (weight < PANGO_WEIGHT_NORMAL) { + PangoFont* loaded = pango_context_load_font(cn1LinuxPangoContext(), f->desc); + int near = 0; + if (loaded != 0) { + PangoFontDescription* actual = pango_font_describe(loaded); + if (actual != 0) { + int delta = (int) pango_font_description_get_weight(actual) - weight; + near = delta > -75 && delta < 75; + pango_font_description_free(actual); + } + g_object_unref(loaded); + } + if (!near) { + pango_font_description_set_weight(f->desc, PANGO_WEIGHT_NORMAL); + } + } f->pixelSize = pixelSize; cn1FontMetrics(f); return f; } +/* + * The weight and the slant a "native:" face asks for. The names are Codename One's: + * Main or Italic, then Thin, Light, Regular, Bold or Black ("native:ItalicBold"). + * + * Every one of them used to load as the regular upright face: the name was reduced + * to the family and the rest of it dropped. A label a theme styles with + * native:MainBold drew like the text around it, and text an application asked for + * in bold or italic -- which the Swing and JavaFX layers ask for under these names + * -- was neither. deriveTrueTypeFont copies the description it is handed, so a font + * derived from one of these keeps what is set here. + */ +static void cn1NativeFace(const char* face, int* weight, int* italic) { + const char* rest = face; + *weight = PANGO_WEIGHT_NORMAL; + *italic = 0; + if (strncmp(face, "Italic", 6) == 0) { + *italic = 1; + rest = face + 6; + } else if (strncmp(face, "Main", 4) == 0) { + rest = face + 4; + } + if (strcmp(rest, "Thin") == 0) { + *weight = PANGO_WEIGHT_THIN; + } else if (strcmp(rest, "Light") == 0) { + *weight = PANGO_WEIGHT_LIGHT; + } else if (strcmp(rest, "Bold") == 0) { + *weight = PANGO_WEIGHT_BOLD; + } else if (strcmp(rest, "Black") == 0) { + *weight = PANGO_WEIGHT_HEAVY; + } +} + JAVA_LONG com_codename1_impl_linux_LinuxNative_createFont___int_int_int_R_long(CODENAME_ONE_THREAD_STATE, JAVA_INT face, JAVA_INT style, JAVA_INT size) { const char* family = "Sans"; int pixelSize = 18; @@ -237,12 +292,15 @@ JAVA_INT com_codename1_impl_linux_LinuxNative_fontHeight___long_R_int(CODENAME_O JAVA_LONG com_codename1_impl_linux_LinuxNative_loadTrueTypeFont___java_lang_String_java_lang_String_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT fontName, JAVA_OBJECT fileName) { const char* name = fontName == JAVA_NULL ? "Sans" : stringToUTF8(threadStateData, fontName); const char* family = name; + int weight = PANGO_WEIGHT_NORMAL; + int italic = 0; (void) fileName; - /* "native:" scheme maps to the system UI family. */ + /* "native:" scheme maps to the system UI family, in the weight and slant the name asks for. */ if (strncmp(name, "native:", 7) == 0) { + cn1NativeFace(name + 7, &weight, &italic); family = "Sans"; } - return (JAVA_LONG) (intptr_t) cn1MakeFont(family, PANGO_WEIGHT_NORMAL, 0, 18); + return (JAVA_LONG) (intptr_t) cn1MakeFont(family, weight, italic, 18); } JAVA_LONG com_codename1_impl_linux_LinuxNative_loadTrueTypeFontFromMemory___java_lang_String_byte_1ARRAY_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT fontName, JAVA_OBJECT data) { @@ -260,10 +318,13 @@ JAVA_LONG com_codename1_impl_linux_LinuxNative_loadTrueTypeFontFromMemory___java FT_Face face = 0; family[0] = 0; if (data == JAVA_NULL || (len = (int) (*(JAVA_ARRAY) data).length) <= 0) { + int weight = PANGO_WEIGHT_NORMAL; + int italic = 0; if (strncmp(name, "native:", 7) == 0) { + cn1NativeFace(name + 7, &weight, &italic); name = "Sans"; } - return (JAVA_LONG) (intptr_t) cn1MakeFont(name, PANGO_WEIGHT_NORMAL, 0, 18); + return (JAVA_LONG) (intptr_t) cn1MakeFont(name, weight, italic, 18); } bytes = (unsigned char*) CN1_ARRAY_DATA(data); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_window.c b/Ports/LinuxPort/nativeSources/cn1_linux_window.c index efccf73b015..0067308f5fc 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_window.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_window.c @@ -664,7 +664,13 @@ static gboolean cn1OnKey(GtkWidget* widget, GdkEventKey* e, gpointer data) { * is torn down GTK clears the toplevel focus to NULL, so CN1 keys resume. */ if (cn1Window != 0) { GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(cn1Window)); - if (focus != 0 && focus != cn1DrawingArea) { + /* The accessibility mirror is not a peer. Its widgets describe Codename One + * components to an assistive technology and have no way to hand text back, + * so a keystroke that arrives while one of them holds the GTK focus is still + * Codename One's. Forwarding it typed into an entry drawn at 1% opacity: + * the text went nowhere the user could see. */ + if (focus != 0 && focus != cn1DrawingArea + && !(cn1AccessibilityFixed != 0 && gtk_widget_is_ancestor(focus, cn1AccessibilityFixed))) { return FALSE; } } @@ -1665,9 +1671,45 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_accessibilityAction___long_java_l cn1LinuxRunOnMainAndWait(cn1A11yActionMain, action); } +/* Takes a window and everything under it out of pointer delivery. + * + * The mirror is made of real GTK widgets -- entries, toggle buttons, scales -- laid + * exactly over the Codename One components they describe, and each of them owns + * GdkWindows that take pointer input. gtk_overlay_set_overlay_pass_through only + * covers the overlay child's own window: GDK says in so many words that a + * pass-through window "can still have a subwindow without pass through". So a + * click on a text field landed on the invisible GtkEntry above it, which took the + * GTK focus, and everything typed afterwards went into that entry instead of the + * application. An assistive technology drives these widgets through ATK actions, + * never through the pointer, so nothing is lost by letting the pointer through. */ +static void cn1A11yPassPointerThrough(GdkWindow* window) { + GList* item; + if (window == 0) { + return; + } + gdk_window_set_pass_through(window, TRUE); + for (item = gdk_window_peek_children(window); item; item = item->next) { + cn1A11yPassPointerThrough((GdkWindow*) item->data); + } +} + static void cn1A11yEndMain(void* pointer) { (void) pointer; - if (cn1AccessibilityFixed) gtk_widget_show_all(cn1AccessibilityFixed); + if (cn1AccessibilityFixed) { + gtk_widget_show_all(cn1AccessibilityFixed); + if (gtk_widget_get_realized(cn1AccessibilityFixed)) { + /* A GtkFixed has no window of its own: this is the child window the + * overlay made for it, and the mirror's windows are all below it. */ + GdkWindow* mirror = gtk_widget_get_window(cn1AccessibilityFixed); + /* Never the window the drawing area lives in: passing THAT through + * would take the pointer away from the whole application. */ + if (mirror != 0 && mirror != gtk_widget_get_window(cn1Overlay) + && mirror != gtk_widget_get_window(cn1DrawingArea) + && mirror != gtk_widget_get_window(cn1Window)) { + cn1A11yPassPointerThrough(mirror); + } + } + } } JAVA_VOID com_codename1_impl_linux_LinuxNative_accessibilityEnd___int(CODENAME_ONE_THREAD_STATE, JAVA_INT changeType) { diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index a73399a623e..9fcb7c4f1e6 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -53,6 +53,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; /** * Native Linux (GTK3, desktop) implementation of the Codename One platform @@ -81,6 +82,9 @@ public class LinuxImplementation extends CodenameOneImplementation { /// editor and browser hosts. Matches CN1_MAIN_WINDOW_SLOT in cn1_linux_gfx.h. static final int MAIN_WINDOW_SLOT = -1; + /// The key code of Tab, the one `Form` moves the focus on. + private static final int KEY_CODE_TAB = 9; + @Override public boolean isHighContrastEnabled() { @@ -180,7 +184,109 @@ public static LinuxImplementation getInstance() { /* -------------------------------------------------------------- helpers */ private static long peer(Object o) { - return o == null ? 0L : ((Long) o).longValue(); + if (o instanceof Long) { + return ((Long) o).longValue(); + } + if (o instanceof NativeImage) { + return ((NativeImage) o).peer; + } + if (o instanceof ImageGraphics) { + return ((ImageGraphics) o).peer; + } + return 0L; + } + + /// The native half of an image: a Cairo surface, and the graphics context drawing + /// into it once one was asked for. + /// + /// An image used to be a boxed pointer, and nothing ever gave the surface back: + /// there was no native to do it. An application that made a picture per frame -- + /// 800x600 is 1.9 MB -- grew by that much per frame for as long as it ran, with + /// the collector reclaiming every Java object on schedule. The handle is what + /// tells the port the image is gone. + /// + /// `finalize` runs on the collector's thread, where a surface the event dispatch + /// thread may be part way through blitting must not be destroyed. It only queues + /// the pointer; `#disposeReleasedImages()` frees it between frames. + static final class NativeImage { + final long peer; + + NativeImage(long peer) { + this.peer = peer; + } + + @Override + protected void finalize() { + if (peer != 0) { + LinuxNative.releaseImage(peer); + } + } + } + + /// The graphics of a mutable image. It keeps the image: the context draws into + /// the image's surface and is freed with it, so the image must outlive every + /// graphics object still drawing there. + static final class ImageGraphics { + final long peer; + final Object image; + + ImageGraphics(long peer, Object image) { + this.peer = peer; + this.image = image; + } + } + + /// Surface bytes handed out since a collection was last asked for. A surface is + /// invisible to the collector -- the Java side of a 2 MB picture is 16 bytes -- + /// so a program can allocate images for a long time without the heap ever + /// reaching the size that starts a cycle. Plain, not atomic: a lost update only + /// postpones the request by one image. + private static long imageBytesSinceGc; + + private static final long IMAGE_BYTES_PER_GC = 32L * 1024 * 1024; + + /// When an image was last made, and how long none must have been made before + /// an idle event dispatch thread asks for the collection that the count above + /// did not reach. An application that draws a picture per frame and then stops + /// leaves the images of its last frames behind, short of the count for good: + /// nothing allocates any more, so nothing ever starts a cycle. While images + /// are still being made the count is what asks. + private static long lastImageMillis; + + private static final long IMAGE_QUIET_MILLIS = 500; + + static Object wrapImage(long peer, int width, int height) { + if (peer != 0) { + if (width <= 0 || height <= 0) { + width = LinuxNative.imageWidth(peer); + height = LinuxNative.imageHeight(peer); + } + imageBytesSinceGc += 4L * width * height; + lastImageMillis = System.currentTimeMillis(); + if (imageBytesSinceGc > IMAGE_BYTES_PER_GC) { + imageBytesSinceGc = 0; + System.gc(); + } + } + return new NativeImage(peer); + } + + /// Frees the images whose handles the collector finalized. Event dispatch thread + /// only, between frames: it is the one moment no native draw can be holding one. + /// The graphics context freed with an image takes its entries in the transform + /// and clip-stack tables with it; a context allocated later at the same address + /// would otherwise start life under a dead image's transform. + private void disposeReleasedImages() { + long image = LinuxNative.nextReleasedImage(); + while (image != 0) { + long graphics = LinuxNative.disposeImage(image); + if (graphics != 0) { + Long key = Long.valueOf(graphics); + graphicsTransforms.remove(key); + clipStacks.remove(key); + } + image = LinuxNative.nextReleasedImage(); + } } private String storagePath(String name) { @@ -645,8 +751,64 @@ public int getDisplayHeight() { return LinuxNative.getDisplayHeight(); } + /// Resizes the application window. The size is the outer one, frame included, + /// as on the JavaSE port; the display follows through the size-changed event + /// the window's new allocation produces, so nothing is laid out here. + /// + /// The window still OPENS at 800x600: it exists before any application code + /// runs, and that default is what every screenshot baseline was taken at. + @Override + public void setWindowSize(int width, int height) { + if (width > 0 && height > 0) { + LinuxNative.mainWindowSetSize(width, height); + } + } + + @Override + public com.codename1.ui.geom.Rectangle getWindowBounds() { + int[] frame = new int[4]; + if (!LinuxNative.mainWindowGetFrame(frame)) { + return super.getWindowBounds(); + } + return new com.codename1.ui.geom.Rectangle(frame[0], frame[1], frame[2], frame[3]); + } + + /// The monitor the application window is on, which is the desktop a + /// percentage of it is taken from. + @Override + public com.codename1.ui.geom.Dimension getDesktopSize() { + int[] bounds = new int[4]; + if (!LinuxNative.mainWindowGetFrame(bounds)) { + return super.getDesktopSize(); + } + LinuxNative.monitorBounds(LinuxNative.monitorForMainWindow(), false, bounds); + if (bounds[2] <= 0 || bounds[3] <= 0) { + return super.getDesktopSize(); + } + return new com.codename1.ui.geom.Dimension(bounds[2], bounds[3]); + } + + /// The hint is a size for the window the first form opens in. Here that window + /// already exists, so the hint is applied as it arrives and consumed, the way + /// the JavaSE port consumes it when it builds its frame. + @Override + public void setInitialWindowSizeHintPercent(com.codename1.ui.geom.Dimension hint) { + super.setInitialWindowSizeHintPercent(hint); + com.codename1.ui.geom.Dimension desktop = hint == null ? null : getDesktopSize(); + if (desktop == null) { + return; + } + int width = Math.min(desktop.getWidth(), + Math.max(1, Math.round(desktop.getWidth() * (hint.getWidth() / 100f)))); + int height = Math.min(desktop.getHeight(), + Math.max(1, Math.round(desktop.getHeight() * (hint.getHeight() / 100f)))); + super.setInitialWindowSizeHintPercent(null); + setWindowSize(width, height); + } + @Override public void flushGraphics() { + disposeReleasedImages(); LinuxNative.flushGraphics(windowGraphicsPeer, 0, 0, getDisplayWidth(), getDisplayHeight()); } @@ -721,6 +883,8 @@ public void run() { @Override public void flushGraphics(int x, int y, int width, int height) { + // Most frames repaint a part of the window and come through here. + disposeReleasedImages(); LinuxNative.flushGraphics(windowGraphicsPeer, x, y, width, height); } @@ -764,11 +928,85 @@ public void screenshot(com.codename1.util.SuccessCallback callback) { @Override public void edtIdle(boolean enter) { - // Intentionally empty: the Win32 message pump and input dispatch run on the + // No pumping here: the message pump and input dispatch run on the // main thread (see runMainEventLoop), and the EDT is woken by the Display // lock notifications those dispatches trigger. The EDT therefore idles by // sleeping on the lock like every other Codename One platform -- it must // not pump or drain on its own (it is not the window's owning thread). + // + // What it does do on its way to sleep is free the images the collector + // let go of. A frame frees them too, but an application that computed a + // picture and then sat still painted no further frame: the images of + // its last collection stayed queued, 70 to 200 MB of them in one + // measured run, for as long as nothing was repainted. + if (enter) { + disposeReleasedImages(); + if (imageBytesSinceGc > 0) { + long quiet = System.currentTimeMillis() - lastImageMillis; + if (quiet > IMAGE_QUIET_MILLIS) { + // See lastImageMillis. Nothing is counted until an image is made. + imageBytesSinceGc = 0; + System.gc(); + releaseWakes = 2; + wakeEdtIn(IMAGE_RELEASE_MILLIS); + } else { + // Too soon to call it quiet, and this thread may not wake again + // on its own: an application that stops moving stops for good. + wakeEdtIn(IMAGE_QUIET_MILLIS - quiet + 1); + } + } else if (releaseWakes > 0) { + releaseWakes--; + if (releaseWakes > 0) { + wakeEdtIn(4 * IMAGE_RELEASE_MILLIS); + } + } + } + } + + /// How long after asking for the quiet collection this thread comes back to free + /// what it let go of, and how many times it still will: once soon, once later for + /// a cycle that took its time. + private static final long IMAGE_RELEASE_MILLIS = 250; + + private int releaseWakes; + + private static final AtomicBoolean WAKE_PENDING = new AtomicBoolean(); + + private static final Runnable NOTHING = new Runnable() { + @Override + public void run() { + } + }; + + /// Has the event dispatch thread pass through `edtIdle` once more, some time + /// from now. The quiet collection above is asked for by a thread on its way to + /// sleep, and the collector answers on its own thread a moment later: the images + /// it released sat in the queue until the next frame, which a still application + /// never paints. A Mandelbrot picture that had finished rendering held 115 + /// surfaces, 220 MB, for as long as nobody touched the window; and when the + /// thread went to sleep less than `IMAGE_QUIET_MILLIS` after the last image, + /// the collection was never asked for at all. + /// + /// One wake-up is pending at a time, hence the atomic: the timer thread clears + /// it and this thread sets it. + private static void wakeEdtIn(final long millis) { + if (!WAKE_PENDING.compareAndSet(false, true)) { + return; + } + Thread t = new Thread(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(millis); + } catch (InterruptedException ex) { + // Woken early is still woken: the pass it asks for is harmless. + } + WAKE_PENDING.set(false); + Display.getInstance().callSerially(NOTHING); + } + }, "cn1-linux-image-release"); + t.setDaemon(true); + t.start(); } // High bits in the native key flag touch, pen, or eraser input (see cn1_linux.h); @@ -882,10 +1120,10 @@ private void drainInput() { dispatchPointerHover(windowId, x, y, key); break; case EVENT_KEY_PRESSED: - windowKeyPressed(windowId, key); + windowKeyPressed(windowId, keyCodeOf(key)); break; case EVENT_KEY_RELEASED: - windowKeyReleased(windowId, key); + windowKeyReleased(windowId, keyCodeOf(key)); break; case EVENT_SIZE_CHANGED: if (windowId == 0) { @@ -1185,7 +1423,7 @@ public Object getNativeGraphics() { @Override public Object getNativeGraphics(Object image) { - return Long.valueOf(LinuxNative.getImageGraphics(peer(image))); + return new ImageGraphics(LinuxNative.getImageGraphics(peer(image)), image); } /* ------------------------------------------------------------- transforms @@ -1931,6 +2169,25 @@ public void drawImage(Object graphics, Object img, int x, int y) { LinuxNative.drawImage(peer(graphics), peer(img), x, y); } + /// Cairo scales an image as it draws it, and `drawImageScaled` has done so + /// since the port was written -- but nothing called it. Without this answer the + /// core scales for the port: `Image.scaled` makes a second surface of the size + /// asked for and keeps it in the image's scale cache for as long as the image + /// lives, one per size. An application that zooms a picture draws it at a new + /// size every frame, so every frame left a surface behind, each larger than the + /// last: a JavaFX canvas zooming an 800x600 snapshot was measured holding 156 + /// surfaces, 1.8 GB, by the end of a one second animation, none of them garbage + /// until the snapshot itself was replaced. + @Override + public boolean isScaledImageDrawingSupported() { + return true; + } + + @Override + public void drawImage(Object graphics, Object img, int x, int y, int w, int h) { + LinuxNative.drawImageScaled(peer(graphics), peer(img), x, y, w, h); + } + @Override public void drawRGB(Object graphics, int[] rgbData, int offset, int x, int y, int w, int h, boolean processAlpha) { LinuxNative.drawRGB(peer(graphics), rgbData, offset, x, y, w, h, processAlpha); @@ -2049,28 +2306,28 @@ public Object deriveTrueTypeFont(Object font, float size, int weight) { @Override public Object createImage(int[] rgb, int width, int height) { - return Long.valueOf(LinuxNative.createImageFromARGB(rgb, width, height)); + return wrapImage(LinuxNative.createImageFromARGB(rgb, width, height), width, height); } @Override public Object createImage(String path) throws IOException { - return Long.valueOf(LinuxNative.createImageFromFile(stripFileUrl(path))); + return wrapImage(LinuxNative.createImageFromFile(stripFileUrl(path)), 0, 0); } @Override public Object createImage(InputStream i) throws IOException { byte[] data = readFully(i); - return Long.valueOf(LinuxNative.createImageFromBytes(data, 0, data.length)); + return wrapImage(LinuxNative.createImageFromBytes(data, 0, data.length), 0, 0); } @Override public Object createImage(byte[] bytes, int offset, int len) { - return Long.valueOf(LinuxNative.createImageFromBytes(bytes, offset, len)); + return wrapImage(LinuxNative.createImageFromBytes(bytes, offset, len), 0, 0); } @Override public Object createMutableImage(int width, int height, int fillColor) { - return Long.valueOf(LinuxNative.createMutableImage(width, height, fillColor)); + return wrapImage(LinuxNative.createMutableImage(width, height, fillColor), width, height); } @Override @@ -2085,7 +2342,7 @@ public int getImageHeight(Object i) { @Override public Object scale(Object nativeImage, int width, int height) { - return Long.valueOf(LinuxNative.scaleImage(peer(nativeImage), width, height)); + return wrapImage(LinuxNative.scaleImage(peer(nativeImage), width, height), width, height); } @Override @@ -2163,6 +2420,14 @@ public void editString(final Component cmp, int maxSize, int constraint, String // The editor goes into the overlay of the window the field lives in; without // the slot it appeared over the main window while the window being typed into // showed nothing. + // The key that started the edit is part of what was typed. The editor + // opens after it was pressed and never sees it, so it is added here; + // without it the first character typed into a field was lost. + if (initiatingKeycode >= ' ' && initiatingKeycode != 127 && initiatingKeycode <= 0xffff + && getGameAction(initiatingKeycode) == 0 + && (maxSize <= 0 || text == null || text.length() < maxSize)) { + text = (text == null ? "" : text) + (char) initiatingKeycode; + } long peer = LinuxNative.editStringAt(x, y, w, h, text == null ? "" : text, singleLine, maxSize, fontPeer, s.getFgColor(), s.getBgColor(), 0, LinuxWindowManager.slotForComponent(cmp)); @@ -2186,7 +2451,16 @@ public void run() { return; } if (LinuxNative.editIsDone(editPeer)) { + // Tab ended it: the editor kept the key from the form, so + // the form is given it now and moves its focus on. In the + // main window only; a second window keeps what it did. + boolean tab = LinuxNative.editTabbed(editPeer) != 0 + && LinuxWindowManager.slotForComponent(cmp) == MAIN_WINDOW_SLOT; hideTextEditor(); + if (tab) { + keyPressed(KEY_CODE_TAB); + keyReleased(KEY_CODE_TAB); + } return; } // Mirror the native control's text into the field as the user @@ -2563,12 +2837,71 @@ public int getBackKeyCode() { @Override public int getGameAction(int keyCode) { - return 0; + switch (keyCode) { + case GAME_KEY_CODE_UP: + return Display.GAME_UP; + case GAME_KEY_CODE_DOWN: + return Display.GAME_DOWN; + case GAME_KEY_CODE_LEFT: + return Display.GAME_LEFT; + case GAME_KEY_CODE_RIGHT: + return Display.GAME_RIGHT; + default: + return 0; + } } @Override public int getKeyCode(int gameAction) { - return 0; + switch (gameAction) { + case Display.GAME_UP: + return GAME_KEY_CODE_UP; + case Display.GAME_DOWN: + return GAME_KEY_CODE_DOWN; + case Display.GAME_LEFT: + return GAME_KEY_CODE_LEFT; + case Display.GAME_RIGHT: + return GAME_KEY_CODE_RIGHT; + default: + return 0; + } + } + + // The codes the arrow keys are handed to Codename One as. They are + // negative, as on the JavaSE port: a positive code is a character, and + // a form, a list and everything else that moves on an arrow asks + // getGameAction what a negative one means. + private static final int GAME_KEY_CODE_UP = -91; + private static final int GAME_KEY_CODE_DOWN = -92; + private static final int GAME_KEY_CODE_LEFT = -93; + private static final int GAME_KEY_CODE_RIGHT = -94; + + /// The Codename One code of a key the window reported. + /// + /// The window hands over the character a key types, and for a key + /// that types none its GDK key value. The arrows are such keys, and + /// their values (0xff51 to 0xff54, and the keypad's 0xff96 to 0xff99) + /// were passed on as they came: getGameAction answered 0 for + /// everything, so no arrow key did anything in any application -- a + /// form did not move its focus, and a game played with the arrows did + /// not play -- while a text component read the value as a character. + static int keyCodeOf(int key) { + switch (key) { + case 0xff51: + case 0xff96: + return GAME_KEY_CODE_LEFT; + case 0xff52: + case 0xff97: + return GAME_KEY_CODE_UP; + case 0xff53: + case 0xff98: + return GAME_KEY_CODE_RIGHT; + case 0xff54: + case 0xff99: + return GAME_KEY_CODE_DOWN; + default: + return key; + } } /* ------------------------------------------------------------ network */ @@ -3722,6 +4055,27 @@ public String getLongMonthName(Date date) { } return LONG_MONTHS[m]; } + + /* + * The short date and the time of day are the locale's own. The + * base class answers Date.toString() for both, which is neither + * short nor a date alone: a date Picker -- and so a JavaFX + * DatePicker or anything else that shows a day -- read + * "February 29, 2024 12:00:00 PM" where every other port shows + * 2/29/2024. An empty answer means the C library could not + * write the moment, and the old text is better than none. + */ + @Override + public String formatDateShortStyle(Date d) { + String s = LinuxNative.formatLocalTime(d.getTime(), 0); + return s == null || s.length() == 0 ? super.formatDateShortStyle(d) : s; + } + + @Override + public String formatTime(Date d) { + String s = LinuxNative.formatLocalTime(d.getTime(), 1); + return s == null || s.length() == 0 ? super.formatTime(d) : s; + } }; } return l10n; diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 8399b7cd1ee..e37e5c1bbc5 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -239,6 +239,15 @@ public static native int desktopWindowCreate(int windowId, String title, int x, public static native boolean mainWindowGetBounds(int[] out); + /// Resizes the application's MAIN window to an outer size, frame included. + /// A no-op when headless, where the surface is the size of the screenshot. + public static native void mainWindowSetSize(int width, int height); + + /// Fills `out` with the main window's outer rectangle, the one + /// `#mainWindowSetSize(int, int)` sets; `#mainWindowGetBounds(int[])` answers + /// the client area. False when there is no window. + public static native boolean mainWindowGetFrame(int[] out); + public static native int desktopWindowGetWidth(int slot); public static native int desktopWindowGetHeight(int slot); @@ -486,6 +495,18 @@ public static native void accessibilityNode(long id, long parentId, String role, public static native long getImageGraphics(long image); + /// Queues an image for disposal. Safe from any thread: this is what an image + /// handle's finalizer calls, on the collector's thread. Nothing is freed here. + public static native void releaseImage(long image); + + /// The next queued image, or 0 when the queue is empty. + public static native long nextReleasedImage(); + + /// Destroys an image's surface and the graphics context cached for it, and + /// returns that context's pointer (0 if it never had one) so the caller can + /// forget what it kept under it. Drawing thread only. + public static native long disposeImage(long image); + /* ------------------------------------------------------- filesystem */ /** @@ -515,6 +536,9 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /** True once the user has committed the native edit (Enter / focus loss). */ public static native boolean editIsDone(long peer); + /** How Tab ended the native edit: 1 forwards, 2 backwards, 0 when it did not. */ + public static native int editTabbed(long peer); + /** The native EDIT control's current text. */ public static native String editGetText(long peer); @@ -528,6 +552,13 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /** Why the most recent open failed, for the exception the port raises. */ public static native String lastIoError(); + /** + * A moment as the user's locale writes a date ({@code what == 0}) or a time + * of day ({@code what == 1}), in the local time zone; empty when it cannot + * be written. + */ + public static native String formatLocalTime(long millis, int what); + /* ---------------------------------------------------------- crypto */ /** Fills {@code out} with fresh entropy; false when the platform RNG failed. */ diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWindowManager.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWindowManager.java index 14468e52ca2..8864a9477d2 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWindowManager.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWindowManager.java @@ -463,7 +463,7 @@ public Object capture(Object peer) { if (img == 0) { return null; } - return Long.valueOf(img); + return LinuxImplementation.wrapImage(img, 0, 0); } @Override diff --git a/Ports/WindowsPort/nativeSources/cn1_windows.h b/Ports/WindowsPort/nativeSources/cn1_windows.h index d372576130a..705424e21b4 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows.h +++ b/Ports/WindowsPort/nativeSources/cn1_windows.h @@ -179,6 +179,13 @@ typedef struct CN1Graphics { JAVA_BOOLEAN clipIsRect; void* clipGeom; /* ID2D1PathGeometry* when clip is a shape, else NULL */ void* clipLayer; /* ID2D1Layer* pushed for a shape clip, else NULL */ + JAVA_BOOLEAN clipGeomHasRect; /* a clipRect narrowed the shape clip: clipX/Y/W/H is + * then a rectangle the shape is ALSO cut to. False + * right after setClipShape, when clipX/Y/W/H is still + * the rectangle that was the clip before the shape + * and is not part of the clip at all */ + JAVA_BOOLEAN clipRectPushed; /* that rectangle was pushed under the shape's layer + * and cn1WinPopClip has to pop it as well */ D2D1_MATRIX_3X2_F clipMaskTransform; /* world transform captured at setClipShape time; * applied to the (raw) clip geometry as the layer * maskTransform so a shape clip lands exactly where diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_graphics.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_graphics.cpp index 3c610c22797..b9569326b8f 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_graphics.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_graphics.cpp @@ -84,6 +84,8 @@ CN1Graphics* cn1WinCreateGraphics(ID2D1RenderTarget* target) { g->clipIsRect = JAVA_TRUE; g->clipGeom = NULL; g->clipLayer = NULL; + g->clipGeomHasRect = JAVA_FALSE; + g->clipRectPushed = JAVA_FALSE; g->clipMaskTransform = D2D1::Matrix3x2F::Identity(); g->font = cn1Win.defaultFont; g->inFrame = JAVA_FALSE; @@ -179,6 +181,22 @@ extern "C" void cn1WinPushClip(CN1Graphics* g) { * the fill) -- so the clip stays fixed while the fill transforms within it. */ if (!g->clipIsRect && g->clipGeom != NULL) { ID2D1Layer* layer = NULL; + /* A clipRect that came after the shape: the clip is the shape AND that + * rectangle (see clipRect below). The rectangle goes on first, under + * identity for the reason given at the end of this function, and the + * shape's layer inside it; cn1WinPopClip takes them off in reverse. */ + if (g->clipGeomHasRect) { + D2D1_RECT_F rc; + D2D1_MATRIX_3X2_F idr = D2D1::Matrix3x2F::Identity(); + rc.left = (FLOAT)g->clipX; + rc.top = (FLOAT)g->clipY; + rc.right = (FLOAT)(g->clipX + g->clipW); + rc.bottom = (FLOAT)(g->clipY + g->clipH); + ID2D1RenderTarget_SetTransform(g->target, &idr); + ID2D1RenderTarget_PushAxisAlignedClip(g->target, &rc, D2D1_ANTIALIAS_MODE_PER_PRIMITIVE); + ID2D1RenderTarget_SetTransform(g->target, &g->transform); + g->clipRectPushed = JAVA_TRUE; + } if (SUCCEEDED(ID2D1RenderTarget_CreateLayer(g->target, NULL, &layer)) && layer != NULL) { D2D1_LAYER_PARAMETERS lp; lp.contentBounds = D2D1::InfiniteRect(); @@ -253,6 +271,20 @@ extern "C" void cn1WinPopClip(CN1Graphics* g) { ID2D1RenderTarget_PopLayer(g->target); ID2D1Layer_Release((ID2D1Layer*) g->clipLayer); g->clipLayer = NULL; + if (g->clipRectPushed) { + ID2D1RenderTarget_PopAxisAlignedClip(g->target); + g->clipRectPushed = JAVA_FALSE; + } + return; + } + if (g->clipRectPushed) { + /* The shape's layer could not be made; its rectangle was still pushed. */ + ID2D1RenderTarget_PopAxisAlignedClip(g->target); + g->clipRectPushed = JAVA_FALSE; + return; + } + if (!g->clipIsRect && g->clipGeom != NULL) { + /* A shape clip whose layer could not be made pushed nothing at all. */ return; } ID2D1RenderTarget_PopAxisAlignedClip(g->target); @@ -335,6 +367,7 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_setClip___long_int_int_int_in g->clipW = __cn1Arg4; g->clipH = __cn1Arg5; g->clipIsRect = JAVA_TRUE; + g->clipGeomHasRect = JAVA_FALSE; cn1WinReleaseClipGeom(g); } @@ -375,12 +408,33 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_setClipShape___long_float_1AR g->clipGeom = cn1WinBuildPathGeometry(coords, types, __cn1Arg4, __cn1Arg5); g->clipMaskTransform = g->transform; /* freeze the world transform at setClip time */ g->clipIsRect = (g->clipGeom == NULL) ? JAVA_TRUE : JAVA_FALSE; - /* Keep clipX/Y/W/H as the geometry's bounding box so getClip* stays sane. */ + /* clipX/Y/W/H is left as it was: the rectangle that was the clip BEFORE the + * shape replaced it. It is what getClip* goes on answering, and it is not + * part of the clip until a clipRect says so. */ + g->clipGeomHasRect = JAVA_FALSE; } JAVA_VOID com_codename1_impl_windows_WindowsNative_clipRect___long_int_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1, JAVA_INT __cn1Arg2, JAVA_INT __cn1Arg3, JAVA_INT __cn1Arg4, JAVA_INT __cn1Arg5) { /* Intersect the requested rectangle with the current clip. */ CN1Graphics* g = (CN1Graphics*)__cn1Arg1; + /* A shape clip is narrowed, not thrown away. This used to end in + * clipIsRect = JAVA_TRUE and a released geometry whatever the clip was, so + * "setClip(shape); clipRect(the clip that was there)" -- which is how + * Graphics.fillShape confines a Paint to the shape it fills, and how any + * caller narrows the clip to a shape without widening it -- left only the + * rectangle that had been the clip BEFORE the shape: a gradient meant for a + * small rounded rectangle covered everything from the panel's corner to the + * end of the gradient's band. The first clipRect after the shape supplies + * the rectangle outright, since clipX/Y/W/H still holds the replaced clip + * then; later ones intersect with it as they always did. */ + if (!g->clipIsRect && g->clipGeom != NULL && !g->clipGeomHasRect) { + g->clipX = __cn1Arg2; + g->clipY = __cn1Arg3; + g->clipW = (__cn1Arg4 > 0) ? __cn1Arg4 : 0; + g->clipH = (__cn1Arg5 > 0) ? __cn1Arg5 : 0; + g->clipGeomHasRect = JAVA_TRUE; + return; + } JAVA_INT curRight = g->clipX + g->clipW; JAVA_INT curBottom = g->clipY + g->clipH; JAVA_INT reqRight = __cn1Arg2 + __cn1Arg4; @@ -395,6 +449,9 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_clipRect___long_int_int_int_i g->clipY = newY; g->clipW = (newRight > newX) ? (newRight - newX) : 0; g->clipH = (newBottom > newY) ? (newBottom - newY) : 0; + if (!g->clipIsRect && g->clipGeom != NULL) { + return; /* the shape stays, cut to the narrower rectangle */ + } g->clipIsRect = JAVA_TRUE; cn1WinReleaseClipGeom(g); } diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_text.c b/Ports/WindowsPort/nativeSources/cn1_windows_text.c index 8bf6d5f91b5..c82bf1f18fe 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_text.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_text.c @@ -324,13 +324,19 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_loadTrueTypeFontFromMemory___ /* * deriveTrueTypeFont produces a new peer at the requested pixel size and weight, * reusing the base font's family. weight carries the CN1 STYLE_* bits. + * + * The base font's own bold and slant are kept. A "native:MainBold" font is + * bold by its name, and every caller that only wants another size of it asks + * with a plain weight: taking the weight alone gave back regular Segoe UI, so + * no sized native font was ever bold or italic. */ JAVA_LONG com_codename1_impl_windows_WindowsNative_deriveTrueTypeFont___long_float_int_R_long( CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1, JAVA_FLOAT __cn1Arg2, JAVA_INT __cn1Arg3) { CN1Font* base = (CN1Font*) (intptr_t) __cn1Arg1; const wchar_t* family = (base != NULL && base->family != NULL) ? base->family : L"Segoe UI"; float px = __cn1Arg2 > 0.0f ? __cn1Arg2 : 15.0f; - CN1Font* font = cn1WinMakeFont(family, px, 0, __cn1Arg3); + int style = __cn1Arg3 | (base != NULL ? base->style : 0); + CN1Font* font = cn1WinMakeFont(family, px, 0, style); return (JAVA_LONG) (intptr_t) font; } diff --git a/docs/demos/common/src/main/snippets/developer-guide/desktop-interop.properties b/docs/demos/common/src/main/snippets/developer-guide/desktop-interop.properties new file mode 100644 index 00000000000..85c703c5447 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/desktop-interop.properties @@ -0,0 +1,14 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::desktop-interop-properties-001[] +mainClass=com.example.Main +kind=swing +// end::desktop-interop-properties-001[] + +// tag::desktop-interop-properties-002[] +kind=library +// end::desktop-interop-properties-002[] + +// tag::desktop-interop-properties-003[] +codename1.arg.desktop.themeMode=native +// end::desktop-interop-properties-003[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/desktop-interop.sh b/docs/demos/common/src/main/snippets/developer-guide/desktop-interop.sh new file mode 100644 index 00000000000..238c7dbd0e9 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/desktop-interop.sh @@ -0,0 +1,66 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::desktop-interop-bash-001[] +mvn cn1:import-desktop-project -Dcn1.desktop.import=/path/to/MyDesktopApp +// end::desktop-interop-bash-001[] + +// tag::desktop-interop-bash-002[] +mvn cn1:import-desktop-project -Dcn1.desktop.import=/path/to/MyDesktopApp \ + -Dcn1.desktop.module=app \ + -Dcn1.desktop.mainClass=com.example.Main +// end::desktop-interop-bash-002[] + +// tag::desktop-interop-bash-003[] +mvn cn1:run +mvn cn1:build -Dcodename1.platform=ios +// end::desktop-interop-bash-003[] + +// tag::desktop-interop-bash-004[] +./gradlew run # the simulator +./gradlew buildIos # an iOS build on the build server +./gradlew buildLinuxDevice +// end::desktop-interop-bash-004[] + +// tag::desktop-interop-bash-005[] +mvn cn1:run # the simulator +mvn cn1:buildIos # iOS, on the build server +mvn cn1:buildAndroid # Android, on the build server +mvn cn1:buildJavascript # the JavaScript port +mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source # an Xcode project +mvn package -Dcodename1.platform=android -Dcodename1.buildTarget=android-source # an Android Studio project +// end::desktop-interop-bash-005[] + +// tag::desktop-interop-bash-006[] +# Linux: a native executable, built on a Linux machine +mvn package -Dcodename1.platform=linux -Dcodename1.buildTarget=local-linux-device + +# macOS: a native .app, built on a Mac with Xcode +mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=local-mac-device + +# Windows: a native .exe, built on Windows or cross-compiled +mvn package -Dcodename1.platform=win -Dcodename1.buildTarget=local-windows-device +// end::desktop-interop-bash-006[] + +// tag::desktop-interop-bash-007[] +mvn package -Dcn1.desktop.report=port.md # Maven +./gradlew build -Pcn1.desktop.report=port.md # Gradle +// end::desktop-interop-bash-007[] + +// tag::desktop-interop-bash-008[] +# From a checkout of the Codename One sources, against a built application: +xvfb-run -a maven/integration-tests/desktop-compat-realport/run.sh \ + /path/to/MyApp/common /path/to/orders.txt /path/to/output +// end::desktop-interop-bash-008[] + +// tag::desktop-interop-bash-009[] +scripts/desktop-compat-benchmarks/bench.sh --app /path/to/MyDesktopApp --name myapp \ + --kind swing --main com.example.Main --work /path/to/work +scripts/desktop-compat-benchmarks/report.py /path/to/work/myapp/result.json +// end::desktop-interop-bash-009[] + +// tag::desktop-interop-bash-010[] +scripts/desktop-compat-benchmarks/mobile-size.sh --target ios \ + --project /path/to/work/myapp/cn1/app --work /path/to/work/myapp-ios +scripts/desktop-compat-benchmarks/mobile-size.sh --target android \ + --project /path/to/work/myapp/cn1/app --work /path/to/work/myapp-android +// end::desktop-interop-bash-010[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/desktop-interop.xml b/docs/demos/common/src/main/snippets/developer-guide/desktop-interop.xml new file mode 100644 index 00000000000..fc6f11c2241 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/desktop-interop.xml @@ -0,0 +1,10 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::desktop-interop-xml-001[] + + com.example + swing-widgets + 1.0 + compile + +// end::desktop-interop-xml-001[] diff --git a/docs/demos/desktop-interop/pom.xml b/docs/demos/desktop-interop/pom.xml new file mode 100644 index 00000000000..6debdbe2051 --- /dev/null +++ b/docs/demos/desktop-interop/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + com.codenameone.developerguide + democode + 1.0-SNAPSHOT + + desktopinteropsnippets + desktopinteropsnippets + + + + com.codenameone + codenameone-core + ${cn1.version} + provided + + + com.codenameone + codenameone-swing-compat + ${cn1.version} + provided + + + com.codenameone + codenameone-javafx-compat + ${cn1.version} + provided + + + diff --git a/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/CounterApp.java b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/CounterApp.java new file mode 100644 index 00000000000..9b613d6d98d --- /dev/null +++ b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/CounterApp.java @@ -0,0 +1,62 @@ +/* + * 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.developerguide.desktopinterop.javafx; + +// tag::desktopInteropJavaFxApp[] +import javafx.application.Application; +import javafx.beans.binding.Bindings; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +public class CounterApp extends Application { + private final IntegerProperty count = new SimpleIntegerProperty(); + + @Override + public void start(Stage stage) { + Label label = new Label(); + label.textProperty().bind(Bindings.concat("Pressed ", count.asString(), " times")); + + Button press = new Button("Press"); + press.setOnAction(e -> count.set(count.get() + 1)); + + VBox root = new VBox(12, label, press); + root.setAlignment(Pos.CENTER); + root.setPadding(new Insets(16)); + + stage.setTitle("Counter"); + stage.setScene(new Scene(root, 320, 200)); + stage.show(); + } + + public static void main(String[] args) { + launch(args); + } +} +// end::desktopInteropJavaFxApp[] diff --git a/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/EmbeddedChart.java b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/EmbeddedChart.java new file mode 100644 index 00000000000..d6e76e857e8 --- /dev/null +++ b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/EmbeddedChart.java @@ -0,0 +1,59 @@ +/* + * 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.developerguide.desktopinterop.javafx; + +import com.codename1.fxcompat.FxInterop; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +/// A Codename One screen that shows a JavaFX scene graph, and a stage opened +/// from a Codename One application. +public class EmbeddedChart { + + public void show() { + // tag::desktopInteropEmbedFx[] + VBox root = new VBox(8, new Label("Orders this week"), new Button("Refresh")); + Scene scene = new Scene(root); + scene.getStylesheets().add("/com/example/orders.css"); + + Form form = new Form("Report", new BorderLayout()); + form.add(BorderLayout.CENTER, FxInterop.asComponent(scene)); + form.show(); + // end::desktopInteropEmbedFx[] + } + + public void openEditor(VBox editor) { + // tag::desktopInteropFxWindows[] + Stage stage = new Stage(); + stage.setTitle("Edit order"); + stage.setScene(new Scene(editor)); + stage.show(); // closing it returns to the form that was showing + // end::desktopInteropFxWindows[] + } +} diff --git a/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/GreeterController.java b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/GreeterController.java new file mode 100644 index 00000000000..e2cec28fa40 --- /dev/null +++ b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/GreeterController.java @@ -0,0 +1,52 @@ +/* + * 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.developerguide.desktopinterop.javafx; + +// tag::desktopInteropFxmlController[] +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; + +public class GreeterController { + @FXML + private Label status; + + @FXML + private TextField name; + + @FXML + private Button greet; + + @FXML + private void initialize() { + greet.disableProperty().bind(name.textProperty().isEmpty()); + } + + @FXML + private void greet(ActionEvent event) { + status.setText("Hello, " + name.getText() + "!"); + } +} +// end::desktopInteropFxmlController[] diff --git a/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/GreeterFxApp.java b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/GreeterFxApp.java new file mode 100644 index 00000000000..a268ef531bd --- /dev/null +++ b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/javafx/GreeterFxApp.java @@ -0,0 +1,50 @@ +/* + * 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.developerguide.desktopinterop.javafx; + +import javafx.application.Application; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.stage.Stage; + +/// A JavaFX application whose screen is an FXML document styled by a +/// style sheet. +public class GreeterFxApp extends Application { + + // tag::desktopInteropFxmlLoad[] + @Override + public void start(Stage stage) throws Exception { + Parent root = FXMLLoader.load(getClass().getResource("greeter.fxml")); + Scene scene = new Scene(root); + scene.getStylesheets().add(getClass().getResource("greeter.css").toExternalForm()); + stage.setTitle("Greeter"); + stage.setScene(scene); + stage.show(); + } + // end::desktopInteropFxmlLoad[] + + public static void main(String[] args) { + launch(args); + } +} diff --git a/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/BadgePanel.java b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/BadgePanel.java new file mode 100644 index 00000000000..e49582dac07 --- /dev/null +++ b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/BadgePanel.java @@ -0,0 +1,66 @@ +/* + * 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.developerguide.desktopinterop.swing; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; + +import javax.swing.JPanel; + +/// A panel that paints itself: a disc with the first letter of a name. +// tag::desktopInteropSwingPaint[] +public class BadgePanel extends JPanel { + private String initial = "?"; + + public void setInitial(String name) { + initial = name == null || name.length() == 0 ? "?" : name.substring(0, 1); + repaint(); + } + + @Override + public Dimension getPreferredSize() { + return new Dimension(120, 120); + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g; + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + int size = Math.min(getWidth(), getHeight()) - 16; + int x = (getWidth() - size) / 2; + int y = (getHeight() - size) / 2; + g2.setColor(new Color(0x2f6fed)); + g2.fillOval(x, y, size, size); + g2.setColor(Color.WHITE); + g2.setFont(getFont().deriveFont(size / 2f)); + FontMetrics metrics = g2.getFontMetrics(); + g2.drawString(initial, (getWidth() - metrics.stringWidth(initial)) / 2, + (getHeight() - metrics.getHeight()) / 2 + metrics.getAscent()); + } +} +// end::desktopInteropSwingPaint[] diff --git a/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/CompactLayout.java b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/CompactLayout.java new file mode 100644 index 00000000000..85cd58b62b4 --- /dev/null +++ b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/CompactLayout.java @@ -0,0 +1,40 @@ +/* + * 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.developerguide.desktopinterop.swing; + +import javax.swing.JSplitPane; + +import com.codename1.ui.CN; + +/// Swing code that asks Codename One what kind of device it is running on. +public class CompactLayout { + + public void apply(JSplitPane split) { + // tag::desktopInteropCodenameOneApi[] + if (!CN.isDesktop() && !CN.isTablet()) { + // A phone: stack the two halves instead of placing them side by side. + split.setOrientation(JSplitPane.VERTICAL_SPLIT); + } + // end::desktopInteropCodenameOneApi[] + } +} diff --git a/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/DeviceSettings.java b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/DeviceSettings.java new file mode 100644 index 00000000000..1b2f841d696 --- /dev/null +++ b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/DeviceSettings.java @@ -0,0 +1,50 @@ +/* + * 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.developerguide.desktopinterop.swing; + +import javax.swing.JFrame; +import javax.swing.JLabel; + +import com.codename1.ui.CN; + +/// A Swing entry point that adjusts how the layer maps it onto the device +/// before it shows anything. +public class DeviceSettings { + + public static void main(String[] args) { + // tag::desktopInteropDisplayProperties[] + if (!CN.isDesktop()) { + // Fit a little more of a wide form on a phone. + CN.setProperty("desktopcompat.scale", "1.6"); + // Keep the menu bar in the window instead of the overflow menu. + CN.setProperty("desktopcompat.menuBar", "window"); + } + // A fixed width font the application ships among its resources. + CN.setProperty("desktopcompat.monospaced", "DejaVu Sans Mono|DejaVuSansMono.ttf"); + + JFrame frame = new JFrame("Orders"); + // end::desktopInteropDisplayProperties[] + frame.add(new JLabel("Orders")); + frame.setVisible(true); + } +} diff --git a/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/EmbeddedReport.java b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/EmbeddedReport.java new file mode 100644 index 00000000000..b26a8353b27 --- /dev/null +++ b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/EmbeddedReport.java @@ -0,0 +1,66 @@ +/* + * 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.developerguide.desktopinterop.swing; + +import java.awt.BorderLayout; + +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; + +import com.codename1.desktopcompat.SwingInterop; +import com.codename1.ui.Form; + +/// A Codename One screen that shows a Swing panel, and Swing windows opened +/// from a Codename One application. +public class EmbeddedReport { + + public void show() { + // tag::desktopInteropEmbedSwing[] + JPanel report = new JPanel(new BorderLayout()); + report.add(new JLabel("Orders this week"), BorderLayout.NORTH); + report.add(new JScrollPane(new JTable(new Object[][] {{"Mon", 12}, {"Tue", 9}}, + new Object[] {"Day", "Orders"})), BorderLayout.CENTER); + + Form form = new Form("Report", new com.codename1.ui.layouts.BorderLayout()); + form.add(com.codename1.ui.layouts.BorderLayout.CENTER, SwingInterop.asComponent(report)); + form.show(); + // end::desktopInteropEmbedSwing[] + } + + public boolean openEditor(JPanel editor) { + // tag::desktopInteropSwingWindows[] + JFrame frame = new JFrame("Edit order"); + frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + frame.setContentPane(editor); + frame.setVisible(true); // closing it returns to the form that was showing + + int answer = JOptionPane.showConfirmDialog(frame, "Save the order?", "Save", + JOptionPane.YES_NO_OPTION); + // end::desktopInteropSwingWindows[] + return answer == JOptionPane.YES_OPTION; + } +} diff --git a/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/ExternalTools.java b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/ExternalTools.java new file mode 100644 index 00000000000..e5be50b9245 --- /dev/null +++ b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/ExternalTools.java @@ -0,0 +1,66 @@ +/* + * 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.developerguide.desktopinterop.swing; + +import com.codename1.ui.CN; + +/// A feature the desktop application implemented by starting processes, moved +/// behind an interface so that the user interface no longer knows how it is done. +public class ExternalTools { + + // tag::desktopInteropIsolate[] + /// What the screens ask for. They hold one of these and nothing else. + public interface Tools { + /// Whether the menu item that opens a terminal should be there at all. + boolean canOpenTerminal(); + + void openTerminal(String directory); + + void showInFileManager(String path); + } + + /// The implementation the Codename One build uses. The desktop project + /// keeps its own, written with ProcessBuilder, in a class this build + /// doesn't compile. + public static class DeviceTools implements Tools { + @Override + public boolean canOpenTerminal() { + return false; + } + + @Override + public void openTerminal(String directory) { + throw new UnsupportedOperationException("No terminal on this device"); + } + + @Override + public void showInFileManager(String path) { + String url = path.startsWith("file:") ? path : "file://" + path; + Boolean can = CN.canExecute(url); + if (can == null || can.booleanValue()) { + CN.execute(url); + } + } + } + // end::desktopInteropIsolate[] +} diff --git a/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/GreeterApp.java b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/GreeterApp.java new file mode 100644 index 00000000000..a6804cdfec4 --- /dev/null +++ b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/GreeterApp.java @@ -0,0 +1,68 @@ +/* + * 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.developerguide.desktopinterop.swing; + +// tag::desktopInteropSwingApp[] +import java.awt.BorderLayout; +import java.awt.GridLayout; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; + +public class GreeterApp { + + public static void main(String[] args) { + SwingUtilities.invokeLater(GreeterApp::showFrame); + } + + private static void showFrame() { + JFrame frame = new JFrame("Greeter"); + frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + + JTextField name = new JTextField(16); + JPanel form = new JPanel(new GridLayout(0, 2, 8, 8)); + form.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + form.add(new JLabel("Name")); + form.add(name); + + BadgePanel badge = new BadgePanel(); + JButton greet = new JButton("Greet"); + greet.addActionListener(e -> { + badge.setInitial(name.getText()); + JOptionPane.showMessageDialog(frame, "Hello, " + name.getText() + "!"); + }); + + frame.getContentPane().add(form, BorderLayout.NORTH); + frame.getContentPane().add(badge, BorderLayout.CENTER); + frame.getContentPane().add(greet, BorderLayout.SOUTH); + frame.pack(); + frame.setVisible(true); + } +} +// end::desktopInteropSwingApp[] diff --git a/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/LoadNames.java b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/LoadNames.java new file mode 100644 index 00000000000..9ba8ef48c61 --- /dev/null +++ b/docs/demos/desktop-interop/src/main/java/com/codenameone/developerguide/desktopinterop/swing/LoadNames.java @@ -0,0 +1,62 @@ +/* + * 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.developerguide.desktopinterop.swing; + +import java.util.ArrayList; +import java.util.List; + +import javax.swing.DefaultListModel; +import javax.swing.JLabel; +import javax.swing.SwingWorker; + +/// Work off the event dispatch thread, with the results delivered on it. +public class LoadNames { + + public void load(DefaultListModel model, JLabel status) { + // tag::desktopInteropSwingWorker[] + new SwingWorker, String>() { + @Override + protected List doInBackground() { + List names = new ArrayList(); + for (String name : new String[] {"Ada", "Grace", "Linus"}) { + names.add(name); + publish(name); + } + return names; + } + + @Override + protected void process(List chunks) { + for (String name : chunks) { + model.addElement(name); + } + } + + @Override + protected void done() { + status.setText(model.getSize() + " names loaded"); + } + }.execute(); + // end::desktopInteropSwingWorker[] + } +} diff --git a/docs/demos/desktop-interop/src/main/resources/com/codenameone/developerguide/desktopinterop/javafx/greeter.css b/docs/demos/desktop-interop/src/main/resources/com/codenameone/developerguide/desktopinterop/javafx/greeter.css new file mode 100644 index 00000000000..5ee5879dc74 --- /dev/null +++ b/docs/demos/desktop-interop/src/main/resources/com/codenameone/developerguide/desktopinterop/javafx/greeter.css @@ -0,0 +1,45 @@ +/* + * 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. + */ +/* tag::desktopInteropFxCss[] */ +.root { + -app-accent: #2f6fed; +} + +.card { + -fx-background-color: white; + -fx-border-color: -app-accent; + -fx-border-width: 2; + -fx-border-radius: 8; + -fx-background-radius: 8; +} + +#status { + -fx-text-fill: -app-accent; + -fx-font-size: 18; + -fx-font-weight: bold; +} + +.card > .button:hover { + -fx-opacity: 0.85; +} +/* end::desktopInteropFxCss[] */ diff --git a/docs/demos/desktop-interop/src/main/resources/com/codenameone/developerguide/desktopinterop/javafx/greeter.fxml b/docs/demos/desktop-interop/src/main/resources/com/codenameone/developerguide/desktopinterop/javafx/greeter.fxml new file mode 100644 index 00000000000..ca37a8c2c23 --- /dev/null +++ b/docs/demos/desktop-interop/src/main/resources/com/codenameone/developerguide/desktopinterop/javafx/greeter.fxml @@ -0,0 +1,18 @@ + + + + + + + + + + + + +