diff --git a/.github/workflows/flutter-bench.yml b/.github/workflows/flutter-bench.yml
new file mode 100644
index 00000000000..9c5cbf64f59
--- /dev/null
+++ b/.github/workflows/flutter-bench.yml
@@ -0,0 +1,466 @@
+name: Flutter benchmark
+
+# Measures ONE application built two ways -- Flutter's own release build and
+# the identical Dart source transpiled by Codename One -- and publishes the
+# comparison to the pull request and to port status.
+#
+# Neither application is vendored. `scripts/flutter-bench/app/prepare.sh` takes
+# the gallery from the Flutter SDK this workflow installs
+# ($FLUTTER_ROOT/dev/integration_tests/new_gallery) and copies the SAME 159
+# files into both sides, so the comparison cannot drift: there is no second
+# copy for an edit to land on. The Codename One side is generated from the
+# SHIPPING archetype, which means a change that breaks the documented Flutter
+# wiring breaks this workflow too instead of passing unnoticed.
+
+on:
+ pull_request:
+ paths:
+ - 'maven/flutter-runtime/**'
+ - 'maven/dart-transpiler/**'
+ - 'maven/dart-runtime/**'
+ - 'maven/codenameone-maven-plugin/**'
+ - 'maven/cn1app-archetype/**'
+ - 'scripts/flutter-bench/**'
+ - '.github/workflows/flutter-bench.yml'
+ schedule:
+ # Nightly, so port status carries a current number and the PR runs have a
+ # trend to sit against rather than a single point.
+ - cron: '0 3 * * *'
+ workflow_dispatch:
+ inputs:
+ platforms:
+ description: 'Comma separated platform ids, or "all"'
+ default: 'all'
+
+concurrency:
+ group: flutter-bench-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ # The Flutter release this benchmark measures against, PINNED rather than
+ # tracking stable.
+ #
+ # A benchmark whose reference moves on its own is not one: the gallery, its
+ # dependency resolution and Flutter's own code generation all change between
+ # releases, so a number from last week and a number from today would differ
+ # for reasons that have nothing to do with this repository. Tracking stable
+ # also broke outright -- 3.47.5's tree resolved google_fonts to a version
+ # without robotoCondensed and three studies stopped compiling.
+ #
+ # Bumping this is a deliberate act that re-baselines the comparison: expect
+ # every number to move, and re-record the baselines under
+ # scripts/flutter-bench/baselines in the same change.
+ FLUTTER_REF: '3.35.4'
+
+permissions:
+ contents: read
+ pull-requests: write
+ # The framework job runs in the pr-ci-container image on ghcr, and pulling it
+ # needs this. Without it the job dies in "Initialize containers" with a bare
+ # "Error response from daemon: denied", which reads like the image is missing
+ # rather than like the token cannot see it.
+ packages: read
+
+jobs:
+ # The framework is built ONCE, here, and the platform jobs consume the
+ # artifacts. Two reasons it cannot be built per platform:
+ #
+ # * It needs JDK 8. codenameone-javase imports javafx.*, which no JDK after
+ # 8 carries, so a JDK 17 build of the reactor fails compiling the JavaSE
+ # port. An earlier version of this workflow set up JDK 8 and then JDK 17,
+ # and since the last setup-java wins it silently built everything on 17.
+ # * macOS arm64 runners have no Temurin 8 at all -- setup-java reports
+ # "Could not find satisfied version for SemVer 8", the oldest offered
+ # being 11.
+ #
+ # The container is the same one pr.yml uses, which bakes JDK 8 and a
+ # cn1-binaries snapshot.
+ framework:
+ name: framework
+ runs-on: ubuntu-latest
+ container: ghcr.io/codenameone/codenameone/pr-ci-container:latest
+ timeout-minutes: 90
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Link cn1-binaries
+ run: ln -s /opt/cn1-binaries ../cn1-binaries
+
+ # The container supplies JAVA_HOME_8 and JAVA_HOME_17; this is how pr.yml
+ # selects between them. Not setup-java, which would download a second JDK
+ # into an image that already has the right ones, and not a hard-coded
+ # path -- an earlier version of this guessed
+ # /usr/lib/jvm/java-8-openjdk-amd64 and the build died on "The JAVA_HOME
+ # environment variable is not defined correctly", which says nothing
+ # about which JDK was wanted or what was there.
+ - name: Check the container's JDKs
+ run: |
+ for var in JAVA_HOME_8 JAVA_HOME_17; do
+ eval "path=\${$var:-}"
+ if [ -z "$path" ] || [ ! -x "$path/bin/javac" ]; then
+ echo "$var is unset or has no javac: '${path:-}'" >&2
+ exit 1
+ fi
+ echo "$var=$path"
+ done
+
+ # JDK 8 for everything that targets it. The flutter modules are NOT in
+ # this reactor: maven/pom.xml puts them behind a profile that activates
+ # on JDK 17 and newer, precisely because they cannot compile here.
+ - name: Build the framework on JDK 8
+ run: |
+ cd maven
+ # -Plocal-dev-javase is required to build the JavaSE port, which
+ # -am pulls in. Without it the build dies in javase's
+ # cn1-generate-build-hint-data execution, which is a long way from
+ # anything this workflow is about.
+ # Through retry.sh, like every other Maven step in this repository: Central
+ # throttles runners with 403/429, and a bare mvn failed this job twice in
+ # one day at resolving org.junit:junit-bom before compiling anything.
+ JAVA_HOME="$JAVA_HOME_8" bash ../scripts/ci/retry.sh mvn -B -q install -DskipTests \
+ -Dmaven.javadoc.skip=true -Dmaven.source.skip=true \
+ -Plocal-dev-javase \
+ -Dmaven.repo.local="$GITHUB_WORKSPACE/m2" \
+ -pl core,dart-transpiler,codenameone-maven-plugin,cn1app-archetype -am
+
+ # Without -am: everything these depend on was installed by the pass
+ # above, and re-resolving it here would rebuild core on the wrong JDK.
+ - name: Build the Flutter runtime on JDK 17
+ run: |
+ cd maven
+ JAVA_HOME="$JAVA_HOME_17" bash ../scripts/ci/retry.sh mvn -B -q install -DskipTests \
+ -Dmaven.javadoc.skip=true -Dmaven.source.skip=true \
+ -Plocal-dev-javase \
+ -Dmaven.repo.local="$GITHUB_WORKSPACE/m2" \
+ -pl dart-runtime,flutter-runtime
+
+ - name: Pack the repository
+ run: tar -C "$GITHUB_WORKSPACE" -czf framework-m2.tgz m2
+
+ - name: Upload the repository
+ uses: actions/upload-artifact@v4
+ with:
+ name: flutter-bench-framework
+ path: framework-m2.tgz
+ retention-days: 1
+
+ # The platforms to measure. Every one on a pull request or the nightly run; on a
+ # manual dispatch, the ones its `platforms` input names. The input used to be
+ # read by nothing, so asking to re-run one platform launched all six -- several
+ # of them two-hour native builds.
+ plan:
+ name: plan
+ runs-on: ubuntu-latest
+ outputs:
+ include: ${{ steps.pick.outputs.include }}
+ steps:
+ - id: pick
+ env:
+ REQUESTED: ${{ github.event_name == 'workflow_dispatch' && inputs.platforms || 'all' }}
+ shell: python3 {0}
+ run: |
+ import json, os, sys
+ ALL = [
+ {"platform": "macos", "runner": "macos-latest"},
+ {"platform": "ios", "runner": "macos-latest"},
+ {"platform": "android", "runner": "ubuntu-latest"},
+ {"platform": "linux", "runner": "ubuntu-latest"},
+ # PINNED, like FLUTTER_REF and for the same reason. windows-latest
+ # is Server 2025 and its Visual Studio has moved to 2026 (major
+ # 18), which Flutter 3.35.4 predates: it detects that install and
+ # reports it healthy, then maps it to no generator it knows and
+ # emits "Visual Studio 16 2019" instead. Pinning Flutter without
+ # pinning the toolchain it has to drive only moved the moving part.
+ {"platform": "windows", "runner": "windows-2022"},
+ {"platform": "javascript", "runner": "ubuntu-latest"},
+ ]
+ requested = os.environ.get("REQUESTED", "").strip().lower()
+ if requested in ("", "all"):
+ chosen = ALL
+ else:
+ names = [n.strip() for n in requested.split(",") if n.strip()]
+ known = {p["platform"] for p in ALL}
+ unknown = [n for n in names if n not in known]
+ if unknown or not names:
+ # Loud, not an empty matrix: a typo must not "succeed" by measuring nothing.
+ sys.exit("unknown platform(s) %s; choose from %s, or all"
+ % (", ".join(unknown) or "", ", ".join(sorted(known))))
+ chosen = [p for p in ALL if p["platform"] in names]
+ print("measuring: " + ", ".join(p["platform"] for p in chosen))
+ with open(os.environ["GITHUB_OUTPUT"], "a") as out:
+ out.write("include=" + json.dumps(chosen) + "\n")
+
+ measure:
+ name: ${{ matrix.platform }}
+ needs: [framework, plan]
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 120
+ strategy:
+ # One platform failing must not hide the others: a benchmark that
+ # publishes nothing because a single runner broke is worse than one that
+ # publishes five platforms and says the sixth did not run.
+ fail-fast: false
+ matrix:
+ include: ${{ fromJson(needs.plan.outputs.include) }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ # JDK 17 only. Nothing here builds the framework, so nothing here needs
+ # the JDK 8 that is unavailable on an arm64 macOS runner anyway.
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 17
+
+ - name: Download the framework
+ uses: actions/download-artifact@v4
+ with:
+ name: flutter-bench-framework
+
+ - name: Unpack the framework
+ shell: bash
+ run: tar -xzf framework-m2.tgz
+
+ # A full SDK CHECKOUT, not a release archive. The benchmark's application
+ # is dev/integration_tests/new_gallery inside the SDK, and the published
+ # release archives do not carry dev/ at all -- so the usual setup actions,
+ # which unpack a release, leave prepare.sh with nothing to copy. Cloning
+ # the SDK is also what pins the reference: the revision is recorded in
+ # every result, so a number can be traced to the Flutter that produced it.
+ - name: Check out the Flutter SDK
+ shell: bash
+ run: |
+ git clone --depth 1 --branch "$FLUTTER_REF" \
+ https://github.com/flutter/flutter.git "$RUNNER_TEMP/flutter"
+ echo "$RUNNER_TEMP/flutter/bin" >> "$GITHUB_PATH"
+ echo "FLUTTER_ROOT=$RUNNER_TEMP/flutter" >> "$GITHUB_ENV"
+
+ - name: Report the toolchain, and prove the gallery is present
+ shell: bash
+ run: |
+ flutter --version
+ flutter precache
+ test -d "$FLUTTER_ROOT/dev/integration_tests/new_gallery/lib" || {
+ echo "this Flutter checkout has no gallery; prepare.sh cannot run" >&2
+ exit 1; }
+ echo "gallery Dart files: $(find "$FLUTTER_ROOT/dev/integration_tests/new_gallery/lib" -name '*.dart' | wc -l)"
+ test -f "$FLUTTER_ROOT/pubspec.lock" || {
+ echo "the SDK checkout has no root pubspec.lock; prepare.sh needs it" >&2
+ echo "to hold the gallery's unpinned dependencies at tested versions" >&2
+ exit 1; }
+
+ # A benchmark records the toolchain it measured with; on Windows it also
+ # has to CHECK it. Flutter picks the CMake generator from the Visual
+ # Studio it detects and falls back to "Visual Studio 16 2019" when it
+ # detects NONE -- so an undetected Visual Studio does not say so. It fails
+ # minutes later inside CMake, claiming VS 2019 could not be found, which
+ # is true and is not the problem. vswhere's own view is printed beside
+ # Flutter's so the two can be compared: a Visual Studio that vswhere lists
+ # and Flutter does not is a different fault from one that is simply absent.
+ - name: Report the Windows toolchain, and prove Flutter can see it
+ if: ${{ runner.os == 'Windows' }}
+ shell: bash
+ run: |
+ VS_VERSION=""
+ VSWHERE="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
+ if [ -x "$VSWHERE" ]; then
+ echo "== vswhere sees:"
+ VS_VERSION="$("$VSWHERE" -products '*' -prerelease -format value \
+ -property installationVersion 2>/dev/null | head -1)"
+ echo "${VS_VERSION:-}"
+ "$VSWHERE" -products '*' -prerelease -format value \
+ -property installationPath || true
+ else
+ echo "== vswhere is not installed at the usual path"
+ fi
+ echo "== flutter doctor:"
+ flutter doctor -v 2>&1 | tee /tmp/doctor.txt || true
+ # Checking that Flutter can MAP the install, not that it can see one.
+ # Presence is the wrong question: doctor reported
+ # "[OK] Visual Studio - develop Windows apps (Visual Studio Enterprise
+ # 2026 18.9.2)" on the very run this replaces, and the build then died
+ # in CMake. flutter_tools/lib/src/windows/visual_studio.dart maps major
+ # 17 and nothing else; every other major, detected or not, silently
+ # becomes the "Visual Studio 16 2019" generator. Update SUPPORTED_VS
+ # when FLUTTER_REF moves, and note that it is the CMake generator
+ # mapping that decides, not what doctor is willing to tick.
+ SUPPORTED_VS=17
+ MAJOR="$(printf '%s' "$VS_VERSION" | cut -d. -f1)"
+ if [ -z "$MAJOR" ] || [ "$MAJOR" != "$SUPPORTED_VS" ]; then
+ echo "This runner's Visual Studio is major '${MAJOR:-none}', and Flutter" >&2
+ echo "$FLUTTER_REF only maps major $SUPPORTED_VS to a CMake generator." >&2
+ echo "It would fall back to 'Visual Studio 16 2019' and fail inside" >&2
+ echo "CMake rather than here -- naming a version nobody asked for." >&2
+ echo "Pin the runner image to one carrying Visual Studio" >&2
+ echo "$SUPPORTED_VS, or move FLUTTER_REF and re-record baselines." >&2
+ exit 1
+ fi
+
+ # Flutter's own Linux build and Codename One's native Linux port both
+ # BOTH sides need a native toolchain here, and they need different
+ # things. Flutter's desktop build wants clang and a C++ standard library;
+ # the Codename One side compiles the native Linux port, whose CMakeLists
+ # resolves the whole GTK/GStreamer/WebKit stack through pkg_check_modules
+ # and stops at the first one missing. The shared script is that stack --
+ # an ad-hoc subset here configured far enough to fail on libcurl, several
+ # minutes into a build that had already transpiled and compiled.
+ - name: Install the Linux desktop toolchain
+ if: ${{ matrix.platform == 'linux' }}
+ run: bash scripts/ci/install-linux-native-deps.sh clang libstdc++-12-dev
+
+ # The Codename One build opens an AWT window (its CSS compiler), so a
+ # headless runner fails inside `cn1:css` after the transpile has already
+ # succeeded. build_apps.sh wraps Maven in xvfb-run on Linux and refuses
+ # to run without it rather than failing later and less legibly. The
+ # helper short-circuits when the image already ships xvfb, which these
+ # usually do.
+ - name: Install xvfb
+ if: ${{ runner.os == 'Linux' }}
+ run: bash scripts/ci/apt-get-install.sh xvfb
+
+ - name: Prepare both applications from one source
+ shell: bash
+ run: |
+ scripts/flutter-bench/app/prepare.sh \
+ --work "${RUNNER_TEMP}/fbench" \
+ --maven-repo "${GITHUB_WORKSPACE}/m2"
+
+ - name: Build both applications
+ id: build
+ shell: bash
+ run: |
+ scripts/flutter-bench/app/build_apps.sh \
+ --work "${RUNNER_TEMP}/fbench" \
+ --platform "${{ matrix.platform }}" \
+ --maven-repo "${GITHUB_WORKSPACE}/m2" | tee "${RUNNER_TEMP}/paths.txt"
+ grep '^cn1=' "${RUNNER_TEMP}/paths.txt" >> "$GITHUB_OUTPUT"
+ grep '^flutter=' "${RUNNER_TEMP}/paths.txt" >> "$GITHUB_OUTPUT"
+
+ # Every leg runs the same measure.sh; the legs differ only in what wraps it.
+ #
+ # Linux runs under Xvfb because both applications are GTK programs that
+ # need a display. build_apps.sh's xvfb-run does not help here: it lives
+ # exactly as long as the Maven build it wraps, so the Measure step started
+ # with no display at all and the adapter reported the platform
+ # unavailable after both applications had built.
+ - name: Measure
+ if: ${{ matrix.platform != 'android' }}
+ shell: bash
+ run: |
+ prefix=""
+ if [ "$RUNNER_OS" = "Linux" ]; then
+ prefix="xvfb-run -a"
+ fi
+ $prefix scripts/flutter-bench/app/measure.sh "${{ matrix.platform }}" \
+ "${{ steps.build.outputs.cn1 }}" "${{ steps.build.outputs.flutter }}" \
+ "${RUNNER_TEMP}/bench-out"
+
+ # Android needs a device. A hosted runner has none, so the adapter found no
+ # emulator and recorded the whole platform as unavailable after building
+ # both APKs. Same emulator, API level and image as the Android port's own
+ # instrumentation leg (scripts-android.yml).
+ - name: Enable KVM for the Android emulator
+ if: ${{ matrix.platform == 'android' }}
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+
+ - name: Measure on an Android emulator
+ if: ${{ matrix.platform == 'android' }}
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: 36
+ arch: x86_64
+ target: google_apis
+ disk-size: 4096M
+ # One line: the action runs each line of `script` in its own shell.
+ script: scripts/flutter-bench/app/measure.sh android "${{ steps.build.outputs.cn1 }}" "${{ steps.build.outputs.flutter }}" "${{ runner.temp }}/bench-out"
+
+ # The result and the baseline recorded from it. The baseline is what a
+ # human commits to arm the gate, so it travels with the result rather than
+ # having to be re-derived.
+ - name: Upload the result
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: flutter-bench-${{ matrix.platform }}
+ path: |
+ ${{ runner.temp }}/bench-out/result-${{ matrix.platform }}.json
+ ${{ runner.temp }}/bench-out/baseline-${{ matrix.platform }}.json
+ if-no-files-found: warn
+
+ publish:
+ name: Publish
+ needs: measure
+ # always(), so a platform that regressed still gets its numbers in front of
+ # a reviewer. The `measure` jobs already carry the red status; hiding the
+ # comment as well would leave the failure with no explanation attached.
+ if: always()
+ runs-on: ubuntu-latest
+ # Write access for the scheduled publication to the port-status-data
+ # branch. Job-scoped, and job permissions replace the workflow's, so the
+ # comment's permission is restated.
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Collect every platform's result
+ uses: actions/download-artifact@v4
+ with:
+ pattern: flutter-bench-*
+ path: results
+ merge-multiple: true
+
+ - name: Render the comment
+ run: |
+ python3 scripts/flutter-bench/run_bench.py \
+ --render 'results/result-*.json' \
+ --markdown flutter-benchmark.md
+ cat flutter-benchmark.md
+
+ - name: Publish the benchmark comment
+ if: ${{ github.event_name == 'pull_request' && hashFiles('flutter-benchmark.md') != '' }}
+ uses: actions/github-script@v9
+ with:
+ retries: 3
+ script: |
+ const { publishQualityComment } = require('./.github/scripts/publish-quality-comment.js');
+ await publishQualityComment({
+ github, context, core,
+ marker: '',
+ reportPath: 'flutter-benchmark.md',
+ });
+
+ # Published to the data branch the website build reads, the way every
+ # port's own status is. Writing it into docs/website in this job's
+ # checkout, as this step used to, put it where nothing would ever see it:
+ # the checkout is discarded when the job ends and nothing committed it.
+ # Master-only, like the port reports, so a branch's numbers never reach
+ # the public page.
+ - name: Publish the benchmark to the port status data
+ # Only a run of every platform: port status shows all of them, and a dispatch
+ # for a subset would publish a table missing the rest.
+ if: >-
+ github.ref == 'refs/heads/master' &&
+ (github.event_name == 'schedule' ||
+ (github.event_name == 'workflow_dispatch' && (inputs.platforms == 'all' || inputs.platforms == '')))
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ python3 scripts/flutter-bench/port_status.py \
+ --results 'results/result-*.json' \
+ --out "${RUNNER_TEMP}/port-status"
+ python3 scripts/flutter-bench/publish_benchmark.py \
+ "${RUNNER_TEMP}/port-status/flutter_benchmark.json" "${{ github.repository }}"
+
+ - name: Upload the rendered report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: flutter-benchmark-report
+ path: flutter-benchmark.md
diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml
index ff633bfdbf4..7c81c5d266e 100644
--- a/.github/workflows/linux-build-run.yml
+++ b/.github/workflows/linux-build-run.yml
@@ -170,33 +170,11 @@ jobs:
- name: Check out repository
uses: actions/checkout@v6
- # The native Linux port's full dependency stack: GTK3 + Cairo + Pango +
- # GdkPixbuf + GLib/GIO (render/widgets), FontConfig/FreeType (bundled-font
- # registration), libcurl (HTTP), GStreamer (media/camera/audio), WebKitGTK
- # (browser), libsecret (secure storage), libnotify (notifications), GeoClue
- # (location), libepoxy + EGL/GLES + the Mesa software driver (the offscreen
- # 3D backend). Plus CMake/Ninja, Xvfb and a font for Pango to lay out.
- #
- # The GStreamer plugin set matters as much as the library: base + good carry
- # appsrc/appsink, videoconvert and mp4mux but NO codec. VideoIO's encoder is
- # x264enc/x265enc (plugins-ugly) parsed by h264parse/h265parse (plugins-bad)
- # with avenc_aac for audio and avdec_* for playback (libav), so without those
- # three packages the port has no encoder and no H.264 decoder at all.
+ # The port's full dependency stack, shared with the Flutter benchmark,
+ # which builds the same local-linux-device target. See the script for what
+ # each group is for and why the GStreamer plugin set is part of it.
- name: Install the GTK build stack + toolchain
- run: |
- set -e
- # Through the shared helper: it refreshes the index once when a .deb
- # 404s, which is what a point release landing mid-job looks like --
- # three openssl packages took this job out on 2026-08-25.
- bash scripts/ci/apt-get-install.sh \
- cmake ninja-build pkg-config unzip xvfb fonts-dejavu-core \
- libgtk-3-dev libcairo2-dev libpango1.0-dev libgdk-pixbuf-2.0-dev libglib2.0-dev \
- libfontconfig1-dev libfreetype-dev \
- libcurl4-openssl-dev libssl-dev \
- libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
- gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav \
- libwebkit2gtk-4.1-dev libsecret-1-dev libnotify-dev libgeoclue-2-dev \
- libepoxy-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri
+ run: bash scripts/ci/install-linux-native-deps.sh
- name: Set up JDK 8
uses: actions/setup-java@v5
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e03906dac1c..3fa744f7755 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -359,6 +359,46 @@ jobs:
[ "$code" = "200" ] || { echo "MISSING on R2: codenameone-guibuilder (HTTP $code)" >&2; exit 1; }
echo "ok: codenameone-guibuilder ${GITHUB_REF_NAME} on R2"
+ # --- Flutter runtime and Dart runtime -----------------------------------
+ # codenameone-dart-runtime and codenameone-flutter-runtime compile at
+ # release 17, so the maven/ reactor only includes them on JDK 17 (the
+ # flutter-modules profile) and the JDK 8 core pass above never builds them.
+ # Without this pass a project that declares codenameone-flutter-runtime at a
+ # released version -- as the archetype documentation says to -- cannot
+ # resolve it. The transpiler itself builds on JDK 8 and ships with the core.
+ - name: Build and stage the Flutter runtimes
+ id: deploy_flutter
+ continue-on-error: true
+ if: >-
+ always() && (steps.deploy.outcome == 'success' ||
+ steps.r2_core.outcome == 'success')
+ run: |
+ export GPG_TTY=$(tty)
+ cd maven
+ # -pl without -am: the parent and core were released above and are in
+ # the local repository at this version; re-staging them here would
+ # produce new signatures for artifacts that are already immutable on R2.
+ # With -pl the plugin stages under the FIRST reactor module's target --
+ # maven/dart-runtime/target/central-staging, holding both runtimes --
+ # not under maven/target, so the core tree is left untouched.
+ xvfb-run -a mvn deploy -Psign-artifacts -Dgpg.passphrase=$MAVEN_GPG_PASSPHRASE \
+ -pl dart-runtime,flutter-runtime -DskipTests -DskipPublishing=true
+ env:
+ MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }}
+
+ - name: Publish Flutter runtimes to R2
+ id: r2_flutter
+ continue-on-error: true
+ if: always() && steps.deploy_flutter.conclusion != 'skipped'
+ run: |
+ bash maven/scripts/r2/publish-staging-to-r2.sh maven/dart-runtime/target/central-staging
+ for artifact in codenameone-dart-runtime codenameone-flutter-runtime; do
+ url="${R2_BASE_URL}/com/codenameone/${artifact}/${GITHUB_REF_NAME}/${artifact}-${GITHUB_REF_NAME}.pom?cb=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ code=$(curl -s -o /dev/null -w "%{http_code}" "$url")
+ [ "$code" = "200" ] || { echo "MISSING on R2: $artifact (HTTP $code)" >&2; exit 1; }
+ echo "ok: $artifact ${GITHUB_REF_NAME} on R2"
+ done
+
- name: Mark the R2 release complete
id: r2_mark_complete
continue-on-error: true
@@ -387,12 +427,14 @@ jobs:
steps.deploy_certificatewizard.outcome == 'success' &&
steps.deploy_settings.outcome == 'success' &&
steps.deploy_guibuilder.outcome == 'success' &&
+ steps.deploy_flutter.outcome == 'success' &&
steps.r2_core.outcome == 'success' &&
steps.r2_core_confirm.outcome == 'success' &&
steps.r2_gamebuilder.outcome == 'success' &&
steps.r2_certificatewizard.outcome == 'success' &&
steps.r2_settings.outcome == 'success' &&
- steps.r2_guibuilder.outcome == 'success'
+ steps.r2_guibuilder.outcome == 'success' &&
+ steps.r2_flutter.outcome == 'success'
run: bash maven/scripts/r2/mark-release-complete.sh "${GITHUB_REF_NAME}"
- name: Regenerate R2 metadata and archetype catalog
@@ -445,12 +487,14 @@ jobs:
bad "${{ steps.deploy_certificatewizard.outcome }}" && fail "Signing Wizard build"
bad "${{ steps.deploy_settings.outcome }}" && fail "Settings build"
bad "${{ steps.deploy_guibuilder.outcome }}" && fail "GUI Builder build"
+ bad "${{ steps.deploy_flutter.outcome }}" && fail "Flutter runtimes build"
bad "${{ steps.r2_core.outcome }}" && fail "core reactor -> R2"
bad "${{ steps.r2_core_confirm.outcome }}" && fail "core artifacts missing on R2"
bad "${{ steps.r2_gamebuilder.outcome }}" && fail "Game Builder -> R2"
bad "${{ steps.r2_certificatewizard.outcome }}" && fail "Signing Wizard -> R2"
bad "${{ steps.r2_settings.outcome }}" && fail "Settings -> R2"
bad "${{ steps.r2_guibuilder.outcome }}" && fail "GUI Builder -> R2"
+ bad "${{ steps.r2_flutter.outcome }}" && fail "Flutter runtimes -> R2"
bad "${{ steps.r2_mark_complete.outcome }}" && fail "marking the R2 release complete"
bad "${{ steps.r2_metadata.outcome }}" && fail "R2 metadata regeneration"
# A skipped marker is not a legitimate skip: it is how a release ends up
diff --git a/.gitignore b/.gitignore
index 2aa288f4901..dc233ecc291 100644
--- a/.gitignore
+++ b/.gitignore
@@ -190,3 +190,8 @@ cn1-build-hints.json
# Concatenated CSS the native-theme build feeds the compiler. Regenerated on
# every run; the parts under native-themes//*.css are the source.
native-themes/*/target/
+
+# Resolved from the port-status-data branch during the website build
+# (scripts/website/sync_port_status_reports.sh); never committed, because
+# there is no checked-in fallback for it.
+docs/website/data/port_status_flutter_benchmark.json
diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
index e668e2fa7cf..14e2107eccc 100644
--- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
+++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
@@ -1176,6 +1176,7 @@ public void drawImageRounded(Object graphics, Object img, int x, int y, int w, i
drawImage(graphics, img, x, y, w, h);
}
+
/// Returns the width of a native image
///
/// #### Parameters
@@ -5312,6 +5313,19 @@ public boolean downloadBytesAsFile(String fileName, byte[] bytes) {
///
/// #### Returns
///
+ /// The platform's own logical-pixel scale factor: device pixels per logical pixel,
+ /// the number iOS calls `UIScreen.scale` and Android calls `density`.
+ ///
+ /// This is NOT the same question as [#getDeviceDensity], even though the two are
+ /// easily confused. Density is a coarse DPI bucket used to pick artwork and to size
+ /// things in physical units. The scale factor is what the platform itself uses to
+ /// convert its own layout units into pixels, and on iOS it is only ever 1, 2 or 3 --
+ /// never the 3.5 that a 560-dpi bucket would imply. Anything laying out in
+ /// platform-logical units (a Flutter-style `dp`) has to ask this question, not the
+ /// density one, or it renders every dimension off by the ratio between them.
+ ///
+ /// #### Returns
+ ///
/// pixels per logical pixel, or 0 when the platform does not report one -- callers
/// should then fall back to deriving it from the density bucket
public float getDevicePixelRatio() {
diff --git a/CodenameOne/src/com/codename1/l10n/SimpleDateFormat.java b/CodenameOne/src/com/codename1/l10n/SimpleDateFormat.java
index 5fd599e8c06..3a0a6d2e1e7 100644
--- a/CodenameOne/src/com/codename1/l10n/SimpleDateFormat.java
+++ b/CodenameOne/src/com/codename1/l10n/SimpleDateFormat.java
@@ -119,6 +119,8 @@ public class SimpleDateFormat extends DateFormat {
private String pattern;
/// The parsed pattern
private List patternTokens;
+ /// The zone fields are formatted in; null means the device's default zone.
+ private TimeZone timeZone;
/// Construct a SimpleDateFormat with no pattern.
public SimpleDateFormat() {
@@ -242,9 +244,33 @@ public int hashCode() {
public Object clone() {
SimpleDateFormat sdf = new SimpleDateFormat(pattern);
sdf.setDateFormatSymbols(dateFormatSymbols);
+ sdf.timeZone = timeZone;
return sdf;
}
+ /// Sets the time zone the date's fields are formatted in, as `java.text.DateFormat`
+ /// does. Without one the device's default zone is used.
+ ///
+ /// A formatter fixed to the device zone cannot print an instant's fields in any other:
+ /// a UTC time had to be moved onto a local one first, and inside a daylight-saving gap
+ /// that local time does not exist, so its hour came out shifted.
+ ///
+ /// #### Parameters
+ ///
+ /// - `zone`: the zone, or null for the device's default
+ public void setTimeZone(TimeZone zone) {
+ this.timeZone = zone;
+ }
+
+ /// The zone set with `setTimeZone`, or the device's default when none was.
+ ///
+ /// #### Returns
+ ///
+ /// the zone fields are formatted in
+ public TimeZone getTimeZone() {
+ return timeZone != null ? timeZone : TimeZone.getDefault();
+ }
+
/*
* (non-Javadoc)
*
@@ -274,8 +300,8 @@ String format(Date source, StringBuilder toAppendTo) {
if (pattern == null) {
return super.format(source, toAppendTo);
}
- // format based on local timezone
- Calendar calendar = Calendar.getInstance(TimeZone.getDefault());
+ // format in the configured zone, the device's by default
+ Calendar calendar = Calendar.getInstance(getTimeZone());
calendar.setTime(source);
List pattern = getPatternTokens();
for (String token : pattern) {
diff --git a/CodenameOne/src/com/codename1/ui/Dialog.java b/CodenameOne/src/com/codename1/ui/Dialog.java
index 07d37d1b1b0..937ecad6dc7 100644
--- a/CodenameOne/src/com/codename1/ui/Dialog.java
+++ b/CodenameOne/src/com/codename1/ui/Dialog.java
@@ -3900,6 +3900,16 @@ public boolean animate() {
}
private boolean isTimedOut() {
+ // Disposing is EDT work, and this is reached from off it: invokeAndBlock's
+ // waiting thread polls isDisposed() while a modal show blocks, and it used to
+ // dispose the dialog and deregister its animation from that thread, while the
+ // EDT was walking the same animation list -- an ArrayIndexOutOfBoundsException
+ // out of ArrayList.remove. Off the EDT the deadline is left alone; the EDT's own
+ // poll (animate) and the timeout clock, which calls back onto the EDT, dispose
+ // it, and the waiting thread sees it disposed on its next check.
+ if (!Display.getInstance().isEdt()) {
+ return false;
+ }
if (time != 0 && System.currentTimeMillis() >= time) {
time = 0;
cancelTimeoutClock();
diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java
index 147454c908f..18627c1cd91 100644
--- a/CodenameOne/src/com/codename1/ui/Display.java
+++ b/CodenameOne/src/com/codename1/ui/Display.java
@@ -1334,6 +1334,31 @@ public boolean isInTransition() {
return false;
}
+ /// The form transition currently being painted, or null when there is none.
+ ///
+ /// The companion to [isInTransition()][#isInTransition()], for code that needs the
+ /// transition itself rather than the fact of one. A transition paints the frame
+ /// BETWEEN two forms, so neither form can be asked what is on screen while one is
+ /// running - painting the destination gives the finished state and painting the
+ /// source gives the state before it began. Handing back the transition lets a caller
+ /// paint the frame that is actually being shown, which is what capturing a transition
+ /// for comparison needs, and pairs with
+ /// [AnimationTime][com.codename1.ui.animations.AnimationTime] to step one frame by
+ /// frame.
+ ///
+ /// #### Returns
+ ///
+ /// the running transition, or null
+ public Transition getRunningTransition() {
+ if (animationQueue != null && !animationQueue.isEmpty()) {
+ Animation a = animationQueue.get(0);
+ if (a instanceof Transition) {
+ return (Transition) a;
+ }
+ }
+ return null;
+ }
+
// Seems to be a false positive on this rule
@SuppressWarnings({"PMD.SimplifyConditional", "PMD.AvoidBranchingStatementAsLastInLoop"})
private void paintTransitionAnimation() {
diff --git a/CodenameOne/src/com/codename1/ui/animations/CommonTransitions.java b/CodenameOne/src/com/codename1/ui/animations/CommonTransitions.java
index 9e834a156e3..6666c9638c0 100644
--- a/CodenameOne/src/com/codename1/ui/animations/CommonTransitions.java
+++ b/CodenameOne/src/com/codename1/ui/animations/CommonTransitions.java
@@ -1441,6 +1441,15 @@ public Transition copy(boolean reverse) {
break;
}
retVal.linearMotion = linearMotion;
+ // A motion the caller supplied has to survive the copy. Display copies a
+ // transition before running it, so everything set through setMotion() -- which is
+ // public API, documented as the way to give a transition "a more appropriate
+ // physical feel" -- was dropped on the way to the screen and the default ease ran
+ // instead. Silently: the transition still played, just not the one that was asked
+ // for.
+ retVal.motionSetManually = motionSetManually;
+ retVal.motion = motion;
+ retVal.lazyMotion = lazyMotion;
return retVal;
}
diff --git a/CodenameOne/src/com/codename1/ui/animations/ContainerTransformTransition.java b/CodenameOne/src/com/codename1/ui/animations/ContainerTransformTransition.java
new file mode 100644
index 00000000000..162b62eea4a
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/animations/ContainerTransformTransition.java
@@ -0,0 +1,563 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.ui.animations;
+
+import com.codename1.ui.Component;
+import com.codename1.ui.Container;
+import com.codename1.ui.Form;
+import com.codename1.ui.Graphics;
+import com.codename1.ui.Image;
+import com.codename1.ui.geom.GeneralPath;
+
+/// A transition in which one component GROWS into the whole of the next form, the way
+/// Material's container transform does: a card or a button becomes the page it opens.
+///
+/// The difference from [MorphTransition][MorphTransition] is what is being animated.
+/// A morph moves a component from where it is in one form to where the same component is
+/// in the other, so it needs a counterpart on both sides and it animates a COMPONENT.
+/// This animates a SURFACE: a rounded rectangle travels from the tapped component's
+/// bounds out to the full form, its corners straightening as it goes, and the two
+/// contents cross-fade inside it -- the thing that was tapped fading out while the page
+/// fades in. Nothing needs to exist on both sides, which is the usual case: a button does
+/// not reappear on the page it opened.
+///
+/// Geometry follows a fast-out-slow-in curve and the cross-fade is deliberately not
+/// symmetric: the outgoing content is gone by the time the incoming content begins,
+/// so the two are never both half visible, which reads as a dissolve rather than a
+/// transformation.
+///
+/// Use it where a tap on something becomes a screen:
+///
+/// ```java
+/// tappedCard.setName("card");
+/// nextForm.setTransitionInAnimator(
+/// ContainerTransformTransition.create("card", 300));
+/// nextForm.show();
+/// ```
+///
+/// @author Shai Almog
+public final class ContainerTransformTransition extends Transition {
+
+ /// Material's container transform curve: fast out, slow in.
+ private static final float CP0 = 0.4f;
+ private static final float CP1 = 0.0f;
+ private static final float CP2 = 0.2f;
+ private static final float CP3 = 1.0f;
+
+
+ /// Material states this transform's colour and opacity changes in fifths of the run.
+ private static final float FIFTH = 0.2f;
+
+ /// Material's scrim over the page being left: black at 54% opacity.
+ private static final int SCRIM_ALPHA = 138;
+
+ private static final int SCALE = 1000;
+
+ private final String componentName;
+ private final int duration;
+
+ private Motion motion;
+ private int progress;
+ private Image sourceBuffer;
+ private Image destBuffer;
+ /// The tapped component on its own, so it can fade out inside the growing surface.
+ private Image originBuffer;
+ private int startX;
+ private int startY;
+ private int startW;
+ private int startH;
+ private int startRadius;
+ private int surfaceColor;
+ private int openColor;
+ private GeneralPath path;
+
+ /// Whether this instance is the CLOSE half, which runs on the mirrored curve.
+ private boolean closing;
+
+ /// Whether the thing being grown out of was round, which changes the SHAPE of the
+ /// travelling surface for the whole run -- see the squash in paint.
+ private boolean originRound;
+
+ private ContainerTransformTransition(String componentName, int duration) {
+ this.componentName = componentName;
+ this.duration = duration;
+ }
+
+ /// Creates a transition that grows the named component into the next form.
+ ///
+ /// #### Parameters
+ ///
+ /// - `componentName`: the [Component#setName(String)][Component#setName(String)] of the
+ /// component in the OUTGOING form that the next form grows out of. When no component
+ /// carries that name the transition still runs, growing from the centre of the screen.
+ ///
+ /// - `duration`: the duration in milliseconds
+ ///
+ /// #### Returns
+ ///
+ /// the transition
+ public static ContainerTransformTransition create(String componentName, int duration) {
+ return new ContainerTransformTransition(componentName, duration);
+ }
+
+ private static Component findByName(Container root, String name) {
+ int count = root.getComponentCount();
+ for (int iter = 0; iter < count; iter++) {
+ Component c = root.getComponentAt(iter);
+ String n = c.getName();
+ if (n != null && n.equals(name)) {
+ return c;
+ }
+ if (c instanceof Container) {
+ Component child = findByName((Container) c, name);
+ if (child != null) {
+ return child;
+ }
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public void initTransition() {
+ Component source = getSource();
+ Component destination = getDestination();
+ // Cleared first: a transition object can be reused, and an early return must not
+ // leave the previous run's motion to be replayed.
+ motion = null;
+ // The Transition contract allows no source -- the first Form shown has nothing to
+ // transition from -- and dereferencing it here made that first show throw. With
+ // either side missing there is nothing to transform between, so no animation.
+ if (source == null || destination == null) {
+ return;
+ }
+ int w = destination.getWidth();
+ int h = destination.getHeight();
+ if (w <= 0 || h <= 0) {
+ return;
+ }
+ // The SAME curve both ways. Closing is the open progress run backwards (see
+ // paint), and 1 - curve(elapsed) is already the mirrored easing -- selecting a
+ // mirrored curve here as well would mirror it twice and give
+ // curve(1 - elapsed), which is a different motion.
+ motion = Motion.createCubicBezierMotion(0, SCALE, duration, CP0, CP1, CP2, CP3);
+ motion.start();
+ progress = 0;
+
+ sourceBuffer = Image.createImage(source.getWidth(), source.getHeight());
+ source.paintComponent(sourceBuffer.getGraphics(), true);
+ destBuffer = Image.createImage(w, h);
+ destination.paintComponent(destBuffer.getGraphics(), true);
+
+ // The thing the surface grows out of lives on whichever page is NOT the one
+ // travelling. Opening, that is the page being left; CLOSING, it is the page being
+ // returned to -- so looking on the source form either way found nothing on the
+ // way back, and the transform fell through to its "no origin" guess and played
+ // the opening animation out of the middle of the screen. Going back looked
+ // nothing like the way in.
+ Component anchorOn = closing ? destination : source;
+ Form anchorForm = anchorOn.getComponentForm();
+ Component origin = anchorForm == null || componentName == null
+ ? null : findByName(anchorForm, componentName);
+ if (origin == null) {
+ // Nothing to grow from. The middle of the screen is a poor guess but it is a
+ // transition rather than nothing at all, and the caller still gets the fade.
+ startW = Math.max(1, w / 8);
+ startH = startW;
+ startX = (w - startW) / 2;
+ startY = (h - startH) / 2;
+ startRadius = startW / 2;
+ surfaceColor = openPage().getStyle().getBgColor();
+ openColor = surfaceColor;
+ } else {
+ startX = origin.getAbsoluteX();
+ startY = origin.getAbsoluteY();
+ startW = Math.max(1, origin.getWidth());
+ startH = Math.max(1, origin.getHeight());
+ // A round thing stays round while it grows; anything else keeps its corners.
+ startRadius = Math.min(startW, startH) / 2;
+ // Square to within a pixel or two IS the test for round: a circular button is
+ // the only thing that can have been one, and the squash below would be wrong
+ // for a card.
+ originRound = Math.abs(startW - startH) <= Math.max(2, startW / 16);
+ surfaceColor = origin.getStyle().getBgColor();
+ openColor = openPage().getStyle().getBgColor();
+ // Cut out of the PAGE's snapshot, not painted from the component.
+ //
+ // The named component is the tapped surface itself, and what is drawn on top
+ // of it -- a glyph, a label, a whole row -- can be a separate component beside
+ // it rather than a child of it. Painting the component alone therefore gave a
+ // bare capsule: the compose button's pencil was simply absent from the card
+ // for the whole transform, where the reference carries it the entire way.
+ // The page has already been photographed a few lines above, and in that
+ // photograph the button is whole.
+ Image anchorShot = closing ? destBuffer : sourceBuffer;
+ originBuffer = Image.createImage(startW, startH, 0);
+ originBuffer.getGraphics().drawImage(anchorShot, -startX, -startY);
+ // The commonest colour in it, not the middle pixel: the middle of a button is
+ // usually its glyph, and taking that made the growing surface the colour of
+ // the icon instead of the colour of the button.
+ surfaceColor = dominantColor(originBuffer, origin.getStyle().getBgColor());
+ // ...and then keep only what was drawn ON the button.
+ //
+ // A cut-out of the page brings the page with it: the compose button sits in
+ // the notch of the bottom bar, so its rectangle is mostly dark bar, and scaled
+ // four times into the card that read as a black frame around the glyph. What
+ // belongs to the button is what lies inside its outline and is not its own
+ // colour -- which is exactly its content, and the card is already painting the
+ // colour underneath it.
+ originBuffer = maskToContent(originBuffer, startW, startH, surfaceColor, originRound);
+ }
+ }
+
+ /// The page that TRAVELS: the one growing out of the origin, or shrinking back into
+ /// it. Opening it is the destination; closing it is the source.
+ private Component openPage() {
+ return closing ? getSource() : getDestination();
+ }
+
+ /// The snapshot of the page that travels.
+ private Image openBuffer() {
+ return closing ? sourceBuffer : destBuffer;
+ }
+
+ /// The snapshot of the page that stays put underneath.
+ private Image staticBuffer() {
+ return closing ? destBuffer : sourceBuffer;
+ }
+
+ @Override
+ public boolean animate() {
+ if (motion == null) {
+ return false;
+ }
+ progress = motion.getValue();
+ return !motion.isFinished();
+ }
+
+ @Override
+ public void paint(Graphics g) {
+ if (motion == null || openBuffer() == null) {
+ return;
+ }
+ // Geometry follows the curve; everything else does not. Material drives the
+ // rectangle off a fast-out-slow-in animation and the colours and opacities off
+ // the RAW one, in fifths: the page behind dims over the first fifth, then the
+ // surface colour and the incoming content cross over during the second, and the
+ // rest of the run is the page settling into place.
+ //
+ // Both are OPEN progress -- 0 is folded into the origin, 1 is the full page --
+ // and closing runs them backwards. Everything below is written once, for the way
+ // in, and the way out is the same transform played in reverse: the rectangle
+ // shrinks back into what was tapped, the scrim lifts, and the contents cross
+ // over the other way. Without this the close ran the OPENING animation, so a
+ // page folded away by growing out of its button a second time.
+ float t = ((float) progress) / SCALE;
+ float linear = motion.getDuration() <= 0 ? 1f
+ : Math.min(1f, ((float) motion.getCurrentMotionTime()) / motion.getDuration());
+ // Only the GEOMETRY reverses. The fifths that govern the colours and the two
+ // contents are measured from the start of whichever run is playing, so closing
+ // crosses them over at the same point in its own run rather than at the mirrored
+ // point -- it just crosses them the other way round, which is the swap below.
+ if (closing) {
+ t = 1f - t;
+ }
+ float cross = crossover(linear);
+ Component dest = getDestination();
+ int fullW = dest.getWidth();
+ int fullH = dest.getHeight();
+
+ // What we came from, unchanged and underneath: the page being left does not move
+ // in a container transform, it is covered.
+ if (staticBuffer() != null) {
+ g.drawImage(staticBuffer(), 0, 0);
+ }
+ // ...and dimmed. Without the scrim the whole background stays at full brightness
+ // through the transition, which is most of the screen disagreeing with the
+ // reference for most of the run -- far more pixels than the surface itself.
+ // Off the CURVED progress, not the raw clock -- unlike the opacities and the
+ // surface colour below, which Material does drive off the raw one. Getting this
+ // one wrong is not a subtle shading difference: the scrim covers the whole
+ // screen, so while it is ramping, every pixel is at the wrong brightness. It
+ // cost a single frame 83% wrong pixels against the reference, between two
+ // neighbours at 5% and 13%, because the raw clock reaches full dim more than
+ // twice as fast as the curve does.
+ //
+ // Measured at the 50ms frame of a 300ms run, mean luma over the screen:
+ // raw predicts 116.6 and we rendered 117.3; the curve predicts 163.4 and the
+ // reference rendered 163.2.
+ // Opening, the scrim arrives over the first fifth and then stands. Closing, it
+ // does NOT mirror that: it lifts smoothly across the whole run, in proportion to
+ // how much of the transform is left. Mirroring the fifths instead held it at
+ // full black over the middle of the run and then dropped it in one step -- the
+ // page behind stayed dark almost until the surface had gone, where the reference
+ // has it brightening the whole way.
+ int scrim = closing
+ ? (int) (SCRIM_ALPHA * t)
+ : (int) (SCRIM_ALPHA * Math.min(1f, t / FIFTH));
+ if (scrim > 0) {
+ int old = g.getAlpha();
+ g.setAlpha(scrim);
+ g.setColor(0);
+ g.fillRect(0, 0, fullW, fullH);
+ g.setAlpha(old);
+ }
+
+ int x = lerp(startX, 0, t);
+ int y = lerp(startY, 0, t);
+ int w = lerp(startW, fullW, t);
+ int h = lerp(startH, fullH, t);
+
+ // The SHAPE is not the rectangle. Material lerps the tapped thing's outline into
+ // the page's, and a circle squashes the rectangle toward a square about its centre
+ // as it goes -- that is what keeps a round button looking round instead of
+ // stretching into a lozenge the instant it starts to grow.
+ //
+ // It is most of the geometry, not a rounding detail. Measured against the
+ // reference at the middle of the close, the rectangle is 685 x 1392 and the
+ // painted surface 685 x 1066: the same box and the same centre, 326 pixels
+ // shorter. Painting the rectangle itself put a third of the card's height in the
+ // wrong place for the whole run.
+ float circularity = originRound ? 1f - t : 0f;
+ int px = x;
+ int py = y;
+ int pw = w;
+ int ph = h;
+ if (circularity > 0) {
+ if (w < h) {
+ int d = (int) (circularity * (h - w) / 2f);
+ py += d;
+ ph -= 2 * d;
+ } else {
+ int d = (int) (circularity * (w - h) / 2f);
+ px += d;
+ pw -= 2 * d;
+ }
+ }
+ // Off the UNADJUSTED box, as Flutter's _adjustBorderRadius is: the radius that
+ // makes the squashed box a circle is half the short side of the box it came from.
+ int radius = originRound
+ ? (int) (circularity * Math.min(w, h) / 2f)
+ : lerp(startRadius, 0, t);
+
+ int[] clip = g.getClip();
+ if (radius > 0 && g.isShapeClipSupported()) {
+ g.setClip(roundRect(px, py, pw, ph, radius));
+ } else {
+ g.setClip(px, py, pw, ph);
+ }
+
+ // The surface holds the tapped thing's colour for the first fifth, crosses to the
+ // page's over the second, and is the page's thereafter.
+ // Opening runs the tapped thing's colour to the page's; closing runs it back.
+ g.setColor(closing ? blend(openColor, surfaceColor, cross)
+ : blend(surfaceColor, openColor, cross));
+ g.fillRect(px, py, pw, ph);
+
+ // Both contents are drawn at their OWN size scaled to the box's WIDTH, anchored
+ // at its top-left corner.
+ //
+ // This is the part that makes it a transform rather than a window. Drawn at 1:1
+ // and clipped, the page inside a half-sized box is the page's top-left QUARTER,
+ // so folding the compose page away showed a crop of its header sliding about
+ // while the reference shows the whole page shrinking into the button. Width, not
+ // height: the aspect ratios of a button and a page have nothing to do with each
+ // other, and fitting the width is what keeps the text at the size the box implies.
+ //
+ // The tapped thing stays FULLY OPAQUE the whole way and is simply covered as the
+ // page arrives over it. That is what the fade variant of the transform does -- its
+ // closed content has a constant opacity of 1 and only the page's opacity moves --
+ // and it is the visible difference between a button that becomes the page and two
+ // pictures dissolving into each other. Measured on the reference at the middle of
+ // the close, the pencil inside the shrinking card is pure black, not a tint.
+ if (originBuffer != null) {
+ drawFittedToWidth(g, originBuffer, x, y, w);
+ }
+
+ float open = closing ? 1f - cross : cross;
+ if (open > 0) {
+ int old = g.getAlpha();
+ g.setAlpha((int) (255 * open));
+ drawFittedToWidth(g, openBuffer(), x, y, w);
+ g.setAlpha(old);
+ }
+ g.setClip(clip[0], clip[1], clip[2], clip[3]);
+ }
+
+ /// Draws an image scaled so its WIDTH is {@code w}, anchored at {@code x, y}, with
+ /// its aspect ratio kept. The caller's clip decides how much of it is seen.
+ private static void drawFittedToWidth(Graphics g, Image img, int x, int y, int w) {
+ if (img == null || img.getWidth() <= 0) {
+ return;
+ }
+ int h = Math.max(1, (int) ((long) img.getHeight() * w / img.getWidth()));
+ g.drawImage(img, x, y, Math.max(1, w), h);
+ }
+
+ /// 0 before the second fifth, 1 after it, and the crossing in between.
+ private static float crossover(float linear) {
+ if (linear <= FIFTH) {
+ return 0f;
+ }
+ if (linear >= FIFTH * 2) {
+ return 1f;
+ }
+ return (linear - FIFTH) / FIFTH;
+ }
+
+ private GeneralPath roundRect(int x, int y, int w, int h, int r) {
+ if (path == null) {
+ path = new GeneralPath();
+ }
+ path.reset();
+ int rad = Math.min(r, Math.min(w, h) / 2);
+ // CLOCKWISE, explicitly. GeneralPath.arcTo defaults to counter-clockwise, and the
+ // rectangle below is walked clockwise, so every corner took the long way round --
+ // a 270 degree sweep that bulges back into the box instead of a 90 degree one.
+ // With a small radius that reads as a slightly soft corner; with a large one the
+ // shape is unrecognisable, and this transition's corners reach half the short side
+ // at the start of the run.
+ path.moveTo(x + rad, y);
+ path.lineTo(x + w - rad, y);
+ path.arcTo(x + w - rad, y + rad, x + w, y + rad, true);
+ path.lineTo(x + w, y + h - rad);
+ path.arcTo(x + w - rad, y + h - rad, x + w - rad, y + h, true);
+ path.lineTo(x + rad, y + h);
+ path.arcTo(x + rad, y + h - rad, x, y + h - rad, true);
+ path.lineTo(x, y + rad);
+ path.arcTo(x + rad, y + rad, x + rad, y, true);
+ path.closePath();
+ return path;
+ }
+
+ private static int lerp(int from, int to, float t) {
+ return from + (int) ((to - from) * t);
+ }
+
+ /// Clears everything outside the tapped thing's outline, and everything inside it that
+ /// is the thing's own colour, leaving its content on transparency.
+ private static Image maskToContent(Image img, int w, int h, int surface, boolean round) {
+ try {
+ int[] px = img.getRGB();
+ int cx = w / 2;
+ int cy = h / 2;
+ // Inside the outline by a few pixels. The edge of a round button is
+ // anti-aliased against whatever is behind it, so the outermost ring is neither
+ // the button's colour nor its content -- and magnified four times it drew a
+ // pale arc across the card that belongs to nothing.
+ int rad = Math.min(w, h) / 2;
+ rad -= Math.max(2, rad / 12);
+ int radSq = rad * rad;
+ int sr = (surface >> 16) & 0xff;
+ int sg = (surface >> 8) & 0xff;
+ int sb = surface & 0xff;
+ for (int y = 0; y < h; y++) {
+ for (int x = 0; x < w; x++) {
+ int i = y * w + x;
+ if (round) {
+ int dx = x - cx;
+ int dy = y - cy;
+ if (dx * dx + dy * dy > radSq) {
+ px[i] = 0;
+ continue;
+ }
+ }
+ int p = px[i];
+ if (Math.abs(((p >> 16) & 0xff) - sr) <= TOLERANCE
+ && Math.abs(((p >> 8) & 0xff) - sg) <= TOLERANCE
+ && Math.abs((p & 0xff) - sb) <= TOLERANCE) {
+ px[i] = 0;
+ }
+ }
+ }
+ return Image.createImage(px, w, h);
+ } catch (Throwable t) {
+ return img;
+ }
+ }
+
+ /// How close to the surface colour counts as the surface rather than its content.
+ private static final int TOLERANCE = 24;
+
+ /// The commonest opaque colour in a snapshot, which is the surface colour of whatever
+ /// was tapped however it came to be painted. Falls back to {@code fallback} when the
+ /// snapshot is empty or unreadable.
+ private static int dominantColor(Image img, int fallback) {
+ try {
+ int[] rgb = img.getRGB();
+ java.util.HashMap counts = new java.util.HashMap();
+ int best = fallback;
+ int bestN = 0;
+ for (int pixel : rgb) {
+ if (((pixel >>> 24) & 0xff) < 128) {
+ continue;
+ }
+ Integer key = Integer.valueOf(pixel & 0xffffff);
+ Object prev = counts.get(key);
+ // instanceof rather than a bare cast inside this try: ParparVM's
+ // CHECKCAST is unchecked, so a failed cast does not throw on iOS
+ // and the catch below would never see it -- the wrong object
+ // would simply be read as an Integer.
+ int n = prev instanceof Integer ? ((Integer) prev).intValue() + 1 : 1;
+ counts.put(key, Integer.valueOf(n));
+ if (n > bestN) {
+ bestN = n;
+ best = key.intValue();
+ }
+ }
+ return bestN == 0 ? fallback : best;
+ } catch (Throwable t) {
+ return fallback;
+ }
+ }
+
+ /// Mixes two packed RGB colours, channel by channel.
+ private static int blend(int from, int to, float t) {
+ int r = lerp((from >> 16) & 0xff, (to >> 16) & 0xff, t);
+ int g = lerp((from >> 8) & 0xff, (to >> 8) & 0xff, t);
+ int b = lerp(from & 0xff, to & 0xff, t);
+ return (r << 16) | (g << 8) | b;
+ }
+
+ @Override
+ public void cleanup() {
+ // The base releases source and destination -- both forms and their whole
+ // component trees, which a caller holding this transition (from
+ // Display.getRunningTransition) otherwise kept reachable after it ended.
+ super.cleanup();
+ sourceBuffer = null;
+ destBuffer = null;
+ originBuffer = null;
+ motion = null;
+ path = null;
+ }
+
+ @Override
+ public Transition copy(boolean reverse) {
+ ContainerTransformTransition t =
+ new ContainerTransformTransition(componentName, duration);
+ t.closing = reverse;
+ return t;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/animations/CupertinoPageTransition.java b/CodenameOne/src/com/codename1/ui/animations/CupertinoPageTransition.java
new file mode 100644
index 00000000000..288435f35b4
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/animations/CupertinoPageTransition.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+
+package com.codename1.ui.animations;
+
+import com.codename1.ui.Component;
+import com.codename1.ui.Graphics;
+import com.codename1.ui.Image;
+
+/// The iOS page push: two pages moving at different speeds, over different distances.
+///
+/// A plain slide holds the two pages a fixed screen apart and moves the pair, which makes
+/// them one rigid object. This transition is the reason iOS depth reads the way it does:
+/// the arriving page crosses the WHOLE screen while the page it covers drifts only a
+/// THIRD of it, and each rides its own curve. The gap between them closes as they travel,
+/// which is what says one is in front of the other.
+///
+/// Sliding both the full distance is not a subtle difference. A third of the way through
+/// a push the strip of the old page still showing is not dimmer or shifted -- it is a
+/// different PART of that page: measured against the reference at 150ms of a 500ms push
+/// on a 1125px screen, the old page belongs 270px to the left and ours had it 855px to
+/// the left, so the visible strip showed its far edge where the reference shows its
+/// middle.
+///
+/// @author Shai Almog
+public final class CupertinoPageTransition extends Transition {
+
+ /// The arriving page's curve: fast ease in to slow ease out, a three-point cubic.
+ private static final float[] ARRIVING = {
+ 0.056f, 0.024f, 0.108f, 0.3085f,
+ 0.198f, 0.541f,
+ 0.3655f, 1.0f, 0.5465f, 0.989f,
+ };
+
+ /// The departing page's curve: linear to ease out. A DIFFERENT curve, which is half
+ /// of why the two do not move as one piece.
+ private static final float[] DEPARTING = {0.35f, 0.91f, 0.33f, 0.97f};
+
+ /// How far the covered page travels, as a fraction of the screen. The other half of
+ /// why they do not move as one piece.
+ private static final int PARALLAX_DENOMINATOR = 3;
+
+ private static final int SCALE = 1000;
+
+ private final int duration;
+ private boolean back;
+
+ private Motion arriving;
+ private Motion departing;
+ private Image sourceBuffer;
+ private Image destBuffer;
+
+ private CupertinoPageTransition(int duration) {
+ this.duration = duration;
+ }
+
+ /// Creates the transition.
+ ///
+ /// #### Parameters
+ ///
+ /// - `duration`: the push duration in milliseconds
+ ///
+ /// #### Returns
+ ///
+ /// the transition
+ public static CupertinoPageTransition create(int duration) {
+ return new CupertinoPageTransition(duration);
+ }
+
+ /// The push duration in milliseconds.
+ ///
+ /// #### Returns
+ ///
+ /// the duration this transition was created with
+ public int getDuration() {
+ return duration;
+ }
+
+ /// Whether this instance plays the way BACK, with the roles of the two pages swapped.
+ ///
+ /// #### Returns
+ ///
+ /// true if this is the pop half of the transition
+ public boolean isBack() {
+ return back;
+ }
+
+ @Override
+ public void initTransition() {
+ Component source = getSource();
+ Component destination = getDestination();
+ if (source == null || destination == null) {
+ return;
+ }
+ int w = destination.getWidth();
+ int h = destination.getHeight();
+ if (w <= 0 || h <= 0) {
+ return;
+ }
+ arriving = Motion.createThreePointCubicMotion(0, SCALE, duration,
+ ARRIVING[0], ARRIVING[1], ARRIVING[2], ARRIVING[3], ARRIVING[4],
+ ARRIVING[5], ARRIVING[6], ARRIVING[7], ARRIVING[8], ARRIVING[9]);
+ departing = Motion.createCubicBezierMotion(0, SCALE, duration,
+ DEPARTING[0], DEPARTING[1], DEPARTING[2], DEPARTING[3]);
+ arriving.start();
+ departing.start();
+
+ sourceBuffer = Image.createImage(source.getWidth(), source.getHeight());
+ source.paintComponent(sourceBuffer.getGraphics(), true);
+ destBuffer = Image.createImage(w, h);
+ destination.paintComponent(destBuffer.getGraphics(), true);
+ }
+
+ @Override
+ public boolean animate() {
+ // Both, though they are set and cleared together: a reader (and a static analyser)
+ // should not have to know that to see this is safe.
+ if (arriving == null || departing == null) {
+ return false;
+ }
+ departing.getValue();
+ return !arriving.isFinished();
+ }
+
+ @Override
+ public void paint(Graphics g) {
+ Component destination = getDestination();
+ if (arriving == null || departing == null || destination == null) {
+ return;
+ }
+ int w = destination.getWidth();
+ float front = arriving.getValue() / (float) SCALE;
+ float behind = departing.getValue() / (float) SCALE;
+ int parallax = w / PARALLAX_DENOMINATOR;
+
+ int sourceX;
+ int destX;
+ if (back) {
+ // Going back: the page on top leaves across the whole screen, and the one
+ // underneath comes home from the third of the way out it was left at.
+ sourceX = Math.round(w * front);
+ destX = -Math.round(parallax * (1 - behind));
+ } else {
+ sourceX = -Math.round(parallax * behind);
+ destX = Math.round(w * (1 - front));
+ }
+
+ // The covered page first: the arriving one is opaque and passes over it.
+ if (sourceBuffer != null) {
+ g.drawImage(sourceBuffer, sourceX, 0);
+ }
+ if (destBuffer != null) {
+ g.drawImage(destBuffer, destX, 0);
+ }
+ }
+
+ @Override
+ public void cleanup() {
+ super.cleanup();
+ sourceBuffer = null;
+ destBuffer = null;
+ arriving = null;
+ departing = null;
+ }
+
+ @Override
+ public Transition copy(boolean reverse) {
+ CupertinoPageTransition t = new CupertinoPageTransition(duration);
+ t.back = reverse;
+ return t;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/animations/Motion.java b/CodenameOne/src/com/codename1/ui/animations/Motion.java
index 7890dfcfcc9..94d77c53158 100644
--- a/CodenameOne/src/com/codename1/ui/animations/Motion.java
+++ b/CodenameOne/src/com/codename1/ui/animations/Motion.java
@@ -48,6 +48,7 @@ public class Motion {
private static final int COLOR_LINEAR = 5;
private static final int EXPONENTIAL_DECAY = 6;
private static final int CRITICAL_DAMPED_SPRING = 7;
+ private static final int THREE_POINT_CUBIC = 8;
private static boolean slowMotion;
private final int[] previousLastReturnedValue = new int[3];
private final long[] previousLastReturnedValueTime = new long[3];
@@ -62,6 +63,14 @@ public class Motion {
private int lastReturnedValue;
private long currentMotionTime = -1;
private long previousCurrentMotionTime = -1;
+ /// The joint and the second segment's control points of a three-point cubic.
+ private float midX;
+ private float midY;
+ private float q0;
+ private float q1;
+ private float q2;
+ private float q3;
+
private float p0;
private float p1;
private float p2;
@@ -165,6 +174,53 @@ public static Motion createCubicBezierMotion(int sourceValue, int destinationVal
return m;
}
+ /// A curve made of TWO cubic beziers joined at a point, which a single cubic cannot
+ /// express.
+ ///
+ /// A plain `cubic-bezier` is monotonic in a way some motion is not: it cannot
+ /// accelerate hard, ease, and then ease out again, because it has only two control
+ /// points to spend. Curves that do this are specified as a pair of beziers meeting at
+ /// a midpoint, each with its own controls, and the joint is where the character of
+ /// the motion changes.
+ ///
+ /// The segments are evaluated in their own normalized space and rescaled, so each
+ /// half is an ordinary CSS cubic-bezier and the two meet exactly at the midpoint.
+ ///
+ /// #### Parameters
+ ///
+ /// - `sourceValue`: the initial value
+ ///
+ /// - `destinationValue`: the value at the end of the motion
+ ///
+ /// - `duration`: the motion duration in milliseconds
+ ///
+ /// - `a1X`, `a1Y`, `b1X`, `b1Y`: control points of the first segment
+ ///
+ /// - `midX`, `midY`: the point the two segments meet at
+ ///
+ /// - `a2X`, `a2Y`, `b2X`, `b2Y`: control points of the second segment
+ ///
+ /// #### Returns
+ ///
+ /// Motion instance
+ public static Motion createThreePointCubicMotion(int sourceValue, int destinationValue,
+ int duration, float a1X, float a1Y, float b1X, float b1Y,
+ float midX, float midY, float a2X, float a2Y, float b2X, float b2Y) {
+ Motion m = new Motion(sourceValue, destinationValue, duration);
+ m.motionType = THREE_POINT_CUBIC;
+ m.p0 = a1X;
+ m.p1 = a1Y;
+ m.p2 = b1X;
+ m.p3 = b1Y;
+ m.midX = midX;
+ m.midY = midY;
+ m.q0 = a2X;
+ m.q1 = a2Y;
+ m.q2 = b2X;
+ m.q3 = b2Y;
+ return m;
+ }
+
/// Equivalent to createCubicBezierMotion with 0, 0.42, 0.58, 1.0 as arguments.
///
/// #### Parameters
@@ -485,6 +541,73 @@ private int getSplineValue() {
return x;
}
+ private int getThreePointCubicValue() {
+ if (isFinished()) {
+ return destinationValue;
+ }
+ float totalTime = duration;
+ float currentTime = Math.min((int) getCurrentMotionTime(), (int) totalTime);
+ if (currentTime < 0f) {
+ currentTime = 0f;
+ }
+ float t = currentTime / totalTime;
+
+ // Each segment is solved in its OWN normalized space: the controls are expressed
+ // relative to the segment's start and divided by its extent, so the solver below
+ // sees an ordinary cubic-bezier from (0,0) to (1,1). The result is then scaled
+ // back, which is what makes the two halves meet exactly at the midpoint instead
+ // of stepping there.
+ boolean first = t < midX;
+ float scaleX = first ? midX : 1f - midX;
+ float scaleY = first ? midY : 1f - midY;
+ float value;
+ if (scaleX <= 0f) {
+ // A segment with no duration is only ever reached at its end.
+ value = first ? midY : 1f;
+ } else if (scaleY <= 0f) {
+ // No vertical extent -- the midpoint lies on the top or bottom edge -- so the
+ // Y axis cannot be normalized. Substituting linear progress here made the
+ // value run toward t and then jump back at the join. X still normalizes, so
+ // solve for the curve parameter as usual and evaluate this segment's Y cubic
+ // from its real endpoints and controls.
+ float scaledT = (t - (first ? 0f : midX)) / scaleX;
+ float x1 = first ? p0 / scaleX : (q0 - midX) / scaleX;
+ float x2 = first ? p2 / scaleX : (q2 - midX) / scaleX;
+ float u = solveBezierForT(scaledT, x1, x2);
+ float y0 = first ? 0f : midY;
+ float y3 = first ? midY : 1f;
+ float c1 = first ? p1 : q1;
+ float c2 = first ? p3 : q3;
+ float inv = 1f - u;
+ value = inv * inv * inv * y0 + 3f * inv * inv * u * c1 + 3f * inv * u * u * c2 + u * u * u * y3;
+ } else {
+ float scaledT = (t - (first ? 0f : midX)) / scaleX;
+ float x1;
+ float y1;
+ float x2;
+ float y2;
+ if (first) {
+ x1 = p0 / scaleX;
+ y1 = p1 / scaleY;
+ x2 = p2 / scaleX;
+ y2 = p3 / scaleY;
+ } else {
+ x1 = (q0 - midX) / scaleX;
+ y1 = (q1 - midY) / scaleY;
+ x2 = (q2 - midX) / scaleX;
+ y2 = (q3 - midY) / scaleY;
+ }
+ float u = solveBezierForT(scaledT, x1, x2);
+ value = bezierAxis(u, y1, y2) * scaleY + (first ? 0f : midY);
+ }
+
+ float dis = Math.abs(destinationValue - sourceValue);
+ if (destinationValue > sourceValue) {
+ return sourceValue + (int) (value * dis);
+ }
+ return sourceValue - (int) (value * dis);
+ }
+
private int getCubicValue() {
//make sure we reach the destination value.
if (isFinished()) {
@@ -631,6 +754,9 @@ public int getValue() {
case CUBIC:
lastReturnedValue = getCubicValue();
break;
+ case THREE_POINT_CUBIC:
+ lastReturnedValue = getThreePointCubicValue();
+ break;
case FRICTION:
lastReturnedValue = getFriction();
break;
diff --git a/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java b/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java
index 0fdec5c52dd..5d72ef0b194 100644
--- a/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java
+++ b/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java
@@ -535,11 +535,27 @@ public boolean equals(Shape shape, Transform t) {
return true;
}
if (shape instanceof Rectangle) {
+ // A path equals a rectangle only when it IS that rectangle. Comparing bounds
+ // alone made EVERY non-rectangular path equal to its own bounding box, and
+ // the clip bookkeeping asks exactly this question before deciding that a
+ // setClip changes nothing and can be skipped.
+ //
+ // That is a silent, total failure of shaped clipping wherever the shape fills
+ // its own component, which is the usual case: a component's clip has just been
+ // narrowed to its bounds, so a circle or rounded rectangle inscribed in it
+ // arrives with bounds equal to the current clip, compares EQUAL, and is
+ // DISCARDED. The subtree then paints square with nothing reported. Only ports
+ // whose clip state goes through this comparison are affected, which is why it
+ // could be reproduced on a device and never in the desktop simulator.
+ //
+ // isRectangle() walks the path, so it is asked only once the bounds have
+ // already matched -- the rare case. A clip that genuinely changed is rejected
+ // on the bounds alone, as before.
Rectangle r = (Rectangle) shape;
Rectangle tmpRect = createRectFromPool();
try {
getBounds(tmpRect);
- return r.equals(tmpRect);
+ return r.equals(tmpRect) && isRectangle();
} finally {
recycle(tmpRect);
}
diff --git a/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java b/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java
index b1fab8a9227..08243252a52 100644
--- a/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java
+++ b/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java
@@ -154,6 +154,9 @@ public final class RoundBorder extends Border {
/// square. This is ignored when the rectangle mode is false
private boolean onlyRightRounded;
private boolean uiid;
+ /// True when the shape is drawn straight onto the Graphics rather than
+ /// through a cached offscreen image. Off by default; see `directPaint`.
+ private boolean directPaint;
/// This is useful for showing an Uber like stroke effect progress bar
private int strokeAngle = 360;
@@ -525,6 +528,44 @@ public boolean isOnlyRightRounded() {
return onlyRightRounded;
}
+ /// Draws the shape straight onto the Graphics instead of through a cached
+ /// offscreen image.
+ ///
+ /// OFF by default, and that default is a compatibility decision rather
+ /// than a preference. Drawing directly anti-aliases the outline against
+ /// whatever is really behind it; the image path anti-aliases against
+ /// transparency and then composites the result, so alpha is quantised
+ /// twice. The two agree to within a fraction of a pixel along the edge --
+ /// invisible to a person, and still a difference a screenshot test
+ /// measures. Turning it on everywhere would move pixels in the UI of every
+ /// application that already uses this border.
+ ///
+ /// Turn it on for a shape whose SIZE ANIMATES, which is where the image
+ /// path is not merely slower but unusable: the cache is keyed by size, so
+ /// a growing circle misses it on every frame and allocates, fills and
+ /// discards a surface as large as the component each time.
+ ///
+ /// #### Parameters
+ ///
+ /// - `directPaint`: true to draw without an offscreen image
+ ///
+ /// #### Returns
+ ///
+ /// border instance so these calls can be chained
+ public RoundBorder directPaint(boolean directPaint) {
+ this.directPaint = directPaint;
+ return this;
+ }
+
+ /// Whether this border draws without an offscreen image.
+ ///
+ /// #### Returns
+ ///
+ /// True when direct painting is enabled.
+ public boolean isDirectPaint() {
+ return directPaint;
+ }
+
private Image createTargetImage(Component c, int w, int h, boolean fast) {
Image target = ImageFactory.createImage(c, w, h, 0);
@@ -618,12 +659,67 @@ private Image createTargetImage(Component c, int w, int h, boolean fast) {
return target;
}
+ /// Whether this border can be drawn straight onto the Graphics instead of
+ /// through an offscreen image.
+ ///
+ /// Two configurations genuinely need the image and are excluded. A shadow is
+ /// built by overdrawing the shape once per spread step and then blurring the
+ /// result, which is a read-back of what has been drawn so far and therefore
+ /// has to own its surface. The `uiid` mode paints the COMPONENT's background
+ /// painter through a shape clip, swapping the component's border out while it
+ /// does so, and that re-entry is only safe against a surface of its own.
+ ///
+ /// Everything else is a fill and an optional stroke of a circle or a pill in
+ /// this border's own colours -- `fillShape` never reads the destination -- so
+ /// drawing it directly produces the same pixels without allocating anything.
+ private boolean canPaintDirectly(Graphics g) {
+ return directPaint && shadowOpacity <= 0 && !uiid && g.isAntiAliasingSupported();
+ }
+
+ /// Draws the shape onto `g` with the same geometry `createTargetImage` would
+ /// have drawn into an image of the component's size.
+ ///
+ /// The clip reproduces the one the image gave for free: a stroke is centred
+ /// on the outline, so half of it falls outside the box and used to be cropped
+ /// by the image's own bounds.
+ private void paintDirectly(Graphics g, int x, int y, int w, int h) {
+ int priorColor = g.getColor();
+ int priorAlpha = g.getAlpha();
+ boolean priorAntiAliased = g.isAntiAliased();
+ g.pushClip();
+ try {
+ g.clipRect(x, y, w, h);
+ g.translate(x, y);
+ try {
+ g.setAntiAliased(true);
+ fillShape(g, color, opacity, w, h, true);
+ } finally {
+ g.translate(-x, -y);
+ }
+ } finally {
+ g.popClip();
+ g.setAntiAliased(priorAntiAliased);
+ g.setColor(priorColor);
+ g.setAlpha(priorAlpha);
+ }
+ }
+
@Override
public void paintBorderBackground(Graphics g, final Component c) {
final int w = c.getWidth();
final int h = c.getHeight();
int x = c.getX();
int y = c.getY();
+ if (w > 0 && h > 0 && canPaintDirectly(g)) {
+ // NO IMAGE, when the caller has asked for it. The cached path
+ // allocates a mutable image the size of the component and rebuilds
+ // it on every size change, which is ruinous for a shape that
+ // ANIMATES its size: a coach-mark circle growing to 1618x1618 over
+ // half a second throws away a 10MB surface per frame and queues a
+ // refinement pass behind each one. A flat fill needs none of that.
+ paintDirectly(g, x, y, w, h);
+ return;
+ }
if (w > 0 && h > 0) {
Object k = c.getClientProperty(CACHE_KEY + instanceVal);
if (k instanceof CacheValue) {
diff --git a/CodenameOne/src/com/codename1/ui/spinner/DateSpinner3D.java b/CodenameOne/src/com/codename1/ui/spinner/DateSpinner3D.java
index fe5d73d4ac1..c1fc13c408f 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/DateSpinner3D.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/DateSpinner3D.java
@@ -37,12 +37,22 @@
import static com.codename1.ui.CN.convertToPixels;
-/// A date spinner allows selecting a date value within the given date range
+/// Three wheels -- day, month and year -- that together select one date inside
+/// a configured range.
///
-/// This is used by the Picker when in lightweight mode.
+/// The body of a lightweight [Picker][com.codename1.ui.spinner.Picker] of type
+/// date, and usable on its own as an ordinary `Container` when a form wants
+/// the wheels inline rather than in a dialog. The range is set with
+/// `#setStartYear(int)` and `#setEndYear(int)`; a value outside it is clamped.
+///
+/// [getValue()][#getValue()] and [setValue(Object)][#setValue(Object)] carry a
+/// `java.util.Date`. The wheels select only the date, but the time of day of
+/// whatever was last passed to `setValue` is remembered and handed back by
+/// `getValue`, so a round trip through this widget does not silently move a
+/// timestamp to midnight.
///
/// @author Steve Hannah
-class DateSpinner3D extends Container implements InternalPickerWidget {
+public class DateSpinner3D extends Container implements InternalPickerWidget {
private final SimpleDateFormat monthFormat = new SimpleDateFormat("MMMM");
private final Container wrapper = new Container(BoxLayout.x());
private final Calendar tmpCal = Calendar.getInstance();
diff --git a/CodenameOne/src/com/codename1/ui/spinner/DateTimeSpinner3D.java b/CodenameOne/src/com/codename1/ui/spinner/DateTimeSpinner3D.java
index 2d8eb70c8a1..feb58b5b1d8 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/DateTimeSpinner3D.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/DateTimeSpinner3D.java
@@ -36,12 +36,19 @@
import static com.codename1.ui.CN.convertToPixels;
-/// The date and time spinner extends the time spinner by allowing to pick a specific day as well
+/// A date and a time of day on one row of wheels: a day wheel beside the hour
+/// and minute wheels of [TimeSpinner3D][com.codename1.ui.spinner.TimeSpinner3D].
///
-/// Used by Picker in lightweight mode.
+/// The body of a lightweight [Picker][com.codename1.ui.spinner.Picker] of type
+/// date and time, and usable on its own as an ordinary `Container`. The day
+/// wheel spans the configured range and shows each day by name rather than by
+/// number, which is what the iOS picker it follows does.
+///
+/// [getValue()][#getValue()] and [setValue(Object)][#setValue(Object)] carry a
+/// `java.util.Date` in which both the date and the time components are used.
///
/// @author Steve Hannah
-class DateTimeSpinner3D extends Container implements InternalPickerWidget {
+public class DateTimeSpinner3D extends Container implements InternalPickerWidget {
private final Date today = new Date();
private final int off;
private final Container wrapper = new Container(BoxLayout.x());
diff --git a/CodenameOne/src/com/codename1/ui/spinner/DurationSpinner3D.java b/CodenameOne/src/com/codename1/ui/spinner/DurationSpinner3D.java
index 51ff57f8b7b..07e7cecfa12 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/DurationSpinner3D.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/DurationSpinner3D.java
@@ -31,10 +31,20 @@
import com.codename1.ui.plaf.Style;
import com.codename1.ui.plaf.UIManager;
-/// A duration Spinner widget Used by the Picker in lightweight mode.
+/// Wheels that select a LENGTH of time rather than a point in it -- days,
+/// hours and minutes, according to how it is configured.
+///
+/// The body of a lightweight [Picker][com.codename1.ui.spinner.Picker] of type
+/// duration, and usable on its own as an ordinary `Container`. Which wheels
+/// appear is decided at construction; a duration picker that only needs hours
+/// and minutes does not show a day wheel.
+///
+/// [getValue()][#getValue()] and [setValue(Object)][#setValue(Object)] carry
+/// the duration in MILLISECONDS, as a `Long`, so that it can be added to a
+/// timestamp without unit conversion at the call site.
///
/// @author Steve Hannah
-class DurationSpinner3D extends Container implements InternalPickerWidget {
+public class DurationSpinner3D extends Container implements InternalPickerWidget {
public static final int FIELD_YEAR = 0;
public static final int FIELD_MONTH = 1;
public static final int FIELD_DAY = 2;
diff --git a/CodenameOne/src/com/codename1/ui/spinner/InternalPickerWidget.java b/CodenameOne/src/com/codename1/ui/spinner/InternalPickerWidget.java
index 85c9d93f2b4..067ad14f2f6 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/InternalPickerWidget.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/InternalPickerWidget.java
@@ -22,12 +22,46 @@
*/
package com.codename1.ui.spinner;
-/// Interface for 3D spinners that allow selected values to set and retrieved
-/// by the Picker.
+/// The value contract every lightweight picker spinner implements, so
+/// [Picker][com.codename1.ui.spinner.Picker] can drive any of them without
+/// knowing which one it holds.
+///
+/// A `Picker` in lightweight mode owns one of the `*Spinner3D` containers
+/// according to its type -- date, time, date and time, duration or a plain
+/// string list -- and moves a value in and out of it through this interface
+/// alone. Implementing it is what makes a container usable as the body of a
+/// picker.
+///
+/// The value's runtime type is the implementation's own: `Date` for
+/// [DateSpinner3D][com.codename1.ui.spinner.DateSpinner3D], an `int[]` of
+/// hours and minutes for
+/// [DurationSpinner3D][com.codename1.ui.spinner.DurationSpinner3D], and so on.
+/// Each implementation documents what it expects; passing something else is a
+/// programming error rather than a recoverable condition.
+///
+/// This is public because the lightweight spinners are, and those are usable
+/// on their own -- embedded in a form rather than shown in a picker dialog.
+/// The name is retained for source compatibility with the releases in which it
+/// was package private.
///
/// @author shannah
-interface InternalPickerWidget {
+public interface InternalPickerWidget {
+ /// The currently selected value.
+ ///
+ /// #### Returns
+ ///
+ /// the selection, in whatever type this implementation documents; never
+ /// null once the widget has been laid out
Object getValue();
+ /// Moves the selection to `value`.
+ ///
+ /// The widget scrolls to the new selection rather than jumping, when it is
+ /// already on screen.
+ ///
+ /// #### Parameters
+ ///
+ /// - `value`: the new selection, in the type this implementation
+ /// documents. A value outside the widget's range is clamped into it.
void setValue(Object value);
}
diff --git a/CodenameOne/src/com/codename1/ui/spinner/Spinner3D.java b/CodenameOne/src/com/codename1/ui/spinner/Spinner3D.java
index c9b918a5f7a..d3c6fff761d 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/Spinner3D.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/Spinner3D.java
@@ -1,8 +1,26 @@
/*
- * To change this license header, choose License Headers in Project Properties.
- * To change this template file, choose Tools | Templates
- * and open the template in the editor.
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
*/
+
package com.codename1.ui.spinner;
import com.codename1.l10n.DateFormat;
@@ -27,12 +45,28 @@
import static com.codename1.ui.ComponentSelector.$;
-/// A spinner widget that tries to look and feel like the iOS picker.
+/// A scrolling wheel of values styled after the iOS picker, and the body of a
+/// lightweight [Picker][com.codename1.ui.spinner.Picker].
+///
+/// Rows come from a [ListModel][com.codename1.ui.list.ListModel], so the
+/// contents can be anything a model can produce; the selection is whatever
+/// that model holds at the selected index. `Spinner3D` is the plain-list
+/// member of the family, beside
+/// [DateSpinner3D][com.codename1.ui.spinner.DateSpinner3D],
+/// [TimeSpinner3D][com.codename1.ui.spinner.TimeSpinner3D],
+/// [DateTimeSpinner3D][com.codename1.ui.spinner.DateTimeSpinner3D] and
+/// [DurationSpinner3D][com.codename1.ui.spinner.DurationSpinner3D].
+///
+/// It is an ordinary `Container`, so it can be added to a form directly rather
+/// than shown through a picker dialog -- which is why it is public. Use
+/// `Picker` instead when you want the platform's native picker where one
+/// exists, because a `Picker` falls back to this only in lightweight mode.
///
-/// This is used by the Picker widget when in lightweight mode.
+/// [getValue()][#getValue()] and [setValue(Object)][#setValue(Object)] carry
+/// the selected model element.
///
/// @author Steve Hannah
-class Spinner3D extends Container implements InternalPickerWidget {
+public class Spinner3D extends Container implements InternalPickerWidget {
private final SpinnerNode root;
private final ScrollingContainer scroller;
diff --git a/CodenameOne/src/com/codename1/ui/spinner/TimeSpinner3D.java b/CodenameOne/src/com/codename1/ui/spinner/TimeSpinner3D.java
index 59826185ae4..9cfcff183ea 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/TimeSpinner3D.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/TimeSpinner3D.java
@@ -36,13 +36,24 @@
import static com.codename1.ui.CN.convertToPixels;
-/// Allows selecting a time of day either in 24 hour batches or AM/PM format.
+/// Hour and minute wheels selecting a time of day, in either 24 hour or AM/PM
+/// form according to `#setShowMeridiem(boolean)`.
///
-/// If `#setDurationMode(boolean)` is true then this will allow
-/// users to set a duration in hours and minutes.
+/// The body of a lightweight [Picker][com.codename1.ui.spinner.Picker] of type
+/// time, and usable on its own as an ordinary `Container`. The minute wheel
+/// can step by more than one minute, which is what a picker offering quarter
+/// hours uses.
+///
+/// With `#setDurationMode(boolean)` set it selects a LENGTH of time instead,
+/// and the meridiem is not shown; prefer
+/// [DurationSpinner3D][com.codename1.ui.spinner.DurationSpinner3D] for that,
+/// which also offers a day wheel.
+///
+/// [getValue()][#getValue()] and [setValue(Object)][#setValue(Object)] carry
+/// the time as minutes since midnight, as an `Integer`.
///
/// @author Steve Hannah
-class TimeSpinner3D extends Container implements InternalPickerWidget {
+public class TimeSpinner3D extends Container implements InternalPickerWidget {
static final int DEFAULT_MINUTE_STEP = 5;
private Spinner3D hour;
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 9056edff3c7..c057d5f4236 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -4225,6 +4225,8 @@ public void blit() {
if(menuDisplayed){
return;
}
+ long blitStartNanos = BLIT_TRACE ? System.nanoTime() : 0;
+ long bufferDoneNanos = 0;
// We keep a blitCounter that gets reset in paintComponent()
// If blit is called a number of times with no call to paintComponet
@@ -4284,6 +4286,9 @@ public void blit() {
}
+ if (BLIT_TRACE) {
+ bufferDoneNanos = System.nanoTime();
+ }
try {
Runnable r = new Runnable() {
public void run() {
@@ -4341,6 +4346,10 @@ public void run() {
} catch(Exception err) {
err.printStackTrace();
}
+ if (BLIT_TRACE) {
+ recordBlit(bufferDoneNanos - blitStartNanos,
+ System.nanoTime() - bufferDoneNanos, bufferSafeMode);
+ }
}
public void blit(int x, int y, int w, int h) {
@@ -12876,6 +12885,60 @@ private void checkLastFrame() {
/**
* @inheritDoc
*/
+ /**
+ * Diagnostic for simulator frame pacing, enabled with -Dcn1.blit.trace=true.
+ *
+ *
The simulator presents a frame by handing the buffer to AWT through
+ * {@code SwingUtilities.invokeAndWait}, which BLOCKS the Codename One EDT until the
+ * AWT event thread has run it. That makes the simulator's frame rate a property of
+ * AWT's scheduling rather than of how long the app takes to paint, and it is
+ * invisible to any measurement taken inside Codename One - which is exactly why an
+ * app can paint a frame in 5ms and still advance only a few times a second.
+ *
+ *
The two phases are reported separately because they have different causes: the
+ * buffer copy is work the simulator does (and in {@code bufferSafeMode} it copies
+ * the whole screen under a lock, every frame), while the present time is pure
+ * waiting on AWT.
+ */
+ private static final boolean BLIT_TRACE = "true".equals(System.getProperty("cn1.blit.trace"));
+ private static int blitTraceCount;
+ private static long blitTraceBufferNanos;
+ private static long blitTracePresentNanos;
+ private static long blitTraceWorstPresentNanos;
+ private static long blitTraceLastReport;
+ private static int blitTraceSafeModeFrames;
+
+ private static synchronized void recordBlit(long bufferNanos, long presentNanos,
+ boolean safeMode) {
+ blitTraceCount++;
+ blitTraceBufferNanos += bufferNanos;
+ blitTracePresentNanos += presentNanos;
+ blitTraceWorstPresentNanos = Math.max(blitTraceWorstPresentNanos, presentNanos);
+ if (safeMode) {
+ blitTraceSafeModeFrames++;
+ }
+ long now = System.currentTimeMillis();
+ if (blitTraceLastReport == 0) {
+ blitTraceLastReport = now;
+ return;
+ }
+ if (now - blitTraceLastReport < 1000) {
+ return;
+ }
+ System.out.println("BLITTRACE frames=" + blitTraceCount
+ + " fps=" + (blitTraceCount * 1000L / Math.max(1, now - blitTraceLastReport))
+ + " bufferMs=" + (blitTraceBufferNanos / 1000000.0 / blitTraceCount)
+ + " presentMs=" + (blitTracePresentNanos / 1000000.0 / blitTraceCount)
+ + " worstPresentMs=" + (blitTraceWorstPresentNanos / 1000000.0)
+ + " safeModeFrames=" + blitTraceSafeModeFrames);
+ blitTraceCount = 0;
+ blitTraceBufferNanos = 0;
+ blitTracePresentNanos = 0;
+ blitTraceWorstPresentNanos = 0;
+ blitTraceSafeModeFrames = 0;
+ blitTraceLastReport = now;
+ }
+
public void flushGraphics(int x, int y, int width, int height) {
if (isShowEDTWarnings()) {
checkEDT();
@@ -14405,7 +14468,7 @@ private java.awt.Font desktopNativeFont(String alias) {
Float weight;
if ("Thin".equals(weightName)) weight = TextAttribute.WEIGHT_EXTRA_LIGHT;
else if ("Light".equals(weightName)) weight = TextAttribute.WEIGHT_LIGHT;
- else if ("Regular".equals(weightName)) weight = TextAttribute.WEIGHT_REGULAR;
+ else if ("Regular".equals(weightName) || "Normal".equals(weightName)) weight = TextAttribute.WEIGHT_REGULAR;
else if ("Bold".equals(weightName)) weight = TextAttribute.WEIGHT_BOLD;
else if ("Black".equals(weightName)) weight = TextAttribute.WEIGHT_HEAVY;
else throw new IllegalArgumentException("Unsupported native font type: " + alias);
@@ -14450,6 +14513,15 @@ public Object loadTrueTypeFont(String fontName, String fileName) {
res = "Medium";
break;
+ // The system face at its TRUE regular weight. native:MainRegular
+ // has meant Roboto-Medium since the alias was introduced and the
+ // iOS port maps it one step heavy too, so the honest regular gets
+ // a name of its own rather than a change that would restyle every
+ // existing application.
+ case "native:MainNormal":
+ res = "Regular";
+ break;
+
case "native:MainBold":
res = "Bold";
break;
diff --git a/Ports/MacPort/nativeSources/CN1MacHost.m b/Ports/MacPort/nativeSources/CN1MacHost.m
index 931e94b8a81..0be07d7a371 100644
--- a/Ports/MacPort/nativeSources/CN1MacHost.m
+++ b/Ports/MacPort/nativeSources/CN1MacHost.m
@@ -170,6 +170,7 @@ - (void)buildWindow {
return;
}
+ cn1StartupPhase("buildWindow.enter");
NSRect frame = NSMakeRect(0, 0, CN1_MAC_DEFAULT_WIDTH, CN1_MAC_DEFAULT_HEIGHT);
NSWindowStyleMask style = NSWindowStyleMaskTitled
| NSWindowStyleMaskClosable
@@ -260,6 +261,7 @@ - (void)buildWindow {
// this process in front. Without this the app launches, runs and draws --
// behind whatever the user was already looking at.
[NSApp activateIgnoringOtherApps:YES];
+ cn1StartupPhase("buildWindow.exit");
}
/// Answering a size query must not WAIT for the window either.
diff --git a/Ports/MacPort/nativeSources/CN1MacMenu.m b/Ports/MacPort/nativeSources/CN1MacMenu.m
index ca9c28b077d..55845db9739 100644
--- a/Ports/MacPort/nativeSources/CN1MacMenu.m
+++ b/Ports/MacPort/nativeSources/CN1MacMenu.m
@@ -35,6 +35,7 @@
}
void CN1MacInstallMainMenu(void) {
+ cn1StartupPhase("installMainMenu.enter");
NSString *appName = CN1MacAppName();
NSMenu *mainMenu = [[NSMenu alloc] initWithTitle:@""];
diff --git a/Ports/MacPort/nativeSources/CN1MacViewController.m b/Ports/MacPort/nativeSources/CN1MacViewController.m
index a1500771b8e..3cf210cea09 100644
--- a/Ports/MacPort/nativeSources/CN1MacViewController.m
+++ b/Ports/MacPort/nativeSources/CN1MacViewController.m
@@ -199,6 +199,8 @@ - (void)drawFrame:(CGRect)rect {
}
- (void)drawFrame:(CGRect)rect allowInactive:(BOOL)allowInactive {
+ static int firstDraw = 1;
+ if (firstDraw) { firstDraw = 0; cn1StartupPhase("firstDrawFrame"); }
METALView *v = (METALView *)[CN1MacHost sharedHost].activeRenderingView;
if (v == nil) {
return;
diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
index ca437f31124..58cbb26bd55 100644
--- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
+++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
@@ -565,6 +565,7 @@ - (BOOL)cn1OpenURL:(UIApplication *)application url:(NSURL *)url sourceApplicati
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
+ { extern void cn1StartupPhase(const char*); cn1StartupPhase("didFinishLaunching"); }
#ifdef CN1_DETECT_JAILBREAK
cn1DetectJailbreakBypassesAndExit();
#endif
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index d26f8ef658a..8deb8768a50 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -657,6 +657,7 @@ void com_codename1_impl_ios_IOSNative_initVM__(CN1_THREAD_STATE_MULTI_ARG JAVA_O
#else
#if !TARGET_OS_WATCH
POOL_BEGIN();
+ cn1StartupPhase("initVM->UIApplicationMain");
int retVal = UIApplicationMain(0, nil, nil, @"CodenameOne_GLAppDelegate");
POOL_END();
#else
@@ -5575,7 +5576,6 @@ void com_codename1_impl_ios_IOSNative_clearRadialGradientPaintMutable__(CN1_THRE
[PaintOp setCurrentMutable:NULL];
}
-
void com_codename1_impl_ios_IOSNative_releasePeer___long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG peer) {
#ifndef CN1_USE_ARC
dispatch_async(dispatch_get_main_queue(), ^{
@@ -13556,7 +13556,7 @@ void com_codename1_impl_ios_IOSNative_registerBundledFont___java_lang_String(CN1
weight = NSFontWeightThin;
} else if ([weightName isEqualToString:@"Light"]) {
weight = NSFontWeightLight;
- } else if ([weightName isEqualToString:@"Regular"]) {
+ } else if ([weightName isEqualToString:@"Regular"] || [weightName isEqualToString:@"Normal"]) {
weight = NSFontWeightRegular;
} else if ([weightName isEqualToString:@"Bold"]) {
weight = NSFontWeightBold;
@@ -13584,6 +13584,13 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_createTruetypeFont___java_lang_String
// Explicit font names continue through the existing shared loader.
fnt = cn1MacSystemFontForAlias(str, pSize);
#endif
+ // The system font at its true regular weight. IOSImplementation maps
+ // native:MainNormal to this sentinel because no HelveticaNeue alias reaches
+ // UIFontWeightRegular -- native:MainRegular is deliberately Medium -- and a
+ // text style asking for weight 400 was therefore rendering one step heavy.
+ if(fnt == nil && [str isEqualToString:@"CN1SystemRegular"]) {
+ fnt = [CN1Font systemFontOfSize:pSize];
+ }
if(fnt == nil && isIOS8_2() && [str hasPrefix:@"HelveticaNeue"]) {
if([str isEqualToString:@"HelveticaNeue-UltraLight"]) {
fnt = [CN1Font systemFontOfSize:pSize weight:UIFontWeightUltraLight];
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index fcc2223ab4c..d240435777d 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -9189,6 +9189,7 @@ public float getDevicePixelRatio() {
return scale > 0 ? scale : super.getDevicePixelRatio();
}
+
@Override
public int getDeviceDensity() {
// IMPORTANT: If you modify this method, you MUST make the equivalent changes
@@ -11291,6 +11292,20 @@ protected String nativeFontName(String fontName) {
if("native:MainRegular".equals(fontName)) {
return "HelveticaNeue-Medium";
}
+ // native:MainNormal is the system font at its TRUE regular weight.
+ //
+ // native:MainRegular has meant HelveticaNeue-Medium since the alias
+ // was introduced, and on iOS 8.2+ that resolves to the system font
+ // at UIFontWeightMedium -- one step heavier than the platform's own
+ // body weight. Changing it would restyle every existing app, so the
+ // honest regular gets a name of its own. Note the macOS branch
+ // already maps native:MainRegular to NSFontWeightRegular, so the two
+ // platforms disagree about the same alias; this name means the same
+ // thing on both.
+ if("native:MainNormal".equals(fontName)) {
+ return "CN1SystemRegular";
+ }
+
if("native:MainBold".equals(fontName)) {
return "HelveticaNeue-Bold";
diff --git a/docs/demos/common/pom.xml b/docs/demos/common/pom.xml
index 40104bae6ec..fb2175fc417 100644
--- a/docs/demos/common/pom.xml
+++ b/docs/demos/common/pom.xml
@@ -19,6 +19,20 @@
provided
+
+
+ com.codenameone
+ codenameone-flutter-runtime
+ ${cn1.version}
+ provided
+
+
diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java
new file mode 100644
index 00000000000..c1ef0dc077b
--- /dev/null
+++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java
@@ -0,0 +1,69 @@
+/*
+ * 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.flutter;
+
+import com.codename1.flutter.FlutterUI;
+import com.codename1.flutter.widgets.Text;
+import com.codename1.ui.Button;
+import com.codename1.ui.Form;
+import com.codename1.ui.Label;
+import com.codename1.ui.layouts.BoxLayout;
+
+/**
+ * The developer guide's Flutter interop examples, compiled rather than taken
+ * on trust.
+ *
+ *
Both entry points are shown with a {@code Text} widget standing in for a
+ * real one, because the point is where the boundary sits, not what the widget
+ * draws.
+ */
+public final class FlutterInteropSnippets {
+
+ private FlutterInteropSnippets() {
+ }
+
+ /** One Dart screen inside an otherwise ordinary Codename One form. */
+ public static void embedOneScreen() {
+ // tag::flutter-interop-java-001[]
+ Form dashboard = new Form("Dashboard", BoxLayout.y());
+ dashboard.add(new Label("Written in Codename One"));
+
+ // A Dart widget tree, compiled to Java at build time. wrap() returns
+ // an ordinary Container, so it is added like any other component.
+ dashboard.add(FlutterUI.wrap(new Text("Rendered from Dart")));
+
+ dashboard.add(new Button("Also Codename One"));
+ dashboard.show();
+ // end::flutter-interop-java-001[]
+ }
+
+ /** The widget tree IS the application. */
+ public static void runTheWholeApplication() {
+ // tag::flutter-interop-java-002[]
+ // runApp mounts the tree in a Form of its own and shows it. Unlike
+ // wrap(), this installs the Material base theme, because the widget
+ // tree is expected to be the whole UI.
+ FlutterUI.runApp(new Text("The entire application"));
+ // end::flutter-interop-java-002[]
+ }
+}
diff --git a/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.sh b/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.sh
new file mode 100644
index 00000000000..13728e3fed8
--- /dev/null
+++ b/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.sh
@@ -0,0 +1,10 @@
+// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline.
+
+// tag::flutter-interop-bash-001[]
+python3 scripts/flutter-bench/run_bench.py --list
+
+python3 scripts/flutter-bench/run_bench.py --platform macos \
+ --cn1-app /path/to/Bench.app \
+ --flutter-app /path/to/gallery.app \
+ --json out/macos.json --markdown out/macos.md
+// end::flutter-interop-bash-001[]
diff --git a/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.xml b/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.xml
new file mode 100644
index 00000000000..d1b7dadd538
--- /dev/null
+++ b/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.xml
@@ -0,0 +1,9 @@
+// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline.
+
+// tag::flutter-interop-xml-001[]
+
+ com.codenameone
+ codenameone-flutter-runtime
+ ${cn1.version}
+
+// end::flutter-interop-xml-001[]
diff --git a/docs/developer-guide/Flutter-Interop.asciidoc b/docs/developer-guide/Flutter-Interop.asciidoc
new file mode 100644
index 00000000000..66e1987105c
--- /dev/null
+++ b/docs/developer-guide/Flutter-Interop.asciidoc
@@ -0,0 +1,203 @@
+[[flutter-interop]]
+== Running Flutter widget code
+
+Codename One can compile Dart widget source into a Codename One UI at build
+time. The Dart is translated to Java, the Java is compiled by the normal
+toolchain, and the resulting widgets render through Codename One's own
+pipeline on every port the framework supports.
+
+There is no Dart VM in the application, no embedded rendering engine, and no
+platform view. A screen written as Dart widgets becomes ordinary Codename One
+components, so it inherits the theme, the event thread, the accessibility
+tree and the native build at no extra cost -- and it can sit in the same `Form` as
+hand-written Codename One code.
+
+Two things follow from that, and they're the reason to reach for this at all:
+
+* An existing Dart UI can be reused without adopting a second runtime, a
+ second build system or a second set of platform plugins.
+* An existing Codename One application can adopt a Dart screen without being
+ rewritten.
+
+=== How it fits together
+
+The pipeline has three stages, and each is an ordinary build step you can run
+and inspect on its own.
+
+image::img/flutter-pipeline.svg[Dart source through the transpiler to the Codename One ports]
+
+Dart widget source in `src/main/flutter` is read by the `transcode-flutter`
+goal during `generate-sources`. It emits Java into
+`target/generated-sources/flutter`, which the normal compile phase picks up.
+At runtime those generated classes call into the Flutter runtime library,
+which implements the widget, element and render-object model on top of
+Codename One containers.
+
+The key design point is where the boundary sits. The runtime reimplements
+Flutter's *layout and composition* model -- constraints down, sizes up,
+elements reconciled against widgets -- but it doesn't reimplement Flutter's
+*rasterizer*. Painting is Codename One's, which is what makes the output a
+real Codename One component tree rather than a texture.
+
+=== Enabling it in a project
+
+Add the runtime dependency to the `common` module. Projects generated from the
+archetype already contain it, commented out:
+
+[source,xml]
+----
+include::../demos/common/src/main/snippets/developer-guide/flutter-interop.xml[tag=flutter-interop-xml-001,indent=0]
+----
+
+Then put Dart files under `common/src/main/flutter`. The goal is already bound
+in the generated `pom.xml` and is a silent no-op while that directory doesn't
+exist, so nothing changes for projects that don't use it.
+
+No Dart SDK is required. The transpiler is Java and runs as part of the Maven
+build; the Dart source is input, not something that gets executed.
+
+The generated package defaults to `com.codename1.generated.flutter` and the
+paths are configurable:
+
+[cols="1,2", options="header"]
+|===
+|Property |Meaning
+
+|`cn1.flutter.sourceDir`
+|Where the Dart lives. Defaults to `src/main/flutter`.
+
+|`cn1.flutter.outputDir`
+|Where the generated Java is written. Defaults to
+`target/generated-sources/flutter`.
+
+|`cn1.flutter.package`
+|The package the generated classes are emitted into.
+|===
+
+=== Use case: One screen inside an existing application
+
+This is the incremental path, and the one to start with. `FlutterUI.wrap`
+inflates a widget into a plain `Container` that can be added anywhere an
+ordinary component can:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java[tag=flutter-interop-java-001,indent=0]
+----
+
+The wrapped subtree is a component like any other: it participates in the
+parent's layout, scrolls with it, and is styled by the same theme. An embedded
+subtree does *not* install the Material base theme, so dropping a
+widget into an existing screen won't restyle the rest of it.
+
+That makes this the right shape for reusing a piece of design work -- a chart,
+a card, an onboarding pane -- without committing the rest of the application to
+anything.
+
+=== Use case: Porting an application wholesale
+
+When the Dart source *is* the application, `FlutterUI.runApp` mounts the tree
+as the root of a new `Form` and shows it, which is the direct analog of
+Flutter's own `runApp`:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java[tag=flutter-interop-java-002,indent=0]
+----
+
+Unlike `wrap`, this path installs the Material base theme, because the widget
+tree is expected to be the whole UI and to look the way its author intended.
+
+A wholesale port is mostly a question of how much of the Dart language and of
+the Flutter libraries the application actually uses. The transpiler reports
+what it can't handle rather than guessing: a construct outside the supported
+subset fails the build with a milestone code identifying the feature, so the
+gap is a list you can work through rather than a runtime surprise.
+
+At runtime the same principle applies. Widgets that aren't implemented are
+recorded through the error inventory rather than drawing nothing,
+so a screen that's missing something says so.
+
+=== What to expect from the result
+
+The transpiled UI is a Codename One application in every respect that matters
+for shipping it: it builds for the same targets, it's signed and packaged the
+same way, and it has no additional runtime to install on the device.
+
+That last point is what the size and start-up differences come from, and it
+cuts both ways -- a Flutter application carries an engine that's good at
+what it does, while a transpiled one carries Codename One's. The benchmark
+described in <> measures both sides of the same application
+on each platform and publishes the result rather than asserting a direction.
+
+image::img/flutter-parity-textfields.png[The same screen rendered by Flutter and by Codename One]
+
+The screen above is the gallery's text-field demo, rendered by Flutter on the
+left and by the transpiled build on the right, from one Dart source file.
+
+[[flutter-benchmark]]
+=== Measuring it against Flutter
+
+The project keeps a benchmark that builds *one* application both ways -- the
+Flutter toolchain's own release build, and the identical Dart source
+transpiled and built by Codename One -- and measures both on the same machine.
+It lives in `scripts/flutter-bench` and runs in CI, publishing its numbers to
+the pull request and to the port status page.
+
+What it reports, per platform:
+
+[cols="1,3", options="header"]
+|===
+|Metric |What it means
+
+|Installed size
+|The sum of the artifact's file lengths, which is what the user's device gives
+up. Not `du`, which rounds every file up to a block.
+
+|Executable code
+|Every compiled binary in the artifact, added together. Counting only the main
+executable is wrong on iOS, where a Flutter application's own code isn't in
+the executable at all -- it's in the frameworks beside it.
+
+|Download size
+|The artifact compressed, because that's what a store ships.
+
+|Cold start
+|Wall time from launching the process to the first frame being on screen,
+measured from outside so neither runtime is trusted to time itself.
+
+|Memory at rest
+|The platform's own accounting for a settled, idle application.
+|===
+
+Three rules keep the numbers honest, and each exists because the obvious
+alternative produced a flattering result:
+
+* *Interleaved runs, best of N.* One run of each side, alternating. A machine
+ that gets busier halfway through then penalizes both sides equally instead
+ of whichever happened to run second, and the load average is recorded with
+ every result so a surprising ratio can be checked against it.
+
+* *Start-up is a bracket, not a point.* The two runtimes don't expose the
+ same event. Flutter's post-frame callback runs before that frame is
+ rasterized, while Codename One's marker fires once the form is on the
+ screen, so comparing them directly charges one runtime for rasterizing its
+ first screen and not the other. The benchmark therefore reports Flutter's
+ figure as a range and computes the ratio from the end least favorable to
+ Codename One.
+
+* *A platform that can't be measured says so.* iOS start-up and memory are
+ reported as not measured rather than taken from a simulator, because Dart
+ can't compile ahead-of-time for the simulator -- a simulator comparison
+ would time Flutter's debug build against a Codename One release build.
+ iOS sizes come from release device bundles, which need no signing to measure.
+
+To run it locally against builds you already have:
+
+[source,bash]
+----
+include::../demos/common/src/main/snippets/developer-guide/flutter-interop.sh[tag=flutter-interop-bash-001,indent=0]
+----
+
+`--list` also reports which platform adapters have been exercised end to end,
+which isn't the same question as which ones exist.
diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc
index c9ecbac11b1..1892676d6ab 100644
--- a/docs/developer-guide/developer-guide.asciidoc
+++ b/docs/developer-guide/developer-guide.asciidoc
@@ -97,6 +97,8 @@ include::Game-Builder.asciidoc[]
include::Game-Assets.asciidoc[]
+include::Flutter-Interop.asciidoc[]
+
= Data, media and networking
include::io.asciidoc[]
diff --git a/docs/developer-guide/img/flutter-parity-textfields.png b/docs/developer-guide/img/flutter-parity-textfields.png
new file mode 100644
index 00000000000..bfe9d161152
Binary files /dev/null and b/docs/developer-guide/img/flutter-parity-textfields.png differ
diff --git a/docs/developer-guide/img/flutter-pipeline.svg b/docs/developer-guide/img/flutter-pipeline.svg
new file mode 100644
index 00000000000..65781d360f7
--- /dev/null
+++ b/docs/developer-guide/img/flutter-pipeline.svg
@@ -0,0 +1,79 @@
+
+
diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt
index 9179b5b5598..aa5aac237f3 100644
--- a/docs/developer-guide/languagetool-accept.txt
+++ b/docs/developer-guide/languagetool-accept.txt
@@ -404,6 +404,7 @@ toolchain
toolchains
toolset
transferal
+transpiled
transpiler
unix
unlayout
diff --git a/docs/website/layouts/_default/port-status.html b/docs/website/layouts/_default/port-status.html
index e98ae000d65..9569fe78862 100644
--- a/docs/website/layouts/_default/port-status.html
+++ b/docs/website/layouts/_default/port-status.html
@@ -4,6 +4,7 @@
{{- $supplement := site.Data.port_status_supplement -}}
{{- $support := site.Data.port_status_support -}}
{{- $environment := site.Data.port_status_environment -}}
+{{- $flutterBench := site.Data.port_status_flutter_benchmark -}}
{{- $snapshotTime := now.UTC -}}
@@ -319,6 +320,58 @@
{{ $support.benchmark.title }}
+ {{- with $flutterBench }}
+
+
+
Flutter compared
+
One Flutter application, built by Flutter and by Codename One
+
+ {{ .app }}. Lower is better for every metric, so a ratio above 1.00x means Codename One is ahead by that factor.
+ {{- with .generated_at }} Measured {{ . }}{{ end }}{{ with .commit }} from commit {{ substr . 0 10 }}{{ end }}.
+
+
+ {{- range $id, $platform := .platforms }}
+
{{ $id }}
+ {{- if eq $platform.status "measured" }}
+
+
+
+
+
Metric
+
Codename One
+
Flutter
+
Ratio
+
+
+
+ {{- range $key, $metric := $platform.metrics }}
+ {{- if $metric.label }}
+