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 @@ + + + From Dart widget source to a native Codename One build + + + + + + + + + + + + BUILD TIME + + + Dart widget + source + src/main/flutter + + + + + transcode- + flutter + Maven goal + + + + + Generated + Java + target/generated + + + + + javac + + app code + + + + + Native build + iOS, Android, + desktop, web + + + + RUN TIME + + + Flutter runtime library + widgets, elements, layout + + + + + Codename One components + theme, EDT, accessibility + + + + + Port rendering pipeline + no engine, no platform view + 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" }} +
+ + + + + + + + + + + {{- range $key, $metric := $platform.metrics }} + {{- if $metric.label }} + + + {{- if eq $metric.unit "bytes" }} + + + {{- else }} + + + {{- end }} + + + {{- end }} + {{- end }} + +
MetricCodename OneFlutterRatio
{{ $metric.label }}{{ printf "%.1f MB" (div (float $metric.codenameone) 1048576.0) }}{{ printf "%.1f MB" (div (float $metric.flutter) 1048576.0) }}{{ printf "%.0f ms" (float $metric.codenameone) }}{{ printf "%.0f ms" (float $metric.flutter) }}{{ printf "%.2fx" (float $metric.ratio) }}
+
+ {{- else }} +

Not measured: {{ $platform.reason }}

+ {{- end }} + {{- end }} +

+ Benchmark harness and method +

+
+ {{- end }} +